mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
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:
+7
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "バージョンを隠す"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "隱藏版本"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user