mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c43d3b2a | ||
|
|
876d3dec8c | ||
|
|
24d1850518 | ||
|
|
fdc974e85a | ||
|
|
70d392729a | ||
|
|
00aa3c22e6 | ||
|
|
4bd15ffcf6 | ||
|
|
c9c610e929 | ||
|
|
e93f7547c2 | ||
|
|
f257d8ee99 | ||
|
|
467fd25fd1 | ||
|
|
734a31d4eb | ||
|
|
f7178510f4 | ||
|
|
0ed9643af1 | ||
|
|
58cef00873 | ||
|
|
27c49a68d0 | ||
|
|
af842ac287 | ||
|
|
b3dcd91f54 | ||
|
|
5c923adf7b | ||
|
|
0de1cdfe7c | ||
|
|
66c9daf5ca | ||
|
|
a96dcbb21f | ||
|
|
3422b5c55b | ||
|
|
e533aac6c4 | ||
|
|
3b3219c73d | ||
|
|
349ea26d8b | ||
|
|
3ef14318f9 | ||
|
|
b6bd032c2c | ||
|
|
5ed8940b48 | ||
|
|
21807417c5 | ||
|
|
a2ca229830 | ||
|
|
b4c34c9361 | ||
|
|
1f27036de1 | ||
|
|
7be9b5ace8 | ||
|
|
b6f42bcddc | ||
|
|
77c6a2d807 | ||
|
|
7e2edbf8a1 | ||
|
|
20ce02a602 | ||
|
|
a407f66268 | ||
|
|
6e375fb963 | ||
|
|
48c7e43521 | ||
|
|
8171693ef6 | ||
|
|
7fb78c2de6 | ||
|
|
7f84be7001 | ||
|
|
d415f19cf5 | ||
|
|
38fe675d6b | ||
|
|
f89d23880e | ||
|
|
1aa8abcd6e | ||
|
|
899b6a3b5b |
@@ -1,7 +1,8 @@
|
||||
name: Publish MCP Server
|
||||
|
||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||
# (no token, no OTP). Triggers when packages/mcp-server or the web app code
|
||||
# bundled into the canvas shell (scripts/build-shell.mjs) changes on main;
|
||||
# skips silently if the package.json version is already on npm — so a
|
||||
# release is just "bump the version in a PR and merge".
|
||||
on:
|
||||
@@ -10,6 +11,16 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- "packages/mcp-server/**"
|
||||
# The canvas shell is bundled from these with the root's lockfile
|
||||
- "components/**"
|
||||
- "contexts/**"
|
||||
- "hooks/**"
|
||||
- "lib/**"
|
||||
- "stores/**"
|
||||
- "app/globals.css"
|
||||
- "package-lock.json"
|
||||
# The package's build scripts import the root's scripts/drawio-zip.mjs
|
||||
- "scripts/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -35,7 +46,9 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
packages/mcp-server/package-lock.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Trusted publishing requires npm >= 11.5.1
|
||||
@@ -54,6 +67,13 @@ jobs:
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# The canvas shell (scripts/build-shell.mjs) is bundled from the web
|
||||
# app's components with the root's esbuild, Tailwind and React
|
||||
- name: Install repository dependencies
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
working-directory: .
|
||||
run: npm ci
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm ci
|
||||
@@ -62,6 +82,9 @@ jobs:
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm test
|
||||
|
||||
# The build downloads the pinned draw.war from GitHub releases
|
||||
# (scripts/fetch-drawio.mjs) and bundles a trimmed draw.io into
|
||||
# dist/drawio; check-package confirms it is in the tarball
|
||||
- name: Build and check package contents
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm run build && npm run check-package
|
||||
|
||||
@@ -28,20 +28,104 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: npm run test -- --run
|
||||
|
||||
# The MCP server package ships its own vitest because its DOM polyfill
|
||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||
# for the Next.js app. Install + run its tests separately so CI catches
|
||||
# multi-page mxfile regressions.
|
||||
# The MCP server package: its own vitest (its DOM polyfill, linkedom, needs
|
||||
# `environment: node`, while the root vitest uses jsdom for the Next.js
|
||||
# app), the npm package as it is built, the draw.io file list, and the
|
||||
# canvas shell in a browser.
|
||||
mcp-shell:
|
||||
name: MCP Server & Shell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# HEAD^1 is the base branch for the version bump reminder below
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
packages/mcp-server/package-lock.json
|
||||
|
||||
# The shell is bundled from the web app's components with the root's
|
||||
# esbuild, Tailwind and React; Playwright comes from the root too
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install MCP server dependencies
|
||||
run: npm --prefix packages/mcp-server ci
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Install Playwright deps (cached)
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
|
||||
- name: Run MCP server unit tests
|
||||
run: npm --prefix packages/mcp-server test
|
||||
|
||||
# Tests run from src/, so check the built npm package separately
|
||||
# Tests run from src/, so check the built npm package separately. The
|
||||
# build downloads the pinned draw.war and bundles a trimmed draw.io.
|
||||
- name: Build MCP server and check package contents
|
||||
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
|
||||
|
||||
# The trimmed draw.io is cut from this list; the guard drives the full
|
||||
# copy (public/drawio) in Chromium and fails when the editor requests a
|
||||
# file the list does not have
|
||||
- name: Download the full draw.io
|
||||
run: node scripts/fetch-drawio.mjs
|
||||
|
||||
- name: Check the MCP draw.io file list
|
||||
run: npm --prefix packages/mcp-server run check-drawio
|
||||
|
||||
# The canvas shell in a browser, from the package as npm installs it:
|
||||
# the packed tarball goes into an empty directory, its dist/index.js is
|
||||
# started over stdio and headless Chromium opens the URL start_session
|
||||
# returns (packages/mcp-server/tests/e2e)
|
||||
- name: Run the MCP shell E2E tests on the packed tarball
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/mcp-pack" "$RUNNER_TEMP/mcp-install"
|
||||
npm pack ./packages/mcp-server --pack-destination "$RUNNER_TEMP/mcp-pack"
|
||||
npm --prefix "$RUNNER_TEMP/mcp-install" install "$RUNNER_TEMP"/mcp-pack/*.tgz
|
||||
MCP_SERVER_ENTRY="$RUNNER_TEMP/mcp-install/node_modules/@next-ai-drawio/mcp-server/dist/index.js" \
|
||||
npm --prefix packages/mcp-server run test:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
# The shell bundles the web app's canvas and the root's dependencies,
|
||||
# so a change under these paths (the ones publish-mcp.yml triggers on)
|
||||
# reaches users with the next MCP release, and publish-mcp.yml only
|
||||
# publishes a new version number
|
||||
- name: Remind to bump the MCP version when bundled code changed
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
changed=$(git diff --name-only HEAD^1 HEAD -- components contexts hooks lib stores app/globals.css package-lock.json scripts packages/mcp-server)
|
||||
base_version=$(git show HEAD^1:packages/mcp-server/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")
|
||||
head_version=$(node -p "require('./packages/mcp-server/package.json').version")
|
||||
if [ -n "$changed" ] && [ "$base_version" = "$head_version" ]; then
|
||||
echo "::warning::Code bundled into the MCP server changed without a version bump in packages/mcp-server/package.json (still ${head_version}). Bump it if the change should be published."
|
||||
fi
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-mcp-shell
|
||||
path: packages/mcp-server/test-results/
|
||||
retention-days: 7
|
||||
|
||||
e2e:
|
||||
# Six jobs at once, each with a sixth of the tests and one worker: the
|
||||
# same conditions per test, a fraction of the time
|
||||
|
||||
@@ -16,6 +16,8 @@ packages/*/dist
|
||||
/coverage
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
packages/*/playwright-report/
|
||||
packages/*/test-results/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -114,6 +114,8 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
Then ask the AI for "a flowchart of user authentication with login, MFA and session management" and the diagram appears in your browser as it is drawn. The MCP server has most of the web app's drawing features:
|
||||
|
||||
- The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more)
|
||||
- The preview is the same canvas as the web app: the AI's changes are outlined, one Ctrl+Z takes them back, and the AI can read the shapes you select
|
||||
- The draw.io editor ships inside the package, so after install the preview works offline (`DRAWIO_BASE_URL` points it at an external draw.io instead)
|
||||
- A screenshot tool, so the AI can look at the rendered diagram and fix it
|
||||
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg` or `.drawio.svg`
|
||||
- Auto-save to `~/.next-ai-drawio/`, so a diagram survives a restart
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next"
|
||||
import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
|
||||
import { notFound } from "next/navigation"
|
||||
import Script from "next/script"
|
||||
import { LocaleProvider } from "@/components/canvas/locale-context"
|
||||
import { AppToaster } from "@/components/workspace/app-toaster"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
@@ -184,7 +185,9 @@ export default async function RootLayout({
|
||||
className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
<DictionaryProvider dictionary={dictionary}>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
<LocaleProvider locale={validLang}>
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
</LocaleProvider>
|
||||
</DictionaryProvider>
|
||||
<AppToaster />
|
||||
</body>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"**",
|
||||
"!public",
|
||||
"!packages/mcp-server/src/preview",
|
||||
"!packages/mcp-server/shell/index.html",
|
||||
"!lib/model-catalog.json"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useParams } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { DrawioFrame } from "@/components/canvas/drawio-frame"
|
||||
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||
import { useLocale } from "@/components/canvas/locale-context"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { DRAWIO_CSS, getDrawioSrc } from "@/lib/drawio/drawio-config"
|
||||
import {
|
||||
DRAWIO_CSS,
|
||||
type DrawioSource,
|
||||
getDrawioSrc,
|
||||
webDrawioSource,
|
||||
} from "@/lib/drawio/drawio-config"
|
||||
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
@@ -13,28 +16,42 @@ import { cn } from "@/lib/utils"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
|
||||
/** Size of the canvas area, for overlays that stay inside it */
|
||||
export interface CanvasSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
|
||||
* the "ask AI" button laid over it (only when the page can drive draw.io
|
||||
* directly, not with an external cross-origin draw.io).
|
||||
* an overlay laid over it (the web app's "ask AI" button; only when the
|
||||
* page can drive draw.io directly, not with an external cross-origin
|
||||
* draw.io).
|
||||
*/
|
||||
export function CanvasStage({
|
||||
className,
|
||||
style,
|
||||
hideOverlays = false,
|
||||
overlay,
|
||||
drawioSource,
|
||||
waitForSavedLocale = false,
|
||||
}: {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
/** Hide the "ask AI" button (e.g. while the start screen covers the canvas) */
|
||||
/** Hide the overlay (e.g. while the start screen covers the canvas) */
|
||||
hideOverlays?: boolean
|
||||
/** Rendered over the canvas once draw.io is ready */
|
||||
overlay?: (size: CanvasSize) => React.ReactNode
|
||||
/** Where the editor comes from; none: the web app's own copy (the MCP
|
||||
* shell passes what its server configured) */
|
||||
drawioSource?: DrawioSource
|
||||
/** Web app: the page is about to switch to the language picked last
|
||||
* time (useSavedLocale in the workspace); do not load draw.io in the
|
||||
* URL's language meanwhile */
|
||||
waitForSavedLocale?: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const params = useParams<{ lang: string }>()
|
||||
const lang = (
|
||||
i18n.locales.includes(params.lang as Locale)
|
||||
? params.lang
|
||||
: i18n.defaultLocale
|
||||
) as Locale
|
||||
const lang = useLocale()
|
||||
const {
|
||||
drawioRef,
|
||||
handleDiagramAutoSave,
|
||||
@@ -43,7 +60,6 @@ export function CanvasStage({
|
||||
resetDrawioReady,
|
||||
isDrawioReady,
|
||||
} = useDiagram()
|
||||
const engine = useChatEngine()
|
||||
const isDark = useSettingsStore((s) => s.isDark)
|
||||
const hasEditor = useCanvasStore((s) => s.hasEditor)
|
||||
// Canvas width when the chat panel starts sliding in or out
|
||||
@@ -53,20 +69,24 @@ export function CanvasStage({
|
||||
// null until draw.io tells us whether we can drive it directly
|
||||
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||
const [size, setSize] = useState<CanvasSize>({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
// The page is about to switch to the language picked last time
|
||||
// (useSavedLocale): load draw.io once, in that language
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
||||
if (saved && saved !== lang && i18n.locales.includes(saved as Locale)) {
|
||||
return
|
||||
// Load draw.io once, in the language the page will have
|
||||
if (waitForSavedLocale) {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
||||
if (
|
||||
saved &&
|
||||
saved !== lang &&
|
||||
i18n.locales.includes(saved as Locale)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
||||
// The theme class is set before the page renders; the settings store
|
||||
// may not have read it yet
|
||||
const dark = document.documentElement.classList.contains("dark")
|
||||
setSrc(getDrawioSrc({ lang, isElectron, dark }))
|
||||
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), dark))
|
||||
}, [lang])
|
||||
|
||||
// A new iframe starts over: draw.io reports load and access again
|
||||
@@ -82,8 +102,7 @@ export function CanvasStage({
|
||||
if (editorAccess !== false || !src) return
|
||||
const startedDark = new URL(src).searchParams.get("dark") === "1"
|
||||
if (startedDark === isDark) return
|
||||
const isElectron = !!(window as { electronAPI?: unknown }).electronAPI
|
||||
setSrc(getDrawioSrc({ lang, isElectron, dark: isDark }))
|
||||
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), isDark))
|
||||
}, [isDark, editorAccess, lang, src])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -159,13 +178,7 @@ export function CanvasStage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOverlays && (
|
||||
<SelectionAsk
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
hidden={engine.isBusy}
|
||||
/>
|
||||
)}
|
||||
{showOverlays && overlay?.(size)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { VersionThumb } from "@/components/canvas/version-card"
|
||||
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,11 +13,8 @@ import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { describeChanges } from "@/lib/version-text"
|
||||
import { describeChangeSummary } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import { VersionThumb } from "./tool-activity"
|
||||
|
||||
function Sheet({
|
||||
label,
|
||||
@@ -40,12 +39,11 @@ function Sheet({
|
||||
export function CompareDialog() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.versions
|
||||
const engine = useChatEngine()
|
||||
const { versions, isBusy, restore } = useVersionsContext()
|
||||
const { getVersionSvg, chartXML } = useDiagram()
|
||||
const versionId = useUiStore((s) => s.compareVersionId)
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const closeCompare = useUiStore((s) => s.closeCompare)
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
|
||||
|
||||
const index = versions.findIndex((v) => v.id === versionId)
|
||||
@@ -82,7 +80,7 @@ export function CompareDialog() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [versionId, engine.isBusy])
|
||||
}, [versionId, isBusy])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -98,7 +96,7 @@ export function CompareDialog() {
|
||||
: same
|
||||
? t.isOnCanvas
|
||||
: changes
|
||||
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChanges(changes, t)}`
|
||||
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChangeSummary(changes, t)}`
|
||||
: formatMessage(t.differsElsewhere, {
|
||||
n: version.number,
|
||||
})}
|
||||
@@ -139,10 +137,10 @@ export function CompareDialog() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!version || same || engine.isBusy}
|
||||
disabled={!version || same || isBusy}
|
||||
onClick={() => {
|
||||
if (!version) return
|
||||
engine.restoreVersion(version.id)
|
||||
restore(version.id)
|
||||
closeCompare()
|
||||
}}
|
||||
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
import type { Locale } from "@/lib/i18n/config"
|
||||
|
||||
const LocaleContext = createContext<Locale | null>(null)
|
||||
|
||||
/** The page's language; the web layout feeds it from its route param */
|
||||
export function LocaleProvider({
|
||||
locale,
|
||||
children,
|
||||
}: {
|
||||
locale: Locale
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<LocaleContext.Provider value={locale}>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLocale(): Locale {
|
||||
const locale = useContext(LocaleContext)
|
||||
if (!locale) {
|
||||
throw new Error("useLocale must be used within a LocaleProvider")
|
||||
}
|
||||
return locale
|
||||
}
|
||||
@@ -10,25 +10,25 @@ const GAP = 10
|
||||
/**
|
||||
* "Ask AI to change this" next to the selected shapes. Clicking it moves
|
||||
* focus to the chat input, where the selection already shows as a chip.
|
||||
* Hidden while an answer is changing the canvas.
|
||||
*/
|
||||
export function SelectionAsk({
|
||||
width,
|
||||
height,
|
||||
hidden,
|
||||
}: {
|
||||
/** Size of the canvas area, for keeping the button inside it */
|
||||
width: number
|
||||
height: number
|
||||
hidden: boolean
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const rect = useCanvasStore((s) => s.selectionRect)
|
||||
const selection = useCanvasStore((s) => s.selection)
|
||||
const isFreehand = useCanvasStore((s) => s.isFreehand)
|
||||
const popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
|
||||
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||
const focusComposer = useUiStore((s) => s.focusComposer)
|
||||
|
||||
if (hidden || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
||||
if (isBusy || popupOpen || !rect || selection.length === 0 || isFreehand) {
|
||||
return null
|
||||
}
|
||||
// Selection scrolled out of view
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Redo2, RotateCcw, Undo2 } from "lucide-react"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import {
|
||||
useVersionsContext,
|
||||
type VersionItem,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
/** Picture of a version (SVG data URL), or what stands in for it */
|
||||
export function VersionThumb({
|
||||
svg,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
/** undefined or null: still being made; "": none could be made */
|
||||
svg: string | null | undefined
|
||||
dimmed?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const t = useDictionary().versions
|
||||
if (svg) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={svg}
|
||||
alt=""
|
||||
className={cn(
|
||||
"object-contain transition-opacity",
|
||||
dimmed && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (svg === "") {
|
||||
return <span className="text-xs text-faint">{t.noPreview}</span>
|
||||
}
|
||||
return (
|
||||
<Shimmer as="span" className="text-xs">
|
||||
{t.rendering}
|
||||
</Shimmer>
|
||||
)
|
||||
}
|
||||
|
||||
interface VersionCardProps extends React.ComponentProps<"div"> {
|
||||
version: VersionItem
|
||||
/** The newest version shows its picture; older ones fold into one row */
|
||||
isLatest: boolean
|
||||
/** What happened ("Drew the diagram"); an undone change says so instead */
|
||||
label: string
|
||||
/** Goes after the summary text (the chat adds its "Show XML" link) */
|
||||
afterSummary?: React.ReactNode
|
||||
/** Panel below the card (the chat shows the AI's XML there) */
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for one diagram version: picture, number, label, what changed, and
|
||||
* the action (undo or redo on the latest, restore on older ones). The
|
||||
* picture opens Compare. Other attributes go on the card's element.
|
||||
*/
|
||||
export function VersionCard({
|
||||
version,
|
||||
isLatest,
|
||||
label,
|
||||
afterSummary,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: VersionCardProps) {
|
||||
const t = useDictionary().versions
|
||||
const source = useVersionsContext()
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
|
||||
const isUndone = source.undoneId === version.id
|
||||
const isOnCanvas = source.onCanvasId === version.id
|
||||
const title = isUndone ? t.undone : label
|
||||
const summary = version.fromScratch
|
||||
? describeTotals(version.summary, t)
|
||||
: describeChangeSummary(version.summary, t)
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
|
||||
isOnCanvas
|
||||
? "bg-marker-soft text-marker-ink"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
v{version.number}
|
||||
</span>
|
||||
)
|
||||
const actionClass =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
const restoreButton = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={source.isBusy || isOnCanvas}
|
||||
onClick={() => source.restore(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-restore"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{formatMessage(t.restoreVersion, { n: version.number })}
|
||||
</button>
|
||||
)
|
||||
const summaryLine = (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary}
|
||||
{afterSummary}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isLatest) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
className,
|
||||
)}
|
||||
data-testid="version-card"
|
||||
{...rest}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
|
||||
aria-label={t.compare}
|
||||
title={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
className="max-h-8 max-w-[52px]"
|
||||
/>
|
||||
</button>
|
||||
{badge}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{summaryLine}
|
||||
</div>
|
||||
{restoreButton}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Undo and redo belong to the newest change while it is (or was) on
|
||||
// the canvas; after an older version was restored, this one is
|
||||
// restored too
|
||||
const canUndo = source.canUndo || source.canRedo
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
isOnCanvas && "border-foreground/15",
|
||||
className,
|
||||
)}
|
||||
data-testid="version-card"
|
||||
{...rest}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
||||
aria-label={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
dimmed={isUndone}
|
||||
className="max-h-20 max-w-[88%]"
|
||||
/>
|
||||
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
|
||||
{t.compare}
|
||||
</span>
|
||||
</button>
|
||||
{/* Title row with the action; the summary runs under both */}
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
||||
{badge}
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{canUndo ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={source.isBusy}
|
||||
onClick={() =>
|
||||
source.canRedo ? source.redo() : source.undo()
|
||||
}
|
||||
className={actionClass}
|
||||
data-testid="version-undo"
|
||||
>
|
||||
{source.canRedo ? (
|
||||
<Redo2 className="size-3.5" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
{source.canRedo ? t.redo : t.undo}
|
||||
</button>
|
||||
) : (
|
||||
restoreButton
|
||||
)}
|
||||
<div className="col-span-2 col-start-2 mt-0.5">
|
||||
{summaryLine}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useVersionsContext } from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
/** Row of version thumbnails under the panel header; click to compare */
|
||||
export function VersionStrip() {
|
||||
export function VersionStrip({
|
||||
minVersions = 2,
|
||||
}: {
|
||||
/** Hidden below this many versions: one version alone adds nothing to
|
||||
* its card, unless the caller knows there is no card for it */
|
||||
minVersions?: number
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||
const { versions, onCanvasId } = useVersionsContext()
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const engine = useChatEngine()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Keep the newest version in view
|
||||
@@ -20,24 +23,7 @@ export function VersionStrip() {
|
||||
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
|
||||
}, [versions.length])
|
||||
|
||||
// Versions with no card in the chat: saved by older app versions, from
|
||||
// a turn that was retried, or drawn by a call that then failed its
|
||||
// screenshot check (a call still running gets its card when done)
|
||||
const hasCardless = versions.some(
|
||||
(v) =>
|
||||
!v.toolCallId ||
|
||||
!engine.messages.some((m) =>
|
||||
m.parts?.some((p) => {
|
||||
const part = p as { toolCallId?: string; state?: string }
|
||||
return (
|
||||
part.toolCallId === v.toolCallId &&
|
||||
part.state !== "output-error"
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
// One version with a card: the strip adds nothing to it
|
||||
if (versions.length < 2 && !hasCardless) return null
|
||||
if (versions.length < minVersions) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
|
||||
@@ -49,13 +35,13 @@ export function VersionStrip() {
|
||||
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
|
||||
>
|
||||
{versions.map((version) => {
|
||||
const isCurrent = version.id === onCanvasVersionId
|
||||
const isCurrent = version.id === onCanvasId
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
title={`v${version.number}${version.id === onCanvasVersionId ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
||||
title={`v${version.number}${isCurrent ? ` · ${dict.versions.currentCanvas}` : ""}`}
|
||||
className={cn(
|
||||
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
|
||||
// An outline with a gap reads on the white
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
import type { ChangeSummary } from "@/lib/diagram-diff"
|
||||
|
||||
/** One saved state of the diagram, as the version card and the compare
|
||||
* dialog show it */
|
||||
export interface VersionItem {
|
||||
id: string
|
||||
/** Shown as v{number} */
|
||||
number: number
|
||||
/** Full multi-page document */
|
||||
xml: string
|
||||
/** The page the change was made on; none: the first page */
|
||||
pageId?: string
|
||||
/** Document before the change, when it is known */
|
||||
beforeXml?: string
|
||||
/** SVG data URL thumbnail; undefined: still being made; "": none could
|
||||
* be made */
|
||||
svg?: string
|
||||
summary: ChangeSummary
|
||||
/** Drawn on an empty canvas (the card shows totals, not changes) */
|
||||
fromScratch?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the versions come from and what can be done with them. The web app
|
||||
* fills it from the chat's version store; the MCP shell from the server's
|
||||
* history.
|
||||
*/
|
||||
export interface VersionsSource {
|
||||
versions: VersionItem[]
|
||||
/** The version the canvas shows now, or null (hand edits, undone) */
|
||||
onCanvasId: string | null
|
||||
/** The latest version, when its change was undone */
|
||||
undoneId: string | null
|
||||
/** Something is changing the canvas: version actions wait */
|
||||
isBusy: boolean
|
||||
/** The latest version's change can be undone */
|
||||
canUndo: boolean
|
||||
/** The latest version's change was undone and can be put back */
|
||||
canRedo: boolean
|
||||
restore: (id: string) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
}
|
||||
|
||||
const VersionsContext = createContext<VersionsSource | null>(null)
|
||||
|
||||
export function VersionsProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: VersionsSource
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<VersionsContext.Provider value={value}>
|
||||
{children}
|
||||
</VersionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useVersionsContext(): VersionsSource {
|
||||
const source = useContext(VersionsContext)
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"useVersionsContext must be used within a VersionsProvider",
|
||||
)
|
||||
}
|
||||
return source
|
||||
}
|
||||
@@ -726,6 +726,11 @@ export function ChatEngineProvider({
|
||||
// isBusy of the latest render, for code that runs after an await
|
||||
const busyRef = useRef(isBusy)
|
||||
busyRef.current = isBusy
|
||||
// Canvas components (version cards, the compare dialog, "ask AI") read
|
||||
// the busy flag from the canvas store
|
||||
useEffect(() => {
|
||||
useCanvasStore.getState().set({ isBusy })
|
||||
}, [isBusy])
|
||||
// The page goes (another language mounts a new one): the answer stops,
|
||||
// so it cannot reach the next page's canvas
|
||||
const stopRef = useRef(stop)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { useRef } from "react"
|
||||
import { VersionStrip } from "@/components/canvas/version-strip"
|
||||
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
|
||||
import { BrandMark } from "@/components/workspace/brand-mark"
|
||||
import { IconButton } from "@/components/workspace/icon-button"
|
||||
@@ -21,11 +22,11 @@ import {
|
||||
useSettingsStore,
|
||||
} from "@/stores/settings-store"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import { Composer } from "./composer"
|
||||
import { MessageList } from "./message-list"
|
||||
import { SessionMenu, useSessionTitle } from "./session-menu"
|
||||
import { VersionStrip } from "./version-strip"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
@@ -97,6 +98,25 @@ export function ChatPanel({
|
||||
const openSettings = useUiStore((s) => s.openSettings)
|
||||
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
|
||||
|
||||
// Versions with no card in the chat: saved by older app versions, from
|
||||
// a turn that was retried, or drawn by a call that then failed its
|
||||
// screenshot check (a call still running gets its card when done). The
|
||||
// strip shows them even when there is only one.
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const hasCardless = versions.some(
|
||||
(v) =>
|
||||
!v.toolCallId ||
|
||||
!engine.messages.some((m) =>
|
||||
m.parts?.some((p) => {
|
||||
const part = p as { toolCallId?: string; state?: string }
|
||||
return (
|
||||
part.toolCallId === v.toolCallId &&
|
||||
part.state !== "output-error"
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -160,7 +180,7 @@ export function ChatPanel({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<VersionStrip />
|
||||
<VersionStrip minVersions={hasCardless ? 1 : 2} />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
VersionsProvider,
|
||||
type VersionsSource,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
|
||||
/**
|
||||
* The chat's versions for the canvas components (version cards, the strip,
|
||||
* the compare dialog): the version store's list and flags, and the chat
|
||||
* engine's restore and undo
|
||||
*/
|
||||
export function ChatVersionsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { restoreVersion, undoVersion } = useChatEngine()
|
||||
const versions = useVersionsStore((s) => s.versions)
|
||||
const onCanvasId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||
const undoneId = useVersionsStore((s) => s.undoneVersionId)
|
||||
const isBusy = useCanvasStore((s) => s.isBusy)
|
||||
|
||||
// Only the latest version can be undone, and only while it is (or was)
|
||||
// on the canvas: after an older version was restored, the latest one
|
||||
// is restored too
|
||||
const latest = versions.at(-1)
|
||||
const undoable = !!latest && latest.beforeXml !== undefined
|
||||
const value: VersionsSource = {
|
||||
versions,
|
||||
onCanvasId,
|
||||
undoneId,
|
||||
isBusy,
|
||||
canUndo: undoable && latest.id === onCanvasId,
|
||||
canRedo: undoable && latest.id === undoneId,
|
||||
restore: restoreVersion,
|
||||
// The engine's undo toggles: it redoes a change that is undone
|
||||
undo: () => latest && undoVersion(latest.id),
|
||||
redo: () => latest && undoVersion(latest.id),
|
||||
}
|
||||
|
||||
return <VersionsProvider value={value}>{children}</VersionsProvider>
|
||||
}
|
||||
@@ -5,19 +5,14 @@ import {
|
||||
Copy,
|
||||
Library,
|
||||
PenTool,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import { VersionCard as VersionCardView } from "@/components/canvas/version-card"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { useCopy } from "@/lib/clipboard"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn, isMxCellXmlComplete } from "@/lib/utils"
|
||||
import { describeChanges, describeTotals } from "@/lib/version-text"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
@@ -83,45 +78,9 @@ function toolText(part: ToolPartLike): string {
|
||||
return JSON.stringify(input, null, 2)
|
||||
}
|
||||
|
||||
/** Picture of a version (SVG data URL), or what stands in for it */
|
||||
export function VersionThumb({
|
||||
svg,
|
||||
dimmed,
|
||||
className,
|
||||
}: {
|
||||
/** undefined or null: still being made; "": none could be made */
|
||||
svg: string | null | undefined
|
||||
dimmed?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const t = useDictionary().versions
|
||||
if (svg) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: data URL thumbnail
|
||||
<img
|
||||
src={svg}
|
||||
alt=""
|
||||
className={cn(
|
||||
"object-contain transition-opacity",
|
||||
dimmed && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (svg === "") {
|
||||
return <span className="text-xs text-faint">{t.noPreview}</span>
|
||||
}
|
||||
return (
|
||||
<Shimmer as="span" className="text-xs">
|
||||
{t.rendering}
|
||||
</Shimmer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for one diagram version produced by a tool call. The latest shows
|
||||
* its picture; older ones fold into one row (the picture opens Compare).
|
||||
* Card for one diagram version produced by a tool call: the shared card
|
||||
* with a link to what the AI wrote (shown in a panel under the card)
|
||||
*/
|
||||
function VersionCard({
|
||||
part,
|
||||
@@ -134,175 +93,49 @@ function VersionCard({
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.versions
|
||||
const engine = useChatEngine()
|
||||
const undoneVersionId = useVersionsStore((s) => s.undoneVersionId)
|
||||
const onCanvasVersionId = useVersionsStore((s) => s.onCanvasVersionId)
|
||||
const openCompare = useUiStore((s) => s.openCompare)
|
||||
const [showCode, setShowCode] = useState(false)
|
||||
const { copied, copy } = useCopy()
|
||||
|
||||
const isUndone = undoneVersionId === version.id
|
||||
const isOnCanvas = onCanvasVersionId === version.id
|
||||
const isEdit = part.type === "tool-edit_diagram"
|
||||
// Undo/redo belongs to the newest change while it is (or was) on the
|
||||
// canvas; after an older version was restored, this one is restored too
|
||||
const canUndo =
|
||||
isLatest && version.beforeXml !== undefined && (isOnCanvas || isUndone)
|
||||
const title = isUndone ? t.undone : isEdit ? t.edited : t.created
|
||||
const summary = version.fromScratch
|
||||
? describeTotals(version.summary, t)
|
||||
: describeChanges(version.summary, t)
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
|
||||
isOnCanvas
|
||||
? "bg-marker-soft text-marker-ink"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
v{version.number}
|
||||
</span>
|
||||
)
|
||||
const actionClass =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
const restoreButton = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={engine.isBusy || isOnCanvas}
|
||||
onClick={() => engine.restoreVersion(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-restore"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{formatMessage(t.restoreVersion, { n: version.number })}
|
||||
</button>
|
||||
)
|
||||
|
||||
// What the AI wrote: a text link in the summary line, so the card's
|
||||
// only button is the action (undo or restore)
|
||||
const summaryLine = (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary}
|
||||
{/* The link wraps as one unit with its separator */}
|
||||
<span className="whitespace-nowrap">
|
||||
<span aria-hidden> · </span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCode((v) => !v)}
|
||||
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 && (
|
||||
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(toolText(part))}
|
||||
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
|
||||
aria-label={dict.chat.copyResponse}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<ToolInputDetails part={part} />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isLatest) {
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-xl border border-border bg-card"
|
||||
data-testid="version-card"
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-1.5 pr-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
|
||||
aria-label={t.compare}
|
||||
title={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
className="max-h-8 max-w-[52px]"
|
||||
/>
|
||||
</button>
|
||||
{badge}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{summaryLine}
|
||||
</div>
|
||||
{restoreButton}
|
||||
</div>
|
||||
{codePanel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-border bg-card",
|
||||
isOnCanvas && "border-foreground/15",
|
||||
)}
|
||||
data-testid="version-card"
|
||||
<VersionCardView
|
||||
version={version}
|
||||
isLatest={isLatest}
|
||||
label={isEdit ? t.edited : t.created}
|
||||
data-tool-state={part.state}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCompare(version.id)}
|
||||
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
|
||||
aria-label={t.compare}
|
||||
>
|
||||
<VersionThumb
|
||||
svg={version.svg}
|
||||
dimmed={isUndone}
|
||||
className="max-h-20 max-w-[88%]"
|
||||
/>
|
||||
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
|
||||
{t.compare}
|
||||
</span>
|
||||
</button>
|
||||
{/* Title row with the action; the summary runs under both */}
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
|
||||
{badge}
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
{canUndo ? (
|
||||
// A text link, so the card's only button is the action (undo
|
||||
// or restore); it wraps as one unit with its separator
|
||||
afterSummary={
|
||||
<span className="whitespace-nowrap">
|
||||
<span aria-hidden> · </span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={engine.isBusy}
|
||||
onClick={() => engine.undoVersion(version.id)}
|
||||
className={actionClass}
|
||||
data-testid="version-undo"
|
||||
onClick={() => setShowCode((v) => !v)}
|
||||
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
|
||||
>
|
||||
{isUndone ? (
|
||||
<Redo2 className="size-3.5" />
|
||||
) : (
|
||||
<Undo2 className="size-3.5" />
|
||||
)}
|
||||
{isUndone ? t.redo : t.undo}
|
||||
{showCode ? t.hideCode : t.showCode}
|
||||
</button>
|
||||
) : (
|
||||
restoreButton
|
||||
)}
|
||||
<div className="col-span-2 col-start-2 mt-0.5">
|
||||
{summaryLine}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{showCode && (
|
||||
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(toolText(part))}
|
||||
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
|
||||
aria-label={dict.chat.copyResponse}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<ToolInputDetails part={part} />
|
||||
</div>
|
||||
</div>
|
||||
{codePanel}
|
||||
</div>
|
||||
)}
|
||||
</VersionCardView>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import { toast } from "sonner"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
|
||||
import { clearHighlights, highlightCells } from "@/lib/drawio/editor-bridge"
|
||||
import {
|
||||
clearHighlights,
|
||||
highlightChangedCells,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
|
||||
import { useVersionsStore } from "@/stores/versions-store"
|
||||
@@ -63,14 +66,7 @@ export function useVersions({
|
||||
fromScratch,
|
||||
})
|
||||
// Mark what changed, unless the whole diagram is new
|
||||
if (!fromScratch) {
|
||||
setTimeout(() => {
|
||||
const marker = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--marker")
|
||||
.trim()
|
||||
highlightCells(touchedIds, marker || "#ffd84d")
|
||||
}, 60)
|
||||
}
|
||||
if (!fromScratch) highlightChangedCells(touchedIds)
|
||||
// The picture is of the canvas: take it right away, before a
|
||||
// newer version can be on it; once one is, this one gets none
|
||||
// rather than the newer one's
|
||||
|
||||
@@ -4,12 +4,14 @@ import { MessageSquare, PanelRightOpen, Settings, Shapes } from "lucide-react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
||||
import { CompareDialog } from "@/components/canvas/compare-dialog"
|
||||
import { SelectionAsk } from "@/components/canvas/selection-ask"
|
||||
import {
|
||||
ChatEngineProvider,
|
||||
useChatEngine,
|
||||
} from "@/components/chat/chat-engine"
|
||||
import { ChatPanel } from "@/components/chat/chat-panel"
|
||||
import { CompareDialog } from "@/components/chat/compare-dialog"
|
||||
import { ChatVersionsProvider } from "@/components/chat/chat-versions"
|
||||
import { LobbyHero } from "@/components/chat/lobby-hero"
|
||||
import { SaveDialog } from "@/components/save-dialog"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
@@ -116,7 +118,9 @@ export function Workspace() {
|
||||
|
||||
return (
|
||||
<ChatEngineProvider>
|
||||
<WorkspaceLayout />
|
||||
<ChatVersionsProvider>
|
||||
<WorkspaceLayout />
|
||||
</ChatVersionsProvider>
|
||||
</ChatEngineProvider>
|
||||
)
|
||||
}
|
||||
@@ -267,6 +271,13 @@ function WorkspaceLayout() {
|
||||
}
|
||||
style={isMobile ? undefined : { right: canvasRight }}
|
||||
hideOverlays={!isMobile && showHero}
|
||||
overlay={(size) => (
|
||||
<SelectionAsk
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
/>
|
||||
)}
|
||||
waitForSavedLocale
|
||||
/>
|
||||
{isMobile ? (
|
||||
mobileView === "chat" &&
|
||||
|
||||
@@ -60,6 +60,20 @@ export interface DiagramCommit {
|
||||
pageId?: string | null
|
||||
}
|
||||
|
||||
/** An export as draw.io's export action takes it: the format, and for PNG
|
||||
* its scale, width cap and the page to render (pageId) */
|
||||
export interface ExportRequest {
|
||||
format: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** What draw.io answered: the image (a data URL, or SVG text) in data; for
|
||||
* format "xml" the document in xml instead */
|
||||
export interface ExportResult {
|
||||
data?: string
|
||||
xml?: string
|
||||
}
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string
|
||||
// chartXML right away, before the re-render (loadDiagram sets both)
|
||||
@@ -91,6 +105,19 @@ interface DiagramContextType {
|
||||
getThumbnailSvg: () => Promise<string | null>
|
||||
getVersionSvg: () => Promise<string | null>
|
||||
captureValidationPng: () => Promise<string | null>
|
||||
/** One export with its own result; null when draw.io does not answer
|
||||
* in time (the MCP shell answers the server's export requests with it) */
|
||||
requestExport: (
|
||||
request: ExportRequest,
|
||||
timeoutMs?: number,
|
||||
) => Promise<ExportResult | null>
|
||||
/** Show a document for an export only (the MCP's one-page projection of
|
||||
* another page): not recorded as the diagram, its autosaves ignored,
|
||||
* until the next loadDiagram puts the real document back */
|
||||
showTransient: (xml: string) => void
|
||||
/** A full load was sent and draw.io has not reported it yet: the
|
||||
* editor still shows the previous document */
|
||||
hasPendingLoad: () => boolean
|
||||
isDrawioReady: boolean
|
||||
onDrawioLoad: () => void
|
||||
resetDrawioReady: () => void
|
||||
@@ -141,13 +168,19 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// The page to show again once a full load is done: draw.io shows the
|
||||
// first page of a loaded document
|
||||
const pageAfterLoadRef = useRef<string | null>(null)
|
||||
// A transient document (showTransient) is on the canvas, and the page
|
||||
// the user was on before it
|
||||
const transientRef = useRef(false)
|
||||
const pageBeforeTransientRef = useRef<string | null>(null)
|
||||
|
||||
// Sends a full load. draw.io runs it when its message arrives, and then
|
||||
// reports "load": until then the editor shows the diagram from before,
|
||||
// so later changes go the same way and keep their order
|
||||
const fullLoad = (xml: string) => {
|
||||
const fullLoad = (
|
||||
xml: string,
|
||||
pageId: string | null = useCanvasStore.getState().currentPageId,
|
||||
) => {
|
||||
if (!drawioRef.current) return
|
||||
const pageId = useCanvasStore.getState().currentPageId
|
||||
pageAfterLoadRef.current =
|
||||
pageId && xml.includes(`id="${pageId}"`) ? pageId : null
|
||||
pendingLoadsRef.current++
|
||||
@@ -252,6 +285,38 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
||||
}
|
||||
|
||||
// Like requestTaggedExport, with the request's other parameters (a PNG's
|
||||
// page and width) and both parts of the answer
|
||||
const requestExport = useCallback(
|
||||
(request: ExportRequest, timeoutMs = 10000) =>
|
||||
new Promise<ExportResult | null>((resolve) => {
|
||||
if (!drawioRef.current) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const id = `export-${++exportSeqRef.current}`
|
||||
const finish = (value: ExportResult | null) => {
|
||||
clearTimeout(timer)
|
||||
delete exportResolversRef.current[id]
|
||||
resolve(value)
|
||||
}
|
||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||
exportResolversRef.current[id] = (data, xml) =>
|
||||
finish({ data, xml })
|
||||
drawioRef.current.exportDiagram({ ...request, message: id })
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const showTransient = (xml: string) => {
|
||||
if (!transientRef.current) {
|
||||
pageBeforeTransientRef.current =
|
||||
useCanvasStore.getState().currentPageId
|
||||
}
|
||||
transientRef.current = true
|
||||
fullLoad(xml)
|
||||
}
|
||||
|
||||
const loadDiagram = (
|
||||
chart: string,
|
||||
skipValidation?: boolean,
|
||||
@@ -261,6 +326,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// The editor bridge is shared: a page that is gone (another language
|
||||
// mounted a new one) must not change the new page's canvas
|
||||
if (!mountedRef.current) return null
|
||||
// A real document ends a transient one; the user goes back to the
|
||||
// page they were on before it
|
||||
const pageBeforeTransient = transientRef.current
|
||||
? pageBeforeTransientRef.current
|
||||
: undefined
|
||||
transientRef.current = false
|
||||
let xmlToLoad = chart
|
||||
|
||||
// Validate XML structure before loading (unless skipped for internal
|
||||
@@ -315,7 +386,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!applied) {
|
||||
// A full load replaces any preview, and its base is stale now
|
||||
resetPreview()
|
||||
fullLoad(xmlToLoad)
|
||||
fullLoad(xmlToLoad, pageBeforeTransient)
|
||||
}
|
||||
|
||||
if (mode === "commit") {
|
||||
@@ -336,10 +407,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: DrawioExportEvent) => {
|
||||
// Thumbnail, version, validation PNG and file save exports go only
|
||||
// to their own caller
|
||||
// Thumbnail, version, validation PNG, file save and requestExport
|
||||
// exports go only to their own caller
|
||||
const tag = data.message?.message
|
||||
if (/^(thumbnail|validation|version|save)-/.test(tag ?? "")) {
|
||||
if (/^(thumbnail|validation|version|save|export)-/.test(tag ?? "")) {
|
||||
exportResolversRef.current[tag as string]?.(data.data, data.xml)
|
||||
return
|
||||
}
|
||||
@@ -369,6 +440,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// read refs: state captured in its closure would stay stale after a remount
|
||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||
if (!data?.xml) return
|
||||
// An edit of a transient document is not the diagram's; nor is one
|
||||
// of the canvas a full load still on its way replaces (draw.io
|
||||
// reports each load, in order, so no autosave of the new document
|
||||
// comes before that)
|
||||
if (transientRef.current || pendingLoadsRef.current > 0) return
|
||||
// Don't overwrite a pending restore - if we have a diagram but
|
||||
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||
if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
|
||||
@@ -522,6 +598,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
getThumbnailSvg,
|
||||
getVersionSvg,
|
||||
captureValidationPng,
|
||||
requestExport,
|
||||
showTransient,
|
||||
hasPendingLoad: () => pendingLoadsRef.current > 0,
|
||||
isDrawioReady,
|
||||
onDrawioLoad,
|
||||
resetDrawioReady,
|
||||
|
||||
@@ -114,6 +114,8 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
然后对 AI 说"画一个用户认证流程图,包含登录、MFA 和会话管理",图会实时出现在浏览器里。MCP 服务器包含网页版的大部分画图能力:
|
||||
|
||||
- 同一套画图规则和图标库(AWS、Azure、GCP、Kubernetes 等)
|
||||
- 预览页就是网页版的画布:AI 的改动会被标出,按一次 Ctrl+Z 就能撤销,AI 还能读到你选中的图形
|
||||
- draw.io 编辑器随包一起安装,装好后预览不需要联网(也可以用 `DRAWIO_BASE_URL` 改用外部的 draw.io)
|
||||
- 截图工具,AI 可以看一眼画好的图并自行修正
|
||||
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
|
||||
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
|
||||
|
||||
@@ -14,7 +14,7 @@ Next AI Draw.io 自带一份 draw.io。`npm run build`(包括 Docker 构建)
|
||||
|
||||
构建会从 [draw.io 发布页](https://github.com/jgraph/drawio/releases) 下载 `draw.war`(约 50 MB)。如果构建机器访问不了 GitHub:
|
||||
|
||||
- 在另一台机器上下载 `scripts/fetch-drawio.mjs` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
|
||||
- 在另一台机器上下载 `packages/mcp-server/src/drawio-version.json` 里写明的那个版本的 `draw.war`,解压到 `public/drawio`(删掉 `WEB-INF` 和 `META-INF`),再把版本号(例如 `v32.0.2`)写进 `public/drawio/.version`。构建时就会使用这份副本。
|
||||
- 或者在能联网的机器上构建镜像,再拷到离线网络里。
|
||||
|
||||
## 来自其他网站的图片
|
||||
|
||||
@@ -14,7 +14,7 @@ No separate draw.io container is needed.
|
||||
|
||||
The build downloads `draw.war` (about 50 MB) from the [draw.io releases](https://github.com/jgraph/drawio/releases). If the build machine cannot reach GitHub:
|
||||
|
||||
- On another machine, download `draw.war` for the version in `scripts/fetch-drawio.mjs`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
|
||||
- On another machine, download `draw.war` for the version in `packages/mcp-server/src/drawio-version.json`, unzip it into `public/drawio` (delete `WEB-INF` and `META-INF`), and write the version, for example `v32.0.2`, into `public/drawio/.version`. The build then uses this copy.
|
||||
- Or build the image on a machine with internet access and transfer it to the offline network.
|
||||
|
||||
## Images From Other Websites
|
||||
|
||||
@@ -114,6 +114,8 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
あとは AI に「ログイン、MFA、セッション管理を含むユーザー認証のフローチャートを描いて」と頼めば、描かれていく様子がブラウザに表示されます。MCP サーバーには Web アプリの作図機能のほとんどが入っています。
|
||||
|
||||
- 同じ作図ルールとアイコンライブラリ(AWS、Azure、GCP、Kubernetes など)
|
||||
- プレビューは Web アプリと同じキャンバス。AI の変更には印が付き、Ctrl+Z 一回で元に戻せ、選択中の図形を AI が読み取れる
|
||||
- draw.io エディターはパッケージに同梱されているので、インストール後はオフラインでもプレビューが動く(外部の draw.io を使うなら `DRAWIO_BASE_URL` を設定する)
|
||||
- スクリーンショットツール。AI が描いた結果を確認して自分で直せる
|
||||
- バージョン履歴、複数ページのダイアグラム、`.drawio`、`.png`、`.svg`、`.drawio.svg` でのダウンロード
|
||||
- `~/.next-ai-drawio/` への自動保存。再起動後も続きから描ける
|
||||
|
||||
@@ -14,7 +14,7 @@ draw.io 用の別コンテナは不要です。
|
||||
|
||||
ビルドは [draw.io のリリース](https://github.com/jgraph/drawio/releases) から `draw.war`(約 50 MB)をダウンロードします。ビルド環境から GitHub にアクセスできない場合は、次のどちらかを行ってください。
|
||||
|
||||
- 別のマシンで `scripts/fetch-drawio.mjs` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
|
||||
- 別のマシンで `packages/mcp-server/src/drawio-version.json` に書かれたバージョンの `draw.war` をダウンロードし、`public/drawio` に展開します(`WEB-INF` と `META-INF` は削除)。さらにバージョン(例:`v32.0.2`)を `public/drawio/.version` に書き込みます。ビルドはこのコピーを使います。
|
||||
- インターネットに接続できるマシンでイメージをビルドし、オフライン環境に転送します。
|
||||
|
||||
## ほかのサイトの画像
|
||||
|
||||
+7
-1
@@ -285,7 +285,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
|
||||
|
||||
@@ -699,5 +699,20 @@
|
||||
"recent": "Recent diagrams",
|
||||
"drawYourself": "Start with a blank canvas",
|
||||
"openFileHint": "Keep working on a diagram you have"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,5 +699,20 @@
|
||||
"recent": "最近の図",
|
||||
"drawYourself": "白紙のキャンバスから始める",
|
||||
"openFileHint": "手元の図の続きを編集"
|
||||
},
|
||||
"shell": {
|
||||
"waiting": "MCP サーバーを待っています…",
|
||||
"toggleTheme": "ライトとダークを切り替え",
|
||||
"connected": "MCP サーバーに接続しました",
|
||||
"offline": "MCP サーバーに接続できません",
|
||||
"tooLarge": "この図は大きすぎて MCP サーバーに保存できません(10 MB 超)。",
|
||||
"unreachable": "MCP サーバーに接続できません。変更は今のところこのタブにだけあります。",
|
||||
"restoredFromFile": "図は保存済みファイルから復元されました。このタブに表示されていた内容は履歴にあります。",
|
||||
"aiChanged": "編集中に AI が図を変更しました。最後の変更は履歴に保存されました。",
|
||||
"historyChanged": "サーバー上の履歴が変わりました。もう一度開いてください。",
|
||||
"restoreFailed": "復元に失敗しました。もう一度お試しください。",
|
||||
"userEdit": "手で変更しました",
|
||||
"showVersions": "バージョンを表示",
|
||||
"hideVersions": "バージョンを隠す"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,5 +699,20 @@
|
||||
"recent": "最近的圖紙",
|
||||
"drawYourself": "從空白畫布開始",
|
||||
"openFileHint": "接著編輯已有的圖"
|
||||
},
|
||||
"shell": {
|
||||
"waiting": "正在等待 MCP 伺服器…",
|
||||
"toggleTheme": "切換淺色或深色",
|
||||
"connected": "已連接 MCP 伺服器",
|
||||
"offline": "無法連接 MCP 伺服器",
|
||||
"tooLarge": "這張圖太大,無法儲存到 MCP 伺服器(超過 10 MB)。",
|
||||
"unreachable": "無法連接 MCP 伺服器。您的修改暫時只保存在這個分頁裡。",
|
||||
"restoredFromFile": "圖表已從儲存的檔案還原。這個分頁之前顯示的內容已放入歷史記錄。",
|
||||
"aiChanged": "您編輯時 AI 修改了圖表。您的最後一次修改已儲存到歷史記錄。",
|
||||
"historyChanged": "伺服器上的歷史記錄已變化,請重新開啟。",
|
||||
"restoreFailed": "還原失敗,請再試一次。",
|
||||
"userEdit": "手動修改",
|
||||
"showVersions": "顯示版本",
|
||||
"hideVersions": "隱藏版本"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,5 +699,20 @@
|
||||
"recent": "最近的图纸",
|
||||
"drawYourself": "从空白画布开始",
|
||||
"openFileHint": "接着编辑已有的图"
|
||||
},
|
||||
"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 {
|
||||
|
||||
Generated
+1
@@ -100,6 +100,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"shx": "^0.4.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"shx": "^0.4.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
|
||||
@@ -24,17 +24,18 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
|
||||
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them. The preview is the web app's canvas: a change Claude makes to the page on screen is outlined, and one Ctrl+Z takes it back
|
||||
- **Selection**: Select shapes in the preview and say "move these"; Claude reads what you selected with `get_selection`
|
||||
- **Drawing Rules and Shape Libraries**: Claude gets the web app's layout and style rules and the icon docs for AWS, Azure, GCP, Kubernetes and more
|
||||
- **Self-check**: Claude can take a screenshot of the rendered diagram and fix what looks wrong
|
||||
- **Draw from Your Files**: ask Claude to draw from a document, image or web page; it reads the source with its own tools and draws. Existing .drawio files open with load_diagram
|
||||
- **Version History**: Restore one of the last 20 versions from the **History** button, shown as thumbnails
|
||||
- **Version History**: The last 20 versions appear as cards next to the canvas, with thumbnails; restore one, undo the latest, or compare a version with the canvas
|
||||
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
|
||||
- **Edit Support**: Modify existing diagrams with natural language instructions, including your own edits in the browser
|
||||
- **Export**: Save diagrams as `.drawio`, `.png`, `.svg`, or `.drawio.svg` files
|
||||
- **Auto-save**: Each diagram is saved to `~/.next-ai-drawio/`, so `claude --resume` can pick it up again
|
||||
- **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call
|
||||
- **Self-contained**: Embedded server, no external dependencies required
|
||||
- **Self-contained**: Embedded server and the draw.io editor in one package; the preview works offline after install
|
||||
|
||||
## Use Case Examples
|
||||
|
||||
@@ -97,6 +98,7 @@ Recreate the whiteboard photo at ~/Desktop/sketch.jpg as a clean draw.io diagram
|
||||
| `load_diagram` | Load a `.drawio` or `.drawio.svg` file from disk |
|
||||
| `edit_diagram` | Edit diagram by ID-based operations; all or nothing |
|
||||
| `get_diagram` | Get the current diagram XML |
|
||||
| `get_selection` | Return the shapes and edges you selected in the preview, so Claude can act on "these" |
|
||||
| `screenshot_diagram` | Return a PNG of a page so Claude can check the result |
|
||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
|
||||
| `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams |
|
||||
@@ -118,11 +120,12 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
|
||||
| `DRAWIO_BASE_URL` | unset (the bundled draw.io) | An external draw.io, such as a self-hosted instance. With it Claude's changes are not outlined on the canvas, Ctrl+Z does not take them back and `get_selection` cannot read the selection |
|
||||
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams and your `instructions.md`; `off` turns auto-save off (`instructions.md` is then read from the default folder) |
|
||||
| `DRAWIO_LANG` | unset | Language of the draw.io editor, such as `en`, `zh`, `zh-tw`, `ja` or `de`. Unset, draw.io chooses (the browser language on `embed.diagrams.net`, English on a self-hosted draw.io) and the user can change it under **Extras > Language** |
|
||||
| `DRAWIO_UI` | unset | draw.io theme: `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple`. Unset, the user picks one under **Extras > Theme** |
|
||||
| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light |
|
||||
| `DRAWIO_DARK` | `auto` | Dark mode of the preview and the editor until the user switches it with the button at the top right: `auto` follows the system, `1` starts dark, `0` starts light |
|
||||
| `DRAWIO_PREVIEW_UI` | `shell` | The preview page; `classic` opens the previous preview page for one more release |
|
||||
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result (costs 2 to 10 s per call) |
|
||||
| `DEBUG` | unset | Set to `true` to log debug messages |
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
MCP (Model Context Protocol) server that enables AI agents like Claude Desktop and Cursor to generate and edit draw.io diagrams with **real-time browser preview**.
|
||||
|
||||
**Self-contained** - includes an embedded HTTP server, no external dependencies required.
|
||||
**Self-contained**: the embedded HTTP server and the draw.io editor are in the package, so after install the preview works offline.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -105,19 +105,20 @@ Use the standard MCP configuration with:
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
|
||||
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them. The preview is the web app's canvas: a change the AI makes to the page on screen is outlined, and one Ctrl+Z takes it back
|
||||
- **Selection**: Select shapes in the preview and say "move these" or "connect this box to that one"; the AI reads what you selected with `get_selection`
|
||||
- **Drawing Rules**: The AI gets the same layout, edge and style rules as the web app, plus the shape library docs (AWS, Azure, GCP, Kubernetes, Cisco and more), so it uses real icon names instead of guessing
|
||||
- **Self-check**: The AI can take a screenshot of the rendered diagram (`screenshot: true` on `create_new_diagram` or `edit_diagram`, or `screenshot_diagram`) and fix overlapping shapes or edges that cross shapes
|
||||
- **Draw from Your Files**: ask the AI to draw from a document, image or web page; it reads the source with the host's own tools and draws. Existing .drawio files open with load_diagram
|
||||
- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML
|
||||
- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History
|
||||
- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails, or ask the AI to undo (`restore_version`)
|
||||
- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io and `load_diagram` can open again), from the **Download** button or through `export_diagram`
|
||||
- **Version History**: The last 20 versions appear as cards next to the canvas, with a thumbnail and what changed. Restore one, undo and redo the latest, or compare a version with the canvas; or ask the AI to undo (`restore_version`)
|
||||
- **Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io and `load_diagram` can open again) through `export_diagram`, or from the download button in the preview's header
|
||||
- **Multi-page**: List, add, rename, and delete pages, and edit any page
|
||||
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, and its last 20 versions to `<session-id>.history.json`, so the diagram, History and undo survive a restart of the MCP client
|
||||
- **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call
|
||||
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode, or fix them with `DRAWIO_UI`, `DRAWIO_DARK` and `DRAWIO_LANG`
|
||||
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
|
||||
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the button at the top right switches the page and the editor between light and dark (the system setting until you choose). `DRAWIO_UI`, `DRAWIO_DARK` and `DRAWIO_LANG` fix them
|
||||
- **Self-contained**: The draw.io editor ships inside the package and is served from the preview's own origin, so after install the preview works offline. `DRAWIO_BASE_URL` points it at another draw.io instead
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -131,6 +132,7 @@ Use the standard MCP configuration with:
|
||||
| `load_diagram` | Load a `.drawio` or `.drawio.svg` file into the session, from a `path` on disk or from its `xml` content (handles compressed files) |
|
||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
|
||||
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
|
||||
| `get_selection` | Return the shapes and edges you selected in the preview (ids, labels, positions), so the AI can act on "these" |
|
||||
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
|
||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
|
||||
| `list_pages` | List every page (tab) with id, name, index, and cell count |
|
||||
@@ -145,7 +147,7 @@ After every change, the diagram is saved as a normal `.drawio` file in `~/.next-
|
||||
|
||||
To continue a diagram in a later conversation (for example after `claude --resume`, or in a new chat), the AI calls `start_session` with `session_id` set to that id: the same preview URL opens, the saved diagram is shown, and auto-save keeps writing to the same file. If the id is no longer in the conversation, `list_saved_diagrams` returns every saved diagram, newest first, with the names and cell counts of its pages, so you can ask for "the architecture diagram from yesterday" and let the AI pick it. `load_diagram` with the file path still works too, and you can open the file in draw.io yourself.
|
||||
|
||||
History comes back with the diagram: the last 20 versions are saved next to it in `<session-id>.history.json` (without thumbnails). When the AI continues the session with `start_session` and its `session_id`, or a preview tab is still open after a restart, the **History** button shows them again and `restore_version` can undo to them.
|
||||
History comes back with the diagram: the last 20 versions are saved next to it in `<session-id>.history.json` (without thumbnails). When the AI continues the session with `start_session` and its `session_id`, or a preview tab is still open after a restart, the version cards show them again and `restore_version` can undo to them.
|
||||
|
||||
The newest 50 files are kept, each with its History file. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off.
|
||||
|
||||
@@ -175,20 +177,24 @@ To give the AI your own drawing rules, write them in `~/.next-ai-drawio/instruct
|
||||
```
|
||||
|
||||
1. **MCP Server** receives tool calls from Claude via stdio
|
||||
2. **Embedded HTTP Server** serves the draw.io UI and handles state
|
||||
2. **Embedded HTTP Server** serves the preview page and the draw.io editor, and handles state
|
||||
3. **Browser** shows real-time diagram updates via polling
|
||||
|
||||
The server listens on `127.0.0.1` only. Every `/api` request must carry a token that the server generates at start and writes into the preview page, so another website open in the same browser cannot read or change the diagram; the preview page also refuses to be embedded in other pages. Opening the preview URL in another browser on the same machine works, since the page comes with the token.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `6002` | Port for the embedded HTTP server |
|
||||
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
|
||||
| `DRAWIO_BASE_URL` | unset (the bundled draw.io) | An external draw.io for the preview, such as a self-hosted instance (see below). The page cannot reach into an editor from another origin, so with it the AI's changes are not outlined, Ctrl+Z does not take them back and `get_selection` cannot read the selection. The version cards and everything else keep working. |
|
||||
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files and your `instructions.md`. Set to `off` to turn auto-save off (`instructions.md` is then read from the default folder). |
|
||||
| `DRAWIO_LANG` | unset | Language of the draw.io editor. Unset, draw.io chooses: the browser language on `embed.diagrams.net`, English on a self-hosted draw.io until the user picks one under **Extras > Language**. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes it and hides that submenu. |
|
||||
| `DRAWIO_LANG` | unset | Language of the draw.io editor and of the preview's own texts. Unset, the preview's texts follow the browser language, and draw.io chooses: English on the bundled copy until the user picks one under **Extras > Language**, the browser language on `embed.diagrams.net`. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes both and hides that submenu. |
|
||||
| `DRAWIO_UI` | unset | draw.io theme. Unset, the user picks one under **Extras > Theme** and draw.io remembers it. `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple` fixes the theme and hides that menu. |
|
||||
| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light. The page header keeps following the system. |
|
||||
| `DRAWIO_DARK` | `auto` | Dark mode of the preview and the editor until the user switches it with the button at the top right (the browser remembers that choice): `auto` follows the system, `1` starts dark, `0` starts light. |
|
||||
| `DRAWIO_PREVIEW_UI` | `shell` | The preview page `start_session` opens. `classic` opens the previous preview page (see [Classic preview page](#classic-preview-page)). |
|
||||
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result, so the AI checks each drawing. Costs 2 to 10 s per call; the preview tab must be open and in front. A call can still pass `screenshot: false`. |
|
||||
| `BROWSER` | unset | Set to `none` and `start_session` does not open the preview in the system browser (the convention of Vite and Create React App dev servers); the result still names the URL. |
|
||||
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
|
||||
|
||||
### Private Deployment (Self-hosted draw.io)
|
||||
@@ -217,6 +223,16 @@ docker run -d -p 8080:8080 jgraph/drawio
|
||||
|
||||
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works offline. One exception: shapes from the Material Design library show icons from `fonts.gstatic.com`.
|
||||
|
||||
With an external draw.io the preview cannot reach into the editor (browsers keep pages and frames from other origins apart), so the AI's changes are not outlined on the canvas, Ctrl+Z does not take them back (use the version cards or `restore_version`), and `get_selection` cannot read what you selected. `start_session` tells the AI about this mode.
|
||||
|
||||
Without `DRAWIO_BASE_URL` the preview uses the trimmed draw.io copy bundled with this package. It has the editor, the shape libraries, the templates, PlantUML and Mermaid; features that load more code when used (the org chart layout, for example) are not included. Set `DRAWIO_BASE_URL` to a full draw.io if you need them.
|
||||
|
||||
Like the web app's bundled copy, it has no image proxy (`/drawio/proxy`): images from other websites, including the avatars in the Org Chart and Mind Map templates and the Arista icons in the network templates, show on the canvas only when online and are left out of PNG and SVG exports and version thumbnails. Insert such images from a file instead, or set `DRAWIO_BASE_URL` to a full draw.io (the `jgraph/drawio` Docker image includes the proxy). See [Offline deployment](https://github.com/DayuanJiang/next-ai-draw-io/blob/main/docs/en/offline-deployment.md).
|
||||
|
||||
### Classic preview page
|
||||
|
||||
`DRAWIO_PREVIEW_UI=classic` opens the preview page of the previous releases: draw.io with a **History** dialog and a **Download** button, without the outline of the AI's changes, Ctrl+Z for them, the version cards, Compare and `get_selection`. It stays for one more release and is then removed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port already in use
|
||||
@@ -248,6 +264,10 @@ Check that the browser URL has the `?mcp=` query parameter. The MCP session ID c
|
||||
|
||||
PNG and SVG files are rendered by draw.io in the preview tab. Browsers slow down tabs that stay in the background, so the tab may not answer in time. Bring the preview tab to the front and try again.
|
||||
|
||||
### Preview is blank inside VS Code Simple Browser
|
||||
|
||||
The preview page refuses to be embedded in other pages (it sends `Content-Security-Policy: frame-ancestors 'self'`), so in-editor browsers such as VS Code Simple Browser, Cursor's built-in browser or a port forward's "Preview in Editor" show a blank page. Open the Browser URL from the `start_session` result in a regular browser (Chrome, Edge, Firefox, Safari) instead.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Files copied from draw.war into dist/drawio by scripts/fetch-drawio.mjs
|
||||
# (the preview page embeds this copy from its own origin). `*` matches within
|
||||
# a path segment, `**` across segments.
|
||||
#
|
||||
# Always included: the language files, the image icon libraries (loaded one
|
||||
# icon at a time, as diagrams use them), the stylesheets, the licenses of the
|
||||
# icon sets, the templates of Insert > Template (the guard only sees the
|
||||
# previews it scrolled to) and the PlantUML parser of Insert > Advanced.
|
||||
images/osa_drive-harddisk.png
|
||||
img/LICENSE
|
||||
img/lib/**
|
||||
js/plantuml/drawio-plantuml.min.js
|
||||
mxgraph/css/**
|
||||
resources/dia*.txt
|
||||
shapes/LICENSE
|
||||
stencils/LICENSE
|
||||
styles/**
|
||||
templates/**
|
||||
|
||||
# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update
|
||||
images/droptarget.png
|
||||
images/github-logo.svg
|
||||
images/logo-flat-small.png
|
||||
images/sidebar-general.png
|
||||
images/spin.gif
|
||||
img/clipart/Gear_128x128.png
|
||||
index.html
|
||||
js/PostConfig.js
|
||||
js/PreConfig.js
|
||||
js/app.min.js
|
||||
js/bootstrap.js
|
||||
js/extensions.min.js
|
||||
js/main.js
|
||||
js/open.js
|
||||
js/shapes-14-6-5.min.js
|
||||
js/stencils.min.js
|
||||
math4/es5/drawio-mathjax.min.js
|
||||
mxgraph/images/maximize.gif
|
||||
open.html
|
||||
shortcuts.svg
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -8,12 +8,14 @@
|
||||
"next-ai-drawio-mcp": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/copy-assets.mjs",
|
||||
"build": "tsc && tsc -p shell && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs && node scripts/build-shell.mjs",
|
||||
"check-drawio": "node scripts/check-drawio-files.mjs",
|
||||
"check-package": "node scripts/check-package.mjs",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
// Browser tests of the built package (npm run build first): each spec
|
||||
// starts the MCP server itself over stdio, so there is no web server here.
|
||||
// Playwright comes from the repository root (npm ci there).
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 90_000,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: "list",
|
||||
use: {
|
||||
browserName: "chromium",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
// Build the canvas shell (shell/, the web app's canvas without the chat)
|
||||
// into dist/shell: shell.js with esbuild, shell.css with Tailwind, and the
|
||||
// page template. Needs the repository checked out with its root
|
||||
// devDependencies installed (esbuild, Tailwind, React and the shared
|
||||
// components come from there), like copy-assets.mjs needs ../../docs.
|
||||
import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const root = join(pkg, "../..")
|
||||
const shell = join(pkg, "shell")
|
||||
const out = join(pkg, "dist/shell")
|
||||
|
||||
// The root's copies: the shell's React must be the one the shared
|
||||
// components import, and the MCP core's pako and saxes should not be
|
||||
// bundled twice (packages/mcp-server has its own node_modules)
|
||||
const rootRequire = createRequire(join(root, "package.json"))
|
||||
const rootPackage = (name) =>
|
||||
dirname(rootRequire.resolve(`${name}/package.json`))
|
||||
const { build } = await import(rootRequire.resolve("esbuild"))
|
||||
const postcss = (await import(rootRequire.resolve("postcss"))).default
|
||||
const tailwindcss = (await import(rootRequire.resolve("@tailwindcss/postcss")))
|
||||
.default
|
||||
|
||||
mkdirSync(out, { recursive: true })
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [join(shell, "main.tsx")],
|
||||
outfile: join(out, "shell.js"),
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: "esm",
|
||||
target: "es2022",
|
||||
platform: "browser",
|
||||
jsx: "automatic",
|
||||
metafile: true,
|
||||
absWorkingDir: root,
|
||||
alias: {
|
||||
"@": root,
|
||||
pako: rootPackage("pako"),
|
||||
saxes: rootPackage("saxes"),
|
||||
},
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
// The shell has no base path and takes its draw.io from the config
|
||||
// the server writes into the page, not from the web app's build
|
||||
// settings
|
||||
"process.env.NEXT_PUBLIC_BASE_PATH": '""',
|
||||
"process.env.NEXT_PUBLIC_DRAWIO_BASE_URL": '""',
|
||||
},
|
||||
})
|
||||
const js = readFileSync(join(out, "shell.js"), "utf8")
|
||||
// A reference that the defines above missed would throw in the browser
|
||||
// ("process is not defined")
|
||||
const leftover = js.match(/process\.env\.[A-Z_]+/)
|
||||
if (leftover) {
|
||||
throw new Error(`shell.js still reads ${leftover[0]}; define it above`)
|
||||
}
|
||||
|
||||
const cssFile = join(shell, "shell.css")
|
||||
const css = await postcss([
|
||||
// Tailwind scans the shell directory on its own; shell.css names the
|
||||
// shared component directories with @source
|
||||
tailwindcss({ base: shell, optimize: { minify: true } }),
|
||||
]).process(readFileSync(cssFile, "utf8"), {
|
||||
from: cssFile,
|
||||
to: join(out, "shell.css"),
|
||||
})
|
||||
writeFileSync(join(out, "shell.css"), css.css)
|
||||
|
||||
cpSync(join(shell, "index.html"), join(out, "index.html"))
|
||||
|
||||
const kb = (n) => `${(n / 1024).toFixed(0)} KB`
|
||||
const bundled = Object.values(result.metafile.outputs).find((o) => o.entryPoint)
|
||||
console.log(
|
||||
`shell: shell.js ${kb(bundled.bytes)}, shell.css ${kb(Buffer.byteLength(css.css))} -> ${out}`,
|
||||
)
|
||||
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
/**
|
||||
* Guard for drawio-files.txt: opens the embedded editor from a FULL draw.io
|
||||
* copy in headless Chromium, uses it the way the preview page does (every
|
||||
* theme the user can switch to, diagrams with shapes from the icon libraries,
|
||||
* the format panel, the shape sidebar, the menus, the page tabs, the export
|
||||
* and edit dialogs, exports) and records every file the editor requests.
|
||||
*
|
||||
* node scripts/check-drawio-files.mjs [--drawio <dir>] [--update]
|
||||
*
|
||||
* The full copy is --drawio, else the web app's public/drawio. Without
|
||||
* --update it fails when the editor requested a file the list does not
|
||||
* have; with --update it rewrites the requested section of the list.
|
||||
* Needs Playwright from the repository root (npm ci at the root).
|
||||
*/
|
||||
import http from "node:http"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { readDrawioVersion } from "../../../scripts/drawio-zip.mjs"
|
||||
import {
|
||||
formatFileList,
|
||||
parseFileList,
|
||||
readFileList,
|
||||
} from "./drawio-file-list.mjs"
|
||||
|
||||
const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const LIST = path.join(PKG, "drawio-files.txt")
|
||||
const args = process.argv.slice(2)
|
||||
const update = args.includes("--update")
|
||||
const drawioDir = path.resolve(
|
||||
args.includes("--drawio")
|
||||
? args[args.indexOf("--drawio") + 1]
|
||||
: path.join(PKG, "../../public/drawio"),
|
||||
)
|
||||
if (!existsSync(path.join(drawioDir, "index.html"))) {
|
||||
console.error(
|
||||
`No draw.io copy at ${drawioDir}. Run "npm run dev" once at the repository root (it downloads one into public/drawio), or pass --drawio <dir>.`,
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
// The list is for the pinned release; the fetch scripts stamp the copy
|
||||
const { version } = readDrawioVersion()
|
||||
const stampFile = path.join(drawioDir, ".version")
|
||||
const stamp = existsSync(stampFile)
|
||||
? readFileSync(stampFile, "utf8").trim()
|
||||
: null
|
||||
if (stamp !== version) {
|
||||
console.error(
|
||||
`${drawioDir} is draw.io ${stamp ?? "(no .version stamp)"}; the package pins ${version}. Delete it and run "npm run dev" at the repository root again, or pass --drawio <dir>.`,
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
const playwrightEntry = path.join(
|
||||
PKG,
|
||||
"../../node_modules/playwright/index.mjs",
|
||||
)
|
||||
if (!existsSync(playwrightEntry)) {
|
||||
console.error("Playwright is missing: run npm ci at the repository root.")
|
||||
process.exit(2)
|
||||
}
|
||||
const { chromium } = await import(pathToFileURL(playwrightEntry).href)
|
||||
|
||||
// The themes a user can switch to under Extras > Theme (themes=1)
|
||||
const THEMES = ["kennedy", "min", "atlas", "dark", "sketch", "simple"]
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".json": "application/json",
|
||||
".xml": "text/xml",
|
||||
".txt": "text/plain",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".ico": "image/x-icon",
|
||||
}
|
||||
|
||||
// The page that embeds the editor, with the preview page's iframe query
|
||||
const parentPage = (ui) => `<!doctype html><html><body style="margin:0">
|
||||
<iframe id="f" style="width:1280px;height:860px;border:0" src="/drawio/index.html?embed=1&proto=json&configure=1&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=0&ui=${ui}"></iframe>
|
||||
<script>
|
||||
const f = document.getElementById('f')
|
||||
window.exports = 0
|
||||
window.addEventListener('message', (e) => {
|
||||
let m; try { m = JSON.parse(e.data) } catch { return }
|
||||
if (m.event === 'configure') f.contentWindow.postMessage(JSON.stringify({ action: 'configure', config: {} }), '*')
|
||||
if (m.event === 'init') window.ready = true
|
||||
if (m.event === 'export') window.exports++
|
||||
})
|
||||
window.send = (msg) => f.contentWindow.postMessage(JSON.stringify(msg), '*')
|
||||
</script></body></html>`
|
||||
|
||||
// Every path the editor asked for, in order; and the ones the full copy lacks
|
||||
const requested = []
|
||||
const notFound = new Set()
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost")
|
||||
if (url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(parentPage(url.searchParams.get("ui") || "kennedy"))
|
||||
return
|
||||
}
|
||||
const rel = path.posix.normalize(
|
||||
decodeURIComponent(url.pathname.replace(/^\/drawio\//, "")),
|
||||
)
|
||||
const file = path.join(drawioDir, rel)
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(file)
|
||||
} catch {
|
||||
notFound.add(rel)
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (!requested.includes(rel)) requested.push(rel)
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME[path.extname(file)] || "application/octet-stream",
|
||||
"Content-Length": stat.size,
|
||||
})
|
||||
res.end(readFileSync(file))
|
||||
})
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r))
|
||||
const port = server.address().port
|
||||
|
||||
const mb = (n) => (n / 1048576).toFixed(2)
|
||||
let seen = 0
|
||||
function report(step) {
|
||||
const added = requested.slice(seen)
|
||||
seen = requested.length
|
||||
if (added.length === 0) return
|
||||
const bytes = added.reduce(
|
||||
(n, f) => n + statSync(path.join(drawioDir, f)).size,
|
||||
0,
|
||||
)
|
||||
const names =
|
||||
added.slice(0, 5).join(", ") + (added.length > 5 ? ", ..." : "")
|
||||
console.log(
|
||||
` ${step}: +${added.length} files, +${mb(bytes)} MB [${names}]`,
|
||||
)
|
||||
}
|
||||
|
||||
// Diagrams: a plain shape, one shape from each icon library, and the styles
|
||||
// that make draw.io load code on demand (hand-drawn look, math)
|
||||
const cell = (id, style, x) =>
|
||||
`<mxCell id="${id}" value="${id}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="60" height="60" as="geometry"/></mxCell>`
|
||||
const doc = (cells, attrs = "") =>
|
||||
`<mxfile><diagram id="p" name="P"><mxGraphModel ${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
const LIBS = {
|
||||
plain: "rounded=1;whiteSpace=wrap;html=1;",
|
||||
aws4: "shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.a1_instance;fillColor=#ED7100;strokeColor=#ffffff;",
|
||||
gcp2: "shape=mxgraph.gcp2.a7_power;fillColor=#4285F4;strokeColor=none;",
|
||||
kubernetes:
|
||||
"shape=mxgraph.kubernetes.icon;prIcon=api;fillColor=#326CE5;strokeColor=none;",
|
||||
cisco19:
|
||||
"shape=mxgraph.cisco19.rect;prIcon=aci;fillColor=#00bceb;strokeColor=none;",
|
||||
flowchart:
|
||||
"shape=mxgraph.flowchart.annotation_1;fillColor=#d5e8d4;strokeColor=#82b366;",
|
||||
webicons:
|
||||
"shape=mxgraph.webicons.adfty;fillColor=#3b5998;strokeColor=none;",
|
||||
bpmn: "shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;",
|
||||
networks:
|
||||
"shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;",
|
||||
azure2_image:
|
||||
"image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;",
|
||||
sketch: "rounded=1;whiteSpace=wrap;html=1;sketch=1;",
|
||||
}
|
||||
const MATH_DOC = doc(
|
||||
cell("math", "rounded=1;whiteSpace=wrap;html=1;", 40).replace(
|
||||
'value="math"',
|
||||
'value="$$x^2$$"',
|
||||
),
|
||||
'math="1"',
|
||||
)
|
||||
|
||||
// Dialogs and toggles, by draw.io action name (Actions.js); each is closed
|
||||
// again right after it opened. Insert > Template and Insert > Advanced >
|
||||
// PlantUML, which load files when used, are driven below. Mermaid needs no
|
||||
// extra files: its converter is in js/extensions.min.js, which the editor
|
||||
// loads at startup. Left out on purpose, to keep the package small: the org
|
||||
// chart layout (it loads js/orgchart.min.js). Not covered either: images
|
||||
// from other websites in some templates (Org Chart avatars, Arista icons)
|
||||
// and the /drawio/proxy requests draw.io makes for them when exporting;
|
||||
// the copy has no proxy, a known limitation (see the README).
|
||||
const ACTIONS = [
|
||||
"editData",
|
||||
"editDiagram",
|
||||
"editStyle",
|
||||
"editGeometry",
|
||||
"editTooltip",
|
||||
"editLink",
|
||||
"insertImage",
|
||||
"exportPng",
|
||||
"exportSvg",
|
||||
"exportJpg",
|
||||
"exportWebp",
|
||||
"exportPdf",
|
||||
"exportXml",
|
||||
"exportHtml",
|
||||
"exportVsdx",
|
||||
"embedHtml",
|
||||
"embedImage",
|
||||
"embedSvg",
|
||||
"embedIframe",
|
||||
"about",
|
||||
"pageSetup",
|
||||
"print",
|
||||
"findReplace",
|
||||
"tags",
|
||||
"layers",
|
||||
"outline",
|
||||
"shapes",
|
||||
"scratchpad",
|
||||
"plugins",
|
||||
"properties",
|
||||
"fromText",
|
||||
"csv",
|
||||
"runLayout",
|
||||
"insertFreehand",
|
||||
"comments",
|
||||
"customZoom",
|
||||
"renamePage",
|
||||
"format",
|
||||
"format",
|
||||
"toggleShapes",
|
||||
"toggleShapes",
|
||||
"darkMode",
|
||||
"lightMode",
|
||||
"autoMode",
|
||||
"highContrast",
|
||||
"highContrast",
|
||||
"mathematicalTypesetting",
|
||||
"mathematicalTypesetting",
|
||||
"keyboardShortcuts",
|
||||
]
|
||||
// Enough for the other themes, whose dialogs are the same code
|
||||
const LIGHT_ACTIONS = ["editData", "exportPng", "shapes", "format", "format"]
|
||||
|
||||
const wait = (page, ms) => page.waitForTimeout(ms)
|
||||
|
||||
/**
|
||||
* Open the editor in one theme and use it. The default theme gets the full
|
||||
* tour; the others the parts that differ by theme (chrome, menus, panels).
|
||||
*/
|
||||
async function drive(browser, ui, full) {
|
||||
console.log(`theme ${ui}${full ? "" : " (light)"}`)
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1300, height: 900 },
|
||||
})
|
||||
page.on("popup", (p) => p.close().catch(() => {}))
|
||||
await page.goto(`http://localhost:${port}/?ui=${ui}`)
|
||||
await page.waitForFunction(() => window.ready === true, null, {
|
||||
timeout: 60000,
|
||||
})
|
||||
await wait(page, 2000)
|
||||
report("editor ready")
|
||||
|
||||
const load = (xml) =>
|
||||
page.evaluate(
|
||||
(x) => window.send({ action: "load", xml: x, autosave: 1 }),
|
||||
xml,
|
||||
)
|
||||
let x = 40
|
||||
for (const [name, style] of Object.entries(LIBS)) {
|
||||
await load(doc(cell(name, style, (x += 100))))
|
||||
await wait(page, full ? 1500 : 500)
|
||||
report(`load ${name}`)
|
||||
if (!full) break
|
||||
}
|
||||
if (full) {
|
||||
await load(MATH_DOC)
|
||||
await wait(page, 2500)
|
||||
report("load math")
|
||||
}
|
||||
|
||||
// The editor instance: in embed mode draw.io calls a plugin callback
|
||||
// registered after startup right away
|
||||
const frame = page.frames().find((f) => f.url().includes("/drawio/"))
|
||||
await frame.evaluate(() => {
|
||||
window.Draw.loadPlugin((u) => {
|
||||
window.__ui = u
|
||||
})
|
||||
window.__ui.editor.graph.selectAll()
|
||||
})
|
||||
const closeAll = async () => {
|
||||
await frame.evaluate(() => {
|
||||
const ui = window.__ui
|
||||
while (ui.dialogs && ui.dialogs.length > 0) ui.hideDialog()
|
||||
ui.hideCurrentMenu?.()
|
||||
})
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, 100)
|
||||
}
|
||||
|
||||
let opened = 0
|
||||
const actions = full ? ACTIONS : LIGHT_ACTIONS
|
||||
for (const name of actions) {
|
||||
const result = await frame.evaluate((n) => {
|
||||
const ui = window.__ui
|
||||
const action = ui.actions.get(n)
|
||||
if (!action) return "missing"
|
||||
try {
|
||||
action.funct()
|
||||
} catch (e) {
|
||||
return `failed: ${e.message}`
|
||||
}
|
||||
return ui.dialog || ui.dialogs?.length ? "dialog" : "ok"
|
||||
}, name)
|
||||
if (result === "dialog") opened++
|
||||
if (result !== "ok" && result !== "dialog") {
|
||||
console.log(` action ${name}: ${result}`)
|
||||
}
|
||||
await wait(page, 400)
|
||||
report(`action ${name}`)
|
||||
await closeAll()
|
||||
}
|
||||
console.log(` ${opened} of ${actions.length} actions opened a dialog`)
|
||||
|
||||
// File > Open Library from > Browser (libraries=1): the picker is an
|
||||
// iframe on open.html, which loads js/open.js
|
||||
await frame.evaluate(() => window.__ui.pickLibrary(window.App.MODE_BROWSER))
|
||||
await wait(page, 1000)
|
||||
report("library picker")
|
||||
await closeAll()
|
||||
|
||||
// Menus: the visible menubar items, the toolbar buttons that open a menu
|
||||
// in the other themes, and every menu rendered with its submenus
|
||||
const menubarItems = frame.locator(".geMenubar .geItem:visible")
|
||||
const menubarCount = await menubarItems.count()
|
||||
for (let i = 0; i < menubarCount; i++) {
|
||||
await menubarItems
|
||||
.nth(i)
|
||||
.click({ timeout: 2000 })
|
||||
.catch(() => {})
|
||||
await wait(page, 300)
|
||||
await closeAll()
|
||||
}
|
||||
let toolbarCount = 0
|
||||
for (const title of [
|
||||
"Diagram",
|
||||
"Insert",
|
||||
"Shapes",
|
||||
"Pages",
|
||||
"View",
|
||||
"Table",
|
||||
"Layout",
|
||||
]) {
|
||||
const button = frame.locator(`a[title="${title}"]:visible`).first()
|
||||
if ((await button.count()) === 0) continue
|
||||
toolbarCount++
|
||||
await button.click({ timeout: 2000 }).catch(() => {})
|
||||
await wait(page, 300)
|
||||
await closeAll()
|
||||
}
|
||||
const failed = await frame.evaluate(() => {
|
||||
const ui = window.__ui
|
||||
const failed = []
|
||||
for (const name of Object.keys(ui.menus.menus)) {
|
||||
const menu = new window.mxPopupMenu()
|
||||
menu.init()
|
||||
document.body.appendChild(menu.div)
|
||||
try {
|
||||
ui.menus.get(name).funct(menu, null)
|
||||
} catch {
|
||||
failed.push(name)
|
||||
}
|
||||
menu.destroy()
|
||||
}
|
||||
return failed
|
||||
})
|
||||
await wait(page, 500)
|
||||
console.log(
|
||||
` menus: ${menubarCount} menubar items, ${toolbarCount} toolbar menus clicked` +
|
||||
(failed.length ? `; not rendered: ${failed.join(", ")}` : ""),
|
||||
)
|
||||
report("menus")
|
||||
|
||||
// Format panel tabs, with a shape selected
|
||||
await frame.evaluate(() => window.__ui.editor.graph.selectAll())
|
||||
for (const tab of ["Style", "Text", "Arrange", "Diagram"]) {
|
||||
const el = frame
|
||||
.locator(`.geFormatContainer div:text-is("${tab}")`)
|
||||
.first()
|
||||
if ((await el.count()) > 0)
|
||||
await el.click({ timeout: 2000 }).catch(() => {})
|
||||
await wait(page, 200)
|
||||
}
|
||||
report("format panel tabs")
|
||||
|
||||
// Shape sidebar: open every palette, then search
|
||||
await frame.evaluate(() => {
|
||||
const ui = window.__ui
|
||||
for (const title of document.querySelectorAll(
|
||||
".geSidebarContainer .geTitle",
|
||||
)) {
|
||||
title.click()
|
||||
}
|
||||
ui.sidebar.searchShapes?.("server")
|
||||
})
|
||||
await wait(page, full ? 2500 : 1000)
|
||||
report("sidebar palettes and search")
|
||||
|
||||
// Pages: insert, switch, duplicate, remove, and the tab bar
|
||||
for (const name of [
|
||||
"insertPage",
|
||||
"nextPage",
|
||||
"previousPage",
|
||||
"duplicatePage",
|
||||
"removePage",
|
||||
]) {
|
||||
await frame.evaluate((n) => window.__ui.actions.get(n)?.funct(), name)
|
||||
await wait(page, 150)
|
||||
await closeAll()
|
||||
}
|
||||
for (const title of ["Insert Page", "Pages"]) {
|
||||
const el = frame.locator(`a[title="${title}"]:visible`).first()
|
||||
if ((await el.count()) > 0)
|
||||
await el.click({ timeout: 2000 }).catch(() => {})
|
||||
await wait(page, 300)
|
||||
await closeAll()
|
||||
}
|
||||
report("page tabs")
|
||||
|
||||
if (full) {
|
||||
// Insert > Advanced > PlantUML: the parser is loaded on first use
|
||||
await frame.evaluate(() => window.__ui.actions.get("plantUml").funct())
|
||||
await frame
|
||||
.locator(".geDialog textarea")
|
||||
.first()
|
||||
.fill("@startuml\nA -> B\n@enduml")
|
||||
await frame
|
||||
.locator('.geDialog button:text-is("Insert")')
|
||||
.first()
|
||||
.click()
|
||||
await wait(page, 3000)
|
||||
report("plantUml insert")
|
||||
await closeAll()
|
||||
|
||||
// Insert > Template: the index and the previews on screen
|
||||
await frame.evaluate(() =>
|
||||
window.__ui.actions.get("insertTemplate").funct(),
|
||||
)
|
||||
await wait(page, 2000)
|
||||
report("template dialog")
|
||||
await closeAll()
|
||||
|
||||
const formats = ["png", "svg", "xmlsvg", "xml"]
|
||||
for (const [i, format] of formats.entries()) {
|
||||
await page.evaluate(
|
||||
(f) => window.send({ action: "export", format: f, scale: 2 }),
|
||||
format,
|
||||
)
|
||||
await page
|
||||
.waitForFunction((n) => window.exports >= n, i + 1, {
|
||||
timeout: 10000,
|
||||
})
|
||||
.catch(() => {})
|
||||
report(`export ${format}`)
|
||||
}
|
||||
const exports = await page.evaluate(() => window.exports)
|
||||
if (exports < formats.length) {
|
||||
console.error(
|
||||
` only ${exports} of ${formats.length} exports answered`,
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
await page.close()
|
||||
}
|
||||
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
for (const ui of THEMES) await drive(browser, ui, ui === THEMES[0])
|
||||
} finally {
|
||||
await browser.close()
|
||||
server.close()
|
||||
}
|
||||
|
||||
// A full copy has every file the editor asks for; 404s mean this is not one
|
||||
if (notFound.size > 0) {
|
||||
console.error(
|
||||
`\nThe editor requested ${notFound.size} files that ${drawioDir} does not have (not a full draw.io copy?):\n ${[...notFound].join("\n ")}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Compare with the list
|
||||
const list = readFileList(LIST)
|
||||
const missing = requested.filter((f) => !list.matches(f)).sort()
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) yield* walk(full)
|
||||
else yield path.relative(drawioDir, full).split(path.sep).join("/")
|
||||
}
|
||||
}
|
||||
const summarize = (fileList) => {
|
||||
let count = 0
|
||||
let bytes = 0
|
||||
for (const rel of walk(drawioDir)) {
|
||||
if (!fileList.matches(rel)) continue
|
||||
count++
|
||||
bytes += statSync(path.join(drawioDir, rel)).size
|
||||
}
|
||||
return `${count} files, ${mb(bytes)} MB unpacked`
|
||||
}
|
||||
|
||||
if (update) {
|
||||
// Only the head decides what is already covered by "always include"
|
||||
const always = parseFileList(list.head)
|
||||
const kept = requested.filter((f) => !always.matches(f))
|
||||
writeFileSync(LIST, formatFileList(list.head, kept))
|
||||
console.log(`\nWrote ${LIST}: ${summarize(readFileList(LIST))}`)
|
||||
} else if (missing.length > 0) {
|
||||
console.error(
|
||||
`\nThe editor requested ${missing.length} files that drawio-files.txt does not list:\n ${missing.join("\n ")}\nRun with --update to add them.`,
|
||||
)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log(`\ndrawio-files.txt is complete: ${summarize(list)}`)
|
||||
}
|
||||
@@ -9,7 +9,26 @@ const REQUIRED = [
|
||||
"dist/preview/index.html",
|
||||
"dist/preview/preview.css",
|
||||
"dist/preview/preview.js",
|
||||
"dist/drawio/index.html",
|
||||
"dist/drawio/js/app.min.js",
|
||||
"dist/drawio/LICENSE",
|
||||
// The draw.io version stamp (part of every file's ETag); a dotfile, so
|
||||
// make sure npm keeps it
|
||||
"dist/drawio/.version",
|
||||
"dist/shell/index.html",
|
||||
"dist/shell/shell.js",
|
||||
"dist/shell/shell.css",
|
||||
]
|
||||
// The canvas shell (750 KB and 50 KB at version 0.4.0); a jump means a
|
||||
// dependency of the shared components grew the bundle
|
||||
const MAX_FILE_BYTES = {
|
||||
"dist/shell/shell.js": 1.5 * 1024 * 1024,
|
||||
"dist/shell/shell.css": 300 * 1024,
|
||||
}
|
||||
// The bundled draw.io is most of the package (15.3 MB packed with draw.io
|
||||
// v32.0.2 at version 0.4.0); a jump means the file list grew more than
|
||||
// intended
|
||||
const MAX_TARBALL_BYTES = 20 * 1024 * 1024
|
||||
|
||||
const output = JSON.parse(
|
||||
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
|
||||
@@ -22,4 +41,23 @@ if (missing.length > 0) {
|
||||
console.error(`npm package is missing: ${missing.join(", ")}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`npm package OK (${files.size} files)`)
|
||||
const mb = (n) => (n / 1048576).toFixed(1)
|
||||
const kb = (n) => (n / 1024).toFixed(0)
|
||||
for (const [path, limit] of Object.entries(MAX_FILE_BYTES)) {
|
||||
const size = pack.files.find((f) => f.path === path).size
|
||||
if (size > limit) {
|
||||
console.error(
|
||||
`${path} is ${kb(size)} KB, over the ${kb(limit)} KB limit`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (pack.size > MAX_TARBALL_BYTES) {
|
||||
console.error(
|
||||
`npm package is ${mb(pack.size)} MB packed, over the ${mb(MAX_TARBALL_BYTES)} MB limit`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`npm package OK (${files.size} files, ${mb(pack.size)} MB packed, ${mb(pack.unpackedSize)} MB unpacked)`,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* drawio-files.txt: the files copied from draw.war into dist/drawio.
|
||||
*
|
||||
* One path per line; `*` matches within a path segment and `**` across
|
||||
* segments; `#` starts a comment. Everything above the marker line is kept
|
||||
* as it is ("always include"); the paths below it are what the embedded
|
||||
* editor requested in a guard run, rewritten by check-drawio-files.mjs
|
||||
* --update.
|
||||
*/
|
||||
import fs from "node:fs"
|
||||
|
||||
export const MARKER =
|
||||
"# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update"
|
||||
|
||||
function toRegExp(pattern) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
const source = escaped
|
||||
.replace(/\*\*/g, "\0")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\0/g, ".*")
|
||||
return new RegExp(`^${source}$`)
|
||||
}
|
||||
|
||||
const entries = (text) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"))
|
||||
|
||||
export function parseFileList(text) {
|
||||
const at = text.indexOf(MARKER)
|
||||
if (at < 0) throw new Error(`drawio-files.txt has no "${MARKER}" line`)
|
||||
const head = text.slice(0, at + MARKER.length)
|
||||
const always = entries(head)
|
||||
const requested = entries(text.slice(at + MARKER.length))
|
||||
|
||||
const exact = new Set()
|
||||
const patterns = []
|
||||
for (const entry of [...always, ...requested]) {
|
||||
if (entry.includes("*")) {
|
||||
patterns.push({ text: entry, re: toRegExp(entry), hit: false })
|
||||
} else {
|
||||
exact.add(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
head,
|
||||
always,
|
||||
requested,
|
||||
/** Whether the list names this file (remembers which patterns hit) */
|
||||
matches(name) {
|
||||
if (exact.has(name)) return true
|
||||
let found = false
|
||||
for (const p of patterns) {
|
||||
if (p.re.test(name)) p.hit = found = true
|
||||
}
|
||||
return found
|
||||
},
|
||||
/** Entries that matched none of `seen` after matches() ran on them */
|
||||
unmatched(seen) {
|
||||
return [
|
||||
...[...exact].filter((name) => !seen.has(name)),
|
||||
...patterns.filter((p) => !p.hit).map((p) => p.text),
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function readFileList(file) {
|
||||
return parseFileList(fs.readFileSync(file, "utf8"))
|
||||
}
|
||||
|
||||
/** The list text with a new requested section (sorted, unique) */
|
||||
export function formatFileList(head, requested) {
|
||||
const body = [...new Set(requested)].sort().join("\n")
|
||||
return `${head}\n${body}\n`
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build step: put a trimmed copy of draw.io into dist/drawio, so the preview
|
||||
* page can embed the editor from its own origin (same-origin editor access,
|
||||
* and no internet needed at runtime).
|
||||
*
|
||||
* Downloads the pinned draw.war (see src/drawio-version.json) into a cache
|
||||
* directory (DRAWIO_WAR_CACHE, default <tmpdir>/next-ai-drawio), checks its
|
||||
* SHA-256, and extracts only the files listed in drawio-files.txt. Set
|
||||
* DRAWIO_WAR=<path> to use a local war without downloading. The list is kept
|
||||
* up to date by scripts/check-drawio-files.mjs.
|
||||
*/
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import {
|
||||
readDrawioVersion,
|
||||
readZipEntries,
|
||||
sha256,
|
||||
} from "../../../scripts/drawio-zip.mjs"
|
||||
import { readFileList } from "./drawio-file-list.mjs"
|
||||
|
||||
const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const DEST = path.join(PKG, "dist/drawio")
|
||||
const { version, sha256: expectedSha, downloadUrl } = readDrawioVersion()
|
||||
|
||||
function log(message) {
|
||||
console.log(`[fetch-drawio] ${message}`)
|
||||
}
|
||||
|
||||
/** The draw.war as a buffer, from DRAWIO_WAR, the cache, or GitHub */
|
||||
async function readWar() {
|
||||
const verified = (buf, source) => {
|
||||
const actual = sha256(buf)
|
||||
if (actual !== expectedSha) {
|
||||
throw new Error(
|
||||
`${source} is not draw.io ${version} (SHA-256 ${actual})`,
|
||||
)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
if (process.env.DRAWIO_WAR) {
|
||||
log(`Using ${process.env.DRAWIO_WAR}`)
|
||||
return verified(fs.readFileSync(process.env.DRAWIO_WAR), "DRAWIO_WAR")
|
||||
}
|
||||
|
||||
const cacheDir =
|
||||
process.env.DRAWIO_WAR_CACHE || path.join(os.tmpdir(), "next-ai-drawio")
|
||||
const cached = path.join(cacheDir, `draw-${version}.war`)
|
||||
if (fs.existsSync(cached)) {
|
||||
try {
|
||||
return verified(fs.readFileSync(cached), cached)
|
||||
} catch (error) {
|
||||
log(`${error.message}; downloading again`)
|
||||
fs.rmSync(cached, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
log(`Downloading draw.io ${version} ...`)
|
||||
const res = await fetch(downloadUrl)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${downloadUrl}`)
|
||||
const buf = verified(Buffer.from(await res.arrayBuffer()), downloadUrl)
|
||||
fs.mkdirSync(cacheDir, { recursive: true })
|
||||
const tmp = `${cached}.tmp-${process.pid}`
|
||||
fs.writeFileSync(tmp, buf)
|
||||
fs.renameSync(tmp, cached)
|
||||
log(`Cached at ${cached}`)
|
||||
return buf
|
||||
}
|
||||
|
||||
/** Apache-2.0 text with a notice naming draw.io, for dist/drawio/LICENSE */
|
||||
function licenseText(warLicense) {
|
||||
const notice =
|
||||
`The files in this directory are an unmodified subset of draw.io ${version}\n` +
|
||||
"(https://github.com/jgraph/drawio), Copyright (c) JGraph Ltd, redistributed\n" +
|
||||
"under the Apache License, Version 2.0 reproduced below. The icon sets under\n" +
|
||||
"img/lib and the stencils built into the scripts have additional terms, see\n" +
|
||||
"img/LICENSE, shapes/LICENSE and stencils/LICENSE.\n\n"
|
||||
if (warLicense) return notice + warLicense
|
||||
// The standard terms, without this project's own copyright notice
|
||||
const apache = fs.readFileSync(path.join(PKG, "../../LICENSE"), "utf8")
|
||||
const end = "END OF TERMS AND CONDITIONS"
|
||||
return notice + apache.slice(0, apache.indexOf(end) + end.length) + "\n"
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const list = readFileList(path.join(PKG, "drawio-files.txt"))
|
||||
const buf = await readWar()
|
||||
|
||||
const tmp = `${DEST}.tmp-${process.pid}`
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
const written = new Set()
|
||||
let bytes = 0
|
||||
let warLicense = null
|
||||
for (const { name, data } of readZipEntries(buf)) {
|
||||
// Server-side parts of the war; never shipped, whatever the list says
|
||||
if (/^(WEB-INF|META-INF)\//.test(name)) continue
|
||||
if (name === "LICENSE") warLicense = data.toString("utf8")
|
||||
if (!list.matches(name)) continue
|
||||
const target = path.join(tmp, name)
|
||||
if (!target.startsWith(tmp + path.sep)) {
|
||||
throw new Error(`Unsafe path in archive: ${name}`)
|
||||
}
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(target, data)
|
||||
written.add(name)
|
||||
bytes += data.length
|
||||
}
|
||||
|
||||
const missing = list.unmatched(written)
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`drawio-files.txt names files that draw.io ${version} does not have:\n ${missing.join("\n ")}\n` +
|
||||
"Run scripts/check-drawio-files.mjs --update to refresh the list.",
|
||||
)
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(tmp, "LICENSE"), licenseText(warLicense))
|
||||
fs.writeFileSync(path.join(tmp, ".version"), `${version}\n`)
|
||||
fs.rmSync(DEST, { recursive: true, force: true })
|
||||
fs.renameSync(tmp, DEST)
|
||||
log(
|
||||
`Installed ${written.size} files (${(bytes / 1048576).toFixed(1)} MB) into dist/drawio`,
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[fetch-drawio] ${error.stack || error}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Download, History, Moon, Sun } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { CanvasStage } from "@/components/canvas/canvas-stage"
|
||||
import { CompareDialog } from "@/components/canvas/compare-dialog"
|
||||
import { VersionsProvider } from "@/components/canvas/versions-context"
|
||||
import { type ExportFormat, SaveDialog } from "@/components/save-dialog"
|
||||
import { AppToaster } from "@/components/workspace/app-toaster"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { DrawioSource } from "@/lib/drawio/drawio-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
import { useNodeVersions } from "./node-versions-source"
|
||||
import type { ShellConfig } from "./runtime-config"
|
||||
import { setDark } from "./theme"
|
||||
import { useMcpSync } from "./use-mcp-sync"
|
||||
import { VersionsPanel } from "./versions-panel"
|
||||
|
||||
const iconButton =
|
||||
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
|
||||
|
||||
/**
|
||||
* The MCP preview: the web app's canvas without the chat. A slim bar names
|
||||
* the session and the sync state; the version cards (from the server's
|
||||
* History), Compare and the export dialog come from the shared canvas
|
||||
* components. The cards sit in a side panel, so the web app's versions strip
|
||||
* above the canvas is left out: it would show the same list twice.
|
||||
*/
|
||||
export function CanvasShell({ config }: { config: ShellConfig }) {
|
||||
const dict = useDictionary()
|
||||
const isDark = useSettingsStore((s) => s.isDark)
|
||||
const { saveDiagramToFile, hasPendingLoad } = useDiagram()
|
||||
const { status, sync } = useMcpSync(config)
|
||||
const versions = useNodeVersions(sync)
|
||||
const [panelOpen, setPanelOpen] = useState(true)
|
||||
const [saveOpen, setSaveOpen] = useState(false)
|
||||
const drawioSource = useMemo<DrawioSource>(
|
||||
() => ({
|
||||
editorUrl: config.drawioBaseUrl,
|
||||
ui: config.drawioUi || undefined,
|
||||
lang: config.drawioLang || undefined,
|
||||
// As the classic preview page: the user's own shape libraries
|
||||
libraries: true,
|
||||
}),
|
||||
[config],
|
||||
)
|
||||
const hasVersions = versions.versions.length > 0
|
||||
const showPanel = panelOpen && hasVersions
|
||||
|
||||
// draw.io exports what it shows: while a page export's one-page
|
||||
// projection is on the canvas, or a full load has yet to reach the
|
||||
// editor, that is not the document. Both end within seconds (the
|
||||
// export's deadline is 15 s); the save waits for them, up to 20 s
|
||||
const save = (filename: string, format: ExportFormat, tries = 0) => {
|
||||
const canvasBusy = sync?.read().projectionActive || hasPendingLoad()
|
||||
if (canvasBusy && tries < 66) {
|
||||
setTimeout(() => save(filename, format, tries + 1), 300)
|
||||
return
|
||||
}
|
||||
saveDiagramToFile(
|
||||
filename,
|
||||
format,
|
||||
undefined,
|
||||
dict.save.savedSuccessfully,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VersionsProvider value={versions}>
|
||||
<div className="flex h-dvh flex-col bg-background text-foreground">
|
||||
<header className="flex h-11 shrink-0 items-center gap-3 border-b border-border bg-surface-0 px-3.5">
|
||||
<span className="text-[13px] font-semibold tracking-[-0.01em]">
|
||||
Next AI Draw.io
|
||||
</span>
|
||||
{config.sessionId && (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground tabular-nums"
|
||||
title={config.sessionId}
|
||||
data-testid="shell-session"
|
||||
>
|
||||
{config.sessionId.slice(-8)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
data-testid="shell-status"
|
||||
data-status={status}
|
||||
>
|
||||
{dict.shell[status]}
|
||||
</span>
|
||||
{hasVersions && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen((open) => !open)}
|
||||
aria-label={
|
||||
panelOpen
|
||||
? dict.shell.hideVersions
|
||||
: dict.shell.showVersions
|
||||
}
|
||||
title={
|
||||
panelOpen
|
||||
? dict.shell.hideVersions
|
||||
: dict.shell.showVersions
|
||||
}
|
||||
aria-pressed={panelOpen}
|
||||
className={cn(
|
||||
iconButton,
|
||||
"ml-auto",
|
||||
panelOpen && "bg-accent text-foreground",
|
||||
)}
|
||||
data-testid="versions-toggle"
|
||||
>
|
||||
<History />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSaveOpen(true)}
|
||||
aria-label={dict.workspace.export}
|
||||
title={dict.workspace.export}
|
||||
className={cn(iconButton, !hasVersions && "ml-auto")}
|
||||
data-testid="download"
|
||||
>
|
||||
<Download />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDark(!isDark)}
|
||||
aria-label={dict.shell.toggleTheme}
|
||||
title={dict.shell.toggleTheme}
|
||||
className={iconButton}
|
||||
data-testid="theme-toggle"
|
||||
>
|
||||
{isDark ? <Sun /> : <Moon />}
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<CanvasStage
|
||||
className="inset-0"
|
||||
drawioSource={drawioSource}
|
||||
/>
|
||||
</div>
|
||||
{showPanel && (
|
||||
<VersionsPanel versions={versions.versions} />
|
||||
)}
|
||||
</div>
|
||||
<CompareDialog />
|
||||
<SaveDialog
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSave={save}
|
||||
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
|
||||
/>
|
||||
<AppToaster />
|
||||
</div>
|
||||
</VersionsProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { i18n, type Locale } from "@/lib/i18n/config"
|
||||
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||
import en from "@/lib/i18n/dictionaries/en.json"
|
||||
import ja from "@/lib/i18n/dictionaries/ja.json"
|
||||
import zh from "@/lib/i18n/dictionaries/zh.json"
|
||||
import zhHant from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||
|
||||
// The web app loads one dictionary on the server (lib/i18n/dictionaries.ts
|
||||
// is server-only); the shell ships all four and picks in the browser
|
||||
const DICTIONARIES: Record<Locale, Dictionary> = {
|
||||
en,
|
||||
zh,
|
||||
ja,
|
||||
"zh-Hant": zhHant,
|
||||
}
|
||||
|
||||
/** Our locale for a language tag ("zh-TW", "ja-JP", "en-US"), or null */
|
||||
export function matchLocale(tag: string): Locale | null {
|
||||
const lower = tag.trim().toLowerCase()
|
||||
if (!lower) return null
|
||||
if (/^zh-(hant|tw|hk|mo)\b/.test(lower)) return "zh-Hant"
|
||||
const base = lower.split("-")[0]
|
||||
if (base === "zh") return "zh"
|
||||
if (base === "ja") return "ja"
|
||||
if (base === "en") return "en"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's language: the server's DRAWIO_LANG when it is one of ours,
|
||||
* else the browser's first language we have, else English.
|
||||
*/
|
||||
export function pickLocale(
|
||||
configLang: string,
|
||||
browserLangs: readonly string[],
|
||||
): Locale {
|
||||
for (const tag of [configLang, ...browserLangs]) {
|
||||
const locale = matchLocale(tag)
|
||||
if (locale) return locale
|
||||
}
|
||||
return i18n.defaultLocale
|
||||
}
|
||||
|
||||
export function getShellDictionary(locale: Locale): Dictionary {
|
||||
return DICTIONARIES[locale]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Next AI Draw.io</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/shell/shell.css">
|
||||
<script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script>
|
||||
<script>
|
||||
// Dark class before the first paint (no white flash). The same rule
|
||||
// as shell/theme.ts: the choice stored under "mcp:dark-mode", else
|
||||
// the server's DRAWIO_DARK, else the system
|
||||
try {
|
||||
const config = window.__MCP_CONFIG__ || {}
|
||||
const stored = localStorage.getItem("mcp:dark-mode")
|
||||
const dark =
|
||||
stored === "true" ||
|
||||
(stored === null &&
|
||||
(config.drawioDark === "dark" ||
|
||||
(config.drawioDark !== "light" &&
|
||||
matchMedia("(prefers-color-scheme: dark)").matches)))
|
||||
if (dark) document.documentElement.classList.add("dark")
|
||||
} catch {}
|
||||
</script>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/shell/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
// Entry of the MCP's canvas shell, bundled by scripts/build-shell.mjs into
|
||||
// dist/shell/shell.js and served at /shell/ by http-server.ts
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { LocaleProvider } from "@/components/canvas/locale-context"
|
||||
import { DiagramProvider } from "@/contexts/diagram-context"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
import { CanvasShell } from "./canvas-shell"
|
||||
import { getShellDictionary, pickLocale } from "./dictionary"
|
||||
import { pageShellConfig } from "./runtime-config"
|
||||
import { initTheme } from "./theme"
|
||||
|
||||
const config = pageShellConfig()
|
||||
const locale = pickLocale(config.lang, navigator.languages)
|
||||
// The stylesheet picks the CJK fonts by the document's language
|
||||
document.documentElement.lang = locale
|
||||
initTheme(config.drawioDark)
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("The shell page has no #root element")
|
||||
createRoot(root).render(
|
||||
<DictionaryProvider dictionary={getShellDictionary(locale)}>
|
||||
<LocaleProvider locale={locale}>
|
||||
<DiagramProvider>
|
||||
<CanvasShell config={config} />
|
||||
</DiagramProvider>
|
||||
</LocaleProvider>
|
||||
</DictionaryProvider>,
|
||||
)
|
||||
@@ -0,0 +1,964 @@
|
||||
/**
|
||||
* The shell's side of the MCP server's state protocol (src/http-server.ts),
|
||||
* ported from the classic preview page (src/preview/preview.js) without the
|
||||
* page: the server is reached through fetch, the canvas through the
|
||||
* SyncCanvas functions the hook (use-mcp-sync.ts) passes in. No React here,
|
||||
* so the state machine can be driven step by step in tests.
|
||||
*
|
||||
* The protocol in short: the tab polls GET /api/state every 2 s and loads a
|
||||
* newer version; a user edit is pushed with the version and state it is
|
||||
* based on, and the server refuses (409) a push based on a version the AI
|
||||
* has since written over, or on a state it has lost (it expired, the
|
||||
* process restarted); the next poll then decides whose diagram wins
|
||||
* (recoverState). get_diagram asks for a sync (the canvas as draw.io has
|
||||
* it) and screenshots and file exports for an image, both through the poll.
|
||||
*/
|
||||
import { isSameDocument, sameFileVars } from "@/lib/diagram-diff"
|
||||
import { hasLoadOnlySettings } from "@/lib/drawio/editor-bridge"
|
||||
import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
|
||||
import {
|
||||
normalizeToMxfile,
|
||||
parseMxfile,
|
||||
serializeMxfile,
|
||||
} from "@/packages/mcp-server/src/pages.ts"
|
||||
import type { SelectionAnswer } from "@/packages/mcp-server/src/selection.ts"
|
||||
|
||||
export type ExportFormat = "png" | "svg" | "xmlsvg"
|
||||
|
||||
/** GET /api/state as the server answers it */
|
||||
export interface ServerState {
|
||||
xml: string | null
|
||||
version: number
|
||||
stateId: string | null
|
||||
blank: boolean
|
||||
syncRequested: boolean
|
||||
exportFormat: ExportFormat | null
|
||||
exportXml: string | null
|
||||
exportOptions: { width?: number; pageId?: string } | null
|
||||
exportId: string | null
|
||||
/** A get_selection request waiting for this tab's answer */
|
||||
selectionId?: string | null
|
||||
/** Changes when History or the entry on the canvas does (the version
|
||||
* cards read History again then); older servers send none */
|
||||
historyKey?: string
|
||||
}
|
||||
|
||||
/** An entry of GET /api/history */
|
||||
export interface HistoryEntryInfo {
|
||||
id: number
|
||||
index: number
|
||||
svg: string
|
||||
xml: string
|
||||
/** Who made it: "user", "recover", "restore"; null for the AI's writes */
|
||||
source: string | null
|
||||
/** The oldest entry with the same content (itself when it is the first) */
|
||||
firstId: number
|
||||
}
|
||||
|
||||
/** GET /api/history as the server answers it, for the state the list is of */
|
||||
export interface HistoryList {
|
||||
entries: HistoryEntryInfo[]
|
||||
stateId: string | null
|
||||
/** The entry whose content the canvas shows (the server's rule), or null */
|
||||
currentId: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* How a server write goes on the canvas: as one undo step on the page the
|
||||
* user is viewing (the editor highlights it and Ctrl+Z takes it back), or
|
||||
* as a full load of the document (see decideLoad)
|
||||
*/
|
||||
export type LoadDecision =
|
||||
| { mode: "commit"; pageId: string | null }
|
||||
| { mode: "load" }
|
||||
|
||||
export interface ExportRequest {
|
||||
format: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
data?: string
|
||||
xml?: string
|
||||
}
|
||||
|
||||
/** The canvas as the sync drives it (the hook maps it onto DiagramProvider) */
|
||||
export interface SyncCanvas {
|
||||
load(xml: string, decision: LoadDecision): void
|
||||
/** Show a one-page projection for an export only: not recorded, its
|
||||
* autosaves ignored until the next load */
|
||||
showTransient(xml: string): void
|
||||
/** draw.io's export; null when it does not answer in time */
|
||||
export(
|
||||
request: ExportRequest,
|
||||
timeoutMs: number,
|
||||
): Promise<ExportResult | null>
|
||||
/** The document on the canvas now ("" before the first load) */
|
||||
currentXml(): string
|
||||
/** The page on screen; null when unknown (external draw.io) */
|
||||
currentPageId(): string | null
|
||||
/** A full load was sent and draw.io has not reported it yet: the
|
||||
* editor still shows the previous document */
|
||||
loadPending(): boolean
|
||||
/** The cells selected in the editor, for get_selection; `unavailable`
|
||||
* when the page cannot reach the editor (external draw.io) */
|
||||
readSelection(): SelectionAnswer
|
||||
}
|
||||
|
||||
export type SyncStatus = "waiting" | "connected" | "offline"
|
||||
|
||||
/** Texts shown to the user (the shell takes them from its dictionary) */
|
||||
export type SyncNotice =
|
||||
| "tooLarge"
|
||||
| "unreachable"
|
||||
| "restoredFromFile"
|
||||
| "aiChanged"
|
||||
| "historyChanged"
|
||||
| "restoreFailed"
|
||||
|
||||
export interface SyncOptions {
|
||||
sessionId: string
|
||||
/** Prefix of the server's API paths ("/api") */
|
||||
apiBase: string
|
||||
/** Every request carries it in the X-Drawio-Token header */
|
||||
token: string
|
||||
canvas: SyncCanvas
|
||||
onNotice: (notice: SyncNotice) => void
|
||||
onStatus?: (status: SyncStatus) => void
|
||||
/** The tab is hidden; another tab of this session may be in front */
|
||||
isHidden?: () => boolean
|
||||
/** The server made a new state for the session: History entries got
|
||||
* new ids, so a list on screen is stale */
|
||||
onStateRecreated?: () => void
|
||||
/** The token of the process now answering on this port, read from a
|
||||
* fresh copy of this page; default: tokenFromPage of location.href */
|
||||
refreshToken?: () => Promise<string | null>
|
||||
fetch?: typeof fetch
|
||||
}
|
||||
|
||||
export interface McpSync {
|
||||
/** Poll now and every 2 s */
|
||||
start(): void
|
||||
stop(): void
|
||||
poll(): Promise<void>
|
||||
/** draw.io is ready for exports (DiagramProvider's isDrawioReady) */
|
||||
setReady(ready: boolean): void
|
||||
/** draw.io sent an autosave: the canvas changed to this document */
|
||||
onAutoSave(xml: string): void
|
||||
/** draw.io reported a load done */
|
||||
onDrawioLoad(): void
|
||||
/** The server's History, with the state its ids belong to; null when
|
||||
* it could not be read, or the state changed meanwhile (the version
|
||||
* cards, shell/node-versions-source.ts) */
|
||||
fetchHistory(): Promise<HistoryList | null>
|
||||
/** Put a History entry back, naming the state its list belongs to */
|
||||
restoreEntry(
|
||||
id: number,
|
||||
listStateId: string | null,
|
||||
): Promise<"ok" | "stale" | "failed">
|
||||
/** Called when the server's diagram or History changed (a write
|
||||
* loaded, an edit saved, a thumbnail sent, the state recreated): the
|
||||
* version cards read History again. Returns the unsubscribe */
|
||||
onServerChange(listener: () => void): () => void
|
||||
/** For tests and the status bar */
|
||||
read(): {
|
||||
stateId: string | null
|
||||
currentVersion: number
|
||||
lastXml: string | null
|
||||
latestXml: string | null
|
||||
projectionActive: boolean
|
||||
status: SyncStatus
|
||||
}
|
||||
}
|
||||
|
||||
export const POLL_INTERVAL_MS = 2000
|
||||
|
||||
/** The API token the server wrote into a shell page (window.__MCP_CONFIG__) */
|
||||
export function tokenFromPage(html: string): string | null {
|
||||
const match = html.match(/window\.__MCP_CONFIG__ = (\{.*?\});/)
|
||||
if (!match) return null
|
||||
try {
|
||||
const token = JSON.parse(match[1]).token
|
||||
return typeof token === "string" && token ? token : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same diagram, as text or as documents (draw.io re-serializes what it
|
||||
* loads: another attribute order, filled-in defaults). Only documents with
|
||||
* a page or a model are compared as documents: two without any would
|
||||
* otherwise count as the same.
|
||||
*/
|
||||
function sameDiagram(a: string | null, b: string | null): boolean {
|
||||
if (a === b) return true
|
||||
if (a === null || b === null) return false
|
||||
const hasModel = (xml: string) => /<(mxGraphModel|diagram)[\s>]/.test(xml)
|
||||
return hasModel(a) && hasModel(b) && isSameDocument(a, b)
|
||||
}
|
||||
|
||||
/** SVG as a data URL, as the server stores thumbnails */
|
||||
function svgDataUrl(svg: string): string {
|
||||
if (svg.startsWith("data:")) return svg
|
||||
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a server write can go on the canvas as one undo step: it has the
|
||||
* same pages as the canvas, changes only the one the user is viewing (the
|
||||
* one with currentPageId; a one-page document needs no id, several pages
|
||||
* with the page unknown load in full), keeps the file variables and needs
|
||||
* no load-only page setting. Pages are compared with the MCP core's
|
||||
* contentFingerprint (names and cells). The editor bridge checks the same
|
||||
* against the live editor and falls back to a full load on its own.
|
||||
*/
|
||||
export function decideLoad(
|
||||
currentXml: string,
|
||||
nextXml: string,
|
||||
currentPageId: string | null,
|
||||
): LoadDecision {
|
||||
const parse = (xml: string) => {
|
||||
const normalized = normalizeToMxfile(xml)
|
||||
return normalized ? parseMxfile(normalized) : null
|
||||
}
|
||||
const full: LoadDecision = { mode: "load" }
|
||||
const current = parse(currentXml)
|
||||
const next = parse(nextXml)
|
||||
if (!current || !next) return full
|
||||
if (
|
||||
!sameFileVars(
|
||||
current.documentElement.getAttribute("vars"),
|
||||
next.documentElement.getAttribute("vars"),
|
||||
)
|
||||
) {
|
||||
return full
|
||||
}
|
||||
const pagesNow = Array.from(current.querySelectorAll("diagram"))
|
||||
const pagesNext = Array.from(next.querySelectorAll("diagram"))
|
||||
if (pagesNow.length === 0 || pagesNow.length !== pagesNext.length) {
|
||||
return full
|
||||
}
|
||||
let index = pagesNow.findIndex(
|
||||
(page) => page.getAttribute("id") === currentPageId,
|
||||
)
|
||||
if (index < 0) {
|
||||
// The page on screen is unknown (an external draw.io cannot tell):
|
||||
// with several pages the target cannot be told, load in full
|
||||
if (pagesNow.length > 1) return full
|
||||
index = 0
|
||||
}
|
||||
const target = pagesNext[index]
|
||||
// The page is found by its id when the document has several
|
||||
if (
|
||||
pagesNow.length > 1 &&
|
||||
target.getAttribute("id") !== pagesNow[index].getAttribute("id")
|
||||
) {
|
||||
return full
|
||||
}
|
||||
const targetModel = target.querySelector("mxGraphModel")
|
||||
if (targetModel && hasLoadOnlySettings(targetModel)) return full
|
||||
// The other pages, as the MCP core compares them (edit-gate.ts)
|
||||
const others = (doc: Document, pages: Element[]) => {
|
||||
pages[index].remove()
|
||||
return contentFingerprint(serializeMxfile(doc))
|
||||
}
|
||||
if (others(current, pagesNow) !== others(next, pagesNext)) return full
|
||||
return { mode: "commit", pageId: target.getAttribute("id") }
|
||||
}
|
||||
|
||||
export function createMcpSync(options: SyncOptions): McpSync {
|
||||
const { sessionId, apiBase, canvas, onNotice, onStatus } = options
|
||||
const fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args))
|
||||
let token = options.token
|
||||
|
||||
let currentVersion = 0
|
||||
let isReady = false
|
||||
// What the server has (the text it sent or we pushed) and the newest
|
||||
// diagram on the canvas, saved to the server or not
|
||||
let lastXml: string | null = null
|
||||
let latestXml: string | null = null
|
||||
// The server state this tab is in step with; null until the first poll
|
||||
let stateId: string | null = null
|
||||
let pushFailing = false
|
||||
const pushesInFlight: string[] = []
|
||||
// After a recovery replaced the canvas, until draw.io reports the load:
|
||||
// an autosave still on its way belongs to the canvas being replaced
|
||||
let awaitingLoad = false
|
||||
let awaitingLoadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// A write taken in place (a commit) makes draw.io autosave its own copy
|
||||
// of it, serialized its way: that one autosave is not an edit
|
||||
let awaitingCommitCopy = false
|
||||
let pollSeq = 0
|
||||
let lastHandledPoll = 0 // polls overlap; older answers are dropped
|
||||
// The edit whose SVG export is pending, and what it was based on
|
||||
let pendingSvgExport: string | null = null
|
||||
// The latest thumbnail export of a loaded server write: the state and
|
||||
// version it showed, and the XML loaded
|
||||
let thumbExport: {
|
||||
stateId: string | null
|
||||
version: number
|
||||
xml: string
|
||||
} | null = null
|
||||
// Its picture was not taken because the user had changed the canvas by
|
||||
// then: taken when the canvas shows the write again (an undo)
|
||||
let thumbMissed = false
|
||||
let pendingMcpExport: ExportFormat | null = null
|
||||
let mcpExportSeq = 0
|
||||
let mcpExportId: string | null = null
|
||||
// A one-page projection is on screen for a page-targeted export
|
||||
let projectionExportActive = false
|
||||
// The restore load of the real document was sent; until draw.io reports
|
||||
// it (or 5 s), the projection still counts as on screen
|
||||
let projectionRestoreTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const endProjection = () => {
|
||||
projectionExportActive = false
|
||||
if (projectionRestoreTimer) clearTimeout(projectionRestoreTimer)
|
||||
projectionRestoreTimer = null
|
||||
}
|
||||
// Load the server state on the next poll even at the same version
|
||||
let forceReload = false
|
||||
let pendingSyncExport = false
|
||||
let syncExportSeq = 0
|
||||
// The selection request answered last: the server shows a request until
|
||||
// the answer arrives, and polls overlap
|
||||
let answeredSelectionId: string | null = null
|
||||
let status: SyncStatus = "waiting"
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const setStatus = (next: SyncStatus) => {
|
||||
if (status === next) return
|
||||
status = next
|
||||
onStatus?.(next)
|
||||
}
|
||||
|
||||
// Listeners of onServerChange, told once per History key seen (the
|
||||
// server sends it with every state and push answer; a write, a restore
|
||||
// and the first hand edit change it, further hand edits do not) and
|
||||
// whenever History changed without one (a rejected edit kept, a
|
||||
// thumbnail stored)
|
||||
const changeListeners = new Set<() => void>()
|
||||
let notedKey: string | null = null
|
||||
let notedStateId: string | null = null
|
||||
const emitChange = () => {
|
||||
for (const listener of changeListeners) listener()
|
||||
}
|
||||
const noteHistory = (key: string, sid: string | null) => {
|
||||
if (key === notedKey && sid === notedStateId) return
|
||||
notedKey = key
|
||||
notedStateId = sid
|
||||
emitChange()
|
||||
}
|
||||
|
||||
// A 403 means another MCP process (with its own token) now answers on
|
||||
// this port: read its token and retry once, so the next poll can recover
|
||||
// the session instead of being refused forever
|
||||
let tokenRefresh: Promise<void> | null = null
|
||||
const refreshToken = () => {
|
||||
if (!tokenRefresh) {
|
||||
const read =
|
||||
options.refreshToken ??
|
||||
(() =>
|
||||
fetchFn(location.href, { cache: "no-store" })
|
||||
.then((r) => r.text())
|
||||
.then(tokenFromPage))
|
||||
tokenRefresh = read()
|
||||
.then((next) => {
|
||||
if (next) token = next
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
tokenRefresh = null
|
||||
})
|
||||
}
|
||||
return tokenRefresh
|
||||
}
|
||||
|
||||
const api = async (path: string, init: RequestInit = {}) => {
|
||||
const send = () =>
|
||||
fetchFn(`${apiBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...(init.headers ?? {}), "X-Drawio-Token": token },
|
||||
})
|
||||
let r = await send()
|
||||
if (r.status === 403) {
|
||||
await refreshToken()
|
||||
r = await send()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
const postJson = (path: string, body: unknown) =>
|
||||
api(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
/** Put a server write on the canvas and take its thumbnail */
|
||||
const loadFromServer = (xml: string, how: "decide" | "full") => {
|
||||
lastXml = xml
|
||||
latestXml = xml
|
||||
const before = canvas.currentXml()
|
||||
const decision: LoadDecision =
|
||||
how === "decide"
|
||||
? decideLoad(before, xml, canvas.currentPageId())
|
||||
: { mode: "load" }
|
||||
canvas.load(xml, decision)
|
||||
// draw.io autosaves its copy only when the write changed its
|
||||
// document: after one that left the canvas as it was, the next
|
||||
// autosave is the user's
|
||||
awaitingCommitCopy =
|
||||
decision.mode === "commit" && !sameDiagram(before, xml)
|
||||
// currentVersion is the write's version
|
||||
thumbExport = { stateId, version: currentVersion, xml }
|
||||
thumbMissed = false
|
||||
setTimeout(captureThumbnail, 500)
|
||||
}
|
||||
|
||||
// The image of the server write on the canvas, for its History entry:
|
||||
// only for the latest load, once draw.io is ready, and only if the
|
||||
// canvas still shows it when the export answers
|
||||
const captureThumbnail = () => {
|
||||
const t = thumbExport
|
||||
if (!t || !isReady) return
|
||||
canvas.export({ format: "svg" }, 5000).then((result) => {
|
||||
if (!result?.data || thumbExport !== t) return
|
||||
if (!sameDiagram(latestXml, t.xml)) {
|
||||
thumbMissed = true
|
||||
return
|
||||
}
|
||||
thumbExport = null
|
||||
thumbMissed = false
|
||||
postJson("/history-svg", {
|
||||
sessionId,
|
||||
svg: svgDataUrl(result.data),
|
||||
stateId: t.stateId,
|
||||
version: t.version,
|
||||
})
|
||||
// The write's History entry has its picture now
|
||||
.then(emitChange)
|
||||
.catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
// Until draw.io reports the load (its messages come in order), an
|
||||
// autosave is from the canvas being replaced; in case no report comes,
|
||||
// not for long
|
||||
const expectLoad = () => {
|
||||
awaitingLoad = true
|
||||
if (awaitingLoadTimer) clearTimeout(awaitingLoadTimer)
|
||||
awaitingLoadTimer = setTimeout(() => {
|
||||
awaitingLoad = false
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Restore the user's real document after a page-targeted projection
|
||||
// export by reloading the server state (it also has any autosave that
|
||||
// was still in flight when the projection started). projectionExportActive
|
||||
// stays set until the poll loads the document: an edit on the
|
||||
// projection before that must not be pushed.
|
||||
const restoreFromProjection = () => {
|
||||
if (!projectionExportActive) return
|
||||
forceReload = true
|
||||
poll()
|
||||
}
|
||||
|
||||
// source is "sync" for replies to a server sync request, "recover" for
|
||||
// the tab's copy after the server recovered the session, else "edit".
|
||||
// sid is the server state the push is based on.
|
||||
async function pushState(
|
||||
xml: string,
|
||||
svg = "",
|
||||
baseVersion = currentVersion,
|
||||
source: "edit" | "sync" | "recover" = "edit",
|
||||
sid = stateId,
|
||||
): Promise<void> {
|
||||
if (!sessionId) return
|
||||
pushesInFlight.push(xml)
|
||||
try {
|
||||
const r = await postJson("/state", {
|
||||
sessionId,
|
||||
xml,
|
||||
svg,
|
||||
baseVersion,
|
||||
source,
|
||||
stateId: sid,
|
||||
})
|
||||
pushFailing = false
|
||||
if (r.ok) {
|
||||
const d = await r.json()
|
||||
// An answer about a state this tab has left since, or one
|
||||
// that comes after a newer version was loaded or saved
|
||||
if (sid !== stateId || d.version < currentVersion) return
|
||||
currentVersion = d.version
|
||||
lastXml = xml
|
||||
noteHistory(d.historyKey ?? String(d.version), stateId)
|
||||
// The canvas changed while this edit was on its way, to
|
||||
// something no pending autosave will send (an undo back to
|
||||
// the previous version): send it now. A sync reply is
|
||||
// draw.io's export of the canvas, in another format than its
|
||||
// autosave.
|
||||
if (
|
||||
latestXml &&
|
||||
latestXml !== xml &&
|
||||
pendingSvgExport !== latestXml &&
|
||||
source === "edit"
|
||||
) {
|
||||
pushState(latestXml)
|
||||
}
|
||||
} else if (r.status === 413) {
|
||||
// Over the server's size limit: the image is most of it, so
|
||||
// try once without it
|
||||
if (svg) pushState(xml, "", baseVersion, source, sid)
|
||||
else onNotice("tooLarge")
|
||||
} else if (r.status === 409) {
|
||||
// The AI wrote a newer version, or the server lost the state
|
||||
// this push was based on; the next poll sorts it out
|
||||
const d = await r.json().catch(() => ({}))
|
||||
if (d.savedToHistory) {
|
||||
onNotice(
|
||||
source === "recover" ? "restoredFromFile" : "aiChanged",
|
||||
)
|
||||
// History grew without a new version
|
||||
emitChange()
|
||||
}
|
||||
poll()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Push failed:", error)
|
||||
setStatus("offline")
|
||||
if (!pushFailing) {
|
||||
pushFailing = true
|
||||
onNotice("unreachable")
|
||||
}
|
||||
} finally {
|
||||
pushesInFlight.splice(pushesInFlight.indexOf(xml), 1)
|
||||
}
|
||||
}
|
||||
|
||||
// The server made a new state for this session: it expired, or the MCP
|
||||
// process restarted. Decide whose diagram wins.
|
||||
const recoverState = (s: ServerState) => {
|
||||
stateId = s.stateId
|
||||
options.onStateRecreated?.()
|
||||
// The old state's pending work is gone with it
|
||||
const projectionShown = projectionExportActive
|
||||
endProjection()
|
||||
forceReload = false
|
||||
pendingMcpExport = null
|
||||
pendingSyncExport = false
|
||||
const mine = latestXml
|
||||
currentVersion = s.version
|
||||
if (s.blank || s.xml === lastXml) {
|
||||
// The server knows nothing, or exactly what this tab last saved:
|
||||
// the canvas can only be newer, so it wins (edits made while the
|
||||
// server was down are saved now)
|
||||
if (projectionShown && mine) {
|
||||
canvas.load(mine, { mode: "load" })
|
||||
expectLoad()
|
||||
}
|
||||
if (mine && !sameDiagram(mine, s.xml)) {
|
||||
pushState(mine, "", s.version)
|
||||
} else if (s.xml && !s.blank && mine) {
|
||||
// The canvas still shows the diagram the server recovered:
|
||||
// its History entry came back without a picture (the file
|
||||
// keeps none), take one now
|
||||
thumbExport = { stateId, version: currentVersion, xml: s.xml }
|
||||
thumbMissed = false
|
||||
captureThumbnail()
|
||||
}
|
||||
} else if (s.xml) {
|
||||
// The server has a diagram this tab never showed (an AI write it
|
||||
// missed, a saved file): show that, and keep this tab's copy in
|
||||
// History unless it is the same. A full load, as the classic
|
||||
// page does: whether the canvas can take the write in place is
|
||||
// decided inside DiagramProvider, and the sync cannot tell; until
|
||||
// draw.io reports the load, an autosave is from the canvas being
|
||||
// replaced
|
||||
loadFromServer(s.xml, "full")
|
||||
expectLoad()
|
||||
if (mine && !sameDiagram(mine, s.xml)) {
|
||||
pushState(mine, "", s.version, "recover")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startSyncExport = () => {
|
||||
pendingSyncExport = true
|
||||
// The version and state the export is taken at: a newer AI write may
|
||||
// load meanwhile, and this older XML must not overwrite it
|
||||
const base = currentVersion
|
||||
const sid = stateId
|
||||
const seq = ++syncExportSeq
|
||||
canvas.export({ format: "xml" }, 5000).then((result) => {
|
||||
// A late reply to an earlier request was taken at another version
|
||||
if (seq !== syncExportSeq) return
|
||||
pendingSyncExport = false
|
||||
if (result?.xml) pushState(result.xml, "", base, "sync", sid)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle an export request from the MCP server (png/svg).
|
||||
//
|
||||
// Plain export: capture whatever page is on screen (PNG: pageId picks a
|
||||
// page, width caps the size).
|
||||
//
|
||||
// Page-targeted export: the server sends a single-page <mxfile>
|
||||
// projection in exportXml. It goes on the canvas as a transient
|
||||
// document, draw.io renders it, exports, then the real document is
|
||||
// loaded back from the server. The session state is never changed.
|
||||
const startMcpExport = (s: ServerState, justLoaded: boolean) => {
|
||||
const format = s.exportFormat as ExportFormat
|
||||
pendingMcpExport = format
|
||||
const seq = ++mcpExportSeq
|
||||
mcpExportId = s.exportId
|
||||
const extra = s.exportOptions ?? {}
|
||||
const fire = () => {
|
||||
const request: ExportRequest =
|
||||
format === "png"
|
||||
? {
|
||||
format: "png",
|
||||
scale: 2,
|
||||
currentPage: !extra.pageId,
|
||||
...extra,
|
||||
}
|
||||
: { format }
|
||||
canvas.export(request, 10000).then((result) => {
|
||||
// A later export is running by now, or this one ended
|
||||
if (seq !== mcpExportSeq || pendingMcpExport === null) return
|
||||
const d = result?.data ?? ""
|
||||
const isPng = format === "png" && d.startsWith("data:image/png")
|
||||
const isSvg =
|
||||
format !== "png" &&
|
||||
(d.startsWith("data:image/svg") || d.startsWith("<svg"))
|
||||
const done = () => {
|
||||
if (seq !== mcpExportSeq) return
|
||||
pendingMcpExport = null
|
||||
// Page-targeted export: restore the user's real document
|
||||
restoreFromProjection()
|
||||
}
|
||||
if (!isPng && !isSvg) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
// Keep pendingMcpExport set until the server has the result:
|
||||
// a poll answered before that still sees the request and
|
||||
// would start the same export again
|
||||
postJson("/state", {
|
||||
sessionId,
|
||||
exportData: d,
|
||||
exportId: mcpExportId,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(done)
|
||||
})
|
||||
}
|
||||
if (s.exportXml) {
|
||||
projectionExportActive = true
|
||||
canvas.showTransient(s.exportXml)
|
||||
// Let draw.io render the loaded page before exporting
|
||||
setTimeout(fire, 600)
|
||||
} else if (justLoaded) {
|
||||
// A write with a screenshot: give the new diagram's external
|
||||
// icon images a moment to load before the PNG is taken
|
||||
setTimeout(fire, 600)
|
||||
} else {
|
||||
fire()
|
||||
}
|
||||
// The whole export, result delivery included, ends in time: a result
|
||||
// POST that never answers must not keep the projection on screen
|
||||
// past the server's deadline (15 s). Only for this export: a later
|
||||
// one may be running by then. The export is over: its result POST
|
||||
// answering after this must not restore the document a second time.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport && seq === mcpExportSeq) {
|
||||
mcpExportSeq++
|
||||
pendingMcpExport = null
|
||||
restoreFromProjection()
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (!sessionId) return
|
||||
const seq = ++pollSeq
|
||||
try {
|
||||
const r = await api(
|
||||
`/state?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
)
|
||||
if (!r.ok) {
|
||||
// Refused (another process answers on this port and its
|
||||
// token could not be read) or failing: edits are not saved
|
||||
setStatus("offline")
|
||||
return
|
||||
}
|
||||
const s: ServerState = await r.json()
|
||||
// An older answer than one already handled (the interval, the
|
||||
// 409 handler and the projection restore each poll): it could
|
||||
// name a state that is gone
|
||||
if (seq < lastHandledPoll) return
|
||||
lastHandledPoll = seq
|
||||
setStatus("connected")
|
||||
if (stateId === null) stateId = s.stateId
|
||||
else if (s.stateId && s.stateId !== stateId) recoverState(s)
|
||||
// Load a new version (before an export, so it pictures the
|
||||
// latest). While a projection is on screen, only the restore
|
||||
// (forceReload) replaces it, so a new version doesn't fight the
|
||||
// projection; currentVersion stays unadvanced until then. The
|
||||
// tab's own push still on its way is not loaded back: the canvas
|
||||
// may have moved on since (an undo), and its answer follows.
|
||||
const ownPush = s.xml !== null && pushesInFlight.includes(s.xml)
|
||||
let justLoaded = false
|
||||
if (
|
||||
(forceReload ||
|
||||
(s.version > currentVersion &&
|
||||
!projectionExportActive &&
|
||||
!ownPush)) &&
|
||||
s.xml
|
||||
) {
|
||||
// The restore after a projection is a full load of the
|
||||
// document the user had
|
||||
const how = forceReload ? "full" : "decide"
|
||||
forceReload = false
|
||||
currentVersion = s.version
|
||||
loadFromServer(s.xml, how)
|
||||
// The projection stays on screen until draw.io reports this
|
||||
// load (its messages come in order): an autosave before that
|
||||
// is of the projection, not an edit
|
||||
if (projectionExportActive && !projectionRestoreTimer) {
|
||||
projectionRestoreTimer = setTimeout(endProjection, 5000)
|
||||
}
|
||||
justLoaded = true
|
||||
}
|
||||
// Also the tab's own push, and a version a projection holds back
|
||||
noteHistory(s.historyKey ?? String(s.version), stateId)
|
||||
// A sync request (get_diagram): after the load above, so draw.io
|
||||
// exports what it just loaded; never while a one-page projection
|
||||
// is on screen, which would be sent as the whole document
|
||||
if (
|
||||
s.syncRequested &&
|
||||
!pendingSyncExport &&
|
||||
isReady &&
|
||||
!projectionExportActive
|
||||
) {
|
||||
startSyncExport()
|
||||
}
|
||||
// Nor an export while the projection is still on screen: it
|
||||
// would picture the projection
|
||||
if (
|
||||
s.exportFormat &&
|
||||
!pendingMcpExport &&
|
||||
isReady &&
|
||||
!projectionExportActive
|
||||
) {
|
||||
startMcpExport(s, justLoaded)
|
||||
}
|
||||
// A selection request (get_selection): the editor's selection
|
||||
// on the page the user is viewing, once draw.io is up (the
|
||||
// bridge attaches to it at its first load), not while a
|
||||
// projection replaces that page, and not while a full load (the
|
||||
// one above, or one still on its way) has yet to reach the
|
||||
// editor: it still shows the previous document
|
||||
if (
|
||||
s.selectionId &&
|
||||
s.selectionId !== answeredSelectionId &&
|
||||
isReady &&
|
||||
!projectionExportActive &&
|
||||
!canvas.loadPending()
|
||||
) {
|
||||
const id = s.selectionId
|
||||
answeredSelectionId = id
|
||||
const answer = () => {
|
||||
// A hidden tab answers later: the canvas may have moved
|
||||
// on since the poll (a projection, a full load, draw.io
|
||||
// starting over). The same conditions as above; when
|
||||
// they no longer hold, the next poll decides again
|
||||
if (
|
||||
!isReady ||
|
||||
projectionExportActive ||
|
||||
canvas.loadPending()
|
||||
) {
|
||||
if (answeredSelectionId === id) {
|
||||
answeredSelectionId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
postJson("/state", {
|
||||
sessionId,
|
||||
selectionId: id,
|
||||
selection: canvas.readSelection(),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(String(r.status))
|
||||
})
|
||||
.catch(() => {
|
||||
// Not delivered: the next poll answers it again
|
||||
if (answeredSelectionId === id) {
|
||||
answeredSelectionId = null
|
||||
}
|
||||
})
|
||||
}
|
||||
// A hidden tab lets a tab in front (the same session open
|
||||
// twice) answer first; the server takes the first answer
|
||||
// and ignores the later one
|
||||
if (options.isHidden?.()) {
|
||||
setTimeout(answer, POLL_INTERVAL_MS + 500)
|
||||
} else {
|
||||
answer()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setStatus("offline")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (!sessionId || interval) return
|
||||
poll()
|
||||
interval = setInterval(poll, POLL_INTERVAL_MS)
|
||||
},
|
||||
stop() {
|
||||
if (interval) clearInterval(interval)
|
||||
interval = null
|
||||
},
|
||||
poll,
|
||||
setReady(ready) {
|
||||
isReady = ready
|
||||
// draw.io is starting over (an external editor reloads on a
|
||||
// theme switch): an export in flight gets no answer, and a
|
||||
// projection on the canvas is gone with the old frame. The next
|
||||
// poll puts the document back and starts the server's export
|
||||
// again
|
||||
if (!ready && pendingMcpExport !== null) {
|
||||
mcpExportSeq++
|
||||
pendingMcpExport = null
|
||||
if (projectionExportActive) forceReload = true
|
||||
}
|
||||
// A write loaded before draw.io was ready gets its thumbnail now
|
||||
if (ready && thumbExport) captureThumbnail()
|
||||
},
|
||||
onAutoSave(xml) {
|
||||
// Ignore autosave while a single-page projection is on screen
|
||||
// for a page-targeted export; otherwise the projection would be
|
||||
// pushed as the session's document
|
||||
if (projectionExportActive) return
|
||||
// An edit of the canvas that recovery is replacing: kept in
|
||||
// History, never over the recovered diagram
|
||||
if (awaitingLoad) {
|
||||
pushState(xml, "", currentVersion, "recover")
|
||||
return
|
||||
}
|
||||
// Also an edit undone back to what the server has, or draw.io's
|
||||
// own copy of a write it took in place (a commit). Compared as
|
||||
// documents only for that copy: a change of the paper size alone
|
||||
// is the same document to isSameDocument when the server's copy
|
||||
// names none
|
||||
latestXml = xml
|
||||
const commitCopy = awaitingCommitCopy
|
||||
awaitingCommitCopy = false
|
||||
// The canvas is back at a write whose picture was not taken (an
|
||||
// edit came first): take it now
|
||||
if (
|
||||
thumbMissed &&
|
||||
thumbExport &&
|
||||
sameDiagram(xml, thumbExport.xml)
|
||||
) {
|
||||
thumbMissed = false
|
||||
captureThumbnail()
|
||||
}
|
||||
if (xml === lastXml || (commitCopy && sameDiagram(xml, lastXml))) {
|
||||
return
|
||||
}
|
||||
// Request an SVG export, then push the edit with it; remember
|
||||
// the version and state it is based on. Without an answer in
|
||||
// time, push it without the image.
|
||||
pendingSvgExport = xml
|
||||
const base = currentVersion
|
||||
const sid = stateId
|
||||
canvas.export({ format: "svg" }, 2000).then((result) => {
|
||||
// A later edit took over the slot, or this one was sent
|
||||
if (pendingSvgExport !== xml) return
|
||||
pendingSvgExport = null
|
||||
const svg = result?.data ? svgDataUrl(result.data) : ""
|
||||
pushState(xml, svg, base, "edit", sid)
|
||||
})
|
||||
},
|
||||
onDrawioLoad() {
|
||||
awaitingLoad = false
|
||||
// A commit that fell back to a full load sends no copy
|
||||
awaitingCommitCopy = false
|
||||
// The restore load after a projection export is done
|
||||
if (projectionRestoreTimer) endProjection()
|
||||
},
|
||||
async fetchHistory() {
|
||||
// A list for a state the server recreated meanwhile has old ids
|
||||
const sid = stateId
|
||||
// A failed read is told again at the next poll: no other event
|
||||
// comes for the same History
|
||||
const failed = () => {
|
||||
notedKey = null
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const r = await api(
|
||||
`/history?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
)
|
||||
if (!r.ok) return failed()
|
||||
const d = await r.json()
|
||||
if (sid !== stateId) return null
|
||||
// A list from a state the poll has not seen yet (another
|
||||
// process answers): its poll tells the cards to read again
|
||||
if (typeof d.stateId === "string" && d.stateId !== sid) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
entries: d.entries ?? [],
|
||||
stateId: sid,
|
||||
currentId:
|
||||
typeof d.currentId === "number" ? d.currentId : null,
|
||||
}
|
||||
} catch {
|
||||
return failed()
|
||||
}
|
||||
},
|
||||
async restoreEntry(id, listStateId) {
|
||||
try {
|
||||
const r = await postJson("/restore", {
|
||||
sessionId,
|
||||
id,
|
||||
stateId: listStateId,
|
||||
})
|
||||
if (r.ok) {
|
||||
await poll()
|
||||
return "ok"
|
||||
}
|
||||
if (r.status === 409) {
|
||||
onNotice("historyChanged")
|
||||
return "stale"
|
||||
}
|
||||
} catch {
|
||||
// reported below
|
||||
}
|
||||
onNotice("restoreFailed")
|
||||
return "failed"
|
||||
},
|
||||
onServerChange(listener) {
|
||||
changeListeners.add(listener)
|
||||
return () => {
|
||||
changeListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
read() {
|
||||
return {
|
||||
stateId,
|
||||
currentVersion,
|
||||
lastXml,
|
||||
latestXml,
|
||||
projectionActive: projectionExportActive,
|
||||
status,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type {
|
||||
VersionItem,
|
||||
VersionsSource,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
type ChangeSummary,
|
||||
diffDiagrams,
|
||||
isSameDocument,
|
||||
} from "@/lib/diagram-diff"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts"
|
||||
import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import type { HistoryEntryInfo, HistoryList, McpSync } from "./mcp-sync-core"
|
||||
|
||||
/**
|
||||
* A version card of the shell: one per distinct content of the server's
|
||||
* History, which keeps every state the diagram went through (a restore
|
||||
* adds a copy of an older entry; copies fold into their first entry).
|
||||
*/
|
||||
export interface NodeVersion extends VersionItem {
|
||||
/** The History entry to restore: the newest copy, as restore_version
|
||||
* picks it (a later copy may carry page settings the first lacks) */
|
||||
entryId: number
|
||||
/** The version this one replaced, when it has a card (undo goes there) */
|
||||
beforeId: string | null
|
||||
/** Who made it: "user", "recover", "restore"; null for the AI */
|
||||
source: string | null
|
||||
}
|
||||
|
||||
// The blank page a tab shows before any drawing: the server keeps it in
|
||||
// History when the first write replaces it, but it is no version to show.
|
||||
// A blank page after a drawing is the canvas cleared, a version of its own
|
||||
// (restore_version offers both; the shell hides only the first)
|
||||
const BLANK_FINGERPRINT = contentFingerprint(BLANK_MXFILE)
|
||||
const isBlankPage = (xml: string) =>
|
||||
!hasCells(xml) && contentFingerprint(xml) === BLANK_FINGERPRINT
|
||||
|
||||
/** The ids of a document's pages; a bare model has one, without an id */
|
||||
function pageIds(xml: string): (string | undefined)[] {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
const ids = Array.from(doc.querySelectorAll("diagram")).map(
|
||||
(page) => page.getAttribute("id") ?? undefined,
|
||||
)
|
||||
return ids.length > 0 ? ids : [undefined]
|
||||
}
|
||||
|
||||
/** The page a change was made on: the first page with a change, else none */
|
||||
function changedPage(beforeXml: string, afterXml: string): string | undefined {
|
||||
for (const id of pageIds(afterXml)) {
|
||||
const { summary } = diffDiagrams(beforeXml, afterXml, id)
|
||||
if (Object.values(summary).some((n) => n > 0)) return id
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** What a version changed, kept from its first appearance (see buildVersions) */
|
||||
interface Change {
|
||||
beforeXml: string
|
||||
pageId?: string
|
||||
summary: ChangeSummary
|
||||
fromScratch: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* What buildVersions keeps between calls, keyed by content: a version's
|
||||
* number and change never move, also when its first copy drops out of the
|
||||
* server's buffer and a later copy stands in. Content no entry of the
|
||||
* list has any more is dropped (the server keeps 20 entries; the full
|
||||
* documents in here would otherwise grow with every edit of the session)
|
||||
*/
|
||||
export interface VersionCache {
|
||||
numbers: Map<string, number>
|
||||
changes: Map<string, Change>
|
||||
/** The number the next new version gets */
|
||||
next: number
|
||||
}
|
||||
|
||||
export const newVersionCache = (): VersionCache => ({
|
||||
numbers: new Map(),
|
||||
changes: new Map(),
|
||||
next: 1,
|
||||
})
|
||||
|
||||
/**
|
||||
* The version cards for a History list: entries folded by content, in the
|
||||
* order of their first copy, numbered as they first appeared in this tab,
|
||||
* each with what changed since the state it replaced (the entry right
|
||||
* before its first copy; after a restore that is another than the card
|
||||
* before). The thumbnail is the newest copy's that has one, or an older
|
||||
* copy's of the same document (isSameDocument: a copy with other page
|
||||
* settings looks different); the newest version on the canvas waits for
|
||||
* its picture (the sync sends it), older ones without any show none.
|
||||
*/
|
||||
export function buildVersions(
|
||||
list: HistoryList,
|
||||
cache: VersionCache,
|
||||
): { versions: NodeVersion[]; onCanvasId: string | null } {
|
||||
// The server folds entries by content (firstId). A blank page after a
|
||||
// drawing is a clear of the canvas, a version of its own, which the
|
||||
// server folds into the blank page the tab started with: blanks are
|
||||
// grouped here, a restore's copy of a clear with the newest clear
|
||||
const groups = new Map<number, HistoryEntryInfo[]>()
|
||||
const keys = new Map<number, number>() // entry id -> its group
|
||||
const befores = new Map<number, HistoryEntryInfo>() // group -> entry before it
|
||||
const hidden = new Set<number>() // the blank page(s) before any drawing
|
||||
let drawn = false
|
||||
let lastClear: number | null = null
|
||||
list.entries.forEach((entry, i) => {
|
||||
let key = entry.firstId
|
||||
if (!isBlankPage(entry.xml)) {
|
||||
drawn = true
|
||||
} else if (drawn || cache.numbers.has(`clear:${entry.id}`)) {
|
||||
// A clear this tab has shown stays one when the drawing before
|
||||
// it dropped out of the server's buffer
|
||||
drawn = true
|
||||
key =
|
||||
entry.source === "restore" && lastClear !== null
|
||||
? lastClear
|
||||
: entry.id
|
||||
lastClear = key
|
||||
} else {
|
||||
hidden.add(key)
|
||||
}
|
||||
keys.set(entry.id, key)
|
||||
const copies = groups.get(key)
|
||||
if (copies) copies.push(entry)
|
||||
else {
|
||||
groups.set(key, [entry])
|
||||
if (i > 0) befores.set(key, list.entries[i - 1])
|
||||
}
|
||||
})
|
||||
const onCanvasId =
|
||||
list.currentId !== null && keys.has(list.currentId)
|
||||
? String(keys.get(list.currentId))
|
||||
: null
|
||||
const versions: NodeVersion[] = []
|
||||
const seen = new Set<string>() // the content keys of this list
|
||||
for (const [key, copies] of groups) {
|
||||
if (hidden.has(key)) continue
|
||||
const first = copies[0]
|
||||
const newest = copies[copies.length - 1]
|
||||
const previous = befores.get(key) ?? null
|
||||
// Clears have one content: each is its own version
|
||||
const contentKey = isBlankPage(first.xml)
|
||||
? `clear:${key}`
|
||||
: contentFingerprint(first.xml)
|
||||
seen.add(contentKey)
|
||||
let number = cache.numbers.get(contentKey)
|
||||
if (number === undefined) {
|
||||
number = cache.next++
|
||||
cache.numbers.set(contentKey, number)
|
||||
}
|
||||
let change = cache.changes.get(contentKey)
|
||||
if (!change) {
|
||||
const beforeXml = previous?.xml ?? ""
|
||||
const pageId = changedPage(beforeXml, first.xml)
|
||||
const { summary, fromScratch } = diffDiagrams(
|
||||
beforeXml,
|
||||
first.xml,
|
||||
pageId,
|
||||
)
|
||||
change = { beforeXml, pageId, summary, fromScratch }
|
||||
cache.changes.set(contentKey, change)
|
||||
}
|
||||
const svg = [...copies]
|
||||
.reverse()
|
||||
.find((c) => c.svg && isSameDocument(c.xml, newest.xml))?.svg
|
||||
const beforeKey = previous ? keys.get(previous.id) : undefined
|
||||
versions.push({
|
||||
id: String(key),
|
||||
entryId: newest.id,
|
||||
beforeId:
|
||||
beforeKey !== undefined && !hidden.has(beforeKey)
|
||||
? String(beforeKey)
|
||||
: null,
|
||||
number,
|
||||
xml: newest.xml,
|
||||
beforeXml: change.beforeXml,
|
||||
...(change.pageId && { pageId: change.pageId }),
|
||||
summary: change.summary,
|
||||
fromScratch: change.fromScratch,
|
||||
svg: svg || "",
|
||||
source: first.source,
|
||||
})
|
||||
}
|
||||
// The newest version on the canvas waits for its picture
|
||||
const latest = versions.at(-1)
|
||||
if (latest && !latest.svg && latest.id === onCanvasId) {
|
||||
latest.svg = undefined
|
||||
}
|
||||
for (const key of cache.numbers.keys()) {
|
||||
if (!seen.has(key)) cache.numbers.delete(key)
|
||||
}
|
||||
for (const key of cache.changes.keys()) {
|
||||
if (!seen.has(key)) cache.changes.delete(key)
|
||||
}
|
||||
return { versions, onCanvasId }
|
||||
}
|
||||
|
||||
/** The versions source with the shell's own version type */
|
||||
export interface NodeVersionsSource extends Omit<VersionsSource, "versions"> {
|
||||
versions: NodeVersion[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The canvas components' versions, from the MCP server's History: read
|
||||
* again whenever the sync reports a change on the server, restored through
|
||||
* it. Undo and redo of the newest version restore the one it replaced, or
|
||||
* it again; the server keeps the state they leave as an entry of its own,
|
||||
* so hand edits are never lost.
|
||||
*/
|
||||
export function useNodeVersions(sync: McpSync | null): NodeVersionsSource {
|
||||
const dict = useDictionary()
|
||||
const [list, setList] = useState<HistoryList | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// Stable across refreshes: a version's number and change never move
|
||||
const cacheRef = useRef(newVersionCache())
|
||||
// The server state the cards are of: another one (the session expired,
|
||||
// the process restarted) is a History of its own, numbered anew
|
||||
const cacheStateIdRef = useRef<string | null>(null)
|
||||
// Reads overlap when changes come fast; only the latest one counts
|
||||
const readSeqRef = useRef(0)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!sync) return
|
||||
const seq = ++readSeqRef.current
|
||||
const next = await sync.fetchHistory()
|
||||
if (!next || seq !== readSeqRef.current) return
|
||||
if (next.stateId !== cacheStateIdRef.current) {
|
||||
cacheRef.current = newVersionCache()
|
||||
cacheStateIdRef.current = next.stateId
|
||||
}
|
||||
setList(next)
|
||||
}, [sync])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sync) return
|
||||
refresh()
|
||||
return sync.onServerChange(refresh)
|
||||
}, [sync, refresh])
|
||||
|
||||
const { versions, onCanvasId } = useMemo(
|
||||
() =>
|
||||
list
|
||||
? buildVersions(list, cacheRef.current)
|
||||
: { versions: [], onCanvasId: null },
|
||||
[list],
|
||||
)
|
||||
|
||||
const restore = async (id: string) => {
|
||||
if (!sync || !list || busy) return
|
||||
const version = versions.find((v) => v.id === id)
|
||||
if (!version) return
|
||||
setBusy(true)
|
||||
// The canvas components wait (SelectionAsk, the cards' buttons)
|
||||
useCanvasStore.getState().set({ isBusy: true })
|
||||
try {
|
||||
const result = await sync.restoreEntry(
|
||||
version.entryId,
|
||||
list.stateId,
|
||||
)
|
||||
if (result === "ok") {
|
||||
toast.success(
|
||||
formatMessage(dict.versions.restoredVersion, {
|
||||
n: version.number,
|
||||
}),
|
||||
{ duration: 4000 },
|
||||
)
|
||||
}
|
||||
// The sync's notices tell about a stale list or a failure; the
|
||||
// list on screen is read again either way
|
||||
await refresh()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
useCanvasStore.getState().set({ isBusy: false })
|
||||
}
|
||||
}
|
||||
|
||||
const latest = versions.at(-1)
|
||||
// The version the newest one replaced (restore_version's steps_back=1)
|
||||
const previous = latest?.beforeId
|
||||
? versions.find((v) => v.id === latest.beforeId)
|
||||
: undefined
|
||||
const canUndo = !!latest && !!previous && onCanvasId === latest.id
|
||||
// The newest change is undone when the canvas is at the version before
|
||||
const undoneId =
|
||||
latest && previous && onCanvasId === previous.id ? latest.id : null
|
||||
|
||||
return {
|
||||
versions,
|
||||
onCanvasId,
|
||||
undoneId,
|
||||
isBusy: busy,
|
||||
canUndo,
|
||||
canRedo: undoneId !== null,
|
||||
restore,
|
||||
undo: () => {
|
||||
if (canUndo && previous) restore(previous.id)
|
||||
},
|
||||
redo: () => {
|
||||
if (undoneId && latest) restore(latest.id)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* What the MCP server writes into the shell page as window.__MCP_CONFIG__
|
||||
* (http-server.ts fills the {{CONFIG_JSON}} placeholder of index.html).
|
||||
*/
|
||||
export interface ShellConfig {
|
||||
sessionId: string
|
||||
/** Every /api request carries it in the X-Drawio-Token header */
|
||||
token: string
|
||||
/** Prefix of the server's API paths */
|
||||
apiBase: string
|
||||
/** The editor page: the bundled copy's index.html on this origin, or an
|
||||
* external draw.io */
|
||||
drawioBaseUrl: string
|
||||
/** draw.io theme fixed by DRAWIO_UI; "" leaves the pick to draw.io */
|
||||
drawioUi: string
|
||||
/** Editor language fixed by DRAWIO_LANG, in draw.io's naming; "" leaves
|
||||
* the pick to draw.io */
|
||||
drawioLang: string
|
||||
/** DRAWIO_DARK: the theme when the user has not chosen one here */
|
||||
drawioDark: "dark" | "light" | "auto"
|
||||
/** DRAWIO_LANG as given, for the shell's own texts */
|
||||
lang: string
|
||||
}
|
||||
|
||||
const DEFAULTS: ShellConfig = {
|
||||
sessionId: "",
|
||||
token: "",
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "/drawio/index.html",
|
||||
drawioUi: "",
|
||||
drawioLang: "",
|
||||
drawioDark: "auto",
|
||||
lang: "",
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's config with every field present: a missing or malformed one
|
||||
* (another server version) takes its default.
|
||||
*/
|
||||
export function readShellConfig(source: unknown): ShellConfig {
|
||||
const given =
|
||||
source && typeof source === "object"
|
||||
? (source as Record<string, unknown>)
|
||||
: {}
|
||||
const config = { ...DEFAULTS }
|
||||
for (const key of Object.keys(DEFAULTS) as (keyof ShellConfig)[]) {
|
||||
const value = given[key]
|
||||
if (key === "drawioDark") {
|
||||
if (value === "dark" || value === "light" || value === "auto") {
|
||||
config.drawioDark = value
|
||||
}
|
||||
} else if (typeof value === "string") {
|
||||
config[key] = value
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/** The config the server wrote into this page */
|
||||
export function pageShellConfig(): ShellConfig {
|
||||
return readShellConfig(
|
||||
(window as { __MCP_CONFIG__?: unknown }).__MCP_CONFIG__,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Stylesheet of the MCP's canvas shell: the web app's theme and Tailwind,
|
||||
* with the utility classes of the shared components the shell bundles.
|
||||
* Built by scripts/build-shell.mjs; Tailwind scans this directory on its
|
||||
* own, and the shared components through the @source lines.
|
||||
*/
|
||||
@import "../../../app/globals.css";
|
||||
@source "../../../components/canvas";
|
||||
@source "../../../components/ui";
|
||||
@source "../../../components/ai-elements";
|
||||
@source "../../../components/workspace/app-toaster.tsx";
|
||||
@source "../../../components/save-dialog.tsx";
|
||||
|
||||
/* System fonts stand in for the web fonts the Next.js layout loads */
|
||||
:root {
|
||||
--font-latin:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useSettingsStore } from "@/stores/settings-store"
|
||||
import type { ShellConfig } from "./runtime-config"
|
||||
|
||||
/**
|
||||
* localStorage key of the user's theme choice here: "true" dark, "false"
|
||||
* light, none: the server's DRAWIO_DARK, else the system. Its own prefix,
|
||||
* so a web app dev server on the same port keeps its own settings.
|
||||
* index.html repeats the key in its inline script (run before this bundle).
|
||||
*/
|
||||
export const THEME_STORAGE_KEY = "mcp:dark-mode"
|
||||
|
||||
/** Whether the shell is dark, from the choice stored here, the server's
|
||||
* setting and the system */
|
||||
export function isDarkPreferred(
|
||||
stored: string | null,
|
||||
drawioDark: ShellConfig["drawioDark"],
|
||||
systemDark: boolean,
|
||||
): boolean {
|
||||
if (stored === "true" || stored === "false") return stored === "true"
|
||||
if (drawioDark === "auto") return systemDark
|
||||
return drawioDark === "dark"
|
||||
}
|
||||
|
||||
function read(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(THEME_STORAGE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// The canvas components read the settings store's isDark (and the theme
|
||||
// class, set before the first paint by index.html)
|
||||
function apply(isDark: boolean) {
|
||||
document.documentElement.classList.toggle("dark", isDark)
|
||||
useSettingsStore.setState({
|
||||
hydrated: true,
|
||||
isDark,
|
||||
theme: isDark ? "dark" : "light",
|
||||
})
|
||||
}
|
||||
|
||||
/** Take the theme the inline script chose, and follow the system while
|
||||
* nothing else decides */
|
||||
export function initTheme(drawioDark: ShellConfig["drawioDark"]) {
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
apply(isDarkPreferred(read(), drawioDark, query.matches))
|
||||
query.addEventListener("change", (event) => {
|
||||
apply(isDarkPreferred(read(), drawioDark, event.matches))
|
||||
})
|
||||
}
|
||||
|
||||
/** The user's choice, kept for the next visit */
|
||||
export function setDark(isDark: boolean) {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, String(isDark))
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode, quota)
|
||||
}
|
||||
apply(isDark)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"incremental": false,
|
||||
"plugins": []
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useDiagram } from "@/contexts/diagram-context"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { diffDiagrams } from "@/lib/diagram-diff"
|
||||
import {
|
||||
highlightChangedCells,
|
||||
readSelectionDetails,
|
||||
} from "@/lib/drawio/editor-bridge"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
import { createMcpSync, type McpSync, type SyncStatus } from "./mcp-sync-core"
|
||||
import type { ShellConfig } from "./runtime-config"
|
||||
|
||||
/**
|
||||
* Keeps the canvas in step with the MCP server (mcp-sync-core.ts has the
|
||||
* protocol): mounted once inside DiagramProvider. Server writes go on the
|
||||
* canvas through loadDiagram, as one undo step when they can; the user's
|
||||
* edits are pushed; the server's export and sync requests are answered.
|
||||
* Returns the connection state for the status bar, and the sync itself for
|
||||
* the version cards (History and restores go through it).
|
||||
*/
|
||||
export function useMcpSync(config: ShellConfig): {
|
||||
status: SyncStatus
|
||||
sync: McpSync | null
|
||||
} {
|
||||
const dict = useDictionary()
|
||||
const diagram = useDiagram()
|
||||
const [status, setStatus] = useState<SyncStatus>("waiting")
|
||||
const [sync, setSync] = useState<McpSync | null>(null)
|
||||
const syncRef = useRef<McpSync | null>(null)
|
||||
// The provider's functions are new on every render; the sync (created
|
||||
// once) reads the latest through refs
|
||||
const diagramRef = useRef(diagram)
|
||||
diagramRef.current = diagram
|
||||
const dictRef = useRef(dict)
|
||||
dictRef.current = dict
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.sessionId) return
|
||||
const sync = createMcpSync({
|
||||
sessionId: config.sessionId,
|
||||
apiBase: config.apiBase,
|
||||
token: config.token,
|
||||
canvas: {
|
||||
load: (xml, decision) =>
|
||||
diagramRef.current.loadDiagram(
|
||||
xml,
|
||||
true,
|
||||
decision.mode,
|
||||
decision.mode === "commit"
|
||||
? { pageId: decision.pageId }
|
||||
: undefined,
|
||||
),
|
||||
showTransient: (xml) => diagramRef.current.showTransient(xml),
|
||||
export: (request, timeoutMs) =>
|
||||
diagramRef.current.requestExport(request, timeoutMs),
|
||||
currentXml: () => diagramRef.current.chartXMLRef.current,
|
||||
currentPageId: () => useCanvasStore.getState().currentPageId,
|
||||
loadPending: () => diagramRef.current.hasPendingLoad(),
|
||||
// The bridge reads the editor directly; without it (an
|
||||
// external draw.io) the selection cannot be read
|
||||
readSelection: () =>
|
||||
readSelectionDetails() ?? {
|
||||
pageId: null,
|
||||
pageName: null,
|
||||
cells: [],
|
||||
unavailable: true,
|
||||
},
|
||||
},
|
||||
onNotice: (notice) =>
|
||||
toast(dictRef.current.shell[notice], { duration: 8000 }),
|
||||
onStatus: setStatus,
|
||||
isHidden: () => document.visibilityState === "hidden",
|
||||
})
|
||||
syncRef.current = sync
|
||||
setSync(sync)
|
||||
sync.setReady(diagramRef.current.isDrawioReady)
|
||||
sync.start()
|
||||
|
||||
// draw.io's autosave and load events, as the classic page reads
|
||||
// them: the frame component handles them for the canvas, the sync
|
||||
// needs them for the protocol (an edit to push, a load done)
|
||||
const drawioOrigin = new URL(config.drawioBaseUrl, location.href).origin
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source === window || event.origin !== drawioOrigin) {
|
||||
return
|
||||
}
|
||||
if (typeof event.data !== "string") return
|
||||
let message: { event?: string; xml?: unknown }
|
||||
try {
|
||||
message = JSON.parse(event.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (
|
||||
message.event === "autosave" &&
|
||||
typeof message.xml === "string"
|
||||
) {
|
||||
sync.onAutoSave(message.xml)
|
||||
} else if (message.event === "load") {
|
||||
sync.onDrawioLoad()
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", onMessage)
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage)
|
||||
sync.stop()
|
||||
syncRef.current = null
|
||||
setSync(null)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
syncRef.current?.setReady(diagram.isDrawioReady)
|
||||
}, [diagram.isDrawioReady])
|
||||
|
||||
// Mark what a server write changed on the page, as the web app does
|
||||
// for its versions (use-versions.ts); any action on the canvas clears it
|
||||
useEffect(() => {
|
||||
diagram.setCommitHandler(({ beforeXml, afterXml, pageId }) => {
|
||||
const { touchedIds, fromScratch } = diffDiagrams(
|
||||
beforeXml,
|
||||
afterXml,
|
||||
pageId,
|
||||
)
|
||||
if (!fromScratch) highlightChangedCells(touchedIds)
|
||||
})
|
||||
return () => diagram.setCommitHandler(null)
|
||||
}, [])
|
||||
|
||||
return { status, sync }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { VersionCard } from "@/components/canvas/version-card"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||
import type { NodeVersion } from "./node-versions-source"
|
||||
|
||||
/** What a card says happened: the web app's chat knows the tool that ran,
|
||||
* the shell tells from who made the entry and what changed */
|
||||
export function versionLabel(version: NodeVersion, dict: Dictionary): string {
|
||||
if (version.source === "user" || version.source === "recover") {
|
||||
return dict.shell.userEdit
|
||||
}
|
||||
return version.fromScratch ? dict.versions.created : dict.versions.edited
|
||||
}
|
||||
|
||||
/** The version cards, oldest first, as the web app's chat shows them */
|
||||
export function VersionsPanel({ versions }: { versions: NodeVersion[] }) {
|
||||
const dict = useDictionary()
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Keep the newest version in view
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" })
|
||||
}, [versions.length])
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex w-[340px] shrink-0 flex-col border-l border-border bg-surface-0"
|
||||
data-testid="versions-panel"
|
||||
>
|
||||
<div className="flex h-9 shrink-0 items-center px-3.5 text-xs font-medium text-muted-foreground">
|
||||
{dict.versions.strip}
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 pb-3 scrollbar-thin"
|
||||
>
|
||||
{versions.map((version, i) => (
|
||||
<VersionCard
|
||||
key={version.id}
|
||||
version={version}
|
||||
isLatest={i === versions.length - 1}
|
||||
label={versionLabel(version, dict)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
|
||||
|
||||
## Workflow
|
||||
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself, or takes the file's content as its 'xml' argument when you already have it in hand). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
|
||||
- When the user refers to what they selected in the preview ("this box", "these arrows"), call get_selection: it returns the selected cells' ids, labels and page, ready for edit_diagram.
|
||||
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
|
||||
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
|
||||
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
|
||||
@@ -28,7 +29,7 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
|
||||
- After a successful create_new_diagram or edit_diagram call, do not describe the diagram; the user sees it in the preview. One short sentence at most.
|
||||
- For artistic requests (a cat, a logo, a scene), compose the picture from standard shapes and connectors while keeping it clear.
|
||||
- To clear the canvas to one blank page, call create_new_diagram with only the two root cells <mxCell id="0"/><mxCell id="1" parent="0"/>; the previous diagram stays in History.
|
||||
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
|
||||
- The preview keeps the last 20 versions (a snapshot before every AI change) and can restore any of them. You can make changes freely; nothing is lost.
|
||||
- If the user asks to undo or go back, call restore_version instead of re-sending the earlier XML from memory.
|
||||
|
||||
## The XML you send
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v32.0.2",
|
||||
"sha256": "3cb8abec8e9bfc7504760c9cdc9194ecf7e8de178aa2a1d668801c32ecf1a1a7",
|
||||
"downloadUrl": "https://github.com/jgraph/drawio/releases/download/v32.0.2/draw.war"
|
||||
}
|
||||
@@ -9,16 +9,30 @@ import { isMxGraphModel } from "./pages.ts"
|
||||
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
/**
|
||||
* Who made an entry's diagram: an edit in the browser ("user"), a tab's
|
||||
* copy kept when the server recovered the session ("recover"), a restored
|
||||
* copy of an older entry ("restore"). The AI's writes and entries loaded
|
||||
* from the saved file carry none.
|
||||
*/
|
||||
export type HistorySource = "user" | "recover" | "restore"
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: number // Stable across shifts of the circular buffer
|
||||
xml: string
|
||||
svg: string
|
||||
source?: HistorySource
|
||||
}
|
||||
|
||||
let nextEntryId = 0
|
||||
const historyStore = new Map<string, HistoryEntry[]>()
|
||||
|
||||
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
export function addHistory(
|
||||
sessionId: string,
|
||||
xml: string,
|
||||
svg = "",
|
||||
source?: HistorySource,
|
||||
): number {
|
||||
let history = historyStore.get(sessionId)
|
||||
if (!history) {
|
||||
history = []
|
||||
@@ -34,7 +48,12 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
return history.length - 1
|
||||
}
|
||||
|
||||
history.push({ id: nextEntryId++, xml, svg })
|
||||
history.push({
|
||||
id: nextEntryId++,
|
||||
xml,
|
||||
svg,
|
||||
...(source && { source }),
|
||||
})
|
||||
|
||||
// Circular buffer
|
||||
if (history.length > MAX_HISTORY) {
|
||||
@@ -75,7 +94,7 @@ export function otherVersions(
|
||||
const result: HistoryEntry[] = []
|
||||
const history = getHistory(sessionId)
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const version = versionOf(history[i].xml)
|
||||
const version = entryVersion(history[i])
|
||||
if (kept.some((seen) => sameVersion(seen, version))) continue
|
||||
kept.push(version)
|
||||
result.push(history[i])
|
||||
@@ -87,12 +106,57 @@ export function otherVersions(
|
||||
export function isInHistory(sessionId: string, xml: string): boolean {
|
||||
const version = versionOf(xml)
|
||||
return getHistory(sessionId).some((entry) =>
|
||||
sameVersion(version, versionOf(entry.xml)),
|
||||
sameVersion(version, entryVersion(entry)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry the canvas shows: the newest one with the content of xml (the
|
||||
* rule of otherVersions), or null when the canvas is in none of them
|
||||
*/
|
||||
export function currentEntryId(sessionId: string, xml: string): number | null {
|
||||
const version = versionOf(xml)
|
||||
const history = getHistory(sessionId)
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
if (sameVersion(version, entryVersion(history[i]))) {
|
||||
return history[i].id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* For every entry, the id of the oldest entry with the same content (its
|
||||
* own when no earlier one has it). A restore adds a copy of an older entry;
|
||||
* the shell shows one version per content, where its first copy is, and
|
||||
* restores the newest copy (as restore_version picks it). An entry is
|
||||
* compared with the first of each group, never with later copies: a bare
|
||||
* model matches any page name, so "same" is not transitive, and every
|
||||
* entry must name a first copy that has its content.
|
||||
*/
|
||||
export function firstCopyIds(sessionId: string): Map<number, number> {
|
||||
const result = new Map<number, number>()
|
||||
const firsts: { version: Version; id: number }[] = []
|
||||
for (const entry of getHistory(sessionId)) {
|
||||
const version = entryVersion(entry)
|
||||
let first = firsts.find((f) => sameVersion(f.version, version))
|
||||
if (!first) {
|
||||
first = { version, id: entry.id }
|
||||
firsts.push(first)
|
||||
}
|
||||
result.set(entry.id, first.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
interface Version {
|
||||
bare: boolean
|
||||
named: string
|
||||
cells: string
|
||||
}
|
||||
|
||||
/** A diagram's fingerprints, with and without its page names */
|
||||
function versionOf(xml: string) {
|
||||
function versionOf(xml: string): Version {
|
||||
return {
|
||||
bare: isMxGraphModel(xml),
|
||||
named: contentFingerprint(xml),
|
||||
@@ -100,12 +164,20 @@ function versionOf(xml: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// An entry's XML never changes: its fingerprints are computed once
|
||||
const entryVersions = new WeakMap<HistoryEntry, Version>()
|
||||
function entryVersion(entry: HistoryEntry): Version {
|
||||
let version = entryVersions.get(entry)
|
||||
if (!version) {
|
||||
version = versionOf(entry.xml)
|
||||
entryVersions.set(entry, version)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// Same rule as checkEditGate: a bare <mxGraphModel> (the browser's sync
|
||||
// can send one) has no page name, so names count only when both have them
|
||||
function sameVersion(
|
||||
a: ReturnType<typeof versionOf>,
|
||||
b: ReturnType<typeof versionOf>,
|
||||
): boolean {
|
||||
function sameVersion(a: Version, b: Version): boolean {
|
||||
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Serves draw.io embed with state sync and history UI
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"
|
||||
import http from "node:http"
|
||||
import { dirname, join } from "node:path"
|
||||
import { dirname, extname, join, posix, resolve, sep } from "node:path"
|
||||
import { pipeline } from "node:stream"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
|
||||
@@ -49,16 +50,123 @@ import { contentFingerprint } from "./edit-gate.ts"
|
||||
import {
|
||||
addHistory,
|
||||
clearHistory,
|
||||
currentEntryId,
|
||||
firstCopyIds,
|
||||
getHistory,
|
||||
getHistoryEntry,
|
||||
updateLastHistorySvg,
|
||||
} from "./history.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { BLANK_MXFILE } from "./pages.ts"
|
||||
import { parseSelectionAnswer, type SelectionAnswer } from "./selection.ts"
|
||||
|
||||
// Configurable draw.io embed URL for private deployments
|
||||
const DRAWIO_BASE_URL =
|
||||
// Configurable draw.io embed URL for private deployments. Set, it replaces
|
||||
// the bundled copy (see drawioDir below).
|
||||
export const DRAWIO_BASE_URL =
|
||||
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||
const EXTERNAL_DRAWIO = !!process.env.DRAWIO_BASE_URL
|
||||
|
||||
// Every /api request must carry this token in the X-Drawio-Token header.
|
||||
// The page reads it from its HTML. Another website cannot add a custom
|
||||
// header without a CORS preflight, which this server never grants, so its
|
||||
// requests stop here whatever Host and Origin they manage to send.
|
||||
const API_TOKEN = randomBytes(16).toString("hex")
|
||||
|
||||
export function getApiToken(): string {
|
||||
return API_TOKEN
|
||||
}
|
||||
|
||||
// The trimmed draw.io copy that scripts/fetch-drawio.mjs puts in dist/drawio,
|
||||
// next to the compiled server; run from src with tsx, it is one level up
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
let drawioDir: string | null =
|
||||
[join(HERE, "drawio"), join(HERE, "../dist/drawio")].find((dir) =>
|
||||
existsSync(join(dir, "index.html")),
|
||||
) ?? null
|
||||
let drawioVersion = readDrawioVersion(drawioDir)
|
||||
|
||||
/** For tests: serve draw.io from this directory (null: no bundled copy) */
|
||||
export function setDrawioDir(dir: string | null): void {
|
||||
drawioDir = dir
|
||||
drawioVersion = readDrawioVersion(dir)
|
||||
}
|
||||
|
||||
// The canvas shell (the web app's canvas without the chat), built by
|
||||
// scripts/build-shell.mjs into dist/shell
|
||||
let shellDir: string | null =
|
||||
[join(HERE, "shell"), join(HERE, "../dist/shell")].find((dir) =>
|
||||
existsSync(join(dir, "index.html")),
|
||||
) ?? null
|
||||
|
||||
/** For tests: serve the shell from this directory (null: not built) */
|
||||
export function setShellDir(dir: string | null): void {
|
||||
shellDir = dir
|
||||
}
|
||||
|
||||
// This package's version, stamped into the shell files' ETags (see
|
||||
// readDrawioVersion for why)
|
||||
const PACKAGE_VERSION: string = JSON.parse(
|
||||
readFileSync(join(HERE, "../package.json"), "utf8"),
|
||||
).version
|
||||
|
||||
/**
|
||||
* The preview page start_session opens: the canvas shell (shell/, the web
|
||||
* app's canvas), or with DRAWIO_PREVIEW_UI=classic the classic page
|
||||
* (src/preview), kept for one more release.
|
||||
*/
|
||||
export type PreviewUi = "classic" | "shell"
|
||||
|
||||
export function previewUiFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): PreviewUi {
|
||||
return (env.DRAWIO_PREVIEW_UI ?? "").toLowerCase() === "classic"
|
||||
? "classic"
|
||||
: "shell"
|
||||
}
|
||||
|
||||
export const PREVIEW_UI: PreviewUi = previewUiFromEnv()
|
||||
|
||||
/**
|
||||
* The page start_session actually opens: the classic page stands in while
|
||||
* the shell is not built (a run from the sources), as embed.diagrams.net
|
||||
* does for a missing dist/drawio
|
||||
*/
|
||||
export function activePreviewUi(ui: PreviewUi = PREVIEW_UI): PreviewUi {
|
||||
return ui === "shell" && shellDir === null ? "classic" : ui
|
||||
}
|
||||
|
||||
export function previewUrl(
|
||||
port: number,
|
||||
sessionId: string,
|
||||
ui: PreviewUi = PREVIEW_UI,
|
||||
): string {
|
||||
const path = activePreviewUi(ui) === "shell" ? "/shell/" : ""
|
||||
return `http://localhost:${port}${path}?mcp=${sessionId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The draw.io version the fetch script stamps into the copy. It is part of
|
||||
* every file's ETag: an install that keeps the archive's dates (npm does
|
||||
* not) would otherwise hand out the old ETag for a changed file of the
|
||||
* same size after an upgrade.
|
||||
*/
|
||||
function readDrawioVersion(dir: string | null): string {
|
||||
if (!dir) return ""
|
||||
try {
|
||||
return readFileSync(join(dir, ".version"), "utf8").trim()
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the preview embeds draw.io from its own origin (the bundled copy),
|
||||
* which lets the page call the editor directly. False with DRAWIO_BASE_URL,
|
||||
* or when the bundled copy is missing.
|
||||
*/
|
||||
export function isSameOriginDrawio(): boolean {
|
||||
return !EXTERNAL_DRAWIO && drawioDir !== null
|
||||
}
|
||||
|
||||
// Extract origin (scheme + host + port) from URL for postMessage security check
|
||||
function getOrigin(url: string): string {
|
||||
@@ -149,8 +257,10 @@ interface SessionState {
|
||||
exportFormat?: ExportFormat // Set by MCP tool to request browser export
|
||||
exportXml?: string // Single-page projection to load before a page-targeted export
|
||||
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
|
||||
exportId?: number // Number of the pending export, echoed with its result
|
||||
exportId?: string // Random id of the pending export, echoed with its result
|
||||
exportData?: string // Base64/SVG data returned by browser after export
|
||||
selectionId?: string // Random id of the pending get_selection request
|
||||
selection?: SelectionAnswer // The page's answer to it
|
||||
}
|
||||
|
||||
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
|
||||
@@ -225,6 +335,8 @@ export function setState(
|
||||
exportOptions: existing?.exportOptions,
|
||||
exportId: existing?.exportId,
|
||||
exportData: existing?.exportData, // Preserve export result
|
||||
selectionId: existing?.selectionId, // Preserve pending selection request
|
||||
selection: existing?.selection,
|
||||
})
|
||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||
if (notify) stateListener?.(sessionId, xml)
|
||||
@@ -243,7 +355,7 @@ export function keepInHistory(sessionId: string, xml: string, svg = ""): void {
|
||||
if (state && !state.userEdited && last && last.xml === state.serverXml) {
|
||||
return
|
||||
}
|
||||
addHistory(sessionId, xml, svg)
|
||||
addHistory(sessionId, xml, svg, state?.userEdited ? "user" : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,13 +382,13 @@ export function requestExport(
|
||||
state.exportOptions = options
|
||||
state.exportFormat = format
|
||||
// The browser sends this back with the result, so a late result of an
|
||||
// export that timed out is not taken for this one
|
||||
state.exportId = ++lastExportId
|
||||
// export that timed out is not taken for this one. Random rather than
|
||||
// counted: a counter restarts with the process, and the page retries a
|
||||
// result refused with 403 against the process that took over the port
|
||||
state.exportId = randomUUID()
|
||||
return true
|
||||
}
|
||||
|
||||
let lastExportId = 0
|
||||
|
||||
export function requestSync(sessionId: string): boolean {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (state) {
|
||||
@@ -302,6 +414,95 @@ export async function waitForSync(
|
||||
return false // Timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the preview tab which cells the user has selected (the get_selection
|
||||
* tool). Answered through the poll like an export: the tab sees selectionId
|
||||
* in GET /api/state and POSTs its reading with that id. Returns false when
|
||||
* the session is unknown.
|
||||
*/
|
||||
export function requestSelection(sessionId: string): boolean {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (!state) return false
|
||||
state.selection = undefined
|
||||
// Random, as exportId: a late answer to an earlier request, or one meant
|
||||
// for the process that had this port before, is not taken for this one
|
||||
state.selectionId = randomUUID()
|
||||
return true
|
||||
}
|
||||
|
||||
/** The tab's answer to the pending selection request, or null in time */
|
||||
export async function waitForSelection(
|
||||
sessionId: string,
|
||||
timeoutMs = 10000,
|
||||
): Promise<SelectionAnswer | null> {
|
||||
const start = Date.now()
|
||||
let answer: SelectionAnswer | undefined
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
// Re-read the store entry each tick: setState replaces it
|
||||
answer = stateStore.get(sessionId)?.selection
|
||||
if (answer) break
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
}
|
||||
const state = stateStore.get(sessionId)
|
||||
if (state) {
|
||||
state.selection = undefined
|
||||
state.selectionId = undefined
|
||||
}
|
||||
if (!answer) log.warn(`Selection timeout for session=${sessionId}`)
|
||||
return answer ?? null
|
||||
}
|
||||
|
||||
// One selection slot per session, as the export slot: overlapping
|
||||
// get_selection calls take turns instead of replacing each other's request
|
||||
let selectionQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
/** requestSelection + waitForSelection, one call at a time; null when the
|
||||
* session is unknown or the tab does not answer in time */
|
||||
export function readSelection(
|
||||
sessionId: string,
|
||||
timeoutMs = 10000,
|
||||
): Promise<SelectionAnswer | null> {
|
||||
const run = selectionQueue.then(() => {
|
||||
if (!requestSelection(sessionId)) return null
|
||||
return waitForSelection(sessionId, timeoutMs)
|
||||
})
|
||||
selectionQueue = run.catch(() => {})
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* What the version cards depend on: History's entries (their count and the
|
||||
* newest id) and the entry the canvas shows. The shell reads History again
|
||||
* only when this changes, so a hand edit (a new version, the same History)
|
||||
* costs no download of every entry
|
||||
*/
|
||||
function historyKeyOf(sessionId: string, state?: SessionState): string {
|
||||
const history = getHistory(sessionId)
|
||||
const current =
|
||||
state && history.length > 0
|
||||
? currentEntryId(sessionId, state.xml)
|
||||
: null
|
||||
return `${history.length}:${history.at(-1)?.id ?? -1}:${current}`
|
||||
}
|
||||
|
||||
/** POST /api/state with a selection: the tab's answer to requestSelection */
|
||||
function handleSelectionResult(
|
||||
sessionId: string,
|
||||
data: { selectionId?: unknown; selection?: unknown },
|
||||
): void {
|
||||
const state = stateStore.get(sessionId)
|
||||
// Only the pending request's answer (none pending: none is taken)
|
||||
if (!state?.selectionId || data.selectionId !== state.selectionId) {
|
||||
log.debug(`Ignored a late selection answer for session=${sessionId}`)
|
||||
return
|
||||
}
|
||||
const answer = parseSelectionAnswer(data.selection)
|
||||
if (!answer) return
|
||||
state.selection = answer
|
||||
state.selectionId = undefined
|
||||
log.debug(`Selection received for session=${sessionId}`)
|
||||
}
|
||||
|
||||
export function startHttpServer(port = 6002): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (server) {
|
||||
@@ -335,6 +536,16 @@ export function startHttpServer(port = 6002): Promise<number> {
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
serverPort = port
|
||||
log.info(`HTTP server running on http://localhost:${port}`)
|
||||
if (!EXTERNAL_DRAWIO && drawioDir === null) {
|
||||
log.warn(
|
||||
"No bundled draw.io (dist/drawio missing); the preview loads it from embed.diagrams.net",
|
||||
)
|
||||
}
|
||||
if (PREVIEW_UI === "shell" && activePreviewUi() === "classic") {
|
||||
log.warn(
|
||||
"The canvas shell is not built (dist/shell missing); start_session opens the classic page",
|
||||
)
|
||||
}
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
@@ -425,7 +636,10 @@ function routeRequest(
|
||||
if (requestOrigin === `http://localhost:${serverPort}`) {
|
||||
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, X-Drawio-Token",
|
||||
)
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
@@ -434,30 +648,43 @@ function routeRequest(
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/drawio/")) {
|
||||
serveStaticFile(
|
||||
req,
|
||||
res,
|
||||
drawioDir,
|
||||
url.pathname.slice("/drawio/".length),
|
||||
drawioVersion,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/shell") {
|
||||
res.writeHead(302, { Location: `/shell/${url.search}` })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (url.pathname.startsWith("/shell/")) {
|
||||
const rest = url.pathname.slice("/shell/".length)
|
||||
if (rest === "" || rest === "index.html") {
|
||||
servePage(req, res, url, "/shell/", getShellPage)
|
||||
} else {
|
||||
serveStaticFile(req, res, shellDir, rest, PACKAGE_VERSION)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.startsWith("/api/") &&
|
||||
req.headers["x-drawio-token"] !== API_TOKEN
|
||||
) {
|
||||
res.writeHead(403)
|
||||
res.end("Forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
const sessionId = url.searchParams.get("mcp") || ""
|
||||
if (sessionId && !isValidSessionId(sessionId)) {
|
||||
res.writeHead(400)
|
||||
res.end("Invalid session id")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-redirect to most recent session if no sessionId provided
|
||||
if (!sessionId) {
|
||||
const recentSessionId = getMostRecentSessionId()
|
||||
if (recentSessionId) {
|
||||
res.writeHead(302, {
|
||||
Location: `/?mcp=${encodeURIComponent(recentSessionId)}`,
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ensureSessionStateInitialized(sessionId)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(getHtmlPage(sessionId))
|
||||
servePage(req, res, url, "/", getHtmlPage)
|
||||
} else if (url.pathname === "/api/state") {
|
||||
handleStateApi(req, res, url)
|
||||
} else if (url.pathname === "/api/history") {
|
||||
@@ -472,6 +699,60 @@ function routeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview page (the classic one at "/", the shell at "/shell/") for the
|
||||
* session in ?mcp=<id>; without one, the most recent session's.
|
||||
*/
|
||||
function servePage(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
url: URL,
|
||||
pagePath: string,
|
||||
render: (sessionId: string) => string | null,
|
||||
): void {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405)
|
||||
res.end("Method Not Allowed")
|
||||
return
|
||||
}
|
||||
const sessionId = url.searchParams.get("mcp") || ""
|
||||
if (sessionId && !isValidSessionId(sessionId)) {
|
||||
res.writeHead(400)
|
||||
res.end("Invalid session id")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-redirect to most recent session if no sessionId provided
|
||||
if (!sessionId) {
|
||||
const recentSessionId = getMostRecentSessionId()
|
||||
if (recentSessionId) {
|
||||
res.writeHead(302, {
|
||||
Location: `${pagePath}?mcp=${encodeURIComponent(recentSessionId)}`,
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const html = render(sessionId)
|
||||
if (html === null) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" })
|
||||
res.end(
|
||||
"The canvas shell is not built (dist/shell is missing); run npm run build in packages/mcp-server",
|
||||
)
|
||||
return
|
||||
}
|
||||
ensureSessionStateInitialized(sessionId)
|
||||
|
||||
// The page holds this process's token: never served from a cache
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
...HTML_SECURITY_HEADERS,
|
||||
})
|
||||
res.end(req.method === "HEAD" ? undefined : html)
|
||||
}
|
||||
|
||||
function handleStateApi(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
@@ -501,6 +782,8 @@ function handleStateApi(
|
||||
exportXml: state?.exportXml || null,
|
||||
exportOptions: state?.exportOptions || null,
|
||||
exportId: state?.exportId ?? null,
|
||||
selectionId: state?.selectionId ?? null,
|
||||
historyKey: historyKeyOf(sessionId, state),
|
||||
}),
|
||||
)
|
||||
} else if (req.method === "POST") {
|
||||
@@ -538,6 +821,14 @@ function handleStateApi(
|
||||
return
|
||||
}
|
||||
|
||||
// The tab is answering a selection request (get_selection)
|
||||
if (data.selection !== undefined) {
|
||||
handleSelectionResult(sessionId, data)
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ success: true }))
|
||||
return
|
||||
}
|
||||
|
||||
// A push can come before the tab's first poll after a
|
||||
// restart: recover the saved file first, so it is compared
|
||||
// with that and never overwrites it unseen
|
||||
@@ -569,7 +860,12 @@ function handleStateApi(
|
||||
!!data.xml &&
|
||||
data.xml !== current.xml
|
||||
if (saved) {
|
||||
addHistory(sessionId, data.xml, data.svg || "")
|
||||
addHistory(
|
||||
sessionId,
|
||||
data.xml,
|
||||
data.svg || "",
|
||||
"recover",
|
||||
)
|
||||
// Saved with the History, as after any change
|
||||
stateListener?.(sessionId, current.xml)
|
||||
}
|
||||
@@ -606,7 +902,7 @@ function handleStateApi(
|
||||
} else if (typeof data.xml === "string" && data.xml) {
|
||||
// A user edit lost the race with an AI write. Keep
|
||||
// it in history so the user can restore it.
|
||||
addHistory(sessionId, data.xml, data.svg || "")
|
||||
addHistory(sessionId, data.xml, data.svg || "", "user")
|
||||
savedToHistory = true
|
||||
if (current) stateListener?.(sessionId, current.xml)
|
||||
}
|
||||
@@ -630,7 +926,13 @@ function handleStateApi(
|
||||
const saved = stateStore.get(sessionId)
|
||||
if (saved && data.source !== "sync") saved.userEdited = true
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ success: true, version }))
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
version,
|
||||
historyKey: historyKeyOf(sessionId, saved),
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||
@@ -660,7 +962,12 @@ function handleHistoryApi(
|
||||
return
|
||||
}
|
||||
|
||||
// The classic page reads index, id and svg; the shell's version cards
|
||||
// the rest: the document, who made it, which earlier entry it is a copy
|
||||
// of, the state the ids belong to, and the entry the canvas shows
|
||||
const history = getHistory(sessionId)
|
||||
const firstIds = firstCopyIds(sessionId)
|
||||
const state = stateStore.get(sessionId)
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
@@ -668,8 +975,13 @@ function handleHistoryApi(
|
||||
index: i,
|
||||
id: entry.id,
|
||||
svg: entry.svg,
|
||||
xml: entry.xml,
|
||||
source: entry.source ?? null,
|
||||
firstId: firstIds.get(entry.id),
|
||||
})),
|
||||
count: history.length,
|
||||
stateId: state?.stateId ?? null,
|
||||
currentId: state ? currentEntryId(sessionId, state.xml) : null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -698,7 +1010,7 @@ export function restoreHistoryEntry(
|
||||
keepInHistory(sessionId, current.xml, current.svg)
|
||||
}
|
||||
const newVersion = setState(sessionId, entry.xml)
|
||||
addHistory(sessionId, entry.xml, entry.svg)
|
||||
addHistory(sessionId, entry.xml, entry.svg, "restore")
|
||||
|
||||
log.info(`Restored session ${sessionId} to history entry ${entryId}`)
|
||||
return newVersion
|
||||
@@ -795,8 +1107,121 @@ function handleHistorySvgApi(
|
||||
})
|
||||
}
|
||||
|
||||
// Pages may be framed only by this origin, and nothing is content-sniffed
|
||||
const HTML_SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".xml": "text/xml; charset=utf-8",
|
||||
".json": "application/json",
|
||||
".ico": "image/x-icon",
|
||||
".wasm": "application/wasm",
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /drawio/<path> or /shell/<path>: a file of the bundled draw.io copy or
|
||||
* of the built shell. `stamp` names the files' version in their ETags.
|
||||
*/
|
||||
function serveStaticFile(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
dir: string | null,
|
||||
rawPath: string,
|
||||
stamp: string,
|
||||
): void {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405)
|
||||
res.end("Method Not Allowed")
|
||||
return
|
||||
}
|
||||
let rel: string
|
||||
try {
|
||||
rel = decodeURIComponent(rawPath)
|
||||
} catch {
|
||||
res.writeHead(400)
|
||||
res.end("Bad Request")
|
||||
return
|
||||
}
|
||||
// One normalized path inside the directory; the war's server-side parts
|
||||
// are never served, whatever was extracted
|
||||
const normalized = posix.normalize(rel)
|
||||
const file = dir ? resolve(dir, normalized) : null
|
||||
if (
|
||||
!dir ||
|
||||
!file ||
|
||||
/[\\\0]/.test(rel) ||
|
||||
/(^|\/)(WEB-INF|META-INF)(\/|$)/i.test(normalized) ||
|
||||
!file.startsWith(dir + sep)
|
||||
) {
|
||||
res.writeHead(404)
|
||||
res.end("Not Found")
|
||||
return
|
||||
}
|
||||
let size: number
|
||||
let etag: string
|
||||
try {
|
||||
const stat = statSync(file)
|
||||
if (!stat.isFile()) throw new Error("not a file")
|
||||
size = stat.size
|
||||
etag = `"${stamp}-${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`
|
||||
} catch {
|
||||
res.writeHead(404)
|
||||
res.end("Not Found")
|
||||
return
|
||||
}
|
||||
// The file names do not change between draw.io versions, so the browser
|
||||
// may keep a copy but asks before using it (a 304 from localhost is
|
||||
// cheap); after a package upgrade the next preview gets the new files
|
||||
const cacheHeaders = {
|
||||
ETag: etag,
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
if (req.headers["if-none-match"] === etag) {
|
||||
res.writeHead(304, cacheHeaders)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const ext = extname(file).toLowerCase()
|
||||
res.writeHead(200, {
|
||||
...cacheHeaders,
|
||||
"Content-Type": MIME_TYPES[ext] || "application/octet-stream",
|
||||
"Content-Length": size,
|
||||
...(ext === ".html" ? HTML_SECURITY_HEADERS : {}),
|
||||
})
|
||||
if (req.method === "HEAD") {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
// pipeline closes both ends when either fails or goes away (a tab closed
|
||||
// mid-download), so no file handle leaks and no error goes unhandled; a
|
||||
// client that left early is routine, not worth a log line
|
||||
pipeline(createReadStream(file), res, () => {})
|
||||
}
|
||||
|
||||
/** Where the iframe loads the editor from (without its query) */
|
||||
function drawioEditorUrl(): string {
|
||||
return isSameOriginDrawio()
|
||||
? "/drawio/index.html"
|
||||
: `${normalizeUrl(DRAWIO_BASE_URL)}/`
|
||||
}
|
||||
|
||||
// The preview page lives in src/preview (the build copies it to dist/preview)
|
||||
const PREVIEW_DIR = join(dirname(fileURLToPath(import.meta.url)), "preview")
|
||||
const PREVIEW_DIR = join(HERE, "preview")
|
||||
let previewTemplate: string | null = null
|
||||
|
||||
function loadPreviewTemplate(): string {
|
||||
@@ -810,47 +1235,108 @@ function loadPreviewTemplate(): string {
|
||||
return previewTemplate
|
||||
}
|
||||
|
||||
/** A JSON string literal that is safe inside a <script> element */
|
||||
const scriptJson = (value: string) =>
|
||||
/** JSON that is safe inside a <script> element ("<" escaped) */
|
||||
const scriptJson = (value: unknown) =>
|
||||
JSON.stringify(value).replace(/</g, "\\u003c")
|
||||
|
||||
/**
|
||||
* The configurable tail of the draw.io iframe query: dark mode, and the
|
||||
* language and theme when the host config fixes them (DRAWIO_DARK,
|
||||
* DRAWIO_LANG, DRAWIO_UI). lang and ui are left out unless set, because
|
||||
* draw.io hides its Extras > Language / Theme submenu once they are given.
|
||||
* The editor settings the host config fixes: theme (DRAWIO_UI), language
|
||||
* (DRAWIO_LANG) and dark mode (DRAWIO_DARK). ui and lang are "" unless set,
|
||||
* because draw.io hides its Extras > Language / Theme submenu once they
|
||||
* are given.
|
||||
*/
|
||||
function hostDrawioSettings(env: NodeJS.ProcessEnv): {
|
||||
ui: string
|
||||
lang: string
|
||||
dark: "dark" | "light" | "auto"
|
||||
} {
|
||||
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
|
||||
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
|
||||
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
|
||||
return {
|
||||
ui: isDrawioTheme(ui) ? ui : "",
|
||||
lang: /^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang) ? lang : "",
|
||||
dark: ["1", "true", "dark"].includes(dark)
|
||||
? "dark"
|
||||
: ["0", "false", "light"].includes(dark)
|
||||
? "light"
|
||||
: // draw.io takes ui=dark as dark mode only when no dark
|
||||
// parameter is present, and the pages always send one
|
||||
!dark && ui === "dark"
|
||||
? "dark"
|
||||
: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The configurable tail of the classic page's draw.io iframe query: dark
|
||||
* mode, and the language and theme when the host config fixes them.
|
||||
*/
|
||||
export function drawioEmbedParams(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const settings = hostDrawioSettings(env)
|
||||
const params = new URLSearchParams()
|
||||
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
|
||||
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
|
||||
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
|
||||
if (["1", "true", "dark"].includes(dark)) {
|
||||
params.set("dark", "1")
|
||||
} else if (["0", "false", "light"].includes(dark)) {
|
||||
params.set("dark", "0")
|
||||
} else {
|
||||
// draw.io takes ui=dark as dark mode only when no dark parameter is
|
||||
// present, and this page always sends one
|
||||
params.set("dark", !dark && ui === "dark" ? "1" : "auto")
|
||||
}
|
||||
if (/^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang)) params.set("lang", lang)
|
||||
if (isDrawioTheme(ui)) params.set("ui", ui)
|
||||
params.set(
|
||||
"dark",
|
||||
settings.dark === "auto"
|
||||
? "auto"
|
||||
: settings.dark === "dark"
|
||||
? "1"
|
||||
: "0",
|
||||
)
|
||||
if (settings.lang) params.set("lang", settings.lang)
|
||||
if (settings.ui) params.set("ui", settings.ui)
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* What the shell page gets as window.__MCP_CONFIG__ (shell/runtime-config.ts
|
||||
* reads it): the session, the API token, where draw.io comes from and the
|
||||
* host's editor settings.
|
||||
*/
|
||||
export function shellConfig(
|
||||
sessionId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
const settings = hostDrawioSettings(env)
|
||||
return {
|
||||
sessionId,
|
||||
token: API_TOKEN,
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: drawioEditorUrl(),
|
||||
drawioUi: settings.ui,
|
||||
drawioLang: settings.lang,
|
||||
drawioDark: settings.dark,
|
||||
lang: env.DRAWIO_LANG ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
/** The shell page, or null when the shell is not built */
|
||||
function getShellPage(sessionId: string): string | null {
|
||||
if (!shellDir) return null
|
||||
const template = readFileSync(join(shellDir, "index.html"), "utf8")
|
||||
return template.replace("{{CONFIG_JSON}}", () =>
|
||||
scriptJson(shellConfig(sessionId)),
|
||||
)
|
||||
}
|
||||
|
||||
function getHtmlPage(sessionId: string): string {
|
||||
return loadPreviewTemplate()
|
||||
.replace("{{SESSION_BADGE}}", () =>
|
||||
sessionId
|
||||
? `<span class="session">${sessionId.slice(-8)}</span>`
|
||||
: "",
|
||||
)
|
||||
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
|
||||
.replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL))
|
||||
.replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
|
||||
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
|
||||
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
|
||||
return (
|
||||
loadPreviewTemplate()
|
||||
.replace("{{SESSION_BADGE}}", () =>
|
||||
sessionId
|
||||
? `<span class="session">${sessionId.slice(-8)}</span>`
|
||||
: "",
|
||||
)
|
||||
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
|
||||
.replace("{{DRAWIO_URL}}", () => drawioEditorUrl())
|
||||
.replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
|
||||
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
|
||||
// Same origin: the page fills in its own origin
|
||||
.replace("{{ORIGIN_JSON}}", () =>
|
||||
scriptJson(isSameOriginDrawio() ? "" : DRAWIO_ORIGIN),
|
||||
)
|
||||
.replace("{{TOKEN_JSON}}", () => scriptJson(API_TOKEN))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,13 +48,18 @@ import {
|
||||
otherVersions,
|
||||
} from "./history.ts"
|
||||
import {
|
||||
activePreviewUi,
|
||||
DRAWIO_BASE_URL,
|
||||
type ExportFormat,
|
||||
type ExportOptions,
|
||||
getServerPort,
|
||||
getState,
|
||||
isSameOriginDrawio,
|
||||
keepInHistory,
|
||||
onSessionRecreate,
|
||||
onStateChange,
|
||||
previewUrl,
|
||||
readSelection,
|
||||
requestExport,
|
||||
requestSync,
|
||||
restoreHistoryEntry,
|
||||
@@ -89,6 +94,7 @@ import {
|
||||
wrapCellsInModel,
|
||||
} from "./pages.ts"
|
||||
import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts"
|
||||
import { describeSelection } from "./selection.ts"
|
||||
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
|
||||
import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts"
|
||||
import { XML_REFERENCE } from "./xml-reference.ts"
|
||||
@@ -119,7 +125,9 @@ onSessionRecreate((sessionId) => {
|
||||
const loaded = parseDrawioFileContent(saved)
|
||||
// History comes back with the diagram (addHistory drops consecutive
|
||||
// duplicates)
|
||||
for (const x of autosaver.loadHistory(sessionId)) addHistory(sessionId, x)
|
||||
for (const e of autosaver.loadHistory(sessionId)) {
|
||||
addHistory(sessionId, e.xml, "", e.source)
|
||||
}
|
||||
return loaded.ok ? loaded.xml : saved
|
||||
})
|
||||
|
||||
@@ -176,6 +184,7 @@ Tools:
|
||||
- create_new_diagram: draw a new diagram, replacing the whole document. Send only the mxCell elements of one page (the server adds the wrapper and root cells), or a full <mxfile> for several pages.
|
||||
- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry; also used to draw a large diagram in parts.
|
||||
- get_diagram: read the current XML, including the user's manual edits.
|
||||
- get_selection: the cells the user has selected in the preview, when they say "this box" or "these arrows".
|
||||
- screenshot_diagram: see the rendered diagram as an image.
|
||||
- load_diagram, export_diagram: open or save .drawio or .drawio.svg files and export .png or .svg. Use absolute paths.
|
||||
- list_saved_diagrams: diagrams saved by earlier sessions; continue one with start_session session_id=<id>.
|
||||
@@ -379,9 +388,10 @@ registerWriteTool(
|
||||
lastSeenXml: "",
|
||||
}
|
||||
|
||||
// Open browser
|
||||
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
|
||||
await open(browserUrl)
|
||||
// Open the browser. BROWSER=none (the convention of CRA and Vite
|
||||
// dev servers) skips it: the e2e tests and headless hosts set it
|
||||
const browserUrl = previewUrl(port, sessionId)
|
||||
if (process.env.BROWSER !== "none") await open(browserUrl)
|
||||
|
||||
// A saved diagram comes back from its file. lastSeenXml stays
|
||||
// empty, so edit_diagram asks for get_diagram first.
|
||||
@@ -407,6 +417,12 @@ registerWriteTool(
|
||||
? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue this diagram in a later conversation, call start_session with session_id=${sessionId}. list_saved_diagrams lists older diagrams.`
|
||||
: ""
|
||||
const rulesNote = `\n\nYour own drawing rules: write them in ${customInstructionsPath()} (Markdown, up to 5000 characters, read on every call).`
|
||||
// The bundled draw.io is served from the preview's own origin;
|
||||
// an external one (DRAWIO_BASE_URL, or a build without the
|
||||
// copy) cannot be reached by the page's scripts
|
||||
const originNote = isSameOriginDrawio()
|
||||
? ""
|
||||
: `\n\nThe preview loads draw.io from ${DRAWIO_BASE_URL} (another origin): same-origin editor features are off.`
|
||||
|
||||
log.info(`Started session ${sessionId}, browser at ${browserUrl}`)
|
||||
|
||||
@@ -414,7 +430,7 @@ registerWriteTool(
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${intro}\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}${rulesNote}\n\n${guideText()}`,
|
||||
text: `${intro}\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${originNote}${saveNote}${rulesNote}\n\n${guideText()}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -619,7 +635,9 @@ To clear the canvas to one blank page, send only the two root cells <mxCell id="
|
||||
: `Diagram content set successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters\n${pageSummary}`,
|
||||
},
|
||||
]
|
||||
// The page on screen is the new document's first page
|
||||
// The page on screen: the new document's first page after a
|
||||
// full load, or the page the user was viewing when the write
|
||||
// went on the canvas in place (both are the model's own work)
|
||||
if (screenshot ?? config.autoScreenshot) {
|
||||
const shot = await captureScreenshot(
|
||||
currentSession.id,
|
||||
@@ -1218,6 +1236,94 @@ server.registerTool(
|
||||
},
|
||||
)
|
||||
|
||||
// Tool: get_selection
|
||||
server.registerTool(
|
||||
"get_selection",
|
||||
{
|
||||
title: "Get selection",
|
||||
description:
|
||||
"Return the cells the user has selected in the preview: their ids and labels, for edges the source and target ids, for shapes the position and size, and the page they are on. " +
|
||||
'Call this when the user refers to what they selected ("this box", "these arrows", "the selected shapes"), then act on the ids with edit_diagram. ' +
|
||||
"Says when nothing is selected. Needs the preview tab to be open.",
|
||||
inputSchema: {},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
if (!currentSession) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: No active session. Please call start_session first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
const sessionId = currentSession.id
|
||||
// The classic preview page never answers: it has no access to
|
||||
// the editor (the shell reads it through the same-origin frame).
|
||||
// It is also what opens while the shell is not built
|
||||
if (activePreviewUi() !== "shell") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The classic preview page cannot read the selection (DRAWIO_PREVIEW_UI=classic, or the canvas shell is not built: run npm run build in packages/mcp-server). Ask the user which shapes they mean, or call get_diagram.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (previewStalled(sessionId)) {
|
||||
return previewStalledError(sessionId)
|
||||
}
|
||||
if (sessionState(sessionId)?.lastPolled === undefined) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: The preview tab is not open, or has not connected yet.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
const answer = await readSelection(sessionId)
|
||||
if (!answer) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: Reading the selection timed out. Make sure the preview tab is open and in front.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: describeSelection(
|
||||
answer,
|
||||
isSameOriginDrawio() ? null : DRAWIO_BASE_URL,
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
log.error("get_selection failed:", message)
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// The browser bridge has one export slot per session, so export requests
|
||||
// run one at a time: a concurrent call waits for the previous one.
|
||||
let exportQueue: Promise<unknown> = Promise.resolve()
|
||||
@@ -1278,7 +1384,7 @@ function previewStalled(sessionId: string): boolean {
|
||||
* already succeeded must not run again, so it points to screenshot_diagram.
|
||||
*/
|
||||
function previewStalledNote(sessionId: string, next = "then retry") {
|
||||
return `The preview tab is not responding (browsers pause background tabs). Ask the user to bring the preview tab to the front (http://localhost:${getServerPort()}?mcp=${sessionId}), ${next}.`
|
||||
return `The preview tab is not responding (browsers pause background tabs). Ask the user to bring the preview tab to the front (${previewUrl(getServerPort(), sessionId)}), ${next}.`
|
||||
}
|
||||
|
||||
function previewStalledError(sessionId: string) {
|
||||
@@ -1411,7 +1517,7 @@ server.registerTool(
|
||||
description:
|
||||
"Render the diagram in the preview and return it as a PNG image, so you can see your own result. " +
|
||||
"Call this once after drawing or heavily editing a complex diagram, then fix overlaps and edges that cross shapes. " +
|
||||
"Without a page selector it shows the page on screen. Needs the preview tab to be open.",
|
||||
"Without a page selector it shows the page on screen; with one it renders that page without changing what the user sees. Needs the preview tab to be open.",
|
||||
inputSchema: { ...pageSelectorSchema },
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
},
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
* and the web app's display_diagram tool.
|
||||
*/
|
||||
import { expandCompactCells } from "./compact-cells.ts"
|
||||
import { hasCells, normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
|
||||
import {
|
||||
generatePageId,
|
||||
hasCells,
|
||||
normalizeToMxfile,
|
||||
parseMxfile,
|
||||
serializeMxfile,
|
||||
wrapCellsInModel,
|
||||
} from "./pages.ts"
|
||||
import {
|
||||
addDefaultStyles,
|
||||
applyStyleClasses,
|
||||
@@ -182,12 +189,26 @@ export function prepareNewDiagram(
|
||||
error: `XML validation failed after expanding the cells - ${rewriteError}`,
|
||||
}
|
||||
}
|
||||
const normalized = normalizeToMxfile(xml, page)
|
||||
let normalized = normalizeToMxfile(xml, page)
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
|
||||
}
|
||||
}
|
||||
// A <diagram> the model sent without an id gets one here. draw.io would
|
||||
// otherwise make its own, which the server never sees, and get_selection
|
||||
// would then name a page edit_diagram cannot find
|
||||
const doc = parseMxfile(normalized)
|
||||
if (doc) {
|
||||
let added = false
|
||||
doc.querySelectorAll("diagram").forEach((d) => {
|
||||
if (!d.getAttribute("id")) {
|
||||
d.setAttribute("id", generatePageId())
|
||||
added = true
|
||||
}
|
||||
})
|
||||
if (added) normalized = serializeMxfile(doc)
|
||||
}
|
||||
return { ok: true, xml: normalized, fixes }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* diagram survives the MCP process (hosts start a new one when a
|
||||
* conversation is resumed). Like the web app's IndexedDB sessions
|
||||
* (lib/session-storage.ts): saved 1 second after the last change, at most
|
||||
* 50 kept. The session's History (the XML of its last 20 versions, without
|
||||
* the thumbnails) is saved next to it as <session-id>.history.json.
|
||||
* 50 kept. The session's History (the XML of its last 20 versions and who
|
||||
* made each, without the thumbnails) is saved next to it as
|
||||
* <session-id>.history.json.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { contentFingerprint } from "./edit-gate.ts"
|
||||
import type { HistorySource } from "./history.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { BLANK_MXFILE, hasCells } from "./pages.ts"
|
||||
|
||||
@@ -33,6 +35,16 @@ const isBlank = (xml: string) =>
|
||||
const DELAY_MS = 1000
|
||||
const MAX_FILES = 50
|
||||
|
||||
/** A History entry as the file keeps it */
|
||||
export interface SavedHistoryEntry {
|
||||
xml: string
|
||||
source?: HistorySource
|
||||
}
|
||||
|
||||
const SOURCES: readonly string[] = ["user", "recover", "restore"]
|
||||
const isSource = (v: unknown): v is HistorySource =>
|
||||
typeof v === "string" && SOURCES.includes(v)
|
||||
|
||||
/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */
|
||||
export function expandHome(p: string): string {
|
||||
if (p === "~") return homedir()
|
||||
@@ -70,7 +82,7 @@ export class Autosaver {
|
||||
// The session's History entries, saved next to its diagram
|
||||
private history: (
|
||||
sessionId: string,
|
||||
) => { id: number; xml: string }[] = () => [],
|
||||
) => { id: number; xml: string; source?: HistorySource }[] = () => [],
|
||||
) {}
|
||||
|
||||
/** Path of a session's file, or null when saving is off. */
|
||||
@@ -83,16 +95,28 @@ export class Autosaver {
|
||||
return this.dir ? join(this.dir, `${sessionId}.history.json`) : null
|
||||
}
|
||||
|
||||
/** The XML of the session's saved History entries, oldest first. */
|
||||
loadHistory(sessionId: string): string[] {
|
||||
/** The session's saved History entries, oldest first. */
|
||||
loadHistory(sessionId: string): SavedHistoryEntry[] {
|
||||
const path = this.historyPathFor(sessionId)
|
||||
if (!path) return []
|
||||
try {
|
||||
const entries: unknown = JSON.parse(readFileSync(path, "utf-8"))
|
||||
return Array.isArray(entries) &&
|
||||
entries.every((e) => typeof e === "string")
|
||||
? entries
|
||||
: []
|
||||
if (!Array.isArray(entries)) return []
|
||||
const result: SavedHistoryEntry[] = []
|
||||
for (const e of entries) {
|
||||
// Files of earlier versions hold the XML strings alone
|
||||
if (typeof e === "string") {
|
||||
result.push({ xml: e })
|
||||
} else if (e && typeof e.xml === "string") {
|
||||
result.push({
|
||||
xml: e.xml,
|
||||
...(isSource(e.source) && { source: e.source }),
|
||||
})
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
log.warn(`Could not read the saved History ${path}: ${error}`)
|
||||
@@ -218,7 +242,9 @@ export class Autosaver {
|
||||
|
||||
// Saved with the diagram, after the same delay, so the History entry
|
||||
// that follows every tool write is included. The thumbnails are left
|
||||
// out (large); the History grid shows the entry's number instead.
|
||||
// out (large); the History grid shows the entry's number instead. Who
|
||||
// made an entry goes with it: the shell's cards tell a clear of the
|
||||
// canvas from a restore of one by it.
|
||||
private writeHistory(sessionId: string): void {
|
||||
const path = this.historyPathFor(sessionId)
|
||||
const entries = this.history(sessionId)
|
||||
@@ -226,7 +252,11 @@ export class Autosaver {
|
||||
const key = `${entries.length}:${entries[entries.length - 1].id}`
|
||||
if (this.historyKeys.get(sessionId) === key) return
|
||||
try {
|
||||
const json = JSON.stringify(entries.map((e) => e.xml))
|
||||
const saved: SavedHistoryEntry[] = entries.map((e) => ({
|
||||
xml: e.xml,
|
||||
...(e.source && { source: e.source }),
|
||||
}))
|
||||
const json = JSON.stringify(saved)
|
||||
writeFileSync(`${path}.tmp`, json, "utf-8")
|
||||
renameSync(`${path}.tmp`, path)
|
||||
this.historyKeys.set(sessionId, key)
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
|
||||
<iframe id="drawio" src="{{DRAWIO_URL}}?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
|
||||
</div>
|
||||
<div id="notice" role="status"></div>
|
||||
<div id="history-modal">
|
||||
@@ -85,7 +85,9 @@
|
||||
</div>
|
||||
<script>
|
||||
const sessionId = {{SESSION_JSON}};
|
||||
const DRAWIO_ORIGIN = {{ORIGIN_JSON}};
|
||||
// Empty when draw.io is served from this origin
|
||||
const DRAWIO_ORIGIN = {{ORIGIN_JSON}} || location.origin;
|
||||
let API_TOKEN = {{TOKEN_JSON}};
|
||||
</script>
|
||||
<script>
|
||||
{{SCRIPT}}
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
const iframe = document.getElementById('drawio');
|
||||
// Every API request carries the token the server wrote into this page. A 403
|
||||
// means another MCP process (with its own token) now answers on this port:
|
||||
// read its token from a fresh copy of this page and retry once, so the next
|
||||
// poll can recover the session instead of being refused forever.
|
||||
let tokenRefresh = null;
|
||||
function refreshToken() {
|
||||
if (!tokenRefresh) {
|
||||
tokenRefresh = fetch(location.href, { cache: 'no-store' })
|
||||
.then((r) => r.text())
|
||||
.then((html) => { const m = html.match(/API_TOKEN = "([0-9a-f]+)"/); if (m) API_TOKEN = m[1]; })
|
||||
.catch(() => {})
|
||||
.finally(() => { tokenRefresh = null; });
|
||||
}
|
||||
return tokenRefresh;
|
||||
}
|
||||
async function api(url, init = {}) {
|
||||
const send = () => fetch(url, { ...init, headers: { ...(init.headers || {}), 'X-Drawio-Token': API_TOKEN } });
|
||||
let r = await send();
|
||||
if (r.status === 403) { await refreshToken(); r = await send(); }
|
||||
return r;
|
||||
}
|
||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||
// The server state this tab is in step with (see stateId in http-server.ts);
|
||||
// null until the first poll
|
||||
@@ -82,7 +103,7 @@ window.addEventListener('message', (e) => {
|
||||
// result: a poll answered before that still sees the
|
||||
// request and would start the same export again.
|
||||
const seq = msg.message.mcpExport;
|
||||
fetch('/api/state', {
|
||||
api('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, exportData: d, exportId: mcpExportId })
|
||||
@@ -126,7 +147,7 @@ window.addEventListener('message', (e) => {
|
||||
const t = thumbExport;
|
||||
if (!t || msg.message.thumbExport !== t.n || latestXml !== t.xml) return;
|
||||
thumbExport = null;
|
||||
fetch('/api/history-svg', {
|
||||
api('/api/history-svg', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, svg, stateId: t.stateId, version: t.version })
|
||||
@@ -184,7 +205,7 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
||||
if (!sessionId) return;
|
||||
pushesInFlight.push(xml);
|
||||
try {
|
||||
const r = await fetch('/api/state', {
|
||||
const r = await api('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
|
||||
@@ -282,7 +303,7 @@ async function poll() {
|
||||
if (!sessionId) return;
|
||||
const seq = ++pollSeq;
|
||||
try {
|
||||
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||
const r = await api('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
// An older answer than one already handled (the interval, the 409
|
||||
@@ -469,7 +490,7 @@ historyBtn.onclick = async () => {
|
||||
// A list for a state the server recreated meanwhile has old ids
|
||||
const sid = stateId;
|
||||
try {
|
||||
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
||||
const r = await api('/api/history?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
if (sid !== stateId) return;
|
||||
@@ -529,7 +550,7 @@ restoreBtn.onclick = async () => {
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = 'Restoring...';
|
||||
try {
|
||||
const r = await fetch('/api/restore', {
|
||||
const r = await api('/api/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, id: selectedId, stateId: historyStateId })
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The cells the user has selected in the preview, as the page reads them
|
||||
* from the editor (shell/use-mcp-sync.ts) and the get_selection tool
|
||||
* reports them. The shape is shared with the shell; only plain data, so the
|
||||
* server can take it from a POST as it is.
|
||||
*/
|
||||
|
||||
export interface SelectedCellInfo {
|
||||
id: string
|
||||
/** The label as text, HTML removed and long ones cut */
|
||||
label: string
|
||||
edge: boolean
|
||||
source?: string
|
||||
target?: string
|
||||
/** The cell's geometry as the XML has it (relative to its parent) */
|
||||
geometry?: { x: number; y: number; width: number; height: number }
|
||||
/** The container the cell is in, when it is not on the layer itself */
|
||||
parent?: string
|
||||
}
|
||||
|
||||
export interface SelectionAnswer {
|
||||
/** The page on screen */
|
||||
pageId: string | null
|
||||
pageName: string | null
|
||||
cells: SelectedCellInfo[]
|
||||
/** The page cannot reach the editor (an external draw.io) */
|
||||
unavailable?: boolean
|
||||
}
|
||||
|
||||
/** A POST body's selection, if it has the shape above; else null */
|
||||
export function parseSelectionAnswer(value: unknown): SelectionAnswer | null {
|
||||
if (!value || typeof value !== "object") return null
|
||||
const given = value as Record<string, unknown>
|
||||
if (given.unavailable === true) {
|
||||
return { pageId: null, pageName: null, cells: [], unavailable: true }
|
||||
}
|
||||
if (!Array.isArray(given.cells)) return null
|
||||
const text = (v: unknown) => (typeof v === "string" ? v : null)
|
||||
const cells: SelectedCellInfo[] = []
|
||||
for (const raw of given.cells) {
|
||||
if (!raw || typeof raw !== "object") continue
|
||||
const c = raw as Record<string, unknown>
|
||||
if (typeof c.id !== "string" || !c.id) continue
|
||||
const cell: SelectedCellInfo = {
|
||||
id: c.id,
|
||||
label: text(c.label) ?? "",
|
||||
edge: c.edge === true,
|
||||
}
|
||||
if (text(c.source)) cell.source = c.source as string
|
||||
if (text(c.target)) cell.target = c.target as string
|
||||
if (text(c.parent)) cell.parent = c.parent as string
|
||||
const g = c.geometry as Record<string, unknown> | undefined
|
||||
if (
|
||||
g &&
|
||||
typeof g === "object" &&
|
||||
[g.x, g.y, g.width, g.height].every(
|
||||
(n) => typeof n === "number" && Number.isFinite(n),
|
||||
)
|
||||
) {
|
||||
cell.geometry = {
|
||||
x: g.x as number,
|
||||
y: g.y as number,
|
||||
width: g.width as number,
|
||||
height: g.height as number,
|
||||
}
|
||||
}
|
||||
cells.push(cell)
|
||||
}
|
||||
return { pageId: text(given.pageId), pageName: text(given.pageName), cells }
|
||||
}
|
||||
|
||||
/** describeSelection details at most this many cells; the rest by id only */
|
||||
export const MAX_LISTED_CELLS = 100
|
||||
|
||||
/**
|
||||
* The get_selection result text. `externalDrawio` names the draw.io origin
|
||||
* when the preview loads it from another origin, where the page cannot
|
||||
* read the editor. A selection of a whole large diagram would fill the
|
||||
* model's context with labels and geometry: the first cells get them, the
|
||||
* rest are listed by id alone (an edit on the selection needs every id).
|
||||
*/
|
||||
export function describeSelection(
|
||||
answer: SelectionAnswer,
|
||||
externalDrawio: string | null,
|
||||
): string {
|
||||
if (answer.unavailable) {
|
||||
return externalDrawio
|
||||
? `The preview loads draw.io from ${externalDrawio} (another origin), where the selection cannot be read. Ask the user which shapes they mean, or call get_diagram.`
|
||||
: "The preview could not read the editor's selection. Ask the user which shapes they mean, or call get_diagram."
|
||||
}
|
||||
const page = answer.pageName
|
||||
? `page "${answer.pageName}"${answer.pageId ? ` (page_id="${answer.pageId}")` : ""}`
|
||||
: "the page on screen"
|
||||
if (answer.cells.length === 0) {
|
||||
return `Nothing is selected on ${page}. Ask the user to select the shapes they mean in the preview, or call get_diagram.`
|
||||
}
|
||||
const listed = answer.cells.slice(0, MAX_LISTED_CELLS)
|
||||
const lines = listed.map((cell) => {
|
||||
const label = cell.label ? `"${cell.label}"` : "(no label)"
|
||||
if (cell.edge) {
|
||||
const ends =
|
||||
cell.source || cell.target
|
||||
? ` from ${cell.source ? `"${cell.source}"` : "nothing"} to ${cell.target ? `"${cell.target}"` : "nothing"}`
|
||||
: " (not connected)"
|
||||
return `- id="${cell.id}" edge ${label}${ends}`
|
||||
}
|
||||
const g = cell.geometry
|
||||
const where = g
|
||||
? ` at x=${g.x} y=${g.y} w=${g.width} h=${g.height}`
|
||||
: ""
|
||||
const parent = cell.parent ? ` inside "${cell.parent}"` : ""
|
||||
return `- id="${cell.id}" shape ${label}${where}${parent}`
|
||||
})
|
||||
const rest = answer.cells.slice(MAX_LISTED_CELLS)
|
||||
if (rest.length > 0) {
|
||||
lines.push(
|
||||
`- and ${rest.length} more cells, listed by id only: ${rest.map((c) => `"${c.id}"`).join(", ")}`,
|
||||
)
|
||||
}
|
||||
const count =
|
||||
answer.cells.length === 1 ? "1 cell" : `${answer.cells.length} cells`
|
||||
const target = answer.pageId ? ` (page_id="${answer.pageId}")` : ""
|
||||
return `${count} selected on ${page}:\n${lines.join("\n")}\n\nUse these ids with edit_diagram${target}; call get_diagram for their full XML.`
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* The canvas shell as start_session opens it by default: the built server
|
||||
* (dist/index.js, or the copy MCP_SERVER_ENTRY names: CI installs the packed
|
||||
* tarball in an empty directory) runs over stdio as an MCP client would
|
||||
* start it, and headless Chromium opens the URL it returns.
|
||||
*/
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
|
||||
const pkg = join(dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
|
||||
let client: Client
|
||||
let dataDir: string
|
||||
let shellUrl: string
|
||||
|
||||
test.beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "mcp-shell-e2e-"))
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [process.env.MCP_SERVER_ENTRY ?? join(pkg, "dist/index.js")],
|
||||
env: {
|
||||
...process.env,
|
||||
DRAWIO_DATA_DIR: dataDir,
|
||||
// Away from the web app's dev server and other test runs
|
||||
PORT: String(6100 + Math.floor(Math.random() * 400)),
|
||||
BROWSER: "none",
|
||||
},
|
||||
stderr: "pipe",
|
||||
})
|
||||
client = new Client({ name: "shell-e2e", version: "0.0.1" })
|
||||
await client.connect(transport)
|
||||
const result = await client.callTool({
|
||||
name: "start_session",
|
||||
arguments: {},
|
||||
})
|
||||
const text = (result.content as { type: string; text?: string }[])
|
||||
.map((c) => c.text ?? "")
|
||||
.join("\n")
|
||||
const match = text.match(/Browser URL: (\S+)/)
|
||||
if (!match) throw new Error(`start_session gave no URL:\n${text}`)
|
||||
shellUrl = match[1]
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.close()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("start_session opens the shell, which loads the bundled draw.io", async ({
|
||||
page,
|
||||
}) => {
|
||||
expect(shellUrl).toMatch(/^http:\/\/localhost:\d+\/shell\/\?mcp=mcp-/)
|
||||
const errors: string[] = []
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text())
|
||||
})
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
|
||||
await page.goto(shellUrl)
|
||||
const iframe = page.locator("iframe[title='draw.io']")
|
||||
await expect(iframe).toHaveAttribute(
|
||||
"src",
|
||||
/^http:\/\/localhost:\d+\/drawio\/index\.html\?embed=1&proto=json&configure=1&/,
|
||||
)
|
||||
// The cover goes once draw.io sent "init" and loaded the first document
|
||||
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
const frame = page.frameLocator("iframe[title='draw.io']")
|
||||
await expect(frame.locator(".geDiagramContainer")).toBeVisible()
|
||||
|
||||
// The shell's own texts and state: the first poll connects it
|
||||
await expect(page.getByTestId("shell-status")).toHaveText(
|
||||
"Connected to the MCP server",
|
||||
)
|
||||
await expect(page.getByTestId("shell-session")).toHaveText(
|
||||
shellUrl.slice(-8),
|
||||
)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
// --- The sync with the server (shell/use-mcp-sync.ts) ---
|
||||
|
||||
// Each test draws labels of its own: the previous test's diagram is on the
|
||||
// canvas when a test starts, and a label shared with it would be visible
|
||||
// before the test's own write arrives
|
||||
const box = (id: string, label: string, x: number) =>
|
||||
`<mxCell id="${id}" value="${label}" x="${x}" y="40" w="120" h="60"/>`
|
||||
|
||||
const toolText = async (name: string, args: Record<string, unknown> = {}) => {
|
||||
const result = await client.callTool({ name, arguments: args })
|
||||
return (result.content as { type: string; text?: string }[])
|
||||
.map((c) => c.text ?? "")
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/** The shell with the bundled draw.io ready and in step with the server */
|
||||
async function openShell(page: Page) {
|
||||
await page.goto(shellUrl)
|
||||
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
await expect(page.getByTestId("shell-status")).toHaveAttribute(
|
||||
"data-status",
|
||||
"connected",
|
||||
)
|
||||
return page.frameLocator("iframe[title='draw.io']")
|
||||
}
|
||||
|
||||
test("a diagram the MCP draws appears in the shell", async ({ page }) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", {
|
||||
xml: `${box("2", "Alpha", 40)}\n${box("3", "Beta", 240)}\n<mxCell id="4" source="2" target="3"/>`,
|
||||
})
|
||||
await expect(frame.getByText("Alpha")).toBeVisible({ timeout: 10_000 })
|
||||
await expect(frame.getByText("Beta")).toBeVisible()
|
||||
})
|
||||
|
||||
test("an edit is marked on the canvas, and one Ctrl+Z takes it back for the server too", async ({
|
||||
page,
|
||||
}) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", {
|
||||
xml: `${box("2", "Node A", 40)}\n${box("3", "Node B", 240)}`,
|
||||
})
|
||||
await expect(frame.getByText("Node A")).toBeVisible({ timeout: 10_000 })
|
||||
await toolText("edit_diagram", {
|
||||
operations: [
|
||||
{ operation: "add", cell_id: "5", new_xml: box("5", "Gamma", 440) },
|
||||
],
|
||||
})
|
||||
await expect(frame.getByText("Gamma")).toBeVisible({ timeout: 10_000 })
|
||||
// The editor bridge outlines the changed shape (editor-bridge.ts
|
||||
// HIGHLIGHT_OUTLINE); the write went on the canvas in place
|
||||
await expect(frame.locator('[stroke="#a86b00"]')).toHaveCount(1)
|
||||
|
||||
await frame
|
||||
.locator(".geDiagramContainer")
|
||||
.click({ position: { x: 10, y: 10 } })
|
||||
await page.keyboard.press("Control+z")
|
||||
await expect(frame.getByText("Gamma")).toHaveCount(0)
|
||||
await expect(frame.getByText("Node A")).toBeVisible()
|
||||
// The undo is pushed: the server's diagram has no Gamma (get_diagram
|
||||
// also names the removed cell in its notes, so look at the XML)
|
||||
await expect
|
||||
.poll(async () => (await toolText("get_diagram")).includes('id="5"'), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(false)
|
||||
expect(await toolText("get_diagram")).toContain('value="Node A"')
|
||||
})
|
||||
|
||||
test("a change made in the editor is pushed, and get_diagram returns it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", { xml: box("2", "Solo", 40) })
|
||||
await expect(frame.getByText("Solo")).toBeVisible({ timeout: 10_000 })
|
||||
// draw.io keeps no global editor; in embed mode Draw.loadPlugin hands
|
||||
// it to the callback at once
|
||||
await frame.locator("body").evaluate((body) => {
|
||||
const w = body.ownerDocument.defaultView as any
|
||||
w.Draw.loadPlugin((ui: any) => {
|
||||
const graph = ui.editor.graph
|
||||
graph.getModel().beginUpdate()
|
||||
try {
|
||||
graph.insertVertex(
|
||||
graph.getDefaultParent(),
|
||||
"manual-1",
|
||||
"Manual",
|
||||
40,
|
||||
200,
|
||||
120,
|
||||
60,
|
||||
"rounded=1;",
|
||||
)
|
||||
} finally {
|
||||
graph.getModel().endUpdate()
|
||||
}
|
||||
})
|
||||
})
|
||||
await expect(frame.getByText("Manual")).toBeVisible()
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await toolText("get_diagram")).includes('id="manual-1"'),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test("screenshot_diagram renders the canvas of the shell", async ({ page }) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", {
|
||||
xml: `${box("2", "Shot A", 40)}\n${box("3", "Shot B", 240)}`,
|
||||
})
|
||||
await expect(frame.getByText("Shot B")).toBeVisible({ timeout: 10_000 })
|
||||
const result = await client.callTool({
|
||||
name: "screenshot_diagram",
|
||||
arguments: {},
|
||||
})
|
||||
const image = (
|
||||
result.content as { type: string; data?: string; mimeType?: string }[]
|
||||
).find((c) => c.type === "image")
|
||||
expect(result.isError ?? false).toBe(false)
|
||||
expect(image?.mimeType).toBe("image/png")
|
||||
expect(image?.data?.length ?? 0).toBeGreaterThan(1000)
|
||||
})
|
||||
|
||||
test("exporting another page shows it for a moment, then the user's page comes back", async ({
|
||||
page,
|
||||
}) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", { xml: box("2", "First page", 40) })
|
||||
await expect(frame.getByText("First page")).toBeVisible({ timeout: 10_000 })
|
||||
await toolText("add_page", {
|
||||
name: "Second",
|
||||
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="s1" value="OnSecond" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||
})
|
||||
await expect(frame.getByText("Second")).toBeVisible({ timeout: 10_000 })
|
||||
// SVG has no page option in draw.io: the server sends a one-page
|
||||
// projection, which the shell shows only for the export
|
||||
const file = join(dataDir, "second.svg")
|
||||
const text = await toolText("export_diagram", {
|
||||
path: file,
|
||||
page_name: "Second",
|
||||
})
|
||||
expect(text).toContain("exported successfully")
|
||||
expect(readFileSync(file, "utf8")).toContain("OnSecond")
|
||||
await expect(frame.getByText("First page")).toBeVisible({ timeout: 10_000 })
|
||||
await expect(frame.locator(".geTabContainer .geActivePage")).toHaveText(
|
||||
"Page-1",
|
||||
)
|
||||
// The projection was never pushed as the document
|
||||
const xml = await toolText("get_diagram")
|
||||
expect(xml).toContain('value="First page"')
|
||||
expect(xml).toContain('name="Second"')
|
||||
})
|
||||
|
||||
test("a screenshot of another page leaves the page on screen alone", async ({
|
||||
page,
|
||||
}) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", { xml: box("2", "Front page", 40) })
|
||||
await expect(frame.getByText("Front page")).toBeVisible({ timeout: 10_000 })
|
||||
await toolText("add_page", {
|
||||
name: "Backside",
|
||||
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="b1" value="OnBack" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||
})
|
||||
const tabs = frame.locator(".geTabContainer")
|
||||
await expect(tabs.getByText("Backside", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
const activeTab = tabs.locator(".geActivePage")
|
||||
await expect(activeTab).toHaveText("Page-1")
|
||||
// The page on screen, as the editor reports it to the server
|
||||
const before = await toolText("get_selection")
|
||||
expect(before).toContain('page "Page-1"')
|
||||
|
||||
// PNG: draw.io renders the other page by its id, off screen; the shell
|
||||
// never shows it as a projection, so the page on screen does not change
|
||||
const png = async (args: Record<string, unknown>) => {
|
||||
const result = await client.callTool({
|
||||
name: "screenshot_diagram",
|
||||
arguments: args,
|
||||
})
|
||||
const image = (
|
||||
result.content as {
|
||||
type: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
}[]
|
||||
).find((c) => c.type === "image")
|
||||
expect(result.isError ?? false).toBe(false)
|
||||
expect(image?.mimeType).toBe("image/png")
|
||||
expect(image?.data?.length ?? 0).toBeGreaterThan(1000)
|
||||
return image?.data ?? ""
|
||||
}
|
||||
const back = await png({ page_name: "Backside" })
|
||||
await expect(activeTab).toHaveText("Page-1")
|
||||
await expect(frame.getByText("Front page")).toBeVisible()
|
||||
expect(await toolText("get_selection")).toContain('page "Page-1"')
|
||||
// Another picture than the page on screen
|
||||
expect(await png({})).not.toBe(back)
|
||||
})
|
||||
|
||||
test("get_selection reports the cells selected in the editor", async ({
|
||||
page,
|
||||
}) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", {
|
||||
xml: `${box("2", "Pick me", 40)}\n${box("3", "Leave me", 240)}\n<mxCell id="4" value="link" source="2" target="3"/>`,
|
||||
})
|
||||
await expect(frame.getByText("Pick me")).toBeVisible({ timeout: 10_000 })
|
||||
const nothing = await toolText("get_selection")
|
||||
expect(nothing).toContain("Nothing is selected")
|
||||
expect(nothing).toContain('page "Page-1"')
|
||||
|
||||
// Select a shape and the edge in the editor, as a click would
|
||||
await frame.locator("body").evaluate((body) => {
|
||||
const w = body.ownerDocument.defaultView as any
|
||||
w.Draw.loadPlugin((ui: any) => {
|
||||
const graph = ui.editor.graph
|
||||
graph.setSelectionCells([
|
||||
graph.model.getCell("2"),
|
||||
graph.model.getCell("4"),
|
||||
])
|
||||
})
|
||||
})
|
||||
const text = await toolText("get_selection")
|
||||
expect(text).toContain('2 cells selected on page "Page-1"')
|
||||
expect(text).toContain('id="2" shape "Pick me" at x=40 y=40 w=120 h=60')
|
||||
expect(text).toContain('id="4" edge "link" from "2" to "3"')
|
||||
expect(text).not.toContain('id="3"')
|
||||
|
||||
// A shape in a container is reported with it, also after the user
|
||||
// entered the container (Ctrl+Shift+End), where it is the default parent
|
||||
await toolText("create_new_diagram", {
|
||||
xml: `<mxCell id="box" value="Box" style="swimlane;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="300" height="200" as="geometry"/></mxCell>\n<mxCell id="kid" value="Kid" style="rounded=1;" vertex="1" parent="box"><mxGeometry x="20" y="30" width="120" height="60" as="geometry"/></mxCell>`,
|
||||
})
|
||||
await expect(frame.getByText("Kid")).toBeVisible({ timeout: 10_000 })
|
||||
await frame.locator("body").evaluate((body) => {
|
||||
const w = body.ownerDocument.defaultView as any
|
||||
w.Draw.loadPlugin((ui: any) => {
|
||||
const graph = ui.editor.graph
|
||||
graph.enterGroup(graph.model.getCell("box"))
|
||||
graph.setSelectionCell(graph.model.getCell("kid"))
|
||||
})
|
||||
})
|
||||
const inside = await toolText("get_selection")
|
||||
expect(inside).toContain(
|
||||
'id="kid" shape "Kid" at x=20 y=30 w=120 h=60 inside "box"',
|
||||
)
|
||||
await frame.locator("body").evaluate((body) => {
|
||||
const w = body.ownerDocument.defaultView as any
|
||||
w.Draw.loadPlugin((ui: any) => ui.editor.graph.exitGroup())
|
||||
})
|
||||
})
|
||||
|
||||
test("the download button saves the diagram as a file", async ({ page }) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", { xml: box("2", "Keep me", 40) })
|
||||
await expect(frame.getByText("Keep me")).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByTestId("download").click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
// The dialog's utility classes are in the shell's stylesheet (shell.css
|
||||
// names the shared component with @source): the formats in two columns
|
||||
const columns = await dialog
|
||||
.getByRole("radiogroup")
|
||||
.evaluate((el) => getComputedStyle(el).gridTemplateColumns)
|
||||
expect(columns.split(" ")).toHaveLength(2)
|
||||
// .drawio is the default format
|
||||
const download = page.waitForEvent("download")
|
||||
await dialog.getByRole("button", { name: "Export" }).click()
|
||||
const file = await download
|
||||
expect(file.suggestedFilename()).toMatch(
|
||||
/^diagram-\d{4}-\d{2}-\d{2}\.drawio$/,
|
||||
)
|
||||
// draw.io's own export of the document (compressed pages, as the web
|
||||
// app saves them; load_diagram opens them)
|
||||
const saved = readFileSync(await file.path(), "utf8")
|
||||
expect(saved).toMatch(/^<mxfile\b/)
|
||||
expect(saved).toContain('name="Page-1"')
|
||||
})
|
||||
|
||||
test("the download waits for a page export's projection, and saves every page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const frame = await openShell(page)
|
||||
await toolText("create_new_diagram", { xml: box("2", "Stay here", 40) })
|
||||
await expect(frame.getByText("Stay here")).toBeVisible({ timeout: 10_000 })
|
||||
await toolText("add_page", {
|
||||
name: "Elsewhere",
|
||||
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="e1" value="OnElsewhere" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||
})
|
||||
await expect(
|
||||
frame
|
||||
.locator(".geTabContainer")
|
||||
.getByText("Elsewhere", { exact: true }),
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByTestId("download").click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
// The export's result is held up for 2 s: the one-page projection of
|
||||
// the other page is surely still on the canvas when Export is clicked
|
||||
await page.route("**/api/state", async (route) => {
|
||||
const request = route.request()
|
||||
if (
|
||||
request.method() === "POST" &&
|
||||
(request.postData() ?? "").includes('"exportData"')
|
||||
) {
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
const exported = toolText("export_diagram", {
|
||||
path: join(dataDir, "elsewhere.svg"),
|
||||
page_name: "Elsewhere",
|
||||
})
|
||||
await expect(frame.getByText("OnElsewhere")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
const download = page.waitForEvent("download")
|
||||
await dialog.getByRole("button", { name: "Export" }).click()
|
||||
// Saved once the user's document is back: both pages, not the projection
|
||||
const saved = readFileSync(await (await download).path(), "utf8")
|
||||
expect(saved.match(/<diagram\b/g)).toHaveLength(2)
|
||||
expect(saved).toContain('name="Page-1"')
|
||||
expect(saved).toContain('name="Elsewhere"')
|
||||
expect(await exported).toContain("exported successfully")
|
||||
})
|
||||
|
||||
// --- Version cards from the server's History (shell/node-versions-source.ts) ---
|
||||
|
||||
test("the version cards follow the server's History; undo, redo and restore go through it", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A session of its own, so History holds only this test's versions
|
||||
const started = await toolText("start_session")
|
||||
const url = started.match(/Browser URL: (\S+)/)?.[1]
|
||||
if (!url) throw new Error(`start_session gave no URL:\n${started}`)
|
||||
await page.goto(url)
|
||||
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
await expect(page.getByTestId("shell-status")).toHaveAttribute(
|
||||
"data-status",
|
||||
"connected",
|
||||
)
|
||||
const frame = page.frameLocator("iframe[title='draw.io']")
|
||||
const cards = page.getByTestId("version-card")
|
||||
const latestCard = cards.last()
|
||||
const getDiagram = () => toolText("get_diagram")
|
||||
|
||||
// Three writes, three cards; the newest can be undone
|
||||
await toolText("create_new_diagram", { xml: box("a", "Card A", 40) })
|
||||
await expect(frame.getByText("Card A")).toBeVisible({ timeout: 10_000 })
|
||||
await expect(cards).toHaveCount(1, { timeout: 10_000 })
|
||||
await expect(cards.first()).toContainText("Drew the diagram")
|
||||
await toolText("edit_diagram", {
|
||||
operations: [
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "b",
|
||||
new_xml: box("b", "Card B", 240),
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(frame.getByText("Card B")).toBeVisible({ timeout: 10_000 })
|
||||
await toolText("edit_diagram", {
|
||||
operations: [
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "c",
|
||||
new_xml: box("c", "Card C", 440),
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(frame.getByText("Card C")).toBeVisible({ timeout: 10_000 })
|
||||
await expect(cards).toHaveCount(3, { timeout: 10_000 })
|
||||
await expect(latestCard).toContainText("Changed the diagram")
|
||||
await expect(latestCard.getByTestId("version-undo")).toHaveText(
|
||||
/Undo change/,
|
||||
)
|
||||
|
||||
// Undo: the server restores the version before; the card says so
|
||||
await latestCard.getByTestId("version-undo").click()
|
||||
await expect(frame.getByText("Card C")).toHaveCount(0, { timeout: 10_000 })
|
||||
await expect(frame.getByText("Card B")).toBeVisible()
|
||||
await expect(latestCard).toContainText("Change undone")
|
||||
await expect(latestCard.getByTestId("version-undo")).toHaveText(
|
||||
/Redo change/,
|
||||
)
|
||||
// A restore adds a History entry, not a card
|
||||
await expect(cards).toHaveCount(3)
|
||||
await expect
|
||||
.poll(async () => (await getDiagram()).includes('id="c"'), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
// Redo: the newest version is back on the canvas and the server
|
||||
await latestCard.getByTestId("version-undo").click()
|
||||
await expect(frame.getByText("Card C")).toBeVisible({ timeout: 10_000 })
|
||||
await expect(latestCard).toContainText("Changed the diagram")
|
||||
await expect(latestCard.getByTestId("version-undo")).toHaveText(
|
||||
/Undo change/,
|
||||
)
|
||||
await expect
|
||||
.poll(async () => (await getDiagram()).includes('id="c"'), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
// A hand edit: no version is on the canvas, so the newest card offers
|
||||
// a restore, not an undo
|
||||
await frame.locator("body").evaluate((body) => {
|
||||
const w = body.ownerDocument.defaultView as any
|
||||
w.Draw.loadPlugin((ui: any) => {
|
||||
const graph = ui.editor.graph
|
||||
graph.getModel().beginUpdate()
|
||||
try {
|
||||
graph.insertVertex(
|
||||
graph.getDefaultParent(),
|
||||
"manual-card",
|
||||
"By hand",
|
||||
40,
|
||||
200,
|
||||
120,
|
||||
60,
|
||||
"rounded=1;",
|
||||
)
|
||||
} finally {
|
||||
graph.getModel().endUpdate()
|
||||
}
|
||||
})
|
||||
})
|
||||
await expect
|
||||
.poll(async () => (await getDiagram()).includes('id="manual-card"'), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(latestCard.getByTestId("version-restore")).toBeVisible()
|
||||
await expect(cards).toHaveCount(3)
|
||||
|
||||
// Restoring the first version keeps the hand edit as a version of its
|
||||
// own, and get_diagram agrees with the canvas
|
||||
await cards.first().getByTestId("version-restore").click()
|
||||
await expect(frame.getByText("Card B")).toHaveCount(0, { timeout: 10_000 })
|
||||
await expect(frame.getByText("By hand")).toHaveCount(0)
|
||||
await expect(frame.getByText("Card A")).toBeVisible()
|
||||
await expect(cards).toHaveCount(4, { timeout: 10_000 })
|
||||
await expect(cards.nth(3)).toContainText("Changed by hand")
|
||||
// The first version is on the canvas: its restore button rests
|
||||
await expect(cards.first().getByTestId("version-restore")).toBeDisabled()
|
||||
await expect
|
||||
.poll(getDiagram, { timeout: 10_000 })
|
||||
.toContain('value="Card A"')
|
||||
const xml = await getDiagram()
|
||||
expect(xml).not.toContain('id="b"')
|
||||
expect(xml).not.toContain('id="manual-card"')
|
||||
// The MCP tool sees the same versions: the hand edit is one step back
|
||||
expect(await toolText("restore_version", { steps_back: 1 })).toContain(
|
||||
"Restored the version 1 step(s) back",
|
||||
)
|
||||
await expect(frame.getByText("By hand")).toBeVisible({ timeout: 10_000 })
|
||||
await expect(cards).toHaveCount(4)
|
||||
})
|
||||
|
||||
test("the theme toggle switches the page and draw.io, and is remembered", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(shellUrl)
|
||||
await expect(page.getByTestId("canvas-loading")).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
const html = page.locator("html")
|
||||
const frameBody = page
|
||||
.frameLocator("iframe[title='draw.io']")
|
||||
.locator("body")
|
||||
await expect(html).not.toHaveClass(/dark/)
|
||||
await expect(frameBody).not.toHaveClass(/geDarkMode/)
|
||||
|
||||
await page.getByTestId("theme-toggle").click()
|
||||
await expect(html).toHaveClass(/dark/)
|
||||
// Same-origin draw.io switches in place, without a reload
|
||||
await expect(frameBody).toHaveClass(/geDarkMode/)
|
||||
expect(
|
||||
await page.evaluate(() => localStorage.getItem("mcp:dark-mode")),
|
||||
).toBe("true")
|
||||
|
||||
await page.reload()
|
||||
await expect(html).toHaveClass(/dark/)
|
||||
await page.getByTestId("theme-toggle").click()
|
||||
await expect(html).not.toHaveClass(/dark/)
|
||||
})
|
||||
@@ -7,6 +7,8 @@ import { installDomPolyfill } from "../src/dom.ts"
|
||||
import {
|
||||
addHistory,
|
||||
clearHistory,
|
||||
currentEntryId,
|
||||
firstCopyIds,
|
||||
getHistory,
|
||||
isInHistory,
|
||||
otherVersions,
|
||||
@@ -116,3 +118,88 @@ describe("otherVersions", () => {
|
||||
expect(otherVersions(id, A)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("entries", () => {
|
||||
it("record who made them", () => {
|
||||
const id = "history-fields"
|
||||
clearHistory(id)
|
||||
addHistory(id, A)
|
||||
addHistory(id, B, "<svg/>", "user")
|
||||
addHistory(id, C, "", "restore")
|
||||
const [first, second, third] = getHistory(id)
|
||||
expect(first.source).toBeUndefined()
|
||||
expect(second.source).toBe("user")
|
||||
expect(second.svg).toBe("<svg/>")
|
||||
expect(third.source).toBe("restore")
|
||||
})
|
||||
})
|
||||
|
||||
// The shell's version cards: which entry the canvas shows, and which
|
||||
// entries are copies of an earlier one (a restore adds one)
|
||||
describe("currentEntryId and firstCopyIds", () => {
|
||||
it("name the newest entry with the canvas's content, by the rule of otherVersions", () => {
|
||||
const id = "history-current"
|
||||
fill(id, [A, B, C, A])
|
||||
const [a, b, c, aCopy] = getHistory(id)
|
||||
expect(currentEntryId(id, A)).toBe(aCopy.id)
|
||||
expect(currentEntryId(id, B_COPY)).toBe(b.id)
|
||||
expect(currentEntryId(id, page("manual"))).toBeNull()
|
||||
// A restore's copy belongs with its first entry; the others are
|
||||
// their own
|
||||
expect([...firstCopyIds(id).entries()]).toEqual([
|
||||
[a.id, a.id],
|
||||
[b.id, b.id],
|
||||
[c.id, c.id],
|
||||
[aCopy.id, a.id],
|
||||
])
|
||||
})
|
||||
|
||||
it("group a bare model with the first named page it matches, never a later one", () => {
|
||||
// A bare model (the browser's sync can send one) has no page name:
|
||||
// it is the same as "Arch" and as "Overview", which differ from each
|
||||
// other. Each entry names a first copy with its own content, so a
|
||||
// card never shows one document and restores another
|
||||
const id = "history-bare-groups"
|
||||
const named = (name: string) => A.replace('name="P"', `name="${name}"`)
|
||||
const bare = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel>`
|
||||
fill(id, [named("Arch"), bare, named("Overview")])
|
||||
const [arch, model, overview] = getHistory(id)
|
||||
const firsts = firstCopyIds(id)
|
||||
expect([...firsts.entries()]).toEqual([
|
||||
[arch.id, arch.id],
|
||||
[model.id, arch.id],
|
||||
[overview.id, overview.id],
|
||||
])
|
||||
// The entry named as first has the content of every entry in its
|
||||
// group (otherVersions of that content never lists it)
|
||||
for (const entry of getHistory(id)) {
|
||||
expect(otherVersions(id, entry.xml).map((e) => e.id)).not.toContain(
|
||||
firsts.get(entry.id),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("agree with otherVersions on a page setting that only the canvas shows", () => {
|
||||
// The same cells with another background: one version to the MCP
|
||||
// core (the model's edits stay valid when the user changes a page
|
||||
// setting), so the canvas still shows that entry and restore_version
|
||||
// offers no other copy of it
|
||||
const id = "history-background"
|
||||
const withBackground = A.replace(
|
||||
"<mxGraphModel>",
|
||||
'<mxGraphModel background="#ffcc00" shadow="1">',
|
||||
)
|
||||
fill(id, [A, B])
|
||||
const [a] = getHistory(id)
|
||||
expect(currentEntryId(id, withBackground)).toBe(a.id)
|
||||
expect(otherVersions(id, withBackground).map((e) => e.xml)).toEqual([B])
|
||||
expect(isInHistory(id, withBackground)).toBe(true)
|
||||
})
|
||||
|
||||
it("return nothing for an empty history", () => {
|
||||
const id = "history-current-empty"
|
||||
clearHistory(id)
|
||||
expect(currentEntryId(id, A)).toBeNull()
|
||||
expect(firstCopyIds(id).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,35 +6,78 @@
|
||||
* node:http so tests can set raw paths and Host/Origin headers.
|
||||
*/
|
||||
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import http from "node:http"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.ts"
|
||||
import { addHistory, getHistory } from "../src/history.ts"
|
||||
import {
|
||||
activePreviewUi,
|
||||
drawioEmbedParams,
|
||||
getApiToken,
|
||||
getState,
|
||||
isSameOriginDrawio,
|
||||
keepInHistory,
|
||||
onSessionRecreate,
|
||||
onStateChange,
|
||||
previewUiFromEnv,
|
||||
previewUrl,
|
||||
readSelection,
|
||||
requestExport,
|
||||
requestSelection,
|
||||
requestSync,
|
||||
restoreHistoryEntry,
|
||||
setDrawioDir,
|
||||
setShellDir,
|
||||
setState,
|
||||
shellConfig,
|
||||
shutdown,
|
||||
startHttpServer,
|
||||
waitForSelection,
|
||||
waitForSync,
|
||||
} from "../src/http-server.ts"
|
||||
|
||||
let port = 0
|
||||
// A stand-in for the bundled draw.io copy (dist/drawio), with a server-side
|
||||
// part of the war that must never be served
|
||||
let drawioDir = ""
|
||||
// A stand-in for the built canvas shell (dist/shell)
|
||||
let shellDir = ""
|
||||
|
||||
beforeAll(async () => {
|
||||
// XML parsing, as the server installs it at startup
|
||||
installDomPolyfill()
|
||||
drawioDir = mkdtempSync(join(tmpdir(), "drawio-static-"))
|
||||
mkdirSync(join(drawioDir, "js"))
|
||||
mkdirSync(join(drawioDir, "WEB-INF"))
|
||||
writeFileSync(join(drawioDir, "index.html"), "<html>draw.io</html>")
|
||||
writeFileSync(join(drawioDir, ".version"), "v1\n")
|
||||
writeFileSync(join(drawioDir, "js/app.min.js"), "// app")
|
||||
writeFileSync(join(drawioDir, "WEB-INF/web.xml"), "<web-app/>")
|
||||
setDrawioDir(drawioDir)
|
||||
shellDir = mkdtempSync(join(tmpdir(), "shell-static-"))
|
||||
writeFileSync(
|
||||
join(shellDir, "index.html"),
|
||||
"<html><script>window.__MCP_CONFIG__ = {{CONFIG_JSON}};</script></html>",
|
||||
)
|
||||
writeFileSync(join(shellDir, "shell.js"), "// shell")
|
||||
writeFileSync(join(shellDir, "shell.css"), "body{}")
|
||||
setShellDir(shellDir)
|
||||
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
shutdown()
|
||||
rmSync(drawioDir, { recursive: true, force: true })
|
||||
rmSync(shellDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
interface Response {
|
||||
@@ -43,7 +86,10 @@ interface Response {
|
||||
body: string
|
||||
}
|
||||
|
||||
/** Send a request; `body` may be split into several writes. */
|
||||
/**
|
||||
* Send a request as the preview page does (same Host, the API token);
|
||||
* `body` may be split into several writes.
|
||||
*/
|
||||
function request(
|
||||
path: string,
|
||||
opts: {
|
||||
@@ -59,7 +105,11 @@ function request(
|
||||
port,
|
||||
path,
|
||||
method: opts.method ?? "GET",
|
||||
headers: { host: `localhost:${port}`, ...opts.headers },
|
||||
headers: {
|
||||
host: `localhost:${port}`,
|
||||
"x-drawio-token": getApiToken(),
|
||||
...opts.headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = []
|
||||
@@ -358,6 +408,154 @@ describe("export requests", () => {
|
||||
expect(getState(id)?.exportData).toBeUndefined()
|
||||
expect(getState(id)?.exportFormat).toBe("svg")
|
||||
})
|
||||
|
||||
it("names exports at random, so a result meant for another process is not taken", async () => {
|
||||
const id = "mcp-export-random-id"
|
||||
setState(id, "<mxfile>x</mxfile>")
|
||||
requestExport(id, "svg")
|
||||
const poll = JSON.parse(
|
||||
(await request(`/api/state?sessionId=${id}`)).body,
|
||||
)
|
||||
expect(poll.exportId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
// The page retries a result refused with 403 against the process
|
||||
// that took over the port; a per-process counter would restart at 1
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
exportData: "<svg>OLD</svg>",
|
||||
exportId: 1,
|
||||
})
|
||||
expect(getState(id)?.exportData).toBeUndefined()
|
||||
expect(getState(id)?.exportFormat).toBe("svg")
|
||||
})
|
||||
})
|
||||
|
||||
describe("selection requests (get_selection)", () => {
|
||||
const picked = {
|
||||
pageId: "p1",
|
||||
pageName: "Page-1",
|
||||
cells: [{ id: "3", label: "Login", edge: false }],
|
||||
}
|
||||
|
||||
it("shows the request to the page and takes its answer once", async () => {
|
||||
const id = "mcp-selection"
|
||||
setState(id, "<mxfile>x</mxfile>")
|
||||
expect(requestSelection(id)).toBe(true)
|
||||
const poll = JSON.parse(
|
||||
(await request(`/api/state?sessionId=${id}`)).body,
|
||||
)
|
||||
expect(poll.selectionId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
// The request survives a write meanwhile
|
||||
setState(id, "<mxfile>y</mxfile>")
|
||||
const waiting = waitForSelection(id, 3000)
|
||||
const answer = await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
selectionId: poll.selectionId,
|
||||
selection: picked,
|
||||
})
|
||||
expect(answer.status).toBe(200)
|
||||
expect(await waiting).toEqual(picked)
|
||||
// Taken: the next poll shows no request
|
||||
const after = JSON.parse(
|
||||
(await request(`/api/state?sessionId=${id}`)).body,
|
||||
)
|
||||
expect(after.selectionId).toBeNull()
|
||||
expect(getState(id)?.xml).toBe("<mxfile>y</mxfile>")
|
||||
})
|
||||
|
||||
it("ignores an answer to another request, and gives up in time", async () => {
|
||||
const id = "mcp-selection-late"
|
||||
setState(id, "<mxfile>x</mxfile>")
|
||||
requestSelection(id)
|
||||
const first = JSON.parse(
|
||||
(await request(`/api/state?sessionId=${id}`)).body,
|
||||
)
|
||||
requestSelection(id)
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
selectionId: first.selectionId,
|
||||
selection: picked,
|
||||
})
|
||||
expect(getState(id)?.selection).toBeUndefined()
|
||||
expect(getState(id)?.selectionId).not.toBe(first.selectionId)
|
||||
// A malformed answer is not taken either
|
||||
const second = getState(id)?.selectionId
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
selectionId: second,
|
||||
selection: { cells: "3" },
|
||||
})
|
||||
expect(getState(id)?.selection).toBeUndefined()
|
||||
expect(await waitForSelection(id, 250)).toBeNull()
|
||||
// The request is withdrawn with the timeout
|
||||
expect(getState(id)?.selectionId).toBeUndefined()
|
||||
expect(requestSelection("mcp-unknown-session")).toBe(false)
|
||||
})
|
||||
|
||||
it("takes no answer while no request is pending", async () => {
|
||||
const id = "mcp-selection-unasked"
|
||||
setState(id, "<mxfile>x</mxfile>")
|
||||
// Both undefined would compare equal
|
||||
await postJson("/api/state", { sessionId: id, selection: picked })
|
||||
expect(getState(id)?.selection).toBeUndefined()
|
||||
requestSelection(id)
|
||||
const { selectionId } = getState(id) ?? {}
|
||||
await postJson("/api/state", { sessionId: id, selection: picked })
|
||||
expect(getState(id)?.selection).toBeUndefined()
|
||||
expect(getState(id)?.selectionId).toBe(selectionId)
|
||||
await waitForSelection(id, 50)
|
||||
})
|
||||
|
||||
it("keeps the first answer when two tabs answer the same request", async () => {
|
||||
const id = "mcp-selection-two-tabs"
|
||||
setState(id, "<mxfile>x</mxfile>")
|
||||
requestSelection(id)
|
||||
const { selectionId } = getState(id) ?? {}
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
selectionId,
|
||||
selection: picked,
|
||||
})
|
||||
const other = { ...picked, cells: [] }
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
selectionId,
|
||||
selection: other,
|
||||
})
|
||||
expect(await waitForSelection(id, 1000)).toEqual(picked)
|
||||
})
|
||||
|
||||
it("lets overlapping get_selection calls take turns, each getting its own answer", async () => {
|
||||
const id = "mcp-selection-overlap"
|
||||
setState(id, "<mxfile>x</mxfile>")
|
||||
const poll = async () =>
|
||||
JSON.parse((await request(`/api/state?sessionId=${id}`)).body)
|
||||
const first = readSelection(id, 3000)
|
||||
const second = readSelection(id, 3000)
|
||||
// The tab sees one request and answers it
|
||||
let shown = await poll()
|
||||
expect(shown.selectionId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
selectionId: shown.selectionId,
|
||||
selection: picked,
|
||||
})
|
||||
expect(await first).toEqual(picked)
|
||||
// Then the second call's request, another id, answered in turn
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
const next = await poll()
|
||||
expect(next.selectionId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(next.selectionId).not.toBe(shown.selectionId)
|
||||
const other = { ...picked, cells: [] }
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
selectionId: next.selectionId,
|
||||
selection: other,
|
||||
})
|
||||
expect(await second).toEqual(other)
|
||||
shown = await poll()
|
||||
expect(shown.selectionId).toBeNull()
|
||||
expect(await readSelection("mcp-unknown-session", 100)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("a session state recreated after it was lost", () => {
|
||||
@@ -463,8 +661,371 @@ describe("preview page", () => {
|
||||
.map((m) => m[1])
|
||||
.join("\n")
|
||||
expect(scripts).toContain('const sessionId = "mcp-test-script";')
|
||||
expect(scripts).toContain(`let API_TOKEN = "${getApiToken()}";`)
|
||||
expect(() => new Function(scripts)).not.toThrow()
|
||||
})
|
||||
|
||||
it("embeds the bundled draw.io from its own origin, else the external one", async () => {
|
||||
expect(isSameOriginDrawio()).toBe(true)
|
||||
const res = await request("/?mcp=mcp-test-origin")
|
||||
expect(res.body).toContain('src="/drawio/index.html?embed=1&')
|
||||
// The page takes its own origin for draw.io's messages
|
||||
expect(res.body).toContain(
|
||||
'const DRAWIO_ORIGIN = "" || location.origin;',
|
||||
)
|
||||
|
||||
setDrawioDir(null)
|
||||
try {
|
||||
expect(isSameOriginDrawio()).toBe(false)
|
||||
const external = await request("/?mcp=mcp-test-origin")
|
||||
expect(external.body).toContain(
|
||||
'src="https://embed.diagrams.net/?embed=1&',
|
||||
)
|
||||
expect(external.body).toContain(
|
||||
'const DRAWIO_ORIGIN = "https://embed.diagrams.net" || location.origin;',
|
||||
)
|
||||
} finally {
|
||||
setDrawioDir(drawioDir)
|
||||
}
|
||||
})
|
||||
|
||||
it("sends the security headers with the page, which is never cached", async () => {
|
||||
const res = await request("/?mcp=mcp-test-headers")
|
||||
expect(res.headers["content-security-policy"]).toBe(
|
||||
"frame-ancestors 'self'",
|
||||
)
|
||||
expect(res.headers["x-content-type-options"]).toBe("nosniff")
|
||||
// It holds the process's token
|
||||
expect(res.headers["cache-control"]).toBe("no-store")
|
||||
})
|
||||
})
|
||||
|
||||
describe("API token", () => {
|
||||
it("refuses an API request without the token, or with another one", async () => {
|
||||
setState("mcp-token", "<mxfile>kept</mxfile>")
|
||||
for (const headers of [
|
||||
{ "x-drawio-token": "" },
|
||||
{ "x-drawio-token": "0123456789abcdef0123456789abcdef" },
|
||||
]) {
|
||||
const get = await request("/api/state?sessionId=mcp-token", {
|
||||
headers,
|
||||
})
|
||||
expect(get.status).toBe(403)
|
||||
const post = await postJson(
|
||||
"/api/state",
|
||||
{ sessionId: "mcp-token", xml: "<mxfile>replaced</mxfile>" },
|
||||
headers,
|
||||
)
|
||||
expect(post.status).toBe(403)
|
||||
const history = await request("/api/history?sessionId=mcp-token", {
|
||||
headers,
|
||||
})
|
||||
expect(history.status).toBe(403)
|
||||
}
|
||||
expect(getState("mcp-token")?.xml).toBe("<mxfile>kept</mxfile>")
|
||||
})
|
||||
|
||||
it("is not needed for the page and the draw.io files", async () => {
|
||||
const headers = { "x-drawio-token": "" }
|
||||
expect((await request("/?mcp=mcp-token", { headers })).status).toBe(200)
|
||||
expect((await request("/drawio/index.html", { headers })).status).toBe(
|
||||
200,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("bundled draw.io files", () => {
|
||||
it("serves a file with its MIME type, cache and nosniff headers", async () => {
|
||||
const res = await request("/drawio/js/app.min.js")
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toBe("// app")
|
||||
expect(res.headers["content-type"]).toBe(
|
||||
"text/javascript; charset=utf-8",
|
||||
)
|
||||
// Revalidated on every use: the file names stay the same across
|
||||
// draw.io versions, so a package upgrade must reach the browser
|
||||
expect(res.headers["cache-control"]).toBe("no-cache")
|
||||
expect(res.headers.etag).toMatch(/^"v1-[0-9a-f]+-[0-9a-f]+"$/)
|
||||
expect(res.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(res.headers["content-security-policy"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("changes the ETag with the draw.io version, whatever the file dates say", async () => {
|
||||
// An install that keeps the archive's dates gives the upgraded file
|
||||
// the same date and, here, the same size as before
|
||||
const file = join(drawioDir, "js/same-size.js")
|
||||
const date = new Date("2020-01-01T00:00:00Z")
|
||||
writeFileSync(file, "// v1")
|
||||
utimesSync(file, date, date)
|
||||
const first = await request("/drawio/js/same-size.js")
|
||||
writeFileSync(file, "// v2")
|
||||
utimesSync(file, date, date)
|
||||
writeFileSync(join(drawioDir, ".version"), "v2\n")
|
||||
setDrawioDir(drawioDir)
|
||||
try {
|
||||
const again = await request("/drawio/js/same-size.js", {
|
||||
headers: { "if-none-match": first.headers.etag as string },
|
||||
})
|
||||
expect(again.status).toBe(200)
|
||||
expect(again.body).toBe("// v2")
|
||||
expect(again.headers.etag).not.toBe(first.headers.etag)
|
||||
} finally {
|
||||
writeFileSync(join(drawioDir, ".version"), "v1\n")
|
||||
setDrawioDir(drawioDir)
|
||||
rmSync(file, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("answers 304 to a matching If-None-Match, and HEAD without a body", async () => {
|
||||
const first = await request("/drawio/js/app.min.js")
|
||||
const again = await request("/drawio/js/app.min.js", {
|
||||
headers: { "if-none-match": first.headers.etag as string },
|
||||
})
|
||||
expect(again.status).toBe(304)
|
||||
expect(again.body).toBe("")
|
||||
expect(again.headers.etag).toBe(first.headers.etag)
|
||||
const changed = await request("/drawio/js/app.min.js", {
|
||||
headers: { "if-none-match": '"0-0"' },
|
||||
})
|
||||
expect(changed.status).toBe(200)
|
||||
const head = await request("/drawio/js/app.min.js", {
|
||||
method: "HEAD",
|
||||
})
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.body).toBe("")
|
||||
expect(head.headers["content-length"]).toBe("6")
|
||||
})
|
||||
|
||||
it("keeps running when a file cannot be read", async () => {
|
||||
// Readable for stat, not for open (root reads anything: skipped)
|
||||
if (process.getuid?.() === 0) return
|
||||
const file = join(drawioDir, "js/locked.js")
|
||||
writeFileSync(file, "// locked", { mode: 0o000 })
|
||||
try {
|
||||
// The connection is dropped (the headers may be out already)
|
||||
const res = await request("/drawio/js/locked.js").catch(() => null)
|
||||
expect(res?.body ?? "").toBe("")
|
||||
} finally {
|
||||
rmSync(file, { force: true })
|
||||
}
|
||||
const next = await request("/drawio/js/app.min.js")
|
||||
expect(next.status).toBe(200)
|
||||
expect(next.body).toBe("// app")
|
||||
})
|
||||
|
||||
it("frames the editor page only from this origin", async () => {
|
||||
const res = await request("/drawio/index.html")
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8")
|
||||
expect(res.headers["content-security-policy"]).toBe(
|
||||
"frame-ancestors 'self'",
|
||||
)
|
||||
})
|
||||
|
||||
it("never serves the war's server-side parts or files outside the copy", async () => {
|
||||
for (const path of [
|
||||
"/drawio/WEB-INF/web.xml",
|
||||
"/drawio/web-inf/web.xml",
|
||||
"/drawio/js/../WEB-INF/web.xml",
|
||||
"/drawio/META-INF/MANIFEST.MF",
|
||||
"/drawio/../package.json",
|
||||
"/drawio/%2e%2e/package.json",
|
||||
"/drawio/js/%2e%2e/%2e%2e/package.json",
|
||||
"/drawio/js/..%5c..%5cpackage.json",
|
||||
"/drawio/",
|
||||
"/drawio/js",
|
||||
"/drawio/missing.js",
|
||||
]) {
|
||||
const res = await request(path)
|
||||
expect([403, 404], path).toContain(res.status)
|
||||
expect(res.body).not.toContain("web-app")
|
||||
expect(res.body).not.toContain('"name"')
|
||||
}
|
||||
})
|
||||
|
||||
it("answers 404 when no copy is bundled", async () => {
|
||||
setDrawioDir(null)
|
||||
try {
|
||||
expect((await request("/drawio/index.html")).status).toBe(404)
|
||||
} finally {
|
||||
setDrawioDir(drawioDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("canvas shell page", () => {
|
||||
/** The config the served page carries */
|
||||
const configOf = (body: string) => {
|
||||
const match = body.match(
|
||||
/window\.__MCP_CONFIG__ = (\{.*?\});<\/script>/s,
|
||||
)
|
||||
if (!match) throw new Error(`no config in ${body}`)
|
||||
return JSON.parse(match[1])
|
||||
}
|
||||
|
||||
it("writes the session, the token and where draw.io comes from into the page", async () => {
|
||||
const res = await request("/shell/?mcp=mcp-shell-page")
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8")
|
||||
expect(res.body).not.toContain("{{")
|
||||
expect(configOf(res.body)).toEqual({
|
||||
sessionId: "mcp-shell-page",
|
||||
token: getApiToken(),
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "/drawio/index.html",
|
||||
drawioUi: "",
|
||||
drawioLang: "",
|
||||
drawioDark: "auto",
|
||||
lang: "",
|
||||
})
|
||||
// The page creates the session, as the classic page does
|
||||
expect(getState("mcp-shell-page")).toBeDefined()
|
||||
// The same page at its index.html name
|
||||
const index = await request("/shell/index.html?mcp=mcp-shell-page")
|
||||
expect(index.status).toBe(200)
|
||||
expect(configOf(index.body).sessionId).toBe("mcp-shell-page")
|
||||
})
|
||||
|
||||
it("passes the host's editor settings on, in the shell's own terms", () => {
|
||||
const config = shellConfig("mcp-s", {
|
||||
DRAWIO_UI: "Sketch",
|
||||
DRAWIO_LANG: "zh-Hant",
|
||||
DRAWIO_DARK: "1",
|
||||
})
|
||||
expect(config.drawioUi).toBe("sketch")
|
||||
expect(config.drawioLang).toBe("zh-tw")
|
||||
expect(config.drawioDark).toBe("dark")
|
||||
// The language as given, for the shell's own texts
|
||||
expect(config.lang).toBe("zh-Hant")
|
||||
expect(shellConfig("mcp-s", { DRAWIO_DARK: "light" }).drawioDark).toBe(
|
||||
"light",
|
||||
)
|
||||
expect(shellConfig("mcp-s", { DRAWIO_UI: "dark" }).drawioDark).toBe(
|
||||
"dark",
|
||||
)
|
||||
expect(shellConfig("mcp-s", { DRAWIO_UI: "neon" }).drawioUi).toBe("")
|
||||
})
|
||||
|
||||
it("names the external draw.io when there is no bundled copy", async () => {
|
||||
setDrawioDir(null)
|
||||
try {
|
||||
const res = await request("/shell/?mcp=mcp-shell-external")
|
||||
expect(configOf(res.body).drawioBaseUrl).toBe(
|
||||
"https://embed.diagrams.net/",
|
||||
)
|
||||
} finally {
|
||||
setDrawioDir(drawioDir)
|
||||
}
|
||||
})
|
||||
|
||||
it("escapes a value that could close the script element", async () => {
|
||||
const before = process.env.DRAWIO_LANG
|
||||
process.env.DRAWIO_LANG = "</script><script>alert(1)</script>"
|
||||
try {
|
||||
const res = await request("/shell/?mcp=mcp-shell-escape")
|
||||
expect(res.body).not.toContain("</script><script>alert")
|
||||
expect(res.body).toContain("\\u003c/script>")
|
||||
expect(configOf(res.body).lang).toBe(process.env.DRAWIO_LANG)
|
||||
} finally {
|
||||
if (before === undefined) delete process.env.DRAWIO_LANG
|
||||
else process.env.DRAWIO_LANG = before
|
||||
}
|
||||
})
|
||||
|
||||
it("sends the security headers, is never cached and needs no token", async () => {
|
||||
const res = await request("/shell/?mcp=mcp-shell-headers", {
|
||||
headers: { "x-drawio-token": "" },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers["content-security-policy"]).toBe(
|
||||
"frame-ancestors 'self'",
|
||||
)
|
||||
expect(res.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(res.headers["cache-control"]).toBe("no-store")
|
||||
})
|
||||
|
||||
it("rejects a bad session id and goes to the most recent session without one", async () => {
|
||||
const bad = await request(
|
||||
`/shell/?mcp=${encodeURIComponent('";alert(1)//')}`,
|
||||
)
|
||||
expect(bad.status).toBe(400)
|
||||
setState("mcp-shell-recent", "<mxfile>recent</mxfile>")
|
||||
const res = await request("/shell/")
|
||||
expect(res.status).toBe(302)
|
||||
// (other tests of this file create sessions in the same millisecond)
|
||||
expect(res.headers.location).toMatch(/^\/shell\/\?mcp=mcp-/)
|
||||
// Without the trailing slash the assets would resolve elsewhere
|
||||
const slash = await request("/shell?mcp=mcp-shell-recent")
|
||||
expect(slash.status).toBe(302)
|
||||
expect(slash.headers.location).toBe("/shell/?mcp=mcp-shell-recent")
|
||||
})
|
||||
|
||||
it("serves the shell's files with their MIME types, without a token", async () => {
|
||||
const js = await request("/shell/shell.js", {
|
||||
headers: { "x-drawio-token": "" },
|
||||
})
|
||||
expect(js.status).toBe(200)
|
||||
expect(js.body).toBe("// shell")
|
||||
expect(js.headers["content-type"]).toBe(
|
||||
"text/javascript; charset=utf-8",
|
||||
)
|
||||
expect(js.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(js.headers["cache-control"]).toBe("no-cache")
|
||||
expect(js.headers.etag).toMatch(/^"\d+\.\d+\.\d+-[0-9a-f]+-[0-9a-f]+"$/)
|
||||
const css = await request("/shell/shell.css")
|
||||
expect(css.headers["content-type"]).toBe("text/css; charset=utf-8")
|
||||
for (const path of [
|
||||
"/shell/../package.json",
|
||||
"/shell/%2e%2e/package.json",
|
||||
"/shell/missing.js",
|
||||
]) {
|
||||
const res = await request(path)
|
||||
expect(res.status, path).toBe(404)
|
||||
expect(res.body).not.toContain('"name"')
|
||||
}
|
||||
})
|
||||
|
||||
it("says so when the shell is not built", async () => {
|
||||
setShellDir(null)
|
||||
try {
|
||||
const page = await request("/shell/?mcp=mcp-shell-unbuilt")
|
||||
expect(page.status).toBe(404)
|
||||
expect(page.body).toContain("npm run build")
|
||||
expect((await request("/shell/shell.js")).status).toBe(404)
|
||||
} finally {
|
||||
setShellDir(shellDir)
|
||||
}
|
||||
})
|
||||
|
||||
it("is what start_session opens, unless DRAWIO_PREVIEW_UI=classic", () => {
|
||||
expect(previewUiFromEnv({})).toBe("shell")
|
||||
expect(previewUiFromEnv({ DRAWIO_PREVIEW_UI: "shell" })).toBe("shell")
|
||||
expect(previewUiFromEnv({ DRAWIO_PREVIEW_UI: "classic" })).toBe(
|
||||
"classic",
|
||||
)
|
||||
expect(previewUiFromEnv({ DRAWIO_PREVIEW_UI: "Classic" })).toBe(
|
||||
"classic",
|
||||
)
|
||||
expect(previewUrl(6002, "mcp-x", "shell")).toBe(
|
||||
"http://localhost:6002/shell/?mcp=mcp-x",
|
||||
)
|
||||
expect(previewUrl(6002, "mcp-x", "classic")).toBe(
|
||||
"http://localhost:6002?mcp=mcp-x",
|
||||
)
|
||||
// Not built: the classic page, which works, rather than a 404; the
|
||||
// tools that need the shell (get_selection) see the classic page too
|
||||
expect(activePreviewUi("shell")).toBe("shell")
|
||||
expect(activePreviewUi("classic")).toBe("classic")
|
||||
setShellDir(null)
|
||||
try {
|
||||
expect(previewUrl(6002, "mcp-x", "shell")).toBe(
|
||||
"http://localhost:6002?mcp=mcp-x",
|
||||
)
|
||||
expect(activePreviewUi("shell")).toBe("classic")
|
||||
} finally {
|
||||
setShellDir(shellDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("draw.io embed parameters from the host config", () => {
|
||||
@@ -682,6 +1243,164 @@ describe("history restore", () => {
|
||||
// The missing image is filled in
|
||||
expect(getHistory(id)[0].svg).toBe("SVG")
|
||||
})
|
||||
})
|
||||
|
||||
// What the shell's version cards read (the classic page uses index, id
|
||||
// and svg of the same list)
|
||||
describe("GET /api/history for the version cards", () => {
|
||||
const page = (cellId: string) =>
|
||||
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
it("lists each entry's document, maker and first copy, with the state and the entry on the canvas", async () => {
|
||||
const id = "mcp-history-cards"
|
||||
addHistory(id, page("a"))
|
||||
addHistory(id, page("b"), "SVG-B", "user")
|
||||
setState(id, page("b"))
|
||||
const res = await request(`/api/history?sessionId=${id}`)
|
||||
expect(res.status).toBe(200)
|
||||
const d = JSON.parse(res.body)
|
||||
const [a, b] = getHistory(id)
|
||||
expect(d.count).toBe(2)
|
||||
expect(d.stateId).toBe(getState(id)?.stateId)
|
||||
expect(d.currentId).toBe(b.id)
|
||||
expect(d.entries[0]).toEqual({
|
||||
index: 0,
|
||||
id: a.id,
|
||||
svg: "",
|
||||
xml: page("a"),
|
||||
source: null,
|
||||
firstId: a.id,
|
||||
})
|
||||
expect(d.entries[1]).toMatchObject({
|
||||
id: b.id,
|
||||
svg: "SVG-B",
|
||||
source: "user",
|
||||
firstId: b.id,
|
||||
})
|
||||
})
|
||||
|
||||
it("gives the state a History key, which a hand edit changes once and a further one not at all", async () => {
|
||||
// The shell reads History (every entry's XML and picture) again
|
||||
// only when the key changes
|
||||
const id = "mcp-history-key"
|
||||
setState(id, page("a"))
|
||||
addHistory(id, page("a"))
|
||||
const [a] = getHistory(id)
|
||||
const first = JSON.parse(
|
||||
(await request(`/api/state?sessionId=${id}`)).body,
|
||||
)
|
||||
expect(first.historyKey).toBe(`1:${a.id}:${a.id}`)
|
||||
// A hand edit: the canvas is at no entry now, History is the same
|
||||
const push = await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: page("b"),
|
||||
baseVersion: first.version,
|
||||
stateId: first.stateId,
|
||||
})
|
||||
expect(push.status).toBe(200)
|
||||
expect(JSON.parse(push.body).historyKey).toBe(`1:${a.id}:null`)
|
||||
const again = await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: page("c"),
|
||||
baseVersion: first.version + 1,
|
||||
stateId: first.stateId,
|
||||
})
|
||||
expect(JSON.parse(again.body).historyKey).toBe(`1:${a.id}:null`)
|
||||
const polled = JSON.parse(
|
||||
(await request(`/api/state?sessionId=${id}`)).body,
|
||||
)
|
||||
expect(polled.historyKey).toBe(`1:${a.id}:null`)
|
||||
// A write keeps the edit in History: the key names the new entry
|
||||
keepInHistory(id, page("c"))
|
||||
setState(id, page("d"))
|
||||
addHistory(id, page("d"))
|
||||
const [, c, d] = getHistory(id)
|
||||
expect(c.xml).toBe(page("c"))
|
||||
const after = JSON.parse(
|
||||
(await request(`/api/state?sessionId=${id}`)).body,
|
||||
)
|
||||
expect(after.historyKey).toBe(`3:${d.id}:${d.id}`)
|
||||
})
|
||||
|
||||
it("names the entry a restored copy stands for, and marks the copy as a restore", async () => {
|
||||
const id = "mcp-history-cards-restore"
|
||||
addHistory(id, page("a"))
|
||||
addHistory(id, page("b"))
|
||||
setState(id, page("b"))
|
||||
const [a, b] = getHistory(id)
|
||||
const restore = await postJson("/api/restore", {
|
||||
sessionId: id,
|
||||
id: a.id,
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
expect(restore.status).toBe(200)
|
||||
const d = JSON.parse(
|
||||
(await request(`/api/history?sessionId=${id}`)).body,
|
||||
)
|
||||
// History: a, b, a' (the copy); the canvas shows a's content
|
||||
expect(d.entries.map((e: { firstId: number }) => e.firstId)).toEqual([
|
||||
a.id,
|
||||
b.id,
|
||||
a.id,
|
||||
])
|
||||
expect(d.entries[2].source).toBe("restore")
|
||||
expect(d.currentId).toBe(d.entries[2].id)
|
||||
})
|
||||
|
||||
it("marks the user's edit that a write replaces, and one that lost the race", async () => {
|
||||
const id = "mcp-history-cards-user"
|
||||
const version = setState(id, page("ai"))
|
||||
addHistory(id, page("ai"))
|
||||
// The user edits in the browser; the AI writes over it
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: page("user"),
|
||||
baseVersion: version,
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
keepInHistory(id, page("user"))
|
||||
const ai2 = setState(id, page("ai2"))
|
||||
addHistory(id, page("ai2"))
|
||||
// A tab that had not loaded the write yet pushes another edit
|
||||
await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: page("late"),
|
||||
baseVersion: ai2 - 1,
|
||||
stateId: getState(id)?.stateId,
|
||||
})
|
||||
const d = JSON.parse(
|
||||
(await request(`/api/history?sessionId=${id}`)).body,
|
||||
)
|
||||
expect(
|
||||
d.entries.map((e: { xml: string; source: string | null }) => [
|
||||
e.xml === page("user")
|
||||
? "user"
|
||||
: e.xml === page("late")
|
||||
? "late"
|
||||
: "ai",
|
||||
e.source,
|
||||
]),
|
||||
).toEqual([
|
||||
["ai", null],
|
||||
["user", "user"],
|
||||
["ai", null],
|
||||
["late", "user"],
|
||||
])
|
||||
// The canvas shows the write, not an edit
|
||||
expect(d.currentId).toBe(getHistory(id)[2].id)
|
||||
})
|
||||
|
||||
it("reports no state and no current entry for a session it does not know", async () => {
|
||||
const d = JSON.parse(
|
||||
(await request("/api/history?sessionId=mcp-never-seen")).body,
|
||||
)
|
||||
expect(d).toEqual({
|
||||
entries: [],
|
||||
count: 0,
|
||||
stateId: null,
|
||||
currentId: null,
|
||||
})
|
||||
})
|
||||
|
||||
// draw.io's own copy of a diagram, as a sync reply or an edit brings it
|
||||
const drawioCopy = (xml: string, attrs = 'pageWidth="850"') =>
|
||||
@@ -806,6 +1525,7 @@ describe("bodies over the size limit", () => {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: `localhost:${port}`,
|
||||
"x-drawio-token": getApiToken(),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import { homedir, tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.ts"
|
||||
import type { HistorySource } from "../src/history.ts"
|
||||
import { Autosaver, defaultDataDir } from "../src/persistence.ts"
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -247,8 +248,10 @@ describe("Autosaver History", () => {
|
||||
const V3 = DIAGRAM.replace('id="a"', 'id="v3"')
|
||||
// One session's History entries, as history.ts keeps them (the saver
|
||||
// leaves the thumbnails out)
|
||||
const withHistory = (dir: string, entries: { id: number; xml: string }[]) =>
|
||||
new Autosaver(dir, 30, 50, () => entries)
|
||||
const withHistory = (
|
||||
dir: string,
|
||||
entries: { id: number; xml: string; source?: HistorySource }[],
|
||||
) => new Autosaver(dir, 30, 50, () => entries)
|
||||
|
||||
it("saves the XML of the History entries next to the diagram", async () => {
|
||||
const dir = tempDir()
|
||||
@@ -262,8 +265,11 @@ describe("Autosaver History", () => {
|
||||
saver.schedule("mcp-h", V2)
|
||||
expect(existsSync(path)).toBe(false)
|
||||
await sleep(80)
|
||||
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual([V1, V2])
|
||||
expect(saver.loadHistory("mcp-h")).toEqual([V1, V2])
|
||||
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual([
|
||||
{ xml: V1 },
|
||||
{ xml: V2 },
|
||||
])
|
||||
expect(saver.loadHistory("mcp-h")).toEqual([{ xml: V1 }, { xml: V2 }])
|
||||
expect(readdirSync(dir).sort()).toEqual([
|
||||
"mcp-h.drawio",
|
||||
"mcp-h.history.json",
|
||||
@@ -278,7 +284,7 @@ describe("Autosaver History", () => {
|
||||
expect(
|
||||
readFileSync(saver.pathFor("mcp-cleared") as string, "utf-8"),
|
||||
).toBe(BLANK)
|
||||
expect(saver.loadHistory("mcp-cleared")).toEqual([V1])
|
||||
expect(saver.loadHistory("mcp-cleared")).toEqual([{ xml: V1 }])
|
||||
})
|
||||
|
||||
it("still skips a blank page whose History holds only blank pages", () => {
|
||||
@@ -304,7 +310,7 @@ describe("Autosaver History", () => {
|
||||
// The old session is still open and saves again, History unchanged
|
||||
saver.schedule(old, V1)
|
||||
saver.flush()
|
||||
expect(saver.loadHistory(old)).toEqual([V1])
|
||||
expect(saver.loadHistory(old)).toEqual([{ xml: V1 }])
|
||||
})
|
||||
|
||||
it("rewrites the History file only when the entries changed", () => {
|
||||
@@ -326,14 +332,20 @@ describe("Autosaver History", () => {
|
||||
saver.schedule("mcp-same", V2)
|
||||
saver.flush()
|
||||
expect(statSync(path).mtimeMs).not.toBe(old)
|
||||
expect(saver.loadHistory("mcp-same")).toEqual([V1, V2])
|
||||
expect(saver.loadHistory("mcp-same")).toEqual([
|
||||
{ xml: V1 },
|
||||
{ xml: V2 },
|
||||
])
|
||||
|
||||
// The circular buffer dropped the oldest entry: same length, new id
|
||||
entries.shift()
|
||||
entries.push({ id: 2, xml: V3 })
|
||||
saver.schedule("mcp-same", V3)
|
||||
saver.flush()
|
||||
expect(saver.loadHistory("mcp-same")).toEqual([V2, V3])
|
||||
expect(saver.loadHistory("mcp-same")).toEqual([
|
||||
{ xml: V2 },
|
||||
{ xml: V3 },
|
||||
])
|
||||
})
|
||||
|
||||
it("writes no History file while there are no entries", () => {
|
||||
@@ -349,18 +361,60 @@ describe("Autosaver History", () => {
|
||||
const saver = new Autosaver(dir, 10)
|
||||
const path = saver.historyPathFor("mcp-bad") as string
|
||||
expect(saver.loadHistory("mcp-bad")).toEqual([])
|
||||
for (const content of ["{not json", '{"a":1}', "[1, 2]", '"x"']) {
|
||||
for (const content of [
|
||||
"{not json",
|
||||
'{"a":1}',
|
||||
"[1, 2]",
|
||||
'"x"',
|
||||
'[{"source":"user"}]',
|
||||
]) {
|
||||
writeFileSync(path, content)
|
||||
expect(saver.loadHistory("mcp-bad")).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it("reads the History files of earlier versions, which hold the XML alone", () => {
|
||||
const dir = tempDir()
|
||||
const saver = new Autosaver(dir, 10)
|
||||
writeFileSync(
|
||||
saver.historyPathFor("mcp-old") as string,
|
||||
JSON.stringify([V1, V2]),
|
||||
)
|
||||
expect(saver.loadHistory("mcp-old")).toEqual([{ xml: V1 }, { xml: V2 }])
|
||||
})
|
||||
|
||||
it("keeps who made each entry, so the shell's cards look the same after a restart", () => {
|
||||
// A clear of the canvas and a restore of it: without the source, the
|
||||
// restored copy would show as a second clear
|
||||
const dir = tempDir()
|
||||
const saver = withHistory(dir, [
|
||||
{ id: 0, xml: V1 },
|
||||
{ id: 1, xml: BLANK, source: "user" },
|
||||
{ id: 2, xml: V2, source: "restore" },
|
||||
{ id: 3, xml: V1, source: "recover" },
|
||||
])
|
||||
saver.schedule("mcp-who", V1)
|
||||
saver.flush()
|
||||
expect(saver.loadHistory("mcp-who")).toEqual([
|
||||
{ xml: V1 },
|
||||
{ xml: BLANK, source: "user" },
|
||||
{ xml: V2, source: "restore" },
|
||||
{ xml: V1, source: "recover" },
|
||||
])
|
||||
// A source the server does not know is left out
|
||||
writeFileSync(
|
||||
saver.historyPathFor("mcp-who") as string,
|
||||
JSON.stringify([{ xml: V1, source: "robot" }]),
|
||||
)
|
||||
expect(saver.loadHistory("mcp-who")).toEqual([{ xml: V1 }])
|
||||
})
|
||||
|
||||
it("flush() writes the History on shutdown", () => {
|
||||
const dir = tempDir()
|
||||
const saver = withHistory(dir, [{ id: 0, xml: V1 }])
|
||||
saver.schedule("mcp-exit", V1)
|
||||
saver.flush()
|
||||
expect(saver.loadHistory("mcp-exit")).toEqual([V1])
|
||||
expect(saver.loadHistory("mcp-exit")).toEqual([{ xml: V1 }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
describeSelection,
|
||||
MAX_LISTED_CELLS,
|
||||
parseSelectionAnswer,
|
||||
type SelectionAnswer,
|
||||
} from "../src/selection.ts"
|
||||
|
||||
const page = { pageId: "p1", pageName: "Flow" }
|
||||
|
||||
describe("get_selection text", () => {
|
||||
it("lists shapes with their geometry and edges with their ends", () => {
|
||||
const text = describeSelection(
|
||||
{
|
||||
...page,
|
||||
cells: [
|
||||
{
|
||||
id: "3",
|
||||
label: "Login",
|
||||
edge: false,
|
||||
geometry: { x: 100, y: 100, width: 120, height: 60 },
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
label: "",
|
||||
edge: false,
|
||||
geometry: { x: 20, y: 30, width: 80, height: 40 },
|
||||
parent: "box",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
label: "yes",
|
||||
edge: true,
|
||||
source: "3",
|
||||
target: "4",
|
||||
},
|
||||
{ id: "9", label: "", edge: true },
|
||||
],
|
||||
},
|
||||
null,
|
||||
)
|
||||
expect(text).toContain(
|
||||
'4 cells selected on page "Flow" (page_id="p1"):',
|
||||
)
|
||||
expect(text).toContain(
|
||||
'- id="3" shape "Login" at x=100 y=100 w=120 h=60',
|
||||
)
|
||||
expect(text).toContain(
|
||||
'- id="7" shape (no label) at x=20 y=30 w=80 h=40 inside "box"',
|
||||
)
|
||||
expect(text).toContain('- id="5" edge "yes" from "3" to "4"')
|
||||
expect(text).toContain('- id="9" edge (no label) (not connected)')
|
||||
expect(text).toContain('edit_diagram (page_id="p1")')
|
||||
})
|
||||
|
||||
it("details the first cells of a large selection and lists the rest by id", () => {
|
||||
const cells = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: `c${i + 1}`,
|
||||
label: `Cell ${i + 1}`,
|
||||
edge: false,
|
||||
geometry: { x: i, y: 0, width: 10, height: 10 },
|
||||
}))
|
||||
const text = describeSelection(
|
||||
{ ...page, cells: cells(MAX_LISTED_CELLS + 5) },
|
||||
null,
|
||||
)
|
||||
expect(text).toContain(`${MAX_LISTED_CELLS + 5} cells selected`)
|
||||
expect(text).toContain(`id="c${MAX_LISTED_CELLS}"`)
|
||||
// No detail past the cap, but every id: the model acts on all of
|
||||
// them, and get_diagram cannot tell which cells were selected
|
||||
expect(text).not.toContain(`id="c${MAX_LISTED_CELLS + 1}"`)
|
||||
expect(text).not.toContain(`shape "Cell ${MAX_LISTED_CELLS + 1}"`)
|
||||
expect(text).toContain(
|
||||
`- and 5 more cells, listed by id only: "c${MAX_LISTED_CELLS + 1}", "c${MAX_LISTED_CELLS + 2}", "c${MAX_LISTED_CELLS + 3}", "c${MAX_LISTED_CELLS + 4}", "c${MAX_LISTED_CELLS + 5}"`,
|
||||
)
|
||||
const exact = describeSelection(
|
||||
{ ...page, cells: cells(MAX_LISTED_CELLS) },
|
||||
null,
|
||||
)
|
||||
expect(exact).not.toContain("more cells")
|
||||
})
|
||||
|
||||
it("says clearly when nothing is selected", () => {
|
||||
const text = describeSelection({ ...page, cells: [] }, null)
|
||||
expect(text).toBe(
|
||||
'Nothing is selected on page "Flow" (page_id="p1"). Ask the user to select the shapes they mean in the preview, or call get_diagram.',
|
||||
)
|
||||
expect(
|
||||
describeSelection(
|
||||
{ pageId: null, pageName: null, cells: [] },
|
||||
null,
|
||||
),
|
||||
).toContain("Nothing is selected on the page on screen.")
|
||||
})
|
||||
|
||||
it("names the external draw.io when the page cannot read the editor", () => {
|
||||
const unavailable: SelectionAnswer = {
|
||||
pageId: null,
|
||||
pageName: null,
|
||||
cells: [],
|
||||
unavailable: true,
|
||||
}
|
||||
expect(
|
||||
describeSelection(unavailable, "https://embed.diagrams.net"),
|
||||
).toBe(
|
||||
"The preview loads draw.io from https://embed.diagrams.net (another origin), where the selection cannot be read. Ask the user which shapes they mean, or call get_diagram.",
|
||||
)
|
||||
// Same origin, but the bridge did not attach (a draw.io change)
|
||||
expect(describeSelection(unavailable, null)).toContain(
|
||||
"could not read the editor's selection",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the page's selection answer", () => {
|
||||
it("takes the fields it knows and drops malformed cells", () => {
|
||||
expect(
|
||||
parseSelectionAnswer({
|
||||
pageId: "p1",
|
||||
pageName: "Flow",
|
||||
cells: [
|
||||
{
|
||||
id: "3",
|
||||
label: "Login",
|
||||
edge: false,
|
||||
geometry: { x: 1, y: 2, width: 3, height: 4 },
|
||||
parent: "box",
|
||||
style: "ignored",
|
||||
},
|
||||
{ id: "5", edge: true, source: "3", target: 4 },
|
||||
{ id: "", label: "no id" },
|
||||
"not a cell",
|
||||
{ id: "8", geometry: { x: "1", y: 2 } },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
pageId: "p1",
|
||||
pageName: "Flow",
|
||||
cells: [
|
||||
{
|
||||
id: "3",
|
||||
label: "Login",
|
||||
edge: false,
|
||||
geometry: { x: 1, y: 2, width: 3, height: 4 },
|
||||
parent: "box",
|
||||
},
|
||||
{ id: "5", label: "", edge: true, source: "3" },
|
||||
{ id: "8", label: "", edge: false },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps the unavailable mark and refuses other shapes", () => {
|
||||
expect(parseSelectionAnswer({ unavailable: true })).toEqual({
|
||||
pageId: null,
|
||||
pageName: null,
|
||||
cells: [],
|
||||
unavailable: true,
|
||||
})
|
||||
expect(parseSelectionAnswer({ cells: "3,4" })).toBeNull()
|
||||
expect(parseSelectionAnswer("3")).toBeNull()
|
||||
expect(parseSelectionAnswer(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -9,12 +9,13 @@
|
||||
* package before testing). Running from source via tsx means it executes as
|
||||
* part of the normal `vitest run`.
|
||||
*
|
||||
* We deliberately do NOT call start_session — it would open a real browser
|
||||
* window via open(). The browser bridge is covered by the Playwright e2e suite.
|
||||
* start_session is called only at the end, with BROWSER=none so no browser
|
||||
* window opens, for the tools that need a session but no preview tab. The
|
||||
* browser bridge is covered by the Playwright e2e suite.
|
||||
*/
|
||||
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
@@ -44,6 +45,7 @@ const EXPECTED_TOOLS = [
|
||||
"get_drawing_guide",
|
||||
"get_shape_library",
|
||||
"screenshot_diagram",
|
||||
"get_selection",
|
||||
"list_saved_diagrams",
|
||||
"restore_version",
|
||||
]
|
||||
@@ -89,7 +91,15 @@ beforeAll(async () => {
|
||||
writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM)
|
||||
proc = spawn(tsxBin, [entry], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, DRAWIO_DATA_DIR: dataDir },
|
||||
env: {
|
||||
...process.env,
|
||||
DRAWIO_DATA_DIR: dataDir,
|
||||
// For the session tests at the end: no browser window, the
|
||||
// default preview (the canvas shell), a port away from other
|
||||
// servers
|
||||
BROWSER: "none",
|
||||
PORT: String(6500 + Math.floor(Math.random() * 400)),
|
||||
},
|
||||
}) as ChildProcessWithoutNullStreams
|
||||
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
@@ -215,6 +225,15 @@ describe("MCP server wiring", () => {
|
||||
expect(resp.result.content[0].text).toContain("start_session")
|
||||
})
|
||||
|
||||
it("refuses get_selection without a session", async () => {
|
||||
const resp = await send("tools/call", {
|
||||
name: "get_selection",
|
||||
arguments: {},
|
||||
})
|
||||
expect(resp.result.isError).toBe(true)
|
||||
expect(resp.result.content[0].text).toContain("start_session")
|
||||
})
|
||||
|
||||
it("keeps every description and the instructions within the host limit", async () => {
|
||||
const resp = await send("tools/list", {})
|
||||
for (const tool of resp.result.tools) {
|
||||
@@ -336,3 +355,36 @@ describe("load_diagram dual-source arguments", () => {
|
||||
expect(resp.result?.content?.[0]?.text).toContain("No active session")
|
||||
})
|
||||
})
|
||||
|
||||
// After the tests above: start_session makes a session current
|
||||
describe("MCP server wiring with a session, without a preview tab", () => {
|
||||
it("get_selection says the preview tab is not connected", async () => {
|
||||
const started = await send("tools/call", {
|
||||
name: "start_session",
|
||||
arguments: {},
|
||||
})
|
||||
expect(started.result.isError).toBeFalsy()
|
||||
expect(started.result.content[0].text).toContain("Browser URL:")
|
||||
const resp = await send("tools/call", {
|
||||
name: "get_selection",
|
||||
arguments: {},
|
||||
})
|
||||
// Without the built shell (tests run before the build on CI and on
|
||||
// a fresh clone) start_session opens the classic page, which cannot
|
||||
// answer a selection request; the tool says so instead of waiting
|
||||
const shellBuilt = existsSync(
|
||||
path.join(__dirname, "../dist/shell/index.html"),
|
||||
)
|
||||
if (shellBuilt) {
|
||||
expect(resp.result.isError).toBe(true)
|
||||
expect(resp.result.content[0].text).toBe(
|
||||
"Error: The preview tab is not open, or has not connected yet.",
|
||||
)
|
||||
} else {
|
||||
expect(resp.result.isError).toBeFalsy()
|
||||
expect(resp.result.content[0].text).toContain(
|
||||
"classic preview page cannot read the selection",
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -146,6 +146,24 @@ describe("reservedIdError", () => {
|
||||
})
|
||||
|
||||
describe("prepareNewDiagram", () => {
|
||||
it("gives every <diagram> without an id one, so the server and draw.io name the same pages", () => {
|
||||
// draw.io makes up an id for a page without one, which the server
|
||||
// never sees; get_selection would then name a page edit_diagram
|
||||
// cannot find
|
||||
const out = prepareNewDiagram(
|
||||
`<mxfile><diagram name="One"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram name="Two" id="kept"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`,
|
||||
)
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) return
|
||||
const ids = [...out.xml.matchAll(/<diagram\b[^>]*\bid="([^"]*)"/g)].map(
|
||||
(m) => m[1],
|
||||
)
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(ids[0]).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
|
||||
expect(ids[1]).toBe("kept")
|
||||
expect(out.xml).toContain('name="One"')
|
||||
})
|
||||
|
||||
it("rejects a shape that uses a root cell id", () => {
|
||||
// It would clash with the added root cell "1" and be renamed,
|
||||
// which breaks the edges that point to it
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Shared by the two draw.io fetch scripts (the web app's scripts/fetch-drawio.mjs
|
||||
* and packages/mcp-server/scripts/fetch-drawio.mjs): the pinned release and a
|
||||
* minimal reader for its draw.war zip file, so no extra dependency is needed.
|
||||
*/
|
||||
import crypto from "node:crypto"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import zlib from "node:zlib"
|
||||
|
||||
// Pinned because the UI relies on draw.io internals; bump deliberately. The
|
||||
// web app and the MCP server embed the same release.
|
||||
const VERSION_FILE = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../packages/mcp-server/src/drawio-version.json",
|
||||
)
|
||||
|
||||
/** { version, sha256, downloadUrl } of the pinned draw.war */
|
||||
export function readDrawioVersion() {
|
||||
return JSON.parse(fs.readFileSync(VERSION_FILE, "utf8"))
|
||||
}
|
||||
|
||||
export function sha256(buf) {
|
||||
return crypto.createHash("sha256").update(buf).digest("hex")
|
||||
}
|
||||
|
||||
// Minimal zip reader: finds the central directory and inflates each entry
|
||||
export function* readZipEntries(buf) {
|
||||
const EOCD_SIG = 0x06054b50
|
||||
let eocd = -1
|
||||
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD_SIG) {
|
||||
eocd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (eocd < 0) throw new Error("Not a zip file (end record not found)")
|
||||
|
||||
const count = buf.readUInt16LE(eocd + 10)
|
||||
let offset = buf.readUInt32LE(eocd + 16)
|
||||
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (buf.readUInt32LE(offset) !== 0x02014b50) {
|
||||
throw new Error("Corrupt zip central directory")
|
||||
}
|
||||
const method = buf.readUInt16LE(offset + 10)
|
||||
const compressedSize = buf.readUInt32LE(offset + 20)
|
||||
const nameLength = buf.readUInt16LE(offset + 28)
|
||||
const extraLength = buf.readUInt16LE(offset + 30)
|
||||
const commentLength = buf.readUInt16LE(offset + 32)
|
||||
const localOffset = buf.readUInt32LE(offset + 42)
|
||||
const name = buf.toString("utf8", offset + 46, offset + 46 + nameLength)
|
||||
offset += 46 + nameLength + extraLength + commentLength
|
||||
|
||||
const localNameLength = buf.readUInt16LE(localOffset + 26)
|
||||
const localExtraLength = buf.readUInt16LE(localOffset + 28)
|
||||
const start = localOffset + 30 + localNameLength + localExtraLength
|
||||
const raw = buf.subarray(start, start + compressedSize)
|
||||
|
||||
if (name.endsWith("/")) continue
|
||||
if (method !== 0 && method !== 8) {
|
||||
throw new Error(`Unsupported zip compression ${method} (${name})`)
|
||||
}
|
||||
yield { name, data: method === 8 ? zlib.inflateRawSync(raw) : raw }
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,10 @@
|
||||
* The release asset (draw.war) is a zip file; it is unpacked with node:zlib
|
||||
* so no extra dependency is needed.
|
||||
*/
|
||||
import crypto from "node:crypto"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import zlib from "node:zlib"
|
||||
import nextEnv from "@next/env"
|
||||
import { readDrawioVersion, readZipEntries, sha256 } from "./drawio-zip.mjs"
|
||||
|
||||
// npm runs this before Next reads the .env files, so read them here: an
|
||||
// external draw.io set in .env.local must skip the download too
|
||||
@@ -26,14 +25,15 @@ nextEnv.loadEnvConfig(
|
||||
process.env.npm_lifecycle_event === "predev",
|
||||
)
|
||||
|
||||
// Pinned because the UI relies on draw.io internals; bump both deliberately
|
||||
const DRAWIO_VERSION = "v32.0.2"
|
||||
// SHA-256 of that release's draw.war, as GitHub lists it
|
||||
const DRAWIO_SHA256 =
|
||||
"3cb8abec8e9bfc7504760c9cdc9194ecf7e8de178aa2a1d668801c32ecf1a1a7"
|
||||
// The pinned release and the SHA-256 of its draw.war, as GitHub lists it
|
||||
// (packages/mcp-server/src/drawio-version.json, shared with the MCP server)
|
||||
const {
|
||||
version: DRAWIO_VERSION,
|
||||
sha256: DRAWIO_SHA256,
|
||||
downloadUrl: DOWNLOAD_URL,
|
||||
} = readDrawioVersion()
|
||||
const DEST = path.join(process.cwd(), "public", "drawio")
|
||||
const STAMP = path.join(DEST, ".version")
|
||||
const DOWNLOAD_URL = `https://github.com/jgraph/drawio/releases/download/${DRAWIO_VERSION}/draw.war`
|
||||
|
||||
function log(message) {
|
||||
console.log(`[fetch-drawio] ${message}`)
|
||||
@@ -47,47 +47,6 @@ function readStamp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal zip reader: finds the central directory and inflates each entry
|
||||
function* readZipEntries(buf) {
|
||||
const EOCD_SIG = 0x06054b50
|
||||
let eocd = -1
|
||||
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD_SIG) {
|
||||
eocd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (eocd < 0) throw new Error("Not a zip file (end record not found)")
|
||||
|
||||
const count = buf.readUInt16LE(eocd + 10)
|
||||
let offset = buf.readUInt32LE(eocd + 16)
|
||||
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (buf.readUInt32LE(offset) !== 0x02014b50) {
|
||||
throw new Error("Corrupt zip central directory")
|
||||
}
|
||||
const method = buf.readUInt16LE(offset + 10)
|
||||
const compressedSize = buf.readUInt32LE(offset + 20)
|
||||
const nameLength = buf.readUInt16LE(offset + 28)
|
||||
const extraLength = buf.readUInt16LE(offset + 30)
|
||||
const commentLength = buf.readUInt16LE(offset + 32)
|
||||
const localOffset = buf.readUInt32LE(offset + 42)
|
||||
const name = buf.toString("utf8", offset + 46, offset + 46 + nameLength)
|
||||
offset += 46 + nameLength + extraLength + commentLength
|
||||
|
||||
const localNameLength = buf.readUInt16LE(localOffset + 26)
|
||||
const localExtraLength = buf.readUInt16LE(localOffset + 28)
|
||||
const start = localOffset + 30 + localNameLength + localExtraLength
|
||||
const raw = buf.subarray(start, start + compressedSize)
|
||||
|
||||
if (name.endsWith("/")) continue
|
||||
if (method !== 0 && method !== 8) {
|
||||
throw new Error(`Unsupported zip compression ${method} (${name})`)
|
||||
}
|
||||
yield { name, data: method === 8 ? zlib.inflateRawSync(raw) : raw }
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.env.NEXT_PUBLIC_DRAWIO_BASE_URL) {
|
||||
log("NEXT_PUBLIC_DRAWIO_BASE_URL is set, skipping the bundled copy")
|
||||
@@ -102,7 +61,7 @@ async function main() {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${DOWNLOAD_URL}`)
|
||||
buf = Buffer.from(await res.arrayBuffer())
|
||||
// The copy runs on the app's own origin: it must be the real release
|
||||
const actual = crypto.createHash("sha256").update(buf).digest("hex")
|
||||
const actual = sha256(buf)
|
||||
if (actual !== DRAWIO_SHA256) {
|
||||
throw new Error(`draw.war checksum mismatch (got ${actual})`)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ interface CanvasState {
|
||||
isFreehand: boolean
|
||||
/** One of draw.io's menus or dialogs is open over the canvas */
|
||||
isDrawioPopupOpen: boolean
|
||||
/** Something is changing the canvas (an answer streams in, a sync
|
||||
* runs): version actions and "ask AI" wait */
|
||||
isBusy: boolean
|
||||
set: (partial: Partial<Omit<CanvasState, "set">>) => void
|
||||
}
|
||||
|
||||
@@ -41,5 +44,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
selectionRect: null,
|
||||
isFreehand: false,
|
||||
isDrawioPopupOpen: false,
|
||||
isBusy: false,
|
||||
set: (partial) => set(partial),
|
||||
}))
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { nanoid } from "nanoid"
|
||||
import { create } from "zustand"
|
||||
import type { ChangeSummary } from "@/lib/diagram-diff"
|
||||
import type { VersionItem } from "@/components/canvas/versions-context"
|
||||
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
|
||||
|
||||
/** One diagram state produced by an AI change, shown as a card in the chat */
|
||||
export interface DiagramVersion {
|
||||
id: string
|
||||
/** Shown as v{number}; it stays when older versions are dropped */
|
||||
number: number
|
||||
/** Full multi-page document after the change */
|
||||
xml: string
|
||||
/** The page the change was made on; older sessions have none (the first page) */
|
||||
pageId?: string
|
||||
/** Diagram before the change; kept only on the latest version */
|
||||
beforeXml?: string
|
||||
/** SVG data URL thumbnail; "" when none could be made */
|
||||
svg?: string
|
||||
/**
|
||||
* One diagram state produced by an AI change, shown as a card in the chat.
|
||||
* What the card displays is the VersionItem part; the rest ties the version
|
||||
* to its chat turn. The number stays when older versions are dropped; the
|
||||
* "before" document is kept only on the latest version; older sessions
|
||||
* have no pageId (the first page).
|
||||
*/
|
||||
export interface DiagramVersion extends VersionItem {
|
||||
/** Index of the user message that started this turn */
|
||||
turnIndex: number
|
||||
toolCallId?: string
|
||||
summary: ChangeSummary
|
||||
/** Drawn on an empty canvas (the card shows totals, not changes) */
|
||||
fromScratch?: boolean
|
||||
/** Saved with the session: this version is what the canvas shows */
|
||||
onCanvas?: boolean
|
||||
/** Saved with the session: this version's change was undone */
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* The canvas modules are reused by the MCP server's browser shell, which
|
||||
* has no chat and no Next.js. They must not import the chat, the tool
|
||||
* handlers, Next-only modules or server-only code. Checked twice: the
|
||||
* import statements of each file, and what esbuild pulls into a bundle of
|
||||
* the canvas components.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { build } from "esbuild"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
const SCANNED = [
|
||||
"components/canvas",
|
||||
"contexts/diagram-context.tsx",
|
||||
"lib/drawio",
|
||||
"lib/diagram-pages.ts",
|
||||
"lib/diagram-diff.ts",
|
||||
"stores/canvas-store.ts",
|
||||
"stores/versions-store.ts",
|
||||
// The MCP's browser shell, built from these modules by esbuild
|
||||
"packages/mcp-server/shell",
|
||||
]
|
||||
|
||||
const FORBIDDEN = [
|
||||
/^@\/components\/chat\//,
|
||||
/^@\/hooks\/use-diagram-tool-handlers/,
|
||||
/^next\/navigation$/,
|
||||
/^next\/font(\/|$)/,
|
||||
/^next\/script$/,
|
||||
/^next\/headers$/,
|
||||
/^server-only$/,
|
||||
]
|
||||
|
||||
function listFiles(entry: string): string[] {
|
||||
const full = path.join(root, entry)
|
||||
if (statSync(full).isFile()) return [entry]
|
||||
return readdirSync(full, { recursive: true })
|
||||
.map(String)
|
||||
.filter((name) => /\.tsx?$/.test(name))
|
||||
.map((name) => path.join(entry, name))
|
||||
}
|
||||
|
||||
/** Module specifiers a file imports at runtime (type-only imports do not
|
||||
* reach a bundle) */
|
||||
function valueImports(source: string): string[] {
|
||||
const code = source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "")
|
||||
const specifiers: string[] = []
|
||||
const statements =
|
||||
/(?:^|\n)\s*(import|export)\s+([\s\S]*?)\s*from\s*["']([^"']+)["']|(?:^|\n)\s*import\s+["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g
|
||||
for (const match of code.matchAll(statements)) {
|
||||
const [, keyword, clause, from, bare, dynamic] = match
|
||||
if (keyword && /^type\s/.test(clause)) continue
|
||||
specifiers.push(from ?? bare ?? dynamic)
|
||||
}
|
||||
return specifiers
|
||||
}
|
||||
|
||||
/** The file an "@/..." or relative specifier points at, when it is one of
|
||||
* ours */
|
||||
function resolveLocal(fromFile: string, specifier: string): string | null {
|
||||
let base: string
|
||||
if (specifier.startsWith("@/")) base = path.join(root, specifier.slice(2))
|
||||
else if (specifier.startsWith(".")) {
|
||||
base = path.resolve(root, path.dirname(fromFile), specifier)
|
||||
} else return null
|
||||
for (const candidate of [
|
||||
base,
|
||||
`${base}.ts`,
|
||||
`${base}.tsx`,
|
||||
path.join(base, "index.ts"),
|
||||
path.join(base, "index.tsx"),
|
||||
]) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return path.relative(root, candidate)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const isServerOnly = (file: string) =>
|
||||
/^\s*import\s+["']server-only["']/m.test(
|
||||
readFileSync(path.join(root, file), "utf8"),
|
||||
)
|
||||
|
||||
describe("canvas import boundary", () => {
|
||||
const files = SCANNED.flatMap(listFiles)
|
||||
|
||||
it("scans the canvas modules", () => {
|
||||
expect(files).toContain("components/canvas/canvas-stage.tsx")
|
||||
expect(files).toContain("lib/drawio/editor-bridge.ts")
|
||||
expect(files).toContain("packages/mcp-server/shell/main.tsx")
|
||||
})
|
||||
|
||||
it.each(files)("%s imports no chat or Next-only module", (file) => {
|
||||
const source = readFileSync(path.join(root, file), "utf8")
|
||||
const offending = valueImports(source).filter((specifier) => {
|
||||
if (FORBIDDEN.some((rule) => rule.test(specifier))) return true
|
||||
const target = resolveLocal(file, specifier)
|
||||
if (!target) return false
|
||||
return target.startsWith("components/chat/") || isServerOnly(target)
|
||||
})
|
||||
expect(offending).toEqual([])
|
||||
})
|
||||
|
||||
it("bundles the canvas components without the chat", async () => {
|
||||
const result = await build({
|
||||
entryPoints: [
|
||||
"components/canvas/canvas-stage.tsx",
|
||||
"components/canvas/compare-dialog.tsx",
|
||||
"components/canvas/version-card.tsx",
|
||||
"components/canvas/version-strip.tsx",
|
||||
],
|
||||
absWorkingDir: root,
|
||||
// Several entries need an outdir, even when nothing is written
|
||||
outdir: "out",
|
||||
bundle: true,
|
||||
write: false,
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
jsx: "automatic",
|
||||
// The MCP core (packages/mcp-server/src) resolves pako from its
|
||||
// own node_modules; one copy, as the shell build must do
|
||||
alias: { "@": root, pako: path.join(root, "node_modules/pako") },
|
||||
external: ["react", "react-dom", "next/*"],
|
||||
minify: true,
|
||||
logLevel: "silent",
|
||||
})
|
||||
const inputs = result.metafile.inputs
|
||||
const bundled = Object.keys(inputs)
|
||||
expect(bundled.length).toBeGreaterThan(10)
|
||||
expect(
|
||||
bundled.filter(
|
||||
(file) =>
|
||||
file.startsWith("components/chat/") ||
|
||||
file.includes("node_modules/server-only/"),
|
||||
),
|
||||
).toEqual([])
|
||||
const nextImports = Object.entries(inputs).flatMap(([file, input]) =>
|
||||
input.imports
|
||||
.map((entry) => entry.path)
|
||||
.filter((specifier) =>
|
||||
FORBIDDEN.some((rule) => rule.test(specifier)),
|
||||
)
|
||||
.map((specifier) => `${file} -> ${specifier}`),
|
||||
)
|
||||
expect(nextImports).toEqual([])
|
||||
// The canvas core stays small enough for the MCP's browser shell
|
||||
// (134 KB minified, pako once)
|
||||
const stage = Object.values(result.metafile.outputs).find(
|
||||
(output) =>
|
||||
output.entryPoint === "components/canvas/canvas-stage.tsx",
|
||||
)
|
||||
expect(stage?.bytes).toBeLessThan(160 * 1024)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import type React from "react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { DiagramProvider, useDiagram } from "@/contexts/diagram-context"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn() } }))
|
||||
// No editor to drive: every load is a full load, and the page to show after
|
||||
// it is recorded
|
||||
const selectPage = vi.fn((_id: string) => true)
|
||||
vi.mock("@/lib/drawio/editor-bridge", () => ({
|
||||
canReplaceDiagram: () => false,
|
||||
commitDiagram: vi.fn(),
|
||||
previewDiagram: vi.fn(),
|
||||
resetPreview: vi.fn(),
|
||||
revertPreview: vi.fn(),
|
||||
selectPage: (id: string) => selectPage(id),
|
||||
}))
|
||||
|
||||
const doc = (pages: string) => `<mxfile>${pages}</mxfile>`
|
||||
const page = (id: string, label: string) =>
|
||||
`<diagram id="${id}" name="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="c-${id}" value="${label}" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram>`
|
||||
|
||||
// The provider with a stand-in draw.io that records loads and exports
|
||||
function setup() {
|
||||
const { result } = renderHook(() => useDiagram(), {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<DiagramProvider>{children}</DiagramProvider>
|
||||
),
|
||||
})
|
||||
const requests: { format: string; message: string; pageId?: string }[] = []
|
||||
const loads: string[] = []
|
||||
result.current.drawioRef.current = {
|
||||
exportDiagram: (r: any) => requests.push(r),
|
||||
load: ({ xml }: { xml: string }) => loads.push(xml),
|
||||
} as any
|
||||
const reply = (
|
||||
request: { message: string },
|
||||
data: string | undefined,
|
||||
xml?: string,
|
||||
) =>
|
||||
act(() =>
|
||||
result.current.handleDiagramExport({
|
||||
event: "export",
|
||||
data,
|
||||
xml,
|
||||
format: "png",
|
||||
message: request,
|
||||
} as any),
|
||||
)
|
||||
return { result, requests, loads, reply }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
useCanvasStore.setState({ currentPageId: null })
|
||||
})
|
||||
|
||||
describe("requestExport", () => {
|
||||
it("passes the request on and resolves with its own answer", async () => {
|
||||
const { result, requests, reply } = setup()
|
||||
const png = result.current.requestExport({
|
||||
format: "png",
|
||||
pageId: "p2",
|
||||
width: 700,
|
||||
})
|
||||
const xml = result.current.requestExport({ format: "xml" })
|
||||
expect(requests).toMatchObject([
|
||||
{ format: "png", pageId: "p2", width: 700 },
|
||||
{ format: "xml" },
|
||||
])
|
||||
// The XML export answers first, with the document instead of data
|
||||
reply(requests[1], undefined, "<mxfile>doc</mxfile>")
|
||||
expect(await xml).toEqual({
|
||||
data: undefined,
|
||||
xml: "<mxfile>doc</mxfile>",
|
||||
})
|
||||
reply(requests[0], "data:image/png;base64,AAAA")
|
||||
expect(await png).toEqual({
|
||||
data: "data:image/png;base64,AAAA",
|
||||
xml: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("resolves to null when draw.io does not answer in time", async () => {
|
||||
vi.useFakeTimers()
|
||||
const { result, requests } = setup()
|
||||
const pending = result.current.requestExport({ format: "svg" }, 1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(await pending).toBeNull()
|
||||
// A late answer goes nowhere
|
||||
expect(
|
||||
result.current.exportResolversRef.current[requests[0].message],
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it("resolves to null without draw.io", async () => {
|
||||
const { result } = setup()
|
||||
result.current.drawioRef.current = null
|
||||
expect(await result.current.requestExport({ format: "svg" })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("showTransient", () => {
|
||||
type Result = ReturnType<typeof setup>["result"]
|
||||
// Load the document, with draw.io's "load" reports: its first one makes
|
||||
// the provider send the document once more, which draw.io reports too
|
||||
const start = (result: Result, xml: string) =>
|
||||
act(() => {
|
||||
result.current.loadDiagram(xml, true)
|
||||
result.current.onDrawioLoad()
|
||||
result.current.onDrawioLoad()
|
||||
})
|
||||
const loaded = (result: Result) => act(() => result.current.onDrawioLoad())
|
||||
const autosave = (result: Result, xml: string) =>
|
||||
act(() => result.current.handleDiagramAutoSave({ xml }))
|
||||
|
||||
it("shows the document without recording it, and ignores its autosaves until the next load", () => {
|
||||
const { result, loads } = setup()
|
||||
start(result, doc(page("p1", "real")))
|
||||
expect(result.current.chartXML).toContain('value="real"')
|
||||
|
||||
act(() => {
|
||||
result.current.showTransient(doc(page("p2", "projection")))
|
||||
})
|
||||
loaded(result)
|
||||
expect(loads.at(-1)).toContain('value="projection"')
|
||||
expect(result.current.chartXML).toContain('value="real"')
|
||||
autosave(result, doc(page("p2", "projection edited")))
|
||||
expect(result.current.chartXML).toContain('value="real"')
|
||||
|
||||
// The real document comes back: recorded, and once draw.io reports
|
||||
// the load, saves count again
|
||||
act(() => {
|
||||
result.current.loadDiagram(doc(page("p1", "real again")), true)
|
||||
})
|
||||
loaded(result)
|
||||
expect(result.current.chartXML).toContain('value="real again"')
|
||||
autosave(result, doc(page("p1", "real edited")))
|
||||
expect(result.current.chartXML).toContain('value="real edited"')
|
||||
})
|
||||
|
||||
it("keeps the document over a projection autosave that comes before draw.io reports the load back", () => {
|
||||
const { result, loads } = setup()
|
||||
const twoPages = doc(page("p1", "one") + page("p2", "two"))
|
||||
start(result, twoPages)
|
||||
act(() => {
|
||||
result.current.showTransient(doc(page("p2", "two")))
|
||||
})
|
||||
loaded(result)
|
||||
// The restore load is sent; the user's last drag on the projection
|
||||
// is autosaved before draw.io gets to the load
|
||||
act(() => {
|
||||
result.current.loadDiagram(twoPages, true, "load")
|
||||
})
|
||||
autosave(result, doc(page("p2", "two moved")))
|
||||
const isDocument = (xml: string | undefined) =>
|
||||
!!xml && xml.includes('id="p1"') && !xml.includes("two moved")
|
||||
expect(isDocument(result.current.chartXMLRef.current)).toBe(true)
|
||||
loaded(result)
|
||||
expect(isDocument(result.current.chartXMLRef.current)).toBe(true)
|
||||
// The frame starts over (an external draw.io on a theme switch) and
|
||||
// gets the document, not the projection
|
||||
act(() => {
|
||||
result.current.resetDrawioReady()
|
||||
})
|
||||
loaded(result)
|
||||
expect(isDocument(loads.at(-1))).toBe(true)
|
||||
})
|
||||
|
||||
it("ignores an autosave of the document a full load replaces", () => {
|
||||
const { result } = setup()
|
||||
start(result, doc(page("p1", "first")))
|
||||
act(() => {
|
||||
result.current.loadDiagram(doc(page("p1", "second")), true, "load")
|
||||
})
|
||||
// draw.io autosaved the user's last change of the first document
|
||||
// before it got to the load
|
||||
autosave(result, doc(page("p1", "first moved")))
|
||||
expect(result.current.chartXML).toContain('value="second"')
|
||||
loaded(result)
|
||||
autosave(result, doc(page("p1", "second moved")))
|
||||
expect(result.current.chartXML).toContain('value="second moved"')
|
||||
})
|
||||
|
||||
it("goes back to the page the user was on before the projection", () => {
|
||||
const { result } = setup()
|
||||
const twoPages = doc(page("p1", "one") + page("p2", "two"))
|
||||
act(() => {
|
||||
result.current.loadDiagram(twoPages, true)
|
||||
result.current.onDrawioLoad()
|
||||
})
|
||||
// The user is on page two; the projection shows page one
|
||||
useCanvasStore.setState({ currentPageId: "p2" })
|
||||
act(() => {
|
||||
result.current.showTransient(doc(page("p1", "one")))
|
||||
})
|
||||
useCanvasStore.setState({ currentPageId: "p1" })
|
||||
selectPage.mockClear()
|
||||
act(() => {
|
||||
result.current.loadDiagram(twoPages, true)
|
||||
result.current.onDrawioLoad()
|
||||
})
|
||||
expect(selectPage).toHaveBeenCalledWith("p2")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,693 @@
|
||||
import { act, cleanup, renderHook } from "@testing-library/react"
|
||||
import type React from "react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||
import en from "@/lib/i18n/dictionaries/en.json"
|
||||
import type {
|
||||
HistoryEntryInfo,
|
||||
HistoryList,
|
||||
McpSync,
|
||||
} from "@/packages/mcp-server/shell/mcp-sync-core"
|
||||
import {
|
||||
buildVersions,
|
||||
newVersionCache as cache,
|
||||
useNodeVersions,
|
||||
} from "@/packages/mcp-server/shell/node-versions-source"
|
||||
import { versionLabel } from "@/packages/mcp-server/shell/versions-panel"
|
||||
import { useCanvasStore } from "@/stores/canvas-store"
|
||||
|
||||
// The shell's version cards come from the MCP server's History (GET
|
||||
// /api/history), read through the sync; restores go back through it
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const toasts = vi.hoisted(() => ({ success: vi.fn() }))
|
||||
vi.mock("sonner", () => ({ toast: toasts }))
|
||||
|
||||
const cell = (id: string, label: string) =>
|
||||
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
||||
const doc = (cells: string, pageId = "p1") =>
|
||||
`<mxfile><diagram id="${pageId}" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
const A = doc(cell("a", "A"))
|
||||
const AB = doc(cell("a", "A") + cell("b", "B"))
|
||||
const ABC = doc(cell("a", "A") + cell("b", "B") + cell("c", "C"))
|
||||
|
||||
function entry(
|
||||
id: number,
|
||||
xml: string,
|
||||
extra: Partial<HistoryEntryInfo> = {},
|
||||
): HistoryEntryInfo {
|
||||
return {
|
||||
id,
|
||||
index: id,
|
||||
svg: "",
|
||||
xml,
|
||||
source: null,
|
||||
firstId: id,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const list = (
|
||||
entries: HistoryEntryInfo[],
|
||||
currentId: number | null,
|
||||
): HistoryList => ({ entries, stateId: "S1", currentId })
|
||||
|
||||
describe("buildVersions", () => {
|
||||
it("makes one numbered version per History entry, with what changed since the one before", () => {
|
||||
const { versions, onCanvasId } = buildVersions(
|
||||
list([entry(0, A), entry(1, AB, { svg: "data:AB" })], 1),
|
||||
cache(),
|
||||
)
|
||||
expect(versions.map((v) => [v.id, v.number])).toEqual([
|
||||
["0", 1],
|
||||
["1", 2],
|
||||
])
|
||||
expect(versions[0].fromScratch).toBe(true)
|
||||
expect(versions[0].summary.shapesAdded).toBe(1)
|
||||
expect(versions[1].fromScratch).toBe(false)
|
||||
expect(versions[1].summary.shapesAdded).toBe(1)
|
||||
expect(versions[1].pageId).toBe("p1")
|
||||
expect(versions[1].beforeXml).toBe(A)
|
||||
expect(versions[1].svg).toBe("data:AB")
|
||||
expect(onCanvasId).toBe("1")
|
||||
})
|
||||
|
||||
it("leaves the blank page out, and counts the first drawing from it", () => {
|
||||
// The server keeps the blank page the tab showed when the first
|
||||
// write replaces it; a page the user named is a version
|
||||
const blank = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const named = blank.replace('name="Page-1"', 'name="Plan"')
|
||||
const { versions, onCanvasId } = buildVersions(
|
||||
list([entry(0, blank), entry(1, A), entry(2, named)], 2),
|
||||
cache(),
|
||||
)
|
||||
expect(versions.map((v) => [v.id, v.number])).toEqual([
|
||||
["1", 1],
|
||||
["2", 2],
|
||||
])
|
||||
expect(versions[0].fromScratch).toBe(true)
|
||||
expect(onCanvasId).toBe("2")
|
||||
// Blank on the canvas: no version is
|
||||
expect(buildVersions(list([entry(0, blank)], 0), cache())).toEqual({
|
||||
versions: [],
|
||||
onCanvasId: "0",
|
||||
})
|
||||
})
|
||||
|
||||
it("folds a restore's copy into the version it copies, where that one is", () => {
|
||||
// History: A, AB, A again (restored); the canvas shows A
|
||||
const { versions, onCanvasId } = buildVersions(
|
||||
list(
|
||||
[
|
||||
entry(0, A),
|
||||
entry(1, AB),
|
||||
entry(2, A, {
|
||||
firstId: 0,
|
||||
svg: "data:A",
|
||||
source: "restore",
|
||||
}),
|
||||
],
|
||||
2,
|
||||
),
|
||||
cache(),
|
||||
)
|
||||
expect(versions.map((v) => v.id)).toEqual(["0", "1"])
|
||||
// The copy's picture stands in for the first copy's missing one
|
||||
expect(versions[0].svg).toBe("data:A")
|
||||
expect(onCanvasId).toBe("0")
|
||||
})
|
||||
|
||||
it("keeps the numbers once given, and gives new versions the next", () => {
|
||||
const kept = cache()
|
||||
buildVersions(list([entry(0, A), entry(1, AB)], 1), kept)
|
||||
// The oldest entry dropped out of the server's buffer
|
||||
const { versions } = buildVersions(
|
||||
list([entry(1, AB), entry(2, ABC)], 2),
|
||||
kept,
|
||||
)
|
||||
expect(versions.map((v) => v.number)).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it("keeps in its cache only the content the list still has, and never hands out a number twice", () => {
|
||||
// 25 writes, the server's buffer holding the last 20: what dropped
|
||||
// out (its full documents) goes, the numbering runs on
|
||||
const kept = cache()
|
||||
const docs = Array.from({ length: 25 }, (_, i) =>
|
||||
doc(
|
||||
Array.from({ length: i + 1 }, (_, j) =>
|
||||
cell(`c${j}`, `C${j}`),
|
||||
).join(""),
|
||||
),
|
||||
)
|
||||
const entries = docs.map((xml, i) => entry(i, xml))
|
||||
let versions: ReturnType<typeof buildVersions>["versions"] = []
|
||||
for (let n = 1; n <= 25; n++) {
|
||||
const window = entries.slice(Math.max(0, n - 20), n)
|
||||
versions = buildVersions(list(window, n - 1), kept).versions
|
||||
}
|
||||
expect(versions).toHaveLength(20)
|
||||
expect(versions.at(-1)?.number).toBe(25)
|
||||
expect(kept.numbers.size).toBe(20)
|
||||
expect(kept.changes.size).toBe(20)
|
||||
// Only the ones before the drawing restored: a restore of the
|
||||
// newest content gives it no new number
|
||||
const back = buildVersions(
|
||||
list(
|
||||
[
|
||||
...entries.slice(6),
|
||||
entry(25, docs[24], { firstId: 24, source: "restore" }),
|
||||
],
|
||||
25,
|
||||
),
|
||||
kept,
|
||||
).versions
|
||||
expect(back.map((v) => v.number)).not.toContain(26)
|
||||
const AD = doc(cell("a", "A") + cell("d", "D"))
|
||||
const next = buildVersions(
|
||||
list([...entries.slice(7), entry(26, AD)], 26),
|
||||
kept,
|
||||
).versions
|
||||
expect(next.at(-1)?.number).toBe(26)
|
||||
})
|
||||
|
||||
it("keeps showing a clear once the drawing before it dropped out of the server's buffer", () => {
|
||||
const blank = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const kept = cache()
|
||||
const later = Array.from({ length: 19 }, (_, i) =>
|
||||
entry(
|
||||
i + 3,
|
||||
doc(
|
||||
Array.from({ length: i + 1 }, (_, j) =>
|
||||
cell(`c${j}`, `C${j}`),
|
||||
).join(""),
|
||||
),
|
||||
),
|
||||
)
|
||||
// The blank page, A, the user cleared the canvas, 19 writes
|
||||
const first = buildVersions(
|
||||
list(
|
||||
[
|
||||
entry(0, blank),
|
||||
entry(1, A),
|
||||
entry(2, blank, { firstId: 0, source: "user" }),
|
||||
...later.slice(0, 17),
|
||||
],
|
||||
19,
|
||||
),
|
||||
kept,
|
||||
)
|
||||
expect(first.versions[1]).toMatchObject({ id: "2", number: 2 })
|
||||
// The buffer moved on: the clear is now the oldest entry, and a
|
||||
// restore of it after that folds into it
|
||||
const scrolled = buildVersions(
|
||||
list(
|
||||
[
|
||||
entry(2, blank, { firstId: 2, source: "user" }),
|
||||
...later,
|
||||
entry(22, blank, { firstId: 2, source: "restore" }),
|
||||
],
|
||||
22,
|
||||
),
|
||||
kept,
|
||||
)
|
||||
expect(scrolled.versions[0]).toMatchObject({ id: "2", number: 2 })
|
||||
expect(scrolled.versions[1].beforeId).toBe("2")
|
||||
expect(scrolled.onCanvasId).toBe("2")
|
||||
// A tab opened now never showed it: the blank is the one it starts with
|
||||
const fresh = buildVersions(
|
||||
list([entry(2, blank, { firstId: 2 }), ...later], 21),
|
||||
cache(),
|
||||
)
|
||||
expect(fresh.versions[0].id).toBe("3")
|
||||
})
|
||||
|
||||
it("counts a write from the state it replaced, not from the card before, after a restore", () => {
|
||||
// History: A, AB, ABC, A again (restored), AD: the AI drew D on A
|
||||
const AD = doc(cell("a", "A") + cell("d", "D"))
|
||||
const { versions } = buildVersions(
|
||||
list(
|
||||
[
|
||||
entry(0, A),
|
||||
entry(1, AB),
|
||||
entry(2, ABC),
|
||||
entry(3, A, { firstId: 0, source: "restore" }),
|
||||
entry(4, AD),
|
||||
],
|
||||
4,
|
||||
),
|
||||
cache(),
|
||||
)
|
||||
expect(versions.map((v) => v.id)).toEqual(["0", "1", "2", "4"])
|
||||
const latest = versions[3]
|
||||
expect(latest.beforeXml).toBe(A)
|
||||
expect(latest.summary.shapesAdded).toBe(1)
|
||||
expect(latest.summary.shapesRemoved).toBe(0)
|
||||
expect(latest.beforeId).toBe("0")
|
||||
// The first drawing replaced nothing a card shows
|
||||
expect(versions[0].beforeId).toBeNull()
|
||||
expect(versions[1].beforeId).toBe("0")
|
||||
})
|
||||
|
||||
it("restores the newest copy of a version: a later copy may carry the user's page settings", () => {
|
||||
// The user set a background on A (kept as a "user" entry, the same
|
||||
// content to the server), then the AI added B
|
||||
const yellow = A.replace(
|
||||
"<mxGraphModel>",
|
||||
'<mxGraphModel background="#ffcc00">',
|
||||
)
|
||||
const { versions } = buildVersions(
|
||||
list(
|
||||
[
|
||||
entry(0, A),
|
||||
entry(1, yellow, { firstId: 0, source: "user" }),
|
||||
entry(2, AB),
|
||||
],
|
||||
2,
|
||||
),
|
||||
cache(),
|
||||
)
|
||||
expect(versions).toHaveLength(2)
|
||||
expect(versions[0].id).toBe("0")
|
||||
expect(versions[0].entryId).toBe(1)
|
||||
expect(versions[0].xml).toBe(yellow)
|
||||
expect(versions[0].source).toBeNull()
|
||||
})
|
||||
|
||||
it("pictures a version as its newest copy looks", () => {
|
||||
// The thumbnail shows the background: the yellow copy's picture
|
||||
// goes with the yellow document the card restores, not the white
|
||||
// one's, and no picture at all when the yellow copy has none
|
||||
const yellow = A.replace(
|
||||
"<mxGraphModel>",
|
||||
'<mxGraphModel background="#ffcc00">',
|
||||
)
|
||||
const pictured = buildVersions(
|
||||
list(
|
||||
[
|
||||
entry(0, A, { svg: "data:white" }),
|
||||
entry(1, yellow, {
|
||||
firstId: 0,
|
||||
source: "user",
|
||||
svg: "data:yellow",
|
||||
}),
|
||||
entry(2, AB, { svg: "data:AB" }),
|
||||
],
|
||||
2,
|
||||
),
|
||||
cache(),
|
||||
)
|
||||
expect(pictured.versions[0].svg).toBe("data:yellow")
|
||||
const unpictured = buildVersions(
|
||||
list(
|
||||
[
|
||||
entry(0, A, { svg: "data:white" }),
|
||||
entry(1, yellow, { firstId: 0, source: "user" }),
|
||||
entry(2, AB, { svg: "data:AB" }),
|
||||
],
|
||||
2,
|
||||
),
|
||||
cache(),
|
||||
)
|
||||
expect(unpictured.versions[0].svg).toBe("")
|
||||
})
|
||||
|
||||
it("shows a clear of the canvas as a version, hiding only the blank page before any drawing", () => {
|
||||
const blank = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
// The server folds the cleared page into the first blank (same
|
||||
// content); then AB is restored
|
||||
const entries = [
|
||||
entry(0, blank),
|
||||
entry(1, A),
|
||||
entry(2, AB),
|
||||
entry(3, blank, { firstId: 0 }),
|
||||
entry(4, AB, { firstId: 2, source: "restore" }),
|
||||
]
|
||||
const cleared = buildVersions(list(entries.slice(0, 4), 3), cache())
|
||||
expect(cleared.versions.map((v) => v.id)).toEqual(["1", "2", "3"])
|
||||
expect(cleared.onCanvasId).toBe("3")
|
||||
expect(cleared.versions[2].beforeId).toBe("2")
|
||||
expect(cleared.versions[2].summary.shapesRemoved).toBe(2)
|
||||
const back = buildVersions(list(entries, 4), cache())
|
||||
expect(back.versions.map((v) => v.id)).toEqual(["1", "2", "3"])
|
||||
expect(back.onCanvasId).toBe("2")
|
||||
// A second clear is a version of its own; a restore of a clear is
|
||||
// a copy of the newest one
|
||||
const twice = buildVersions(
|
||||
list(
|
||||
[
|
||||
...entries,
|
||||
entry(5, blank, { firstId: 0 }),
|
||||
entry(6, blank, { firstId: 0, source: "restore" }),
|
||||
],
|
||||
6,
|
||||
),
|
||||
cache(),
|
||||
)
|
||||
expect(twice.versions.map((v) => [v.id, v.number])).toEqual([
|
||||
["1", 1],
|
||||
["2", 2],
|
||||
["3", 3],
|
||||
["5", 4],
|
||||
])
|
||||
expect(twice.onCanvasId).toBe("5")
|
||||
})
|
||||
|
||||
it("keeps a version's number and change when its first copy drops out of the server's buffer", () => {
|
||||
const kept = cache()
|
||||
const docs = Array.from({ length: 20 }, (_, i) =>
|
||||
doc(
|
||||
Array.from({ length: i + 1 }, (_, j) =>
|
||||
cell(`c${j}`, `C${j}`),
|
||||
).join(""),
|
||||
),
|
||||
)
|
||||
const full = docs.map((xml, i) => entry(i, xml))
|
||||
buildVersions(list(full, 19), kept)
|
||||
// v1 restored: its copy is added and the original, the oldest
|
||||
// entry, drops out; the copy is now the first with that content
|
||||
const after = [
|
||||
...full.slice(1),
|
||||
entry(20, docs[0], { firstId: 20, source: "restore" }),
|
||||
]
|
||||
const { versions, onCanvasId } = buildVersions(list(after, 20), kept)
|
||||
const v1 = versions.find((v) => v.id === "20")
|
||||
expect(v1?.number).toBe(1)
|
||||
expect(v1?.fromScratch).toBe(true)
|
||||
expect(v1?.summary.shapesAdded).toBe(1)
|
||||
expect(v1?.beforeXml).toBe("")
|
||||
expect(versions.map((v) => v.number)).not.toContain(21)
|
||||
expect(onCanvasId).toBe("20")
|
||||
})
|
||||
|
||||
it("lets the newest version on the canvas wait for its picture, older ones show none", () => {
|
||||
const { versions } = buildVersions(
|
||||
list([entry(0, A), entry(1, AB)], 1),
|
||||
cache(),
|
||||
)
|
||||
expect(versions[0].svg).toBe("")
|
||||
expect(versions[1].svg).toBeUndefined()
|
||||
const edited = buildVersions(
|
||||
list([entry(0, A), entry(1, AB)], null),
|
||||
cache(),
|
||||
)
|
||||
expect(edited.versions[1].svg).toBe("")
|
||||
})
|
||||
|
||||
it("labels a version by who made it and what it did", () => {
|
||||
const dict = en as unknown as Dictionary
|
||||
const { versions } = buildVersions(
|
||||
list(
|
||||
[entry(0, A), entry(1, AB, { source: "user" }), entry(2, ABC)],
|
||||
2,
|
||||
),
|
||||
cache(),
|
||||
)
|
||||
expect(versions.map((v) => versionLabel(v, dict))).toEqual([
|
||||
"Drew the diagram",
|
||||
"Changed by hand",
|
||||
"Changed the diagram",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
/** A stand-in for the sync: History as the server would answer, restores
|
||||
* recorded */
|
||||
function fakeSync(initial: HistoryList) {
|
||||
let current = initial
|
||||
const listeners = new Set<() => void>()
|
||||
const restores: { id: number; stateId: string | null }[] = []
|
||||
let restoreResult: "ok" | "stale" | "failed" = "ok"
|
||||
const sync = {
|
||||
fetchHistory: vi.fn(async () => current),
|
||||
restoreEntry: vi.fn(async (id: number, stateId: string | null) => {
|
||||
restores.push({ id, stateId })
|
||||
return restoreResult
|
||||
}),
|
||||
onServerChange: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
} as unknown as McpSync
|
||||
return {
|
||||
sync,
|
||||
restores,
|
||||
/** The server changed: the list it would answer now */
|
||||
serve(next: HistoryList) {
|
||||
current = next
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
failRestores(result: "stale" | "failed") {
|
||||
restoreResult = result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<DictionaryProvider dictionary={en as unknown as Dictionary}>
|
||||
{children}
|
||||
</DictionaryProvider>
|
||||
)
|
||||
|
||||
const flush = () => act(() => Promise.resolve())
|
||||
|
||||
describe("useNodeVersions", () => {
|
||||
it("reads History when the sync starts and whenever the server changed", async () => {
|
||||
const server = fakeSync(list([entry(0, A)], 0))
|
||||
const { result } = renderHook(() => useNodeVersions(server.sync), {
|
||||
wrapper,
|
||||
})
|
||||
await flush()
|
||||
expect(result.current.versions.map((v) => v.number)).toEqual([1])
|
||||
expect(result.current.onCanvasId).toBe("0")
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
|
||||
await act(async () =>
|
||||
server.serve(list([entry(0, A), entry(1, AB)], 1)),
|
||||
)
|
||||
expect(result.current.versions.map((v) => v.number)).toEqual([1, 2])
|
||||
expect(result.current.onCanvasId).toBe("1")
|
||||
expect(result.current.canUndo).toBe(true)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
})
|
||||
|
||||
it("numbers the History of another server state anew", async () => {
|
||||
// The process restarted: entry ids start over and name other content
|
||||
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
|
||||
const { result } = renderHook(() => useNodeVersions(server.sync), {
|
||||
wrapper,
|
||||
})
|
||||
await flush()
|
||||
expect(result.current.versions.map((v) => v.number)).toEqual([1, 2])
|
||||
const AB2 = doc(cell("a", "A") + cell("b", "B"), "p2")
|
||||
const ABC2 = doc(cell("a", "A") + cell("b", "B") + cell("c", "C"), "p2")
|
||||
await act(async () =>
|
||||
server.serve({
|
||||
entries: [entry(0, AB2), entry(1, ABC2)],
|
||||
stateId: "S2",
|
||||
currentId: 1,
|
||||
}),
|
||||
)
|
||||
expect(result.current.versions.map((v) => v.number)).toEqual([1, 2])
|
||||
const [first, second] = result.current.versions
|
||||
expect(first.fromScratch).toBe(true)
|
||||
expect(first.summary.shapesAdded).toBe(2)
|
||||
expect(second.pageId).toBe("p2")
|
||||
})
|
||||
|
||||
it("undoes to the version the newest one replaced, after a restore in between", async () => {
|
||||
// History: A, AB, ABC, A again (restored), AD; undo from AD goes to
|
||||
// A (its newest copy), as restore_version does, not to ABC
|
||||
const AD = doc(cell("a", "A") + cell("d", "D"))
|
||||
const server = fakeSync(
|
||||
list(
|
||||
[
|
||||
entry(0, A),
|
||||
entry(1, AB),
|
||||
entry(2, ABC),
|
||||
entry(3, A, { firstId: 0, source: "restore" }),
|
||||
entry(4, AD),
|
||||
],
|
||||
4,
|
||||
),
|
||||
)
|
||||
const { result } = renderHook(() => useNodeVersions(server.sync), {
|
||||
wrapper,
|
||||
})
|
||||
await flush()
|
||||
expect(result.current.canUndo).toBe(true)
|
||||
await act(async () => {
|
||||
result.current.undo()
|
||||
})
|
||||
expect(server.restores).toEqual([{ id: 3, stateId: "S1" }])
|
||||
// Back at A: AD is the undone one
|
||||
await act(async () =>
|
||||
server.serve(
|
||||
list(
|
||||
[
|
||||
entry(0, A),
|
||||
entry(1, AB),
|
||||
entry(2, ABC),
|
||||
entry(3, A, { firstId: 0, source: "restore" }),
|
||||
entry(4, AD),
|
||||
entry(5, A, { firstId: 0, source: "restore" }),
|
||||
],
|
||||
5,
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(result.current.onCanvasId).toBe("0")
|
||||
expect(result.current.undoneId).toBe("4")
|
||||
expect(result.current.canRedo).toBe(true)
|
||||
})
|
||||
|
||||
it("has no versions without a sync", () => {
|
||||
const { result } = renderHook(() => useNodeVersions(null), { wrapper })
|
||||
expect(result.current.versions).toEqual([])
|
||||
expect(result.current.onCanvasId).toBeNull()
|
||||
})
|
||||
|
||||
it("restores through the sync with the list's state, and says so", async () => {
|
||||
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
|
||||
const { result } = renderHook(() => useNodeVersions(server.sync), {
|
||||
wrapper,
|
||||
})
|
||||
await flush()
|
||||
await act(async () => {
|
||||
result.current.restore("0")
|
||||
})
|
||||
expect(server.restores).toEqual([{ id: 0, stateId: "S1" }])
|
||||
expect(toasts.success).toHaveBeenCalledWith("Restored v1", {
|
||||
duration: 4000,
|
||||
})
|
||||
// The canvas was busy for the restore, and is free again
|
||||
expect(useCanvasStore.getState().isBusy).toBe(false)
|
||||
})
|
||||
|
||||
it("undoes the newest version by restoring the one before, and redoes it", async () => {
|
||||
// The server keeps the state it left as a copy of the entry
|
||||
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
|
||||
const { result } = renderHook(() => useNodeVersions(server.sync), {
|
||||
wrapper,
|
||||
})
|
||||
await flush()
|
||||
await act(async () => {
|
||||
result.current.undo()
|
||||
})
|
||||
expect(server.restores).toEqual([{ id: 0, stateId: "S1" }])
|
||||
await act(async () =>
|
||||
server.serve(
|
||||
list(
|
||||
[entry(0, A), entry(1, AB), entry(2, A, { firstId: 0 })],
|
||||
2,
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(result.current.versions).toHaveLength(2)
|
||||
expect(result.current.onCanvasId).toBe("0")
|
||||
expect(result.current.undoneId).toBe("1")
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
result.current.redo()
|
||||
})
|
||||
expect(server.restores.at(-1)).toEqual({ id: 1, stateId: "S1" })
|
||||
await act(async () =>
|
||||
server.serve(
|
||||
list(
|
||||
[
|
||||
entry(0, A),
|
||||
entry(1, AB),
|
||||
entry(2, A, { firstId: 0 }),
|
||||
entry(3, AB, { firstId: 1 }),
|
||||
],
|
||||
3,
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(result.current.onCanvasId).toBe("1")
|
||||
expect(result.current.undoneId).toBeNull()
|
||||
expect(result.current.canUndo).toBe(true)
|
||||
})
|
||||
|
||||
it("offers neither undo nor redo after a hand edit, and keeps that edit as a version once a write follows", async () => {
|
||||
const server = fakeSync(list([entry(0, A), entry(1, AB)], null))
|
||||
const { result } = renderHook(() => useNodeVersions(server.sync), {
|
||||
wrapper,
|
||||
})
|
||||
await flush()
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
expect(result.current.undoneId).toBeNull()
|
||||
// The AI writes: the server kept the hand edit first
|
||||
const manual = doc(cell("a", "A") + cell("b", "B") + cell("m", "M"))
|
||||
await act(async () =>
|
||||
server.serve(
|
||||
list(
|
||||
[
|
||||
entry(0, A),
|
||||
entry(1, AB),
|
||||
entry(2, manual, { source: "user" }),
|
||||
entry(3, ABC),
|
||||
],
|
||||
3,
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(result.current.versions.map((v) => v.source)).toEqual([
|
||||
null,
|
||||
null,
|
||||
"user",
|
||||
null,
|
||||
])
|
||||
})
|
||||
|
||||
it("does not restore twice at once, nor what is not a version", async () => {
|
||||
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
|
||||
let resolveRestore: (r: "ok") => void = () => {}
|
||||
server.sync.restoreEntry = vi.fn(
|
||||
() =>
|
||||
new Promise<"ok">((resolve) => {
|
||||
resolveRestore = resolve
|
||||
}),
|
||||
) as unknown as McpSync["restoreEntry"]
|
||||
const { result } = renderHook(() => useNodeVersions(server.sync), {
|
||||
wrapper,
|
||||
})
|
||||
await flush()
|
||||
act(() => {
|
||||
result.current.restore("0")
|
||||
})
|
||||
await flush()
|
||||
expect(result.current.isBusy).toBe(true)
|
||||
act(() => {
|
||||
result.current.restore("0")
|
||||
result.current.restore("missing")
|
||||
})
|
||||
expect(server.sync.restoreEntry).toHaveBeenCalledTimes(1)
|
||||
await act(async () => resolveRestore("ok"))
|
||||
expect(result.current.isBusy).toBe(false)
|
||||
})
|
||||
|
||||
it("stays quiet when a restore was refused, and reads the list again", async () => {
|
||||
const server = fakeSync(list([entry(0, A), entry(1, AB)], 1))
|
||||
server.failRestores("stale")
|
||||
const { result } = renderHook(() => useNodeVersions(server.sync), {
|
||||
wrapper,
|
||||
})
|
||||
await flush()
|
||||
toasts.success.mockClear()
|
||||
const reads = (server.sync.fetchHistory as ReturnType<typeof vi.fn>)
|
||||
.mock.calls.length
|
||||
await act(async () => {
|
||||
result.current.restore("0")
|
||||
})
|
||||
expect(toasts.success).not.toHaveBeenCalled()
|
||||
expect(
|
||||
(server.sync.fetchHistory as ReturnType<typeof vi.fn>).mock.calls
|
||||
.length,
|
||||
).toBe(reads + 1)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ const html = readFileSync(join(dir, "index.html"), "utf8")
|
||||
.replace("{{DRAWIO_URL}}", "about:blank")
|
||||
.replace("{{SESSION_JSON}}", '""')
|
||||
.replace("{{ORIGIN_JSON}}", '"https://embed.diagrams.net"')
|
||||
.replace("{{TOKEN_JSON}}", '"test-token"')
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) =>
|
||||
m[1].replace("{{SCRIPT}}", ""),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ const html = readFileSync(join(dir, "index.html"), "utf8")
|
||||
.replace("{{DRAWIO_URL}}", "about:blank")
|
||||
.replace("{{SESSION_JSON}}", '"mcp-test"')
|
||||
.replace("{{ORIGIN_JSON}}", JSON.stringify(DRAWIO))
|
||||
.replace("{{TOKEN_JSON}}", JSON.stringify("test-token"))
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) =>
|
||||
m[1].replace("{{SCRIPT}}", ""),
|
||||
)
|
||||
@@ -21,10 +22,12 @@ const preview = readFileSync(join(dir, "preview.js"), "utf8")
|
||||
|
||||
const pageListeners: Array<[string, EventListener]> = []
|
||||
|
||||
type Answer = { status: number; body: unknown }
|
||||
// A string body is sent as it is (the page's HTML); anything else as JSON
|
||||
type Answer = { status: number; body?: unknown }
|
||||
interface Call {
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
answer: (a: Answer) => void
|
||||
fail: () => void
|
||||
@@ -47,10 +50,19 @@ function openPage() {
|
||||
calls.push({
|
||||
url,
|
||||
method: init?.method ?? "GET",
|
||||
headers: (init?.headers ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||
answer: ({ status, body }) =>
|
||||
resolve(
|
||||
new Response(JSON.stringify(body), { status }),
|
||||
new Response(
|
||||
typeof body === "string"
|
||||
? body
|
||||
: JSON.stringify(body),
|
||||
{ status },
|
||||
),
|
||||
),
|
||||
fail: () => reject(new TypeError("Failed to fetch")),
|
||||
})
|
||||
@@ -152,6 +164,50 @@ describe("MCP preview after the server recreated its session", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("fetches the new process's token after a 403 and then recovers", async () => {
|
||||
const t = await inStep()
|
||||
// The user edits; the push reaches a new process, which has its own
|
||||
// token, and is refused, as is the next poll
|
||||
t.fromDrawio({ event: "autosave", xml: "<mxfile>B</mxfile>" })
|
||||
t.fromDrawio({ event: "export", data: "<svg/>" })
|
||||
await t.settle()
|
||||
const refused = t.next("POST")
|
||||
expect(refused.headers["X-Drawio-Token"]).toBe("test-token")
|
||||
refused.answer({ status: 403 })
|
||||
await t.settle()
|
||||
// The page reads its own HTML again for the token
|
||||
const reload = t.next("GET")
|
||||
expect(reload.url).toBe(location.href)
|
||||
expect(reload.headers["X-Drawio-Token"]).toBeUndefined()
|
||||
reload.answer({
|
||||
status: 200,
|
||||
body: '<script>let API_TOKEN = "0123abcd";</script>',
|
||||
})
|
||||
await t.settle()
|
||||
// The push is retried with the new token; the new process refuses
|
||||
// it for its state id, and the poll that follows recovers
|
||||
const retried = t.next("POST")
|
||||
expect(retried.headers["X-Drawio-Token"]).toBe("0123abcd")
|
||||
retried.answer({
|
||||
status: 409,
|
||||
body: { error: "Session was recreated" },
|
||||
})
|
||||
await t.settle()
|
||||
const poll = t.next("GET")
|
||||
expect(poll.headers["X-Drawio-Token"]).toBe("0123abcd")
|
||||
poll.answer(state("S2", 1, "<mxfile>A</mxfile>"))
|
||||
await t.settle()
|
||||
await t.settle()
|
||||
expect(t.page.read().stateId).toBe("S2")
|
||||
const push = t.next("POST")
|
||||
expect(push.headers["X-Drawio-Token"]).toBe("0123abcd")
|
||||
expect(push.body).toMatchObject({
|
||||
xml: "<mxfile>B</mxfile>",
|
||||
stateId: "S2",
|
||||
baseVersion: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("shows the server's diagram and keeps the tab's in History", async () => {
|
||||
const t = await inStep()
|
||||
const poll = t.page.poll()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The MCP's canvas shell takes its settings from the config the server
|
||||
* writes into the page, and picks its language and theme in the browser.
|
||||
*/
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getDrawioSrc } from "@/lib/drawio/drawio-config"
|
||||
import { i18n } from "@/lib/i18n/config"
|
||||
import {
|
||||
getShellDictionary,
|
||||
matchLocale,
|
||||
pickLocale,
|
||||
} from "@/packages/mcp-server/shell/dictionary"
|
||||
import { readShellConfig } from "@/packages/mcp-server/shell/runtime-config"
|
||||
import {
|
||||
isDarkPreferred,
|
||||
THEME_STORAGE_KEY,
|
||||
} from "@/packages/mcp-server/shell/theme"
|
||||
|
||||
const shellDir = join(process.cwd(), "packages/mcp-server/shell")
|
||||
|
||||
describe("shell runtime config", () => {
|
||||
it("takes the server's values", () => {
|
||||
const config = readShellConfig({
|
||||
sessionId: "mcp-abc",
|
||||
token: "0123",
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "https://embed.diagrams.net/",
|
||||
drawioUi: "sketch",
|
||||
drawioLang: "ja",
|
||||
drawioDark: "dark",
|
||||
lang: "ja",
|
||||
})
|
||||
expect(config.sessionId).toBe("mcp-abc")
|
||||
expect(config.drawioBaseUrl).toBe("https://embed.diagrams.net/")
|
||||
expect(config.drawioUi).toBe("sketch")
|
||||
expect(config.drawioDark).toBe("dark")
|
||||
})
|
||||
|
||||
it("fills in defaults for what is missing or malformed", () => {
|
||||
expect(readShellConfig(undefined)).toEqual({
|
||||
sessionId: "",
|
||||
token: "",
|
||||
apiBase: "/api",
|
||||
drawioBaseUrl: "/drawio/index.html",
|
||||
drawioUi: "",
|
||||
drawioLang: "",
|
||||
drawioDark: "auto",
|
||||
lang: "",
|
||||
})
|
||||
const odd = readShellConfig({
|
||||
sessionId: 42,
|
||||
drawioDark: "sometimes",
|
||||
token: "t",
|
||||
})
|
||||
expect(odd.sessionId).toBe("")
|
||||
expect(odd.drawioDark).toBe("auto")
|
||||
expect(odd.token).toBe("t")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shell draw.io URL", () => {
|
||||
it("offers draw.io's theme menu unless a theme is fixed", () => {
|
||||
// Embedded draw.io shows Extras > Theme only with themes=1 and no ui
|
||||
const free = new URL(
|
||||
getDrawioSrc({ editorUrl: "/drawio/index.html" }, false),
|
||||
)
|
||||
expect(free.searchParams.get("themes")).toBe("1")
|
||||
expect(free.searchParams.get("ui")).toBeNull()
|
||||
const fixed = new URL(
|
||||
getDrawioSrc(
|
||||
{ editorUrl: "/drawio/index.html", ui: "kennedy" },
|
||||
false,
|
||||
),
|
||||
)
|
||||
expect(fixed.searchParams.get("ui")).toBe("kennedy")
|
||||
expect(fixed.searchParams.get("themes")).toBeNull()
|
||||
})
|
||||
|
||||
it("offers draw.io's custom libraries when asked, as the classic page did", () => {
|
||||
const withLibraries = new URL(
|
||||
getDrawioSrc(
|
||||
{ editorUrl: "/drawio/index.html", libraries: true },
|
||||
false,
|
||||
),
|
||||
)
|
||||
expect(withLibraries.searchParams.get("libraries")).toBe("1")
|
||||
const without = new URL(
|
||||
getDrawioSrc({ editorUrl: "/drawio/index.html" }, false),
|
||||
)
|
||||
expect(without.searchParams.get("libraries")).toBe("0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shell language", () => {
|
||||
it("maps language tags to the dictionaries we have", () => {
|
||||
expect(matchLocale("en-US")).toBe("en")
|
||||
expect(matchLocale("zh")).toBe("zh")
|
||||
expect(matchLocale("zh-CN")).toBe("zh")
|
||||
expect(matchLocale("zh-Hans-CN")).toBe("zh")
|
||||
expect(matchLocale("zh-TW")).toBe("zh-Hant")
|
||||
expect(matchLocale("zh-Hant")).toBe("zh-Hant")
|
||||
expect(matchLocale("ZH-HK")).toBe("zh-Hant")
|
||||
expect(matchLocale("ja-JP")).toBe("ja")
|
||||
expect(matchLocale("de")).toBeNull()
|
||||
expect(matchLocale("")).toBeNull()
|
||||
})
|
||||
|
||||
it("prefers the server's language, then the browser's, then English", () => {
|
||||
expect(pickLocale("zh-tw", ["en-US"])).toBe("zh-Hant")
|
||||
expect(pickLocale("", ["de-DE", "ja-JP", "en"])).toBe("ja")
|
||||
expect(pickLocale("de", ["fr"])).toBe("en")
|
||||
expect(pickLocale("", [])).toBe("en")
|
||||
})
|
||||
|
||||
it("has every dictionary, with the shell's own texts", () => {
|
||||
for (const locale of i18n.locales) {
|
||||
const dict = getShellDictionary(locale)
|
||||
expect(Object.keys(dict.shell).sort(), locale).toEqual([
|
||||
"aiChanged",
|
||||
"connected",
|
||||
"hideVersions",
|
||||
"historyChanged",
|
||||
"offline",
|
||||
"restoreFailed",
|
||||
"restoredFromFile",
|
||||
"showVersions",
|
||||
"toggleTheme",
|
||||
"tooLarge",
|
||||
"unreachable",
|
||||
"userEdit",
|
||||
"waiting",
|
||||
])
|
||||
expect(dict.shell.waiting, locale).toBeTruthy()
|
||||
expect(dict.workspace.canvasLoading, locale).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("shell theme", () => {
|
||||
it("follows the stored choice, then the server, then the system", () => {
|
||||
expect(isDarkPreferred("true", "light", false)).toBe(true)
|
||||
expect(isDarkPreferred("false", "dark", true)).toBe(false)
|
||||
expect(isDarkPreferred(null, "dark", false)).toBe(true)
|
||||
expect(isDarkPreferred(null, "light", true)).toBe(false)
|
||||
expect(isDarkPreferred(null, "auto", true)).toBe(true)
|
||||
expect(isDarkPreferred(null, "auto", false)).toBe(false)
|
||||
expect(isDarkPreferred("maybe", "auto", true)).toBe(true)
|
||||
})
|
||||
|
||||
it("is applied before the first paint by the page's own script", () => {
|
||||
const html = readFileSync(join(shellDir, "index.html"), "utf8")
|
||||
expect(THEME_STORAGE_KEY.startsWith("mcp:")).toBe(true)
|
||||
expect(html).toContain(`localStorage.getItem("${THEME_STORAGE_KEY}")`)
|
||||
expect(html).toContain("window.__MCP_CONFIG__ = {{CONFIG_JSON}};")
|
||||
expect(html).toContain('src="/shell/shell.js"')
|
||||
expect(html).toContain('href="/shell/shell.css"')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import enDict from "@/lib/i18n/dictionaries/en.json"
|
||||
import jaDict from "@/lib/i18n/dictionaries/ja.json"
|
||||
import zhDict from "@/lib/i18n/dictionaries/zh.json"
|
||||
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||
import { describeChanges, describeTotals } from "@/lib/version-text"
|
||||
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
|
||||
import {
|
||||
MAX_VERSIONS,
|
||||
useVersionsStore,
|
||||
@@ -39,7 +39,7 @@ describe("withPageDefaults", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("describeChanges", () => {
|
||||
describe("describeChangeSummary", () => {
|
||||
const en = {
|
||||
shapesAddedOne: "added 1 shape",
|
||||
shapesAddedOther: "added {count} shapes",
|
||||
@@ -51,7 +51,7 @@ describe("describeChanges", () => {
|
||||
|
||||
it("uses singular and plural forms and sentence case", () => {
|
||||
expect(
|
||||
describeChanges(
|
||||
describeChangeSummary(
|
||||
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
|
||||
en,
|
||||
),
|
||||
@@ -59,7 +59,7 @@ describe("describeChanges", () => {
|
||||
})
|
||||
|
||||
it("says so when nothing changed", () => {
|
||||
expect(describeChanges({ ...EMPTY_SUMMARY }, en)).toBe(
|
||||
expect(describeChangeSummary({ ...EMPTY_SUMMARY }, en)).toBe(
|
||||
"No visible changes",
|
||||
)
|
||||
})
|
||||
@@ -71,7 +71,7 @@ describe("describeChanges", () => {
|
||||
listSeparator: ",",
|
||||
}
|
||||
expect(
|
||||
describeChanges(
|
||||
describeChangeSummary(
|
||||
{ ...EMPTY_SUMMARY, shapesAdded: 3, edgesChanged: 1 },
|
||||
zh,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react"
|
||||
import type React from "react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { VersionCard } from "@/components/canvas/version-card"
|
||||
import { VersionStrip } from "@/components/canvas/version-strip"
|
||||
import {
|
||||
type VersionItem,
|
||||
VersionsProvider,
|
||||
type VersionsSource,
|
||||
} from "@/components/canvas/versions-context"
|
||||
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
|
||||
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||
import en from "@/lib/i18n/dictionaries/en.json"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// jsdom has no scrolling; the strip scrolls to its newest version
|
||||
Element.prototype.scrollTo = vi.fn()
|
||||
|
||||
const version = (id: string, number: number): VersionItem => ({
|
||||
id,
|
||||
number,
|
||||
xml: `<mxfile>${id}</mxfile>`,
|
||||
beforeXml: "",
|
||||
svg: "",
|
||||
summary: { ...EMPTY_SUMMARY, shapesAdded: 2 },
|
||||
})
|
||||
|
||||
function source(overrides: Partial<VersionsSource> = {}): VersionsSource {
|
||||
return {
|
||||
versions: [version("v1", 1), version("v2", 2)],
|
||||
onCanvasId: "v2",
|
||||
undoneId: null,
|
||||
isBusy: false,
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
restore: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mount(ui: React.ReactNode, value: VersionsSource) {
|
||||
return render(
|
||||
<DictionaryProvider dictionary={en as unknown as Dictionary}>
|
||||
<VersionsProvider value={value}>{ui}</VersionsProvider>
|
||||
</DictionaryProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("VersionCard", () => {
|
||||
it("undoes the latest version from the source", () => {
|
||||
const value = source()
|
||||
const { getByTestId, container } = mount(
|
||||
<VersionCard
|
||||
version={value.versions[1]}
|
||||
isLatest
|
||||
label="Changed the diagram"
|
||||
data-tool-state="output-available"
|
||||
/>,
|
||||
value,
|
||||
)
|
||||
expect(container.textContent).toContain("v2")
|
||||
expect(container.textContent).toContain("Added 2 shapes")
|
||||
expect(getByTestId("version-card").dataset.toolState).toBe(
|
||||
"output-available",
|
||||
)
|
||||
fireEvent.click(getByTestId("version-undo"))
|
||||
expect(value.undo).toHaveBeenCalled()
|
||||
expect(value.redo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("redoes an undone change and says so", () => {
|
||||
const value = source({
|
||||
onCanvasId: null,
|
||||
undoneId: "v2",
|
||||
canUndo: false,
|
||||
canRedo: true,
|
||||
})
|
||||
const { getByTestId, container } = mount(
|
||||
<VersionCard version={value.versions[1]} isLatest label="x" />,
|
||||
value,
|
||||
)
|
||||
expect(container.textContent).toContain("Change undone")
|
||||
fireEvent.click(getByTestId("version-undo"))
|
||||
expect(value.redo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("restores an older version; not while busy", () => {
|
||||
const value = source({ isBusy: true })
|
||||
const { getByTestId, rerender } = mount(
|
||||
<VersionCard
|
||||
version={value.versions[0]}
|
||||
isLatest={false}
|
||||
label="x"
|
||||
/>,
|
||||
value,
|
||||
)
|
||||
expect(
|
||||
(getByTestId("version-restore") as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
const idle = source()
|
||||
rerender(
|
||||
<DictionaryProvider dictionary={en as unknown as Dictionary}>
|
||||
<VersionsProvider value={idle}>
|
||||
<VersionCard
|
||||
version={idle.versions[0]}
|
||||
isLatest={false}
|
||||
label="x"
|
||||
/>
|
||||
</VersionsProvider>
|
||||
</DictionaryProvider>,
|
||||
)
|
||||
fireEvent.click(getByTestId("version-restore"))
|
||||
expect(idle.restore).toHaveBeenCalledWith("v1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("VersionStrip", () => {
|
||||
it("shows one thumbnail per version, the one on the canvas marked", () => {
|
||||
const { getAllByTestId } = mount(<VersionStrip />, source())
|
||||
const thumbs = getAllByTestId("version-thumb")
|
||||
expect(thumbs).toHaveLength(2)
|
||||
expect(thumbs[1].title).toContain("Canvas now")
|
||||
expect(thumbs[0].title).not.toContain("Canvas now")
|
||||
})
|
||||
|
||||
it("hides below the minimum", () => {
|
||||
const one = source({ versions: [version("v1", 1)] })
|
||||
const { container, rerender } = mount(<VersionStrip />, one)
|
||||
expect(container.textContent).toBe("")
|
||||
rerender(
|
||||
<DictionaryProvider dictionary={en as unknown as Dictionary}>
|
||||
<VersionsProvider value={one}>
|
||||
<VersionStrip minVersions={1} />
|
||||
</VersionsProvider>
|
||||
</DictionaryProvider>,
|
||||
)
|
||||
expect(container.textContent).toContain("Versions")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user