feat: add interactive React Flow canvas and floating toolbars
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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 创建接口保持通过。
|
||||
- 遵循用户不访问浏览器的安排,未做实际浏览器拖动、触控手势和视觉验收;不将状态测试表述为已完成浏览器手感验收。
|
||||
|
||||
Generated
+240
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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="" />
|
||||
</Link>
|
||||
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
|
||||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||||
</button>
|
||||
{error && <p className={styles.floatingError} role="alert">{error}</p>}
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -187,40 +190,6 @@ export default function DashboardSidebar() {
|
||||
</nav>
|
||||
{error && <p className={styles.error} role="alert">{error}</p>}
|
||||
<div className={styles.accountArea}>
|
||||
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup}>
|
||||
<div className={styles.accountHeading}>
|
||||
<span className={styles.workspaceAvatar} aria-hidden="true">{(currentWorkspace?.name || "啡跃").slice(0, 1)}</span>
|
||||
<div><strong>{currentWorkspace?.name || "你的工作空间"}</strong><small>{roleLabel}{demo ? " · 本地演示" : ""}</small></div>
|
||||
</div>
|
||||
<div className={styles.menuGroup}>
|
||||
<button type="button" className={styles.menuItem} disabled={!profile} onClick={() => openPanel("settings")}>
|
||||
<Settings size={16} strokeWidth={1.6} /><span>账户设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.separator} />
|
||||
<div className={styles.popupIdentity}>
|
||||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||||
<div><strong>{name}</strong><small>{profile?.email || profile?.username || "正在读取账户…"}</small></div>
|
||||
</div>
|
||||
<div className={styles.workspaceList}>
|
||||
{workspaces.map((w) => (
|
||||
<button type="button" key={w.id} className={styles.menuItem} aria-current={w.id === workspace ? "true" : undefined} onClick={() => {
|
||||
accountPopup.current?.hidePopover();
|
||||
setQuery("");
|
||||
setSelectedWorkspace(w.id);
|
||||
router.push(`/dashboard?workspace=${encodeURIComponent(w.id)}${demo ? "&demo=1" : ""}`);
|
||||
}}>
|
||||
<span className={styles.workspaceMini} aria-hidden="true">{w.name.slice(0, 1)}</span>
|
||||
<span>{w.name}</span>{w.id === workspace && <Check size={15} strokeWidth={1.8} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className={`${styles.menuItem} ${styles.newWorkspace}`} disabled={status !== "ready"} onClick={() => openPanel("workspace")}>
|
||||
<Plus size={16} strokeWidth={1.6} /><span>新建工作空间</span>
|
||||
</button>
|
||||
<div className={styles.separator} />
|
||||
<button type="button" className={`${styles.menuItem} ${styles.logout}`} onClick={logout}>退出登录</button>
|
||||
</div>
|
||||
<button type="button" className={styles.accountButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
|
||||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||||
<span className={styles.accountText}><strong>{name}</strong><small>{currentWorkspace?.name || (demo ? "本地演示" : "个人账户")}</small></span>
|
||||
@@ -229,6 +198,40 @@ export default function DashboardSidebar() {
|
||||
</div>
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
<div ref={accountPopup} id="workspace-account" popover="auto" className={styles.accountPopup} data-floating={collapsed} aria-label="账户和工作空间菜单">
|
||||
<div className={styles.accountHeading}>
|
||||
<span className={styles.workspaceAvatar} aria-hidden="true">{(currentWorkspace?.name || "啡跃").slice(0, 1)}</span>
|
||||
<div><strong>{currentWorkspace?.name || "你的工作空间"}</strong><small>{roleLabel}{demo ? " · 本地演示" : ""}</small></div>
|
||||
</div>
|
||||
<div className={styles.menuGroup}>
|
||||
<button type="button" className={styles.menuItem} disabled={!profile} onClick={() => openPanel("settings")}>
|
||||
<Settings size={16} strokeWidth={1.6} /><span>账户设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.separator} />
|
||||
<div className={styles.popupIdentity}>
|
||||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||||
<div><strong>{name}</strong><small>{profile?.email || profile?.username || "正在读取账户…"}</small></div>
|
||||
</div>
|
||||
<div className={styles.workspaceList}>
|
||||
{workspaces.map((w) => (
|
||||
<button type="button" key={w.id} className={styles.menuItem} aria-current={w.id === workspace ? "true" : undefined} onClick={() => {
|
||||
accountPopup.current?.hidePopover();
|
||||
setQuery("");
|
||||
setSelectedWorkspace(w.id);
|
||||
router.push(`/dashboard?workspace=${encodeURIComponent(w.id)}${demo ? "&demo=1" : ""}`);
|
||||
}}>
|
||||
<span className={styles.workspaceMini} aria-hidden="true">{w.name.slice(0, 1)}</span>
|
||||
<span>{w.name}</span>{w.id === workspace && <Check size={15} strokeWidth={1.8} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className={`${styles.menuItem} ${styles.newWorkspace}`} disabled={status !== "ready"} onClick={() => openPanel("workspace")}>
|
||||
<Plus size={16} strokeWidth={1.6} /><span>新建工作空间</span>
|
||||
</button>
|
||||
<div className={styles.separator} />
|
||||
<button type="button" className={`${styles.menuItem} ${styles.logout}`} onClick={logout}>退出登录</button>
|
||||
</div>
|
||||
{panel && profile && (
|
||||
<AccountPanel mode={panel} profile={profile} demo={demo} onClose={() => setPanel(null)} onProfile={setProfile}
|
||||
onWorkspace={(created) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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; }
|
||||
@@ -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<CanvasNode>();
|
||||
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<HTMLInputElement>(null);
|
||||
const projectInput = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
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<string>((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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||
<NodeEditing.Provider value={editing}>
|
||||
<ReactFlow<CanvasNode> 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 } }}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
||||
</ReactFlow>
|
||||
</NodeEditing.Provider>
|
||||
<div className={styles.topRight}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!state.past.length} onClick={() => dispatch({ type: "undo" })} />
|
||||
<IconButton icon={Redo2} label="重做" disabled={!state.future.length} onClick={() => dispatch({ type: "redo" })} />
|
||||
<span className={styles.divider} />
|
||||
<IconButton icon={Minus} label="缩小" onClick={() => void flow.zoomOut({ duration })} />
|
||||
<button className={styles.zoomValue} onClick={() => void flow.zoomTo(1, { duration })} title="恢复 100% 缩放">{Math.round(zoom * 100)}%</button>
|
||||
<IconButton icon={Plus} label="放大" onClick={() => void flow.zoomIn({ duration })} />
|
||||
<IconButton icon={Maximize} label="适应画布" onClick={() => nodes.length ? void flow.fitView({ padding: 0.2, duration }) : void flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration })} />
|
||||
<span className={styles.divider} />
|
||||
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
|
||||
</div>
|
||||
<span className={styles.saveStatus} role="status">{saved ? "保存在此浏览器" : "正在保存到此浏览器…"}</span>
|
||||
</div>
|
||||
<div className={`${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
|
||||
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
|
||||
<span className={styles.divider} />
|
||||
<IconButton icon={Type} label="添加文字" onClick={() => addNode("text")} />
|
||||
<IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} />
|
||||
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
|
||||
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
|
||||
<span className={styles.divider} />
|
||||
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
|
||||
</div>
|
||||
<button className={styles.helpButton} popoverTarget="canvas-help" aria-label="画布操作帮助"><HelpCircle size={18} /></button>
|
||||
<div id="canvas-help" popover="auto" className={styles.helpPopup}>
|
||||
<strong>自由创作,从移动画布开始</strong>
|
||||
<p>拖动空白处平移,拖动节点移动位置。</p>
|
||||
<p>触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。</p>
|
||||
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
|
||||
<p>拖动节点两侧圆点创建连线;Delete 删除所选内容。</p>
|
||||
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
|
||||
<p>当前编辑保存在此浏览器,可导出 JSON 备份。</p>
|
||||
</div>
|
||||
{error && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
|
||||
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphCanvas({ board }: { board: Board }) {
|
||||
return <ReactFlowProvider><CanvasEditor board={board} /></ReactFlowProvider>;
|
||||
}
|
||||
@@ -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<CanvasNode>) {
|
||||
const editing = useContext(NodeEditing);
|
||||
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
||||
return (
|
||||
<article className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><span>{data.title}</span></header>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
{data.kind === "image" ? <img className={styles.image} src={data.src} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
<textarea className="nodrag nowheel" aria-label={data.title} placeholder={data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
|
||||
value={data.text} maxLength={100000} onFocus={editing.begin} onChange={(event) => editing.edit(id, event.target.value)} />
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<main className={styles.graph} aria-label={board ? board.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
|
||||
<div className={styles.graphSurface} aria-label={board ? board.title + "的画布" : "画布"} />
|
||||
{board ? <GraphCanvas key={workspaceId + ":" + boardId} board={board} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
{error ? <p className={styles.graphStatus} role="alert">{error}</p>
|
||||
: !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
|
||||
</main>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<CanvasNode>[] }
|
||||
| { 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<string>();
|
||||
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<string>();
|
||||
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 } };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user