From d4b45c5051fadeae8330f0458d33143ac440a0ad Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 00:52:07 +0800 Subject: [PATCH] feat: add interactive React Flow canvas and floating toolbars --- README.md | 15 +- VALIDATION.md | 11 + package-lock.json | 240 ++++++++++++++++++ package.json | 1 + src/app/dashboard/dashboard.tsx | 71 +++--- src/app/dashboard/layout.tsx | 1 + src/components/canvas/canvas.module.css | 37 +++ src/components/canvas/graph-canvas.tsx | 214 ++++++++++++++++ src/components/canvas/material-node.tsx | 25 ++ src/components/workspace/graph-page.tsx | 3 +- src/components/workspace/workspace.module.css | 4 + src/lib/canvas.ts | 103 ++++++++ tests/canvas.test.mjs | 89 +++++++ 13 files changed, 778 insertions(+), 36 deletions(-) create mode 100644 src/components/canvas/canvas.module.css create mode 100644 src/components/canvas/graph-canvas.tsx create mode 100644 src/components/canvas/material-node.tsx create mode 100644 src/lib/canvas.ts create mode 100644 tests/canvas.test.mjs 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 || "你的工作空间"}{roleLabel}{demo ? " · 本地演示" : ""}
-
-
- -
-
-
- -
{name}{profile?.email || profile?.username || "正在读取账户…"}
-
-
- {workspaces.map((w) => ( - - ))} -
- -
- -
+
+
+ +
{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.title}
+ + {data.kind === "image" ? {data.title} : ( +