diff --git a/README.md b/README.md
index 582d2ab..7dbb49e 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,11 @@ Google / Slack 的提供方回调仍是 Foundry 后端地址;后端的项目
- 底部按钮、账户菜单和设置面板使用 Foundry `profile.avatar_url` 提供的 Google 头像;无头像或图片加载失败时显示姓名首字。
- 新建页面使用 Foundry Core 的 `POST /workspaces/{workspace}/materials`,提交 `kind: board`、默认名称“未命名图谱”和内容版本;由后端生成 ID。没有空间时先创建“个人空间”,成功后跳转到图谱的独立地址。
- 本地演示提供示例图谱、搜索、新建和浏览器保存,支持图谱独立地址与刷新。
-- 图形编辑、连接线、分享、Agent、收藏及完整布局持久化尚未接入。图谱页面移除顶部标题栏,画布覆盖整个视口,侧栏和快捷浮框覆盖其上;演示数据不写入线上。
+- 图谱页面使用 React Flow 12.12.1,支持空白处平移、节点拖动、连线、多选、缩放和文本编辑。文字、脚本、图片节点和创作模板可从底部悬浮工具栏插入。
+- 右上角悬浮框提供撤销、重做、缩放、恢复 100%、适应画布和 JSON 导出;底部支持 JSON 导入及操作帮助。
+- 图谱名称、ID 和 workspace 仍来自 Foundry。当前节点、连线与视图属于当前浏览器的本地草稿,按 workspace/board 分开保存,尚未同步到 Foundry 或其他设备。可导出 JSON 备份,导入操作可撤销。
+- 分享、Agent、收藏、自由绘图及完整服务端布局持久化尚未接入。画布覆盖整个视口,侧栏和快捷浮框覆盖其上;演示数据不写入线上。
+- 收起侧栏后,左上角浮框显示 Logo 和用户头像;头像与展开侧栏底部的账户按钮打开同一个菜单。
- 本地演示仅用于查看界面,不提供后端认证与权限。
Figma 原始 Logo、人物插画与图标已下载到 public/assets,不依赖临时 Figma URL。品牌保持啡跃 Endoleap;shadcn Button / Dialog 使用官方 registry 源码。依赖版本固定,package-lock.json 可用于 npm ci。
@@ -81,3 +85,12 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、
共 88 个原始资源:6 套 PNG(logo/icon/mono 的 light 与 dark 版本,每套 16、24、32、48、64、128、180、192、256、512、1024、2048px),8 个 SVG,以及 8 个应用图标文件(含 3 种 ICO、Apple Touch 和 PWA PNG)。所有文件与来源 Git blob 哈希一致。
收起侧栏后的浮框使用 Logo 返回 `/`;展开侧栏后仍保留新建页面按钮。首页与账户页品牌组件使用原始 SVG,根布局声明 ICO、深浅色 PNG favicon 和 Apple Touch 图标。
+
+## 画布操作
+
+- 拖动空白处平移;拖动节点标题或边框移动节点。抓手模式下也可从节点内容处平移。
+- V 选择、H 抓手,按住空格临时平移,Shift 框选;拖动节点两侧的圆点创建连线。
+- 触控板滚动平移,Ctrl / ⌘ 加滚动或双指捏合缩放。右上角提供缩放和适应画布。
+- 底部可添加文字、图片、脚本或三节点创作模板。图片支持 PNG/JPEG/WebP/GIF,单张上限 2 MB。
+- Ctrl / ⌘ + Z 撤销,Shift + Ctrl / ⌘ + Z 重做;支持 JSON 导入导出和 40 步撤销历史。
+- 状态与导入回归:`node --experimental-strip-types --test tests/canvas.test.mjs tests/workspace.test.mjs`。
diff --git a/VALIDATION.md b/VALIDATION.md
index e2da26c..f86fc02 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -57,3 +57,14 @@
- 全局 Brand 组件与根布局 favicon/Apple Touch 元信息使用迁入资源。
- 按用户约定不访问浏览器,视觉与点击交互由用户试用反馈;自动检查覆盖资源 HTTP 响应和字节一致性、图标元信息及既有接口流程。
- 本次类型检查、隔离 Webpack 生产构建、6 项 workspace 测试及扩展 API 冒烟检查均通过;88 个品牌资源的 HTTP 状态和响应字节与本地原文件一致。
+
+## React Flow 与悬浮工具栏(2026-11-06)
+
+- 根因:此前画布仅为静态点阵 div,没有安装或渲染 React Flow。本次加入固定版本 `@xyflow/react@12.12.1` 和官方样式,使用 ReactFlowProvider / ReactFlow 实现真正的平移、缩放、节点拖动、连线与多选。
+- 默认启用空白处左键拖动平移和触控板滚动平移,Ctrl/Meta 激活滚动缩放,支持捏合与空格平移;抓手模式关闭节点拖动,并让节点编辑区域的指针事件穿透到画布。
+- 底部工具栏提供选择、抓手、文字、图片、脚本、创作模板与导入;右上角提供撤销重做、缩放、100%、适应画布及导出。没有接入虚假的 AI 生成或会员功能。
+- 左上角增加 Google 头像入口;共享账户 Popover 移出会变为 inert 的侧栏,收起时将弹出位置改为左上角,保留原账户设置、空间切换及退出功能。
+- 节点、连线和视图暂存 localStorage,按 workspace 与 board 分离;这不是服务端同步。导入校验节点/边/图片来源/视图/大小,导出提供 JSON 备份;存储失败明确提示。
+- 13 项自动测试通过:节点位置变化与撤销重做、删除连线清理、文字编辑事务、导入导出、恶意图片与无效图谱拒绝、草稿隔离、历史上限及既有工作空间流程。
+- 类型检查、隔离 Webpack 生产构建和完整 API 冒烟回归通过;原有 88 个品牌资源与账户/Foundry 创建接口保持通过。
+- 遵循用户不访问浏览器的安排,未做实际浏览器拖动、触控手势和视觉验收;不将状态测试表述为已完成浏览器手感验收。
diff --git a/package-lock.json b/package-lock.json
index 7ee87cd..9746f13 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
"@radix-ui/react-slot": "1.5.0",
+ "@xyflow/react": "12.12.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.69.0",
@@ -1399,6 +1400,55 @@
"tailwindcss": "4.3.4"
}
},
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.12",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz",
+ "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz",
+ "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
@@ -1769,6 +1819,48 @@
"node": ">=16.20.0"
}
},
+ "node_modules/@xyflow/react": {
+ "version": "12.12.1",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.1.tgz",
+ "integrity": "sha512-g41OHmP+GL5wDBQKpgYt8AF6RlB4CrABtiStXhCMqdItK6gPdSpsuDk8V0YtdyvIvMPTjDJvkuLkz0JbSYu5Cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.84",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17",
+ "@types/react-dom": ">=17",
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.84",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.84.tgz",
+ "integrity": "sha512-96zfGFjH3oZ3T0MRdH4gJ0SlXuCIxbBuHVevZ4xry5UTxqjzjt010+LRdholgepq7jhrcaz1JNtzrETscwnkEA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -1825,6 +1917,12 @@
"url": "https://polar.sh/cva"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -1847,6 +1945,111 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.1.tgz",
+ "integrity": "sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.2.tgz",
+ "integrity": "sha512-sAzH4Y4nsLo8eSqw5jKiZt+LU2wZmL/qS+OkxQGjhwazB/D7TY34R/QK1eshfGr7cZEKymKZ/Haem7m+lu98Sg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.1.tgz",
+ "integrity": "sha512-OWeoayCq8J69hUIjvL+Mb6Q6Oaj/fyHV5XaXByEUAG+JUjIfTGDnk3qGpcXrD3VmQ3BSV96fcbM6fqq7C0tbPA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.2.tgz",
+ "integrity": "sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.1.tgz",
+ "integrity": "sha512-ODSf35vqCrlCfp7hrm0eZjvOosSXSYLjqvIlhMBV9q9VOx7EBJG6pc+uXBswEZNJgCGJ+qnKYcWutbZDIxe/Xg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.2.tgz",
+ "integrity": "sha512-RcjCsdFgyFoDB+QXLC5m60ZkTN0k9zH+ovoLlTdJnDS1AbfbcuowiM4pCKSTOclNibS4f4J4cbv2m7O7y8NTOQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.1.tgz",
+ "integrity": "sha512-kfjD3BDQu/FOatpZ6JdMSyIoDwTxIpI29ySTI2J5ZlDaH1WbuJGQJ2Q9oJkWTEZEW98j6idezoomSD3Cn2zwiw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2703,6 +2906,43 @@
"optional": true
}
}
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+ "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 3e5344c..5ec770f 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
"@radix-ui/react-slot": "1.5.0",
+ "@xyflow/react": "12.12.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.69.0",
diff --git a/src/app/dashboard/dashboard.tsx b/src/app/dashboard/dashboard.tsx
index 5c2dfc7..65ea2ae 100644
--- a/src/app/dashboard/dashboard.tsx
+++ b/src/app/dashboard/dashboard.tsx
@@ -153,6 +153,9 @@ export default function DashboardSidebar() {
srcSet="/brand/png/logo-light/endoleap-16.png 1x, /brand/png/logo-light/endoleap-32.png 2x, /brand/png/logo-light/endoleap-48.png 3x"
width={16} height={16} alt="" />
+
{error &&
{error}
}
)}
@@ -187,40 +190,6 @@ export default function DashboardSidebar() {
{error && {error}
}
-
-
-
{(currentWorkspace?.name || "啡跃").slice(0, 1)}
-
{currentWorkspace?.name || "你的工作空间"}{roleLabel}{demo ? " · 本地演示" : ""}
-
-
-
-
-
-
-
-
{name}{profile?.email || profile?.username || "正在读取账户…"}
-
-
- {workspaces.map((w) => (
-
- ))}
-
-
-
-
-
+
+
+
{(currentWorkspace?.name || "啡跃").slice(0, 1)}
+
{currentWorkspace?.name || "你的工作空间"}{roleLabel}{demo ? " · 本地演示" : ""}
+
+
+
+
+
+
+
+
{name}{profile?.email || profile?.username || "正在读取账户…"}
+
+
+ {workspaces.map((w) => (
+
+ ))}
+
+
+
+
+
{panel && profile && (
setPanel(null)} onProfile={setProfile}
onWorkspace={(created) => {
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index 91a92e9..5dd8dc0 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -1,3 +1,4 @@
+import "@xyflow/react/dist/style.css";
import { Suspense, type ReactNode } from "react";
import DashboardSidebar from "./dashboard";
import styles from "@/components/workspace/workspace.module.css";
diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css
new file mode 100644
index 0000000..2a459a2
--- /dev/null
+++ b/src/components/canvas/canvas.module.css
@@ -0,0 +1,37 @@
+.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
+.editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; }
+.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
+.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
+.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
+.node { position: relative; width: 260px; padding: 14px; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
+.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
+.nodeHeading { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; font-size: 11px; color: #92877c; }
+.node textarea { display: block; width: 100%; min-height: 140px; padding: 0; resize: vertical; border: 0; background: none; color: #37352f; font-family: inherit; font-size: 13px; line-height: 1.8; outline: none; }
+.node textarea::placeholder { color: #b2a99e; }
+.image { display: block; max-width: 100%; max-height: 280px; margin: auto; border-radius: 5px; object-fit: contain; }
+.node :global(.react-flow__handle) { width: 8px; height: 8px; background: #a47764; border: 2px solid #fff; }
+.toolbar { display: flex; align-items: center; gap: 5px; padding: 9px; border: 1px solid #e2dcd3; border-radius: 14px; background: #fffffff5; color: #4b443d; box-shadow: 0 7px 24px #32251a0a, 0 1px 3px #32251a05; backdrop-filter: blur(12px); }
+.toolbar button { box-sizing: content-box; padding: 8px; border-radius: 7px; }
+.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
+.toolbar button:disabled { cursor: not-allowed; }
+.divider { width: 1px; height: 22px; margin: 0 4px; background: #ece6df; }
+.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
+.toolbar .zoomValue { box-sizing: border-box; width: 48px; height: 32px; padding: 0; font-size: 12px; font-variant-numeric: tabular-nums; }
+.saveStatus { padding-right: 5px; font-size: 10px; color: #a59b90; }
+.bottomToolbar { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 10; }
+.helpButton { position: absolute; right: 16px; bottom: 22px; display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid #e2dcd3; border-radius: 50%; background: white; color: #74675c; box-shadow: 0 3px 12px #32251a0a; z-index: 10; }
+.helpPopup { position: fixed; inset: auto 16px 68px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
+.helpPopup strong { color: #49372e; font-size: 13px; }
+.helpPopup p { margin-top: 9px; }
+.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
+@media (max-width: 700px) {
+ .topRight { top: 58px; right: 10px; }
+ .toolbar { padding: 5px; gap: 2px; border-radius: 11px; }
+ .toolbar button { padding: 6px; }
+ .divider { margin: 0 2px; }
+ .bottomToolbar { bottom: 16px; max-width: calc(100vw - 48px); }
+ .helpButton { right: 7px; bottom: 20px; width: 28px; height: 28px; }
+ .saveStatus { font-size: 9px; }
+}
+
+.handMode .node textarea, .handMode .node img { pointer-events: none; }
diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx
new file mode 100644
index 0000000..19b34a3
--- /dev/null
+++ b/src/components/canvas/graph-canvas.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react";
+import {
+ Background, BackgroundVariant, ReactFlow, ReactFlowProvider, SelectionMode,
+ addEdge, useReactFlow, useViewport, type Connection,
+} from "@xyflow/react";
+import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, Type, Undo2, Upload } from "lucide-react";
+import { useReducedMotion } from "motion/react";
+import { IconButton } from "@/components/ui/icon-button";
+import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
+import type { Board } from "@/lib/workspace";
+import { MaterialNode, NodeEditing } from "./material-node";
+import styles from "./canvas.module.css";
+
+const nodeTypes = { material: MaterialNode };
+
+function CanvasEditor({ board }: { board: Board }) {
+ const flow = useReactFlow();
+ const { zoom } = useViewport();
+ const reduceMotion = useReducedMotion();
+ const storageKey = canvasStorageKey(board.workspace_id, board.id);
+ const [initial] = useState(() => {
+ try {
+ const stored = localStorage.getItem(storageKey);
+ return { document: stored ? decodeCanvas(stored) : emptyCanvas(), error: "" };
+ } catch { return { document: emptyCanvas(), error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
+ });
+ const [state, dispatch] = useReducer(canvasReducer, { past: [], present: initial.document, future: [] } satisfies CanvasHistory);
+ const [tool, setTool] = useState<"select" | "hand">("select");
+ const [error, setError] = useState(initial.error);
+ const [saved, setSaved] = useState(true);
+ const imageInput = useRef(null);
+ const projectInput = useRef(null);
+ const firstSave = useRef(true);
+ const { nodes, edges } = state.present;
+ const duration = reduceMotion ? 0 : 180;
+
+ useEffect(() => {
+ if (firstSave.current) { firstSave.current = false; return; }
+ setSaved(false);
+ const timeout = setTimeout(() => {
+ try {
+ localStorage.setItem(storageKey, JSON.stringify(decodeCanvas(JSON.stringify(state.present))));
+ setSaved(true);
+ } catch { setError("浏览器存储空间不足或暂不可用,请导出图谱备份。当前内容仍保留在画布中。"); }
+ }, 400);
+ return () => clearTimeout(timeout);
+ }, [state.present, storageKey]);
+
+ // Flush the latest view and nodes when navigating away before the debounce runs.
+ const latest = useRef(state.present);
+ latest.current = state.present;
+ useEffect(() => {
+ const flush = () => {
+ try { localStorage.setItem(storageKey, JSON.stringify(decodeCanvas(JSON.stringify(latest.current)))); }
+ catch { /* The editor already reports storage failures while it is open. */ }
+ };
+ window.addEventListener("pagehide", flush);
+ return () => { window.removeEventListener("pagehide", flush); flush(); };
+ }, [storageKey]);
+
+ const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []);
+ const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []);
+ const editing = useMemo(() => ({ begin: beginEdit, edit }), [beginEdit, edit]);
+
+ const center = () => {
+ const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
+ const offset = (nodes.length % 6) * 18;
+ return { x: point.x - 130 + offset, y: point.y - 80 + offset };
+ };
+ function addNode(kind: CanvasNode["data"]["kind"], src?: string) {
+ if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
+ dispatch({ type: "nodes", changes: [{ type: "add", item: makeCanvasNode(kind, center(), src) }] });
+ setTool("select");
+ }
+ function addTemplate() {
+ if (nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
+ const position = center();
+ const brief = makeCanvasNode("text", { x: position.x - 300, y: position.y });
+ brief.data = { ...brief.data, title: "创意简报", text: "你想表达什么?\n\n记录主题、受众和一个关键想法。" };
+ const idea = makeCanvasNode("text", { x: position.x + 20, y: position.y - 100 });
+ idea.data = { ...idea.data, title: "视觉参考", text: "收集颜色、光线和画面氛围。" };
+ const script = makeCanvasNode("script", { x: position.x + 340, y: position.y });
+ script.data.text = "从第一个镜头开始,写下你的故事。";
+ dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes, brief, idea, script], edges: [...edges,
+ { id: crypto.randomUUID(), source: brief.id, target: idea.id },
+ { id: crypto.randomUUID(), source: idea.id, target: script.id },
+ ] } });
+ setTool("select");
+ }
+ function connect(connection: Connection) {
+ dispatch({ type: "replace", document: { ...state.present, edges: addEdge({ ...connection, id: crypto.randomUUID() }, edges) } });
+ }
+ async function readImage(event: ChangeEvent) {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > 2_000_000) {
+ setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
+ }
+ try {
+ const src = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(new Error("无法读取图片。"));
+ reader.readAsDataURL(file);
+ });
+ addNode("image", src);
+ } catch { setError("无法读取图片,请重新选择。"); }
+ }
+ async function importProject(event: ChangeEvent) {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ try {
+ if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
+ const document = decodeCanvas(await file.text());
+ dispatch({ type: "replace", document });
+ void flow.setViewport(document.viewport);
+ setError("");
+ } catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
+ }
+ function exportProject() {
+ const content = JSON.stringify({ ...state.present, viewport: flow.getViewport() }, null, 2);
+ const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${board.title.replace(/[\\/:*?"<>|]/g, "_") || "未命名图谱"}.json`;
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+
+ useEffect(() => {
+ const keydown = (event: KeyboardEvent) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ if (target.closest("input, textarea, [contenteditable=true], [role=dialog], [popover]:popover-open")) return;
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
+ event.preventDefault(); dispatch({ type: event.shiftKey ? "redo" : "undo" });
+ } else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "y") {
+ event.preventDefault(); dispatch({ type: "redo" });
+ } else if (!event.metaKey && !event.ctrlKey && !event.altKey) {
+ if (event.key.toLowerCase() === "v") setTool("select");
+ if (event.key.toLowerCase() === "h") setTool("hand");
+ }
+ };
+ window.addEventListener("keydown", keydown);
+ return () => window.removeEventListener("keydown", keydown);
+ }, []);
+
+ return (
+
+
+ nodes={nodes} edges={edges} nodeTypes={nodeTypes}
+ onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
+ onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
+ onNodeDragStart={beginEdit} onConnect={connect}
+ onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })}
+ defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
+ panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
+ nodesDraggable={tool === "select"} nodesConnectable={tool === "select"}
+ elementsSelectable={tool === "select"} selectionMode={SelectionMode.Partial}
+ panActivationKeyCode="Space" selectionKeyCode="Shift"
+ deleteKeyCode={["Backspace", "Delete"]} preventScrolling
+ defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
+
+
+
+
+
+ dispatch({ type: "undo" })} />
+ dispatch({ type: "redo" })} />
+
+ void flow.zoomOut({ duration })} />
+
+ void flow.zoomIn({ duration })} />
+ nodes.length ? void flow.fitView({ padding: 0.2, duration }) : void flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration })} />
+
+
+
+
{saved ? "保存在此浏览器" : "正在保存到此浏览器…"}
+
+
+ setTool("select")} />
+ setTool("hand")} />
+
+ addNode("text")} />
+ imageInput.current?.click()} />
+ addNode("script")} />
+
+
+ projectInput.current?.click()} />
+
+
+
+
自由创作,从移动画布开始
+
拖动空白处平移,拖动节点移动位置。
+
触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。
+
V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选
+
拖动节点两侧圆点创建连线;Delete 删除所选内容。
+
⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。
+
当前编辑保存在此浏览器,可导出 JSON 备份。
+
+ {error &&
}
+
+
+
+ );
+}
+
+export default function GraphCanvas({ board }: { board: Board }) {
+ return ;
+}
diff --git a/src/components/canvas/material-node.tsx b/src/components/canvas/material-node.tsx
new file mode 100644
index 0000000..cb21918
--- /dev/null
+++ b/src/components/canvas/material-node.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import { Handle, Position, type NodeProps } from "@xyflow/react";
+import { FileText, Image as ImageIcon, Type } from "lucide-react";
+import type { CanvasNode } from "@/lib/canvas";
+import styles from "./canvas.module.css";
+
+export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {} });
+
+export function MaterialNode({ id, data, selected }: NodeProps) {
+ const editing = useContext(NodeEditing);
+ const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
+ return (
+
+
+
+ {data.kind === "image" ?
: (
+
+ );
+}
diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx
index 778b8d8..8615136 100644
--- a/src/components/workspace/graph-page.tsx
+++ b/src/components/workspace/graph-page.tsx
@@ -6,6 +6,7 @@ import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import type { Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
+import GraphCanvas from "@/components/canvas/graph-canvas";
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const demo = useSearchParams().get("demo") === "1";
@@ -46,7 +47,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
return (
-
+ {board ? : }
{error ? {error}
: !board && 正在打开图谱…
}
diff --git a/src/components/workspace/workspace.module.css b/src/components/workspace/workspace.module.css
index d0bc957..1cbb09c 100644
--- a/src/components/workspace/workspace.module.css
+++ b/src/components/workspace/workspace.module.css
@@ -116,3 +116,7 @@
.logoLink img { display: block; width: 16px; height: 16px; }
.logoLink:hover { background: #e9e7e3; }
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
+
+.floatingAvatarButton { display: grid; place-items: center; width: 28px; height: 24px; border-radius: 5px; }
+.floatingAvatarButton:hover { background: #e9e7e3; }
+.accountPopup[data-floating="true"] { inset: 56px auto auto 12px; max-height: calc(100dvh - 70px); transform-origin: top left; }
diff --git a/src/lib/canvas.ts b/src/lib/canvas.ts
new file mode 100644
index 0000000..44eac63
--- /dev/null
+++ b/src/lib/canvas.ts
@@ -0,0 +1,103 @@
+import { applyEdgeChanges, applyNodeChanges } from "@xyflow/react";
+import type { Edge, EdgeChange, Node, NodeChange, Viewport, XYPosition } from "@xyflow/react";
+
+export type CanvasNode = Node<{
+ kind: "text" | "image" | "script";
+ title: string;
+ text: string;
+ src?: string;
+}, "material">;
+export type CanvasDocument = { schema_version: 1; nodes: CanvasNode[]; edges: Edge[]; viewport: Viewport };
+export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] };
+export type CanvasAction =
+ | { type: "checkpoint" }
+ | { type: "replace"; document: CanvasDocument }
+ | { type: "nodes"; changes: NodeChange[] }
+ | { type: "edges"; changes: EdgeChange[] }
+ | { type: "edit"; id: string; text: string }
+ | { type: "viewport"; viewport: Viewport }
+ | { type: "undo" | "redo" };
+
+export function emptyCanvas(): CanvasDocument {
+ return { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
+}
+export function canvasStorageKey(workspace: string, board: string) {
+ return `endoleap_canvas_v1:${workspace}:${board}`;
+}
+function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory {
+ return { past: [...state.past, state.present].slice(-40), present, future: [] };
+}
+export function canvasReducer(state: CanvasHistory, action: CanvasAction): CanvasHistory {
+ switch (action.type) {
+ case "checkpoint": return recorded(state, state.present);
+ case "replace": return recorded(state, action.document);
+ case "nodes": {
+ const nodes = applyNodeChanges(action.changes, state.present.nodes);
+ const ids = new Set(nodes.map((node) => node.id));
+ const present = { ...state.present, nodes, edges: state.present.edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)) };
+ return action.changes.some((change) => change.type === "remove" || change.type === "add")
+ ? recorded(state, present) : { ...state, present };
+ }
+ case "edges": {
+ // React Flow may report the same edge removal after its node was removed.
+ // Keep that notification from consuming a second undo step.
+ const changes = action.changes.filter((change) => change.type !== "remove" || state.present.edges.some((edge) => edge.id === change.id));
+ if (!changes.length) return state;
+ const present = { ...state.present, edges: applyEdgeChanges(changes, state.present.edges) };
+ return changes.some((change) => change.type === "remove" || change.type === "add")
+ ? recorded(state, present) : { ...state, present };
+ }
+ case "edit": return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, text: action.text } } : node) } };
+ case "viewport": return { ...state, present: { ...state.present, viewport: action.viewport } };
+ case "undo": {
+ const last = state.past.at(-1);
+ return last ? { past: state.past.slice(0, -1), present: { ...last, viewport: state.present.viewport }, future: [state.present, ...state.future] } : state;
+ }
+ case "redo": {
+ const next = state.future[0];
+ return next ? { past: [...state.past, state.present].slice(-40), present: { ...next, viewport: state.present.viewport }, future: state.future.slice(1) } : state;
+ }
+ }
+}
+
+export function makeCanvasNode(kind: CanvasNode["data"]["kind"], position: XYPosition, src?: string): CanvasNode {
+ return {
+ id: crypto.randomUUID(), type: "material", position,
+ data: { kind, title: kind === "image" ? "视觉参考" : kind === "script" ? "脚本" : "灵感笔记", text: "", ...(src ? { src } : {}) },
+ };
+}
+function validImage(src: unknown): src is string {
+ if (typeof src !== "string") return false;
+ if (/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(src)) return src.length < 3_000_000;
+ try { const url = new URL(src); return url.protocol === "https:" && !url.username && !url.password && src.length < 2048; }
+ catch { return false; }
+}
+export function decodeCanvas(raw: string): CanvasDocument {
+ if (raw.length > 5_000_000) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
+ const value = JSON.parse(raw);
+ if (!value || value.schema_version !== 1 || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
+ throw new Error("不是有效的啡跃图谱文件。");
+ const ids = new Set();
+ const nodes: CanvasNode[] = value.nodes.map((node: CanvasNode) => {
+ if (!node || typeof node.id !== "string" || !/^[\w-]{1,128}$/.test(node.id) || ids.has(node.id) || !node.position ||
+ ![node.position.x, node.position.y].every((n) => Number.isFinite(n) && Math.abs(n) <= 1_000_000) ||
+ !node.data || !["text", "image", "script"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
+ typeof node.data.text !== "string" || node.data.text.length > 100_000 || (node.data.kind === "image" && !validImage(node.data.src)))
+ throw new Error("图谱节点格式无效。");
+ ids.add(node.id);
+ return { id: node.id, type: "material", position: { x: node.position.x, y: node.position.y }, data: {
+ kind: node.data.kind, title: node.data.title, text: node.data.text, ...(node.data.kind === "image" ? { src: node.data.src } : {}),
+ } };
+ });
+ const edgeIds = new Set();
+ const edges: Edge[] = value.edges.map((edge: Edge) => {
+ if (!edge || typeof edge.id !== "string" || !/^[\w-]{1,128}$/.test(edge.id) || edgeIds.has(edge.id) || !ids.has(edge.source) || !ids.has(edge.target))
+ throw new Error("图谱连线格式无效。");
+ edgeIds.add(edge.id);
+ return { id: edge.id, source: edge.source, target: edge.target };
+ });
+ const viewport = value.viewport;
+ if (!viewport || ![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom < 0.1 || viewport.zoom > 4)
+ throw new Error("图谱视图格式无效。");
+ return { schema_version: 1, nodes, edges, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } };
+}
diff --git a/tests/canvas.test.mjs b/tests/canvas.test.mjs
new file mode 100644
index 0000000..7c2ed69
--- /dev/null
+++ b/tests/canvas.test.mjs
@@ -0,0 +1,89 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
+
+function seeded() {
+ const document = emptyCanvas();
+ document.nodes = [makeCanvasNode("text", { x: 20, y: 30 }), makeCanvasNode("script", { x: 400, y: 30 })];
+ document.edges = [{ id: "edge", source: document.nodes[0].id, target: document.nodes[1].id }];
+ return { past: [], present: document, future: [] };
+}
+
+test("node dragging updates its position; undo and redo retain the current viewport", () => {
+ let state = seeded();
+ const id = state.present.nodes[0].id;
+ state = canvasReducer(state, { type: "checkpoint" });
+ state = canvasReducer(state, { type: "nodes", changes: [{ id, type: "position", position: { x: 220, y: -30 }, dragging: true }] });
+ assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 });
+ state = canvasReducer(state, { type: "viewport", viewport: { x: -500, y: 200, zoom: 0.5 } });
+ state = canvasReducer(state, { type: "undo" });
+ assert.deepEqual(state.present.nodes[0].position, { x: 20, y: 30 });
+ assert.deepEqual(state.present.viewport, { x: -500, y: 200, zoom: 0.5 });
+ state = canvasReducer(state, { type: "redo" });
+ assert.deepEqual(state.present.nodes[0].position, { x: 220, y: -30 });
+});
+
+test("deleting a node removes attached edges and undo restores both", () => {
+ const initial = seeded();
+ const removed = canvasReducer(initial, { type: "nodes", changes: [{ id: initial.present.nodes[0].id, type: "remove" }] });
+ assert.equal(removed.present.nodes.length, 1);
+ assert.equal(removed.present.edges.length, 0);
+ const afterEdgeNotification = canvasReducer(removed, { type: "edges", changes: [{ id: "edge", type: "remove" }] });
+ assert.equal(afterEdgeNotification.past.length, 1);
+ const restored = canvasReducer(afterEdgeNotification, { type: "undo" });
+ assert.equal(restored.present.nodes.length, 2);
+ assert.equal(restored.present.edges.length, 1);
+});
+
+test("a text editing session is undoable as one operation", () => {
+ let state = seeded();
+ const id = state.present.nodes[0].id;
+ state = canvasReducer(state, { type: "checkpoint" });
+ state = canvasReducer(state, { type: "edit", id, text: "a" });
+ state = canvasReducer(state, { type: "edit", id, text: "a new idea" });
+ assert.equal(state.past.length, 1);
+ assert.equal(canvasReducer(state, { type: "undo" }).present.nodes[0].data.text, "");
+});
+
+test("export/import retains nodes, edges, text, images and the viewport", () => {
+ const document = seeded().present;
+ const image = makeCanvasNode("image", { x: 80, y: 90 }, "data:image/png;base64,YQ==");
+ document.nodes.push(image);
+ document.nodes[0].data.text = "hello";
+ document.nodes[0].selected = true;
+ document.viewport = { x: 12, y: -28, zoom: 0.67 };
+ const restored = decodeCanvas(JSON.stringify(document));
+ assert.equal(restored.nodes[0].data.text, "hello");
+ assert.equal(restored.nodes[0].selected, undefined);
+ assert.equal(restored.nodes[2].data.src, image.data.src);
+ assert.deepEqual(restored.edges, document.edges);
+ assert.deepEqual(restored.viewport, document.viewport);
+});
+
+test("import rejects dangling connections, duplicate nodes, unsafe images and invalid zoom", () => {
+ for (const mutate of [
+ (doc) => { doc.edges[0].target = "missing"; },
+ (doc) => { doc.nodes.push(doc.nodes[0]); },
+ (doc) => { doc.nodes.push(makeCanvasNode("image", { x: 0, y: 0 }, "javascript:alert(1)")); },
+ (doc) => { doc.viewport.zoom = 0; },
+ ]) {
+ const document = seeded().present;
+ mutate(document);
+ assert.throws(() => decodeCanvas(JSON.stringify(document)));
+ }
+});
+
+test("draft storage is isolated by workspace and graph", () => {
+ assert.notEqual(canvasStorageKey("workspace-a", "board"), canvasStorageKey("workspace-b", "board"));
+ assert.notEqual(canvasStorageKey("workspace", "board-a"), canvasStorageKey("workspace", "board-b"));
+});
+
+test("undo history is bounded and new edits clear the redo stack", () => {
+ let state = seeded();
+ for (let i = 0; i < 50; i++) state = canvasReducer(state, { type: "replace", document: { ...state.present, edges: [] } });
+ assert.equal(state.past.length, 40);
+ state = canvasReducer(state, { type: "undo" });
+ assert.equal(state.future.length, 1);
+ state = canvasReducer(state, { type: "nodes", changes: [{ type: "add", item: makeCanvasNode("text", { x: 0, y: 0 }) }] });
+ assert.equal(state.future.length, 0);
+});