Compare commits

..
Author SHA1 Message Date
dayuan.jiang 69fb66804d feat(settings): reorganize the settings dialog and the model settings
Tabs Models, General, Drawing and About, each with a fixed title row.

The models tab becomes three pages: the user's providers with one status
each and the server's models; a searchable picker of all 24 providers in
four groups; and a provider page in three steps (connection, models,
test). A new provider gets its first suggested model, and a passing test
offers to use the model in the chat. Bedrock asks for one way of signing
in, with an optional session token for temporary access keys.

The composer's model picker lists every model, marking untested and
failed ones, and can open the provider picker. A chat error that offers
model settings opens the provider the request went to.
2026-10-08 17:44:08 +09:00
13 changed files with 96 additions and 170 deletions
+3 -9
View File
@@ -43,14 +43,8 @@ jobs:
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
e2e: e2e:
# Six jobs at once, each with a sixth of the tests and one worker: the name: E2E Tests
# same conditions per test, a fraction of the time
name: E2E Tests (${{ matrix.shard }}/6)
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -82,7 +76,7 @@ jobs:
run: npm run build run: npm run build
- name: Run E2E tests - name: Run E2E tests
run: npm run test:e2e -- --shard=${{ matrix.shard }}/6 run: npm run test:e2e
env: env:
CI: true CI: true
@@ -90,6 +84,6 @@ jobs:
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
if: always() if: always()
with: with:
name: playwright-report-${{ matrix.shard }} name: playwright-report
path: playwright-report/ path: playwright-report/
retention-days: 7 retention-days: 7
+5 -5
View File
@@ -1,10 +1,10 @@
import { import {
ChevronDown, ChevronDown,
Download, Download,
FilePlus,
MessageSquareDashed, MessageSquareDashed,
PanelRightClose, PanelRightClose,
Settings, Settings2,
SquarePen,
} from "lucide-react" } from "lucide-react"
import { useRef } from "react" import { useRef } from "react"
import { DevXmlSimulator } from "@/components/dev-xml-simulator" import { DevXmlSimulator } from "@/components/dev-xml-simulator"
@@ -139,14 +139,14 @@ export function ChatPanel({
disabled={engine.isBusy} disabled={engine.isBusy}
data-testid="new-chat-button" data-testid="new-chat-button"
> >
<FilePlus /> <SquarePen />
</IconButton> </IconButton>
<IconButton <IconButton
label={dict.nav.settings} label={dict.nav.settings}
onClick={() => openSettings()} onClick={() => openSettings()}
data-testid="settings-button" data-testid="settings-button"
> >
<Settings /> <Settings2 />
</IconButton> </IconButton>
{!mobile && ( {!mobile && (
<IconButton <IconButton
@@ -173,7 +173,7 @@ export function ChatPanel({
/> />
)} )}
<div className="shrink-0 px-4 pt-1 pb-3"> <div className="shrink-0 p-2.5 pt-1">
<Composer <Composer
placeholder={ placeholder={
engine.messages.length > 0 && engine.messages.length > 0 &&
+42 -32
View File
@@ -18,18 +18,14 @@ interface Example {
file?: { path: string; name: string; type: string } file?: { path: string; name: string; type: string }
} }
/** Secondary start buttons: outlined, so they read as clickable */
const startButtonClass =
"inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-card px-3 text-[13px] text-foreground transition-colors hover:bg-accent [&_svg]:text-muted-foreground"
async function loadExampleFile(file: NonNullable<Example["file"]>) { async function loadExampleFile(file: NonNullable<Example["file"]>) {
const response = await fetch(getAssetUrl(file.path)) const response = await fetch(getAssetUrl(file.path))
const blob = await response.blob() const blob = await response.blob()
return new File([blob], file.name, { type: file.type }) return new File([blob], file.name, { type: file.type })
} }
/** One start-screen example: a small picture over its title */ /** One start-screen choice: a small picture, a title and one line */
function ExampleTile({ function ExampleCard({
onClick, onClick,
title, title,
description, description,
@@ -39,7 +35,6 @@ function ExampleTile({
}: { }: {
onClick: () => void onClick: () => void
title: string title: string
/** Shown as the tooltip: the title alone names the example */
description: string description: string
picture: React.ReactNode picture: React.ReactNode
/** An icon on a tinted tile instead of a diagram on paper */ /** An icon on a tinted tile instead of a diagram on paper */
@@ -50,20 +45,24 @@ function ExampleTile({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
title={description} className="group/example flex min-w-0 items-center gap-2.5 rounded-xl border border-border bg-card p-1.5 pr-2.5 text-left transition-colors hover:border-foreground/25"
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
data-testid={testId} data-testid={testId}
> >
<span <span
className={cn( className={cn(
"flex h-16 w-full items-center justify-center overflow-hidden rounded-lg", "flex h-12 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg",
plain ? "bg-muted" : "sheet-light bg-white", plain ? "bg-muted" : "sheet-light bg-white",
)} )}
> >
{picture} {picture}
</span> </span>
<span className="px-1 text-[13px] leading-snug font-medium text-foreground"> <span className="min-w-0 flex-1">
{title} <span className="line-clamp-2 text-[13px] leading-snug font-medium text-foreground">
{title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{description}
</span>
</span> </span>
</button> </button>
) )
@@ -93,7 +92,6 @@ export function LobbyHero({
key: "paper", key: "paper",
title: dict.examples.paperToDiagram, title: dict.examples.paperToDiagram,
description: dict.examples.paperDescription, description: dict.examples.paperDescription,
image: "/paper_demo.svg",
prompt: "Summarize this paper as a diagram", prompt: "Summarize this paper as a diagram",
file: { file: {
path: "/chain-of-thought.txt", path: "/chain-of-thought.txt",
@@ -187,12 +185,10 @@ export function LobbyHero({
placeholder={t.placeholder} placeholder={t.placeholder}
/> />
{/* Examples: one row of tiles; the ways to bring your own
diagram follow as a separate group */}
<div <div
className={cn( className={cn(
"mt-5 grid gap-2", "mt-5 grid gap-2",
compact ? "grid-cols-1" : "grid-cols-5", compact ? "grid-cols-1" : "grid-cols-3",
)} )}
> >
{examples.map((example) => {examples.map((example) =>
@@ -213,7 +209,7 @@ export function LobbyHero({
</span> </span>
</button> </button>
) : ( ) : (
<ExampleTile <ExampleCard
key={example.key} key={example.key}
onClick={() => pickExample(example)} onClick={() => pickExample(example)}
title={example.title} title={example.title}
@@ -225,7 +221,7 @@ export function LobbyHero({
<img <img
src={getAssetUrl(example.image)} src={getAssetUrl(example.image)}
alt="" alt=""
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]" className="max-h-10 max-w-[56px] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
/> />
) : ( ) : (
<FileText className="size-5 text-muted-foreground" /> <FileText className="size-5 text-muted-foreground" />
@@ -235,34 +231,48 @@ export function LobbyHero({
/> />
), ),
)} )}
{!compact && (
<ExampleCard
onClick={onOpenFile}
title={dict.workspace.openFile}
description={t.openFileHint}
picture={
<FileUp className="size-5 text-muted-foreground" />
}
plain
/>
)}
</div> </div>
<p className="mt-2.5 text-center text-xs text-faint"> <p className="mt-3 text-center text-xs text-faint">
{dict.examples.cachedNote} {dict.examples.cachedNote}
</p> </p>
{/* Ways to start without an example, as plain buttons */} <div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[13px] text-muted-foreground">
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
{engine.sessions.length > 0 && ( {engine.sessions.length > 0 && (
<SessionMenu align="center" onOpenFile={onOpenFile}> <SessionMenu align="center" onOpenFile={onOpenFile}>
<button type="button" className={startButtonClass}> <button
type="button"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
<History className="size-4" /> <History className="size-4" />
{t.recent} {t.recent}
</button> </button>
</SessionMenu> </SessionMenu>
)} )}
<button {compact && (
type="button" <button
onClick={onOpenFile} type="button"
className={startButtonClass} onClick={onOpenFile}
data-testid="open-file" className="inline-flex items-center gap-1.5 hover:text-foreground"
> >
<FileUp className="size-4" /> <FileUp className="size-4" />
{dict.workspace.openFile} {dict.workspace.openFile}
</button> </button>
)}
<button <button
type="button" type="button"
onClick={onDrawYourself} onClick={onDrawYourself}
className={startButtonClass} className="inline-flex items-center gap-1.5 hover:text-foreground"
data-testid="draw-yourself" data-testid="draw-yourself"
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
+26 -26
View File
@@ -2,6 +2,7 @@ import {
AlertCircle, AlertCircle,
Check, Check,
ChevronDown, ChevronDown,
Code2,
Copy, Copy,
Library, Library,
PenTool, PenTool,
@@ -179,23 +180,17 @@ function VersionCard({
</button> </button>
) )
// What the AI wrote: a text link in the summary line, so the card's // What the AI wrote, on every card (main showed it on every tool call)
// only button is the action (undo or restore) const codeButton = (
const summaryLine = ( <button
<div className="text-xs text-muted-foreground"> type="button"
{summary} onClick={() => setShowCode((v) => !v)}
{/* The link wraps as one unit with its separator */} aria-label={showCode ? t.hideCode : t.showCode}
<span className="whitespace-nowrap"> title={showCode ? t.hideCode : t.showCode}
<span aria-hidden> · </span> className="inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
<button >
type="button" <Code2 className="size-4" />
onClick={() => setShowCode((v) => !v)} </button>
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
>
{showCode ? t.hideCode : t.showCode}
</button>
</span>
</div>
) )
const codePanel = showCode && ( const codePanel = showCode && (
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5"> <div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
@@ -236,12 +231,15 @@ function VersionCard({
/> />
</button> </button>
{badge} {badge}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1" title={summary}>
<div className="truncate text-[13px] text-foreground"> <div className="truncate text-[13px] text-foreground">
{title} {title}
</div> </div>
{summaryLine} <div className="truncate text-xs text-muted-foreground">
{summary}
</div>
</div> </div>
{codeButton}
{restoreButton} {restoreButton}
</div> </div>
{codePanel} {codePanel}
@@ -273,12 +271,17 @@ function VersionCard({
{t.compare} {t.compare}
</span> </span>
</button> </button>
{/* Title row with the action; the summary runs under both */} <div className="flex items-center gap-2.5 px-3 py-2.5">
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
{badge} {badge}
<div className="truncate text-[13px] font-medium text-foreground"> <div className="min-w-0 flex-1" title={summary}>
{title} <div className="truncate text-[13px] font-medium text-foreground">
{title}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{summary}
</div>
</div> </div>
{codeButton}
{canUndo ? ( {canUndo ? (
<button <button
type="button" type="button"
@@ -297,9 +300,6 @@ function VersionCard({
) : ( ) : (
restoreButton restoreButton
)} )}
<div className="col-span-2 col-start-2 mt-0.5">
{summaryLine}
</div>
</div> </div>
{codePanel} {codePanel}
</div> </div>
+2 -2
View File
@@ -220,14 +220,14 @@ export function ModelSelector({
<span className="min-w-0 truncate text-xs"> <span className="min-w-0 truncate text-xs">
{selectedModel {selectedModel
? shortModelName(selectedModel.modelId) ? shortModelName(selectedModel.modelId)
: dict.modelConfig.serverDefault} : dict.modelConfig.default}
</span> </span>
) : ( ) : (
// Keep an sr-only label for screen readers when hidden // Keep an sr-only label for screen readers when hidden
<span className="sr-only"> <span className="sr-only">
{selectedModel {selectedModel
? selectedModel.modelId ? selectedModel.modelId
: dict.modelConfig.serverDefault} : dict.modelConfig.default}
</span> </span>
)} )}
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> <ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
+2 -2
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { MessageSquare, PanelRightOpen, Settings, Shapes } from "lucide-react" import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { CanvasStage } from "@/components/canvas/canvas-stage" import { CanvasStage } from "@/components/canvas/canvas-stage"
@@ -100,7 +100,7 @@ function StartBar({
onClick={() => openSettings()} onClick={() => openSettings()}
data-testid="settings-button" data-testid="settings-button"
> >
<Settings /> <Settings2 />
</IconButton> </IconButton>
</div> </div>
) )
+4 -4
View File
@@ -72,7 +72,7 @@
"replicateDescription": "Upload and replicate an existing flowchart", "replicateDescription": "Upload and replicate an existing flowchart",
"creativeDrawing": "Creative drawing", "creativeDrawing": "Creative drawing",
"creativeDescription": "Draw something fun and creative", "creativeDescription": "Draw something fun and creative",
"cachedNote": "Examples use saved answers: they appear instantly and don't call the AI.", "cachedNote": "Examples open instantly and don't use your model.",
"mcpServer": "MCP Server", "mcpServer": "MCP Server",
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor" "mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
}, },
@@ -96,7 +96,7 @@
"language": "Language", "language": "Language",
"languageDescription": "Choose your interface language.", "languageDescription": "Choose your interface language.",
"theme": "Theme", "theme": "Theme",
"themeDescription": "Light or dark interface. The canvas follows the same theme.", "themeDescription": "Light or dark interface. The diagram always stays on a white sheet, the way it exports.",
"diagramStyle": "Diagram style", "diagramStyle": "Diagram style",
"diagramStyleDescription": "How the AI draws new diagrams: Styled uses colors and icons, Minimal keeps shapes plain.", "diagramStyleDescription": "How the AI draws new diagrams: Styled uses colors and icons, Minimal keeps shapes plain.",
"sendShortcut": "Send shortcut", "sendShortcut": "Send shortcut",
@@ -400,7 +400,7 @@
"searchModels": "Search models...", "searchModels": "Search models...",
"noModelsFound": "No models found.", "noModelsFound": "No models found.",
"default": "Default", "default": "Default",
"serverDefault": "Server model", "serverDefault": "Server Default",
"serverModels": "Server Models", "serverModels": "Server Models",
"userModels": "User Models", "userModels": "User Models",
"configureModels": "Configure Models...", "configureModels": "Configure Models...",
@@ -695,7 +695,7 @@
}, },
"lobby": { "lobby": {
"title": "What would you like to draw?", "title": "What would you like to draw?",
"lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.", "lede": "Describe a diagram, or upload a screenshot or PDF. You get a regular draw.io diagram that you can keep editing and export.",
"placeholder": "For example: a login sequence diagram with SMS codes and retries", "placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams", "recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas", "drawYourself": "Start with a blank canvas",
+4 -4
View File
@@ -72,7 +72,7 @@
"replicateDescription": "既存のフローチャートをアップロードして複製", "replicateDescription": "既存のフローチャートをアップロードして複製",
"creativeDrawing": "自由なイラスト", "creativeDrawing": "自由なイラスト",
"creativeDescription": "楽しくてクリエイティブなものを描く", "creativeDescription": "楽しくてクリエイティブなものを描く",
"cachedNote": "例は保存済みの回答を使うので、すぐに表示され、AI を呼び出しません。", "cachedNote": "例はすぐに開き、モデルは使いません。",
"mcpServer": "MCP サーバー", "mcpServer": "MCP サーバー",
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用" "mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
}, },
@@ -96,7 +96,7 @@
"language": "言語", "language": "言語",
"languageDescription": "インターフェース言語を選択します。", "languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ", "theme": "テーマ",
"themeDescription": "画面をライトにするかダークにするか。キャンバスも同じテーマになります。", "themeDescription": "画面をライトにするかダークにするか。図はエクスポートしたときと同じく、常に白い用紙の上に表示されます。",
"diagramStyle": "図のスタイル", "diagramStyle": "図のスタイル",
"diagramStyleDescription": "AI が新しい図を描くときのスタイル。スタイル付きは色やアイコンを使い、ミニマルは素朴な図形だけにします。", "diagramStyleDescription": "AI が新しい図を描くときのスタイル。スタイル付きは色やアイコンを使い、ミニマルは素朴な図形だけにします。",
"sendShortcut": "送信のショートカット", "sendShortcut": "送信のショートカット",
@@ -355,7 +355,7 @@
"searchModels": "モデルを検索...", "searchModels": "モデルを検索...",
"noModelsFound": "モデルが見つかりません。", "noModelsFound": "モデルが見つかりません。",
"default": "デフォルト", "default": "デフォルト",
"serverDefault": "サーバーのモデル", "serverDefault": "サーバーデフォルト",
"serverModels": "サーバーモデル", "serverModels": "サーバーモデル",
"userModels": "ユーザーモデル", "userModels": "ユーザーモデル",
"configureModels": "モデルを設定...", "configureModels": "モデルを設定...",
@@ -695,7 +695,7 @@
}, },
"lobby": { "lobby": {
"title": "どんな図を描きますか?", "title": "どんな図を描きますか?",
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。", "lede": "図の内容を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは通常の draw.io の図なので、続けて編集もエクスポートもできます。",
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図", "placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図", "recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める", "drawYourself": "白紙のキャンバスから始める",
+4 -4
View File
@@ -72,7 +72,7 @@
"replicateDescription": "上傳並複製現有流程圖", "replicateDescription": "上傳並複製現有流程圖",
"creativeDrawing": "創意繪畫", "creativeDrawing": "創意繪畫",
"creativeDescription": "繪製有趣且富有創意的內容", "creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例使用保存好的答案,立即顯示,不呼叫 AI。", "cachedNote": "範例會立即開啟,不呼叫模型。",
"mcpServer": "MCP 伺服器", "mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用" "mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
}, },
@@ -96,7 +96,7 @@
"language": "語言", "language": "語言",
"languageDescription": "選擇介面語言。", "languageDescription": "選擇介面語言。",
"theme": "主題", "theme": "主題",
"themeDescription": "介面用淺色還是深色,畫布跟隨同一主題。", "themeDescription": "介面用淺色還是深色。圖紙始終是白底,和匯出後的樣子一致。",
"diagramStyle": "圖形風格", "diagramStyle": "圖形風格",
"diagramStyleDescription": "AI 畫新圖時的風格:精緻會用顏色和圖示,簡約只用樸素的圖形。", "diagramStyleDescription": "AI 畫新圖時的風格:精緻會用顏色和圖示,簡約只用樸素的圖形。",
"sendShortcut": "傳送快速鍵", "sendShortcut": "傳送快速鍵",
@@ -400,7 +400,7 @@
"searchModels": "搜尋模型...", "searchModels": "搜尋模型...",
"noModelsFound": "未找到模型。", "noModelsFound": "未找到模型。",
"default": "預設", "default": "預設",
"serverDefault": "伺服器模型", "serverDefault": "伺服器預設",
"serverModels": "伺服器模型", "serverModels": "伺服器模型",
"userModels": "使用者模型", "userModels": "使用者模型",
"configureModels": "配置模型...", "configureModels": "配置模型...",
@@ -695,7 +695,7 @@
}, },
"lobby": { "lobby": {
"title": "說說你要畫什麼", "title": "說說你要畫什麼",
"lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。", "lede": "描述一張圖,或上傳截圖、PDF。畫出來的是標準的 draw.io 圖表,可以接著改,也可以匯出。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試", "placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙", "recent": "最近的圖紙",
"drawYourself": "從空白畫布開始", "drawYourself": "從空白畫布開始",
+4 -4
View File
@@ -72,7 +72,7 @@
"replicateDescription": "上传并复制现有流程图", "replicateDescription": "上传并复制现有流程图",
"creativeDrawing": "创意绘画", "creativeDrawing": "创意绘画",
"creativeDescription": "绘制有趣且富有创意的内容", "creativeDescription": "绘制有趣且富有创意的内容",
"cachedNote": "示例使用保存好的答案,立即显示,不调用 AI。", "cachedNote": "示例会立即打开,不调用模型。",
"mcpServer": "MCP 服务器", "mcpServer": "MCP 服务器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用" "mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
}, },
@@ -96,7 +96,7 @@
"language": "语言", "language": "语言",
"languageDescription": "选择界面语言。", "languageDescription": "选择界面语言。",
"theme": "主题", "theme": "主题",
"themeDescription": "界面用浅色还是深色,画布跟随同一主题。", "themeDescription": "界面用浅色还是深色。图纸始终是白底,和导出后的样子一致。",
"diagramStyle": "图形风格", "diagramStyle": "图形风格",
"diagramStyleDescription": "AI 画新图时的风格:精致会用颜色和图标,简约只用朴素的图形。", "diagramStyleDescription": "AI 画新图时的风格:精致会用颜色和图标,简约只用朴素的图形。",
"sendShortcut": "发送快捷键", "sendShortcut": "发送快捷键",
@@ -400,7 +400,7 @@
"searchModels": "搜索模型...", "searchModels": "搜索模型...",
"noModelsFound": "未找到模型。", "noModelsFound": "未找到模型。",
"default": "默认", "default": "默认",
"serverDefault": "服务器模型", "serverDefault": "服务器默认",
"serverModels": "服务器模型", "serverModels": "服务器模型",
"userModels": "用户模型", "userModels": "用户模型",
"configureModels": "配置模型...", "configureModels": "配置模型...",
@@ -695,7 +695,7 @@
}, },
"lobby": { "lobby": {
"title": "说说你要画什么", "title": "说说你要画什么",
"lede": "描述一张图,或上传截图、PDF。得到的是可以继续编辑和导出的 draw.io 图。", "lede": "描述一张图,或者上传截图、PDF。画出来的是标准的 draw.io 图表,可以接着改,也可以导出。",
"placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试", "placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试",
"recent": "最近的图纸", "recent": "最近的图纸",
"drawYourself": "从空白画布开始", "drawYourself": "从空白画布开始",
@@ -216,20 +216,6 @@ export function applyDiagramOperations(
continue continue
} }
// A cut-off cell (its XML still streaming in a preview) would
// parse in Chrome to a cell without its geometry
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "update",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML // Parse the new XML
const newDoc = parser.parseFromString( const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`, `<wrapper>${op.new_xml}</wrapper>`,
@@ -282,20 +268,6 @@ export function applyDiagramOperations(
continue continue
} }
// A cut-off cell (its XML still streaming in a preview) would
// parse in Chrome to a cell without its geometry
const syntaxError = getXmlSyntaxError(
`<wrapper>${op.new_xml}</wrapper>`,
)
if (syntaxError) {
errors.push({
type: "add",
cellId: op.cell_id,
message: `new_xml is not well-formed XML: ${syntaxError}`,
})
continue
}
// Parse the new XML // Parse the new XML
const newDoc = parser.parseFromString( const newDoc = parser.parseFromString(
`<wrapper>${op.new_xml}</wrapper>`, `<wrapper>${op.new_xml}</wrapper>`,
@@ -149,52 +149,3 @@ describe("a wrapped mxCell with its wrapper's id", () => {
expect(result).toContain('label="B"') expect(result).toContain('label="B"')
}) })
}) })
describe("cut-off new_xml", () => {
// While edit_diagram streams, the last operation's XML is often
// incomplete. Chrome's DOMParser keeps the partial cell, so it must be
// refused before it reaches the diagram.
const CUT = `<mxCell id="b" value="B2" style="rounded=1;" vertex="1" parent="1">`
it("refuses an update whose XML is cut off", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{ operation: "update", cell_id: "b", new_xml: CUT },
])
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ type: "update", cellId: "b" })
expect(errors[0].message).toContain("not well-formed")
expect(result).toContain('value="B"')
expect(result).not.toContain("B2")
})
it("refuses an add whose XML is cut off", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{
operation: "add",
cell_id: "n",
new_xml: `<mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="1" y="2" width="3"`,
},
])
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ type: "add", cellId: "n" })
expect(result).not.toContain('id="n"')
})
it("applies the complete operations before a cut-off one", () => {
const { result, errors } = applyDiagramOperations(DOC, [
{
operation: "update",
cell_id: "b",
new_xml: `<mxCell id="b" value="B3" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`,
},
{
operation: "add",
cell_id: "n",
new_xml: `<mxCell id="n" value="N" vertex="1" parent="1">`,
},
])
expect(errors).toHaveLength(1)
expect(result).toContain('value="B3"')
expect(result).not.toContain('id="n"')
})
})
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 67 KiB