Merge origin/main (#984, the canvas shell) into the cleanup branch

Conflicts resolved in 16 files. Where main's new shell, classic page and
tests depend on a wire format or a signature the cleanup had changed, main
wins; where main only extended code the cleanup had restructured, main's
change is carried over into the new structure:

- MCP HTTP server: the poll response keeps main's separate export fields
  (exportFormat, exportXml, exportOptions, exportId with a random id)
  instead of the cleanup's pendingExport object; the classic preview page
  is main's version
- MCP index: start_session keeps the cleanup's structure and gains main's
  BROWSER=none, previewUrl and same-origin note; get_selection is written
  in the registerTool style; create_new_diagram keeps the cleanup's
  helpers with main's comment
- history: addHistory takes main's source parameter and returns the index
  again (main's signature)
- new-diagram: the cleanup's prepareNewModel split plus main's page ids
  for diagrams without one
- diagram context: one ExportResult type (main's, with optional fields)
  for both the tagged exports and main's requestExport; the cleanup's
  single export resolver stays
- versions store and version-text test: main's VersionItem base type and
  describeChangeSummary rename, without the legacy history conversion the
  cleanup removed
- dictionaries: main's shell section added; openFileHint stays removed
  (unused on main too)
- tests: the server wiring test keeps the SDK client and gains main's
  session cases; the HTTP server test uses main's export fields; the DOM
  polyfill comes from tests/setup.ts
- package.json: main's postcss kept, the cleanup's removals kept; lockfile
  regenerated
This commit is contained in:
dayuan.jiang
2026-10-12 11:59:51 +09:00
88 changed files with 9744 additions and 605 deletions
+7 -1
View File
@@ -284,7 +284,13 @@ export function sameFileVars(a: string | null, b: string | null): boolean {
/**
* Whether two documents hold the same pages (names, cells, their order),
* page settings and file variables
* page settings and file variables. This is the web app's one rule for "is
* this version on the canvas" (versions, compare, one-step commits). The
* MCP server's edit gate has its own (contentFingerprint): it compares the
* cells as written, and leaves page settings and file variables out. The
* two disagree both ways (a background change is a difference only here;
* a geometry written "40.0" and "40" is one only there), so neither is a
* subset of the other.
*/
export function isSameDocument(a: string, b: string): boolean {
const pagesOf = (doc: Document) => {
+48 -23
View File
@@ -6,41 +6,66 @@ import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
const EXTERNAL_DRAWIO_URL = process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || ""
/**
* URL of the draw.io editor iframe. By default this is the copy bundled in
* public/drawio, served from our own origin.
* Where the editor comes from and how it starts. The web app fills it from
* its build settings (webDrawioSource); the MCP's canvas shell from the
* config its server writes into the page.
*/
export function getDrawioSrc({
lang,
isElectron,
dark,
}: {
lang: Locale
isElectron: boolean
dark: boolean
}): string {
// index.html: Next.js does not serve directory indexes
const base =
EXTERNAL_DRAWIO_URL ||
`${window.location.origin}${getAssetUrl("/drawio/index.html")}`
const url = new URL(base)
export interface DrawioSource {
/** The editor page: the bundled copy's index.html on this origin, or an
* external draw.io (cross-origin, limited features) */
editorUrl: string
/** draw.io theme (its ui parameter); none: draw.io's default and the
* user's own pick */
ui?: string
/** Language in draw.io's naming ("zh-tw"); none: draw.io's own pick */
lang?: string
/** No calls to external services (the desktop app) */
offline?: boolean
/** draw.io's custom libraries (File > New Library, Open Library from,
* and the buttons under the shapes); the web app leaves them out */
libraries?: boolean
}
/**
* The web app's editor: the copy in public/drawio, served from our own
* origin (index.html, as Next.js serves no directory indexes), or
* NEXT_PUBLIC_DRAWIO_BASE_URL; the simple theme in the page's language.
*/
export function webDrawioSource(lang: Locale): DrawioSource {
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
return {
editorUrl: EXTERNAL_DRAWIO_URL || getAssetUrl("/drawio/index.html"),
ui: "simple",
// draw.io names Traditional Chinese "zh-tw"
lang: lang === "zh-Hant" ? "zh-tw" : lang,
offline: isElectron && !EXTERNAL_DRAWIO_URL,
}
}
/** URL of the draw.io editor iframe */
export function getDrawioSrc(source: DrawioSource, dark: boolean): string {
const url = new URL(source.editorUrl, window.location.href)
const params: Record<string, string> = {
embed: "1",
proto: "json",
configure: "1",
ui: "simple",
}
// Without a fixed theme, embedded draw.io shows Extras > Theme only
// with themes=1 (its isThemeMenuVisible)
if (source.ui) params.ui = source.ui
else params.themes = "1"
Object.assign(params, {
spin: "0",
libraries: "0",
libraries: source.libraries ? "1" : "0",
noSaveBtn: "1",
noExitBtn: "1",
saveAndExit: "0",
// draw.io's own dark mode (switched in place later when the editor
// can be driven directly)
dark: dark ? "1" : "0",
// draw.io names Traditional Chinese "zh-tw"
lang: lang === "zh-Hant" ? "zh-tw" : lang,
}
// No calls to external services from the desktop app
if (isElectron && !EXTERNAL_DRAWIO_URL) params.offline = "1"
})
if (source.lang) params.lang = source.lang
if (source.offline) params.offline = "1"
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
+79 -13
View File
@@ -11,6 +11,7 @@
*/
import { isSameDocument, sameFileVars } from "@/lib/diagram-diff"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import type { SelectionAnswer } from "@/packages/mcp-server/src/selection.ts"
import { type SelectedCell, useCanvasStore } from "@/stores/canvas-store"
type EditorUi = any
@@ -262,19 +263,19 @@ function hasShapes(xml: string): boolean {
})
}
// Page settings draw.io applies on a full load only: replacing the page in
// place (Editor.readGraphState) keeps the old ones. (Its adaptive colors and
// theme stay too: a diagram the AI writes does not set them.)
function hasLoadOnlySettings(model: string): boolean {
const page = new DOMParser().parseFromString(
model,
"text/xml",
).documentElement
/**
* Page settings draw.io applies on a full load only: replacing the page in
* place (Editor.readGraphState) keeps the old ones. (Its adaptive colors and
* theme stay too: a diagram the AI writes does not set them.) Takes the
* page's mxGraphModel element; the MCP shell's sync asks the same question
* of a document before it reaches the editor.
*/
export function hasLoadOnlySettings(model: Element): boolean {
return (
page.hasAttribute("backgroundImage") ||
page.hasAttribute("extFonts") ||
page.getAttribute("math") === "1" ||
page.getAttribute("shadow") === "1"
model.hasAttribute("backgroundImage") ||
model.hasAttribute("extFonts") ||
model.getAttribute("math") === "1" ||
model.getAttribute("shadow") === "1"
)
}
@@ -322,7 +323,9 @@ export function canReplaceDiagram(xml: string): boolean {
// A document with them, or replacing one with them, loads in full
return (
model !== null &&
!hasLoadOnlySettings(model) &&
!hasLoadOnlySettings(
new DOMParser().parseFromString(model, "text/xml").documentElement,
) &&
!canvasHasLoadOnlySettings()
)
}
@@ -547,6 +550,19 @@ export function highlightCells(ids: string[], color: string) {
}
}
/**
* Marks the cells an AI change touched in the page's marker color, once
* draw.io has drawn them (the web app's versions and the MCP shell share it)
*/
export function highlightChangedCells(ids: string[]) {
setTimeout(() => {
const marker = getComputedStyle(document.documentElement)
.getPropertyValue("--marker")
.trim()
highlightCells(ids, marker || "#ffd84d")
}, 60)
}
// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
@@ -584,6 +600,56 @@ function readSelection(): SelectedCell[] {
}))
}
/**
* The selection with what an editing model needs (the MCP's get_selection):
* the page it is on, each cell's label, an edge's ends, a shape's geometry
* as the XML has it (relative to its container) and that container. Null
* without an editor (a cross-origin draw.io).
*/
export function readSelectionDetails(): SelectionAnswer | null {
const g = graph()
if (!g) return null
const page = ui?.currentPage
const cells = (g.getSelectionCells() as any[])
.filter((cell) => cell?.id)
.map((cell) => {
const info: SelectionAnswer["cells"][number] = {
id: String(cell.id),
label: cellLabel(cell),
edge: !!g.model.isEdge(cell),
}
if (info.edge) {
const source = g.model.getTerminal(cell, true)
const target = g.model.getTerminal(cell, false)
if (source?.id) info.source = String(source.id)
if (target?.id) info.target = String(target.id)
} else {
const geo = g.getCellGeometry(cell)
if (geo) {
info.geometry = {
x: geo.x,
y: geo.y,
width: geo.width,
height: geo.height,
}
}
}
// The container the cell is in; a layer is none (and the
// default parent is the group the user entered, if any, so it
// cannot tell the two apart)
const parent = g.model.getParent(cell)
if (parent?.id && !g.model.isLayer(parent)) {
info.parent = String(parent.id)
}
return info
})
return {
pageId: page?.getId ? String(page.getId()) : null,
pageName: page?.getName ? String(page.getName()) : null,
cells,
}
}
function readSelectionRect() {
const g = graph()
if (!g || g.isSelectionEmpty()) return null
+15
View File
@@ -610,5 +610,20 @@
"placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas"
},
"shell": {
"waiting": "Waiting for the MCP server…",
"toggleTheme": "Switch between light and dark",
"connected": "Connected to the MCP server",
"offline": "Can't reach the MCP server",
"tooLarge": "This diagram is too large to save to the MCP server (over 10 MB).",
"unreachable": "Can't reach the MCP server. Your changes are only in this tab for now.",
"restoredFromFile": "The diagram was restored from its saved file. What this tab showed before is in History.",
"aiChanged": "The AI changed the diagram while you were editing. Your last change was saved in History.",
"historyChanged": "History changed on the server. Please open it again.",
"restoreFailed": "Restore failed. Please try again.",
"userEdit": "Changed by hand",
"showVersions": "Show the versions",
"hideVersions": "Hide the versions"
}
}
+15
View File
@@ -610,5 +610,20 @@
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める"
},
"shell": {
"waiting": "MCP サーバーを待っています…",
"toggleTheme": "ライトとダークを切り替え",
"connected": "MCP サーバーに接続しました",
"offline": "MCP サーバーに接続できません",
"tooLarge": "この図は大きすぎて MCP サーバーに保存できません(10 MB 超)。",
"unreachable": "MCP サーバーに接続できません。変更は今のところこのタブにだけあります。",
"restoredFromFile": "図は保存済みファイルから復元されました。このタブに表示されていた内容は履歴にあります。",
"aiChanged": "編集中に AI が図を変更しました。最後の変更は履歴に保存されました。",
"historyChanged": "サーバー上の履歴が変わりました。もう一度開いてください。",
"restoreFailed": "復元に失敗しました。もう一度お試しください。",
"userEdit": "手で変更しました",
"showVersions": "バージョンを表示",
"hideVersions": "バージョンを隠す"
}
}
+15
View File
@@ -610,5 +610,20 @@
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙",
"drawYourself": "從空白畫布開始"
},
"shell": {
"waiting": "正在等待 MCP 伺服器…",
"toggleTheme": "切換淺色或深色",
"connected": "已連接 MCP 伺服器",
"offline": "無法連接 MCP 伺服器",
"tooLarge": "這張圖太大,無法儲存到 MCP 伺服器(超過 10 MB)。",
"unreachable": "無法連接 MCP 伺服器。您的修改暫時只保存在這個分頁裡。",
"restoredFromFile": "圖表已從儲存的檔案還原。這個分頁之前顯示的內容已放入歷史記錄。",
"aiChanged": "您編輯時 AI 修改了圖表。您的最後一次修改已儲存到歷史記錄。",
"historyChanged": "伺服器上的歷史記錄已變化,請重新開啟。",
"restoreFailed": "還原失敗,請再試一次。",
"userEdit": "手動修改",
"showVersions": "顯示版本",
"hideVersions": "隱藏版本"
}
}
+15
View File
@@ -610,5 +610,20 @@
"placeholder": "比如:用户登录的时序图,包含短信验证码和失败重试",
"recent": "最近的图纸",
"drawYourself": "从空白画布开始"
},
"shell": {
"waiting": "正在等待 MCP 服务器…",
"toggleTheme": "切换浅色或深色",
"connected": "已连接 MCP 服务器",
"offline": "无法连接 MCP 服务器",
"tooLarge": "这张图太大,无法保存到 MCP 服务器(超过 10 MB)。",
"unreachable": "无法连接 MCP 服务器。您的修改暂时只保存在这个标签页里。",
"restoredFromFile": "图表已从保存的文件恢复。这个标签页之前显示的内容已放入历史记录。",
"aiChanged": "您编辑时 AI 修改了图表。您的最后一次修改已保存到历史记录。",
"historyChanged": "服务器上的历史记录已变化,请重新打开。",
"restoreFailed": "恢复失败,请重试。",
"userEdit": "手动修改",
"showVersions": "显示版本",
"hideVersions": "隐藏版本"
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ const PARTS: [keyof ChangeSummary, string][] = [
* singular (...One) and plural (...Other) form; languages without plurals
* use the same text for both.
*/
export function describeChanges(
export function describeChangeSummary(
summary: ChangeSummary,
t: VersionDict,
): string {