diff --git a/README.md b/README.md index 564f6a7..28ce204 100644 --- a/README.md +++ b/README.md @@ -105,13 +105,16 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、 左上和底部工具栏按右上视图控件的分组节奏增加横向留白(间隙 6px、横向内边距 6px),按钮仍为 32×32px,竖向内边距仍为 4px。三处分隔线统一;左侧标题不再额外叠加外边距,右上现有样式保持不变。 -选中卡片后,上、下、左、右各显示一个加号。点击会在对应方向新增文字卡片并连线,视图跟随新节点;重复添加会避开已有节点,整个操作可一步撤销。方向连线也会保存在本地草稿和导出的 JSON 中,旧版左右连接继续兼容。 +卡片四边正中心提供连接点;选中后连接点通过动画变为加号。拖动点或加号建立贝塞尔连线;点击加号打开菜单,选择文本、上传图片或脚本后再创建节点并连线。选中卡片拖线到空白处,也会在释放位置打开类型菜单。新增节点与连线组成一步撤销。 -## 元素变换与继承 +## 元素变换、样式与继承 -- 卡片选中后可拖动四边或四角调整大小;拖动上方旋转柄旋转,Shift 吸附到 15°,双击旋转柄复位。旋转后仍按元素自身方向缩放。 -- 底部 T 添加纯文本,便签按钮添加文本卡片。纯文本没有卡片外框、连接点或四向加号;缩放时同步改变字号及文本框大小。 -- 大小、角度与字号保存在本地草稿和 JSON 中;一次拖动对应一步撤销,旧文本卡片导入后仍是卡片。 -- 业务类继承为 `CanvasElement → CardElement → TextCardElement / ImageCardElement / ScriptCardElement`;`PlainTextElement` 直接继承 `CanvasElement`。基础元素拥有缩放和旋转能力,卡片层追加连线和分支能力。React 组件通过组合共享 `TransformableNode`,不使用组件类继承。 -- `/dashboard/{workspace}/{board}` 的两个 ID 分别标识工作空间和图谱。前者确定访问范围,后者定位该空间内的图谱;读取和改名接口也使用相同的空间层级。 -- 回归:`node --experimental-strip-types --test tests/canvas-geometry.test.mjs tests/canvas.test.mjs tests/workspace.test.mjs`。 +- 选中后拖动右下角小符号调整大小,已移除八向缩放控件。样式浮栏中的旋转按钮开启旋转模式,拖动元素旋转,Shift 吸附 15°;点击外部或按 Esc 退出。纯文本宽、高独立调整,文字随宽度换行,字号由样式栏控制;图标保持等比缩放。 +- 卡片标题位于框外,整个框内是文本编辑区域;卡片与纯文本均取消独立的聚焦内框。 +- 样式浮栏提供文字颜色、字体、字号、加粗、斜体、下划线、对齐、行距、背景及旋转;样式作用于整个文本节点。选中与取消选中、连接点加号和菜单均有动画,尊重系统减少动态效果偏好。 +- 底部 T 添加纯文本,便签按钮添加文本卡片。纯文本没有卡片外框、连接点或四向加号。 +- 底部图形按钮打开左侧图标浮层,可搜索并插入 45 个 Lucide 图标;图标支持移动、缩放、旋转和颜色设置,无需额外图标目录服务。 +- 大小、角度、样式、字号和图标标识保存在本地草稿及 JSON;支持撤销和重做。目前尚未存入数据库,Core 的 board.content 不接受这些字段。后端需求及验收契约见 [CORE_CANVAS_API.md](docs/CORE_CANVAS_API.md)。 +- 业务类继承为 `CanvasElement → CardElement → TextCardElement / ImageCardElement / ScriptCardElement`;`PlainTextElement` 和 `IconElement` 直接继承 `CanvasElement`。基础元素拥有缩放和旋转能力,卡片层追加连线和分支能力。React 组件通过组合共享 `TransformableNode`。 +- `/dashboard/{workspace}/{board}` 的两个 ID 分别标识工作空间和图谱。 +- 回归:`node --experimental-strip-types --test tests/canvas-style.test.mjs tests/canvas-geometry.test.mjs tests/canvas.test.mjs tests/workspace.test.mjs`。 diff --git a/VALIDATION.md b/VALIDATION.md index e689332..9a1a1b4 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -1,5 +1,18 @@ # 本地验收记录 +## 连接交互、样式栏与图标(2026-11-06) + +- 移除“保存在此浏览器”状态行;卡片连接点恢复四边中心,未选中显示点,选中后动画变为加号。同一个 React Flow Handle 支持拖出贝塞尔线;点击加号打开文本/图片/脚本菜单,图片需用户选图,取消不会创建空节点。选中卡片拖线到空白处,在释放位置打开菜单。 +- 连接点大小与加号显隐、选中边框有 CSS 过渡,样式栏与菜单提供 Motion 进入/退出动画;尊重减少动态效果偏好。 +- 标题移到框外,textarea 充满卡片内部,卡片与纯文本均覆盖全局聚焦阴影,去掉额外内框。 +- 删除八向缩放和常驻旋转柄,仅保留右下角缩放符号。旋转从样式栏开启,拖动元素旋转,点击外部或 Esc 退出;Shift 吸附 15°。 +- 样式支持颜色、背景、字体、字号、粗体、斜体、下划线、对齐及行距;节点样式可撤销、导入导出和本地保存。根据用户后续反馈,纯文本改为宽高独立调整、自动换行,缩放不改变字号;仅图标保持等比。 +- 底部图标入口打开左侧浮层,提供 45 个 Lucide 图标及中英文名称搜索,支持插入、缩放、旋转、颜色和 JSON 往返。浮层使用 Portal,避免被展开的工作空间侧栏遮住。 +- 核对 Core main e8ed5e4 的 content/materials 实现:board.content 目前只允许 schema_version,无法存储节点或样式。尚未对接不存在的 API,也未修改真实账户或服务端数据。完整接口建议、字段和验收标准见 docs/CORE_CANVAS_API.md。 +- 29 项自动测试及类型检查通过;覆盖选择不同类型的分支、空白处释放位置、样式完整往返与撤销、非法样式拒绝、图标继承与缩放,以及已有画布/工作空间流程。 +- 最终源代码的隔离 Webpack 生产构建、完整 API/路由/88 个品牌资源冒烟回归均通过;未改动正在使用的开发服务缓存。 +- 按用户既定安排未访问项目浏览器,实际拖线手感、弹层位置与动画视觉由用户试用反馈;自动状态测试不代表浏览器交互验收。 + ## 元素继承、缩放、旋转与纯文本(2026-11-06) - 新增业务元素类继承,卡片与纯文本共享基础变换;纯文本不提供连接点或四向分支。 diff --git a/docs/CORE_CANVAS_API.md b/docs/CORE_CANVAS_API.md new file mode 100644 index 0000000..c255c79 --- /dev/null +++ b/docs/CORE_CANVAS_API.md @@ -0,0 +1,116 @@ +# Canvas 数据持久化:交给 Core 的接口需求 + +## 已核对的现状 + +核对 Core main `e8ed5e4f9ffc526f4a42b001850ffc240420a1e0`: + +- `internal/platform/materials.go` 的 material GET / PATCH 保存名称、描述、content,并以带引号的 `If-Match` 控制版本。 +- `internal/platform/content.go` 的 `Content` 只有 schema_version/text/article/image/parts,解码器使用 `DisallowUnknownFields`;board 只允许空内容 `{ "schema_version": 1 }`。直接提交 nodes、edges 或样式会被拒绝,不能用现有接口冒充已存库。 +- 未发现 Canvas 快照读写接口。当前前端节点、文本样式、图标、角度、尺寸和连线仍保存在本地草稿及导出的 JSON,尚未存入数据库。 + +## 必需接口 + +建议为 board 增加独立 Canvas 快照,使用独立的版本号,避免改名与编辑画布互相覆盖。 + +后端路径:`/projects/{project}/workspaces/{workspace}/materials/{board}/canvas` + +前端同源代理路径:`/api/foundry/workspaces/{workspace}/materials/{board}/canvas`。Core 完成后前端需扩展当前代理白名单,并接入下述读写接口;本次没有向不存在的接口发送请求。 + +### GET + +响应 `200`,`ETag: "7"`: + +```json +{ + "version": 7, + "document": { + "schema_version": 1, + "nodes": [ + { + "id": "text-node-1", + "type": "material", + "position": { "x": 100, "y": 200 }, + "width": 320, + "height": 220, + "data": { + "kind": "text", + "title": "灵感笔记", + "text": "正文", + "rotation": 30, + "fontSize": 24, + "textStyle": { + "color": "#37352f", + "background": "transparent", + "bold": true, + "italic": false, + "underline": false, + "align": "left", + "fontFamily": "sans", + "lineHeight": 1.5 + } + } + }, + { + "id": "icon-node-1", + "type": "icon", + "position": { "x": 500, "y": 200 }, + "width": 80, + "height": 80, + "data": { + "kind": "icon", + "title": "火箭", + "text": "", + "icon": "Rocket", + "rotation": 0, + "textStyle": { "color": "#78523f" } + } + } + ], + "edges": [], + "viewport": { "x": 0, "y": 0, "zoom": 1 } + } +} +``` + +没有快照的旧 board 应返回 version=1 的空图谱(nodes/edges 为空、viewport 为 0/0/1),可自动初始化。非 board 或不可访问的材料按已有资源错误约定返回。 + +### PATCH + +请求 `If-Match: "7"`,body 为 `{ "document": { ...完整快照... } }`,原子替换整张图谱,成功响应 `200`,body `{ "version": 8 }`,`ETag: "8"`。 + +- 必须校验 project/workspace/board 归属及现有成员/材料授权;GET 允许 viewer,PATCH 只允许 admin/editor。 +- 缺版本返回 428,版本冲突返回 412 / `version_conflict`,冲突不得覆盖数据库内容。 +- 无效快照返回 400 / `invalid_canvas`,超限返回 413;拒绝时不能部分写入。 +- 返回持久化版本,刷新、重新登录或另一台设备 GET 时必须能还原全部内容。 +- Canvas 与材料名称/描述分别更新,不能在保存快照时覆盖名称,也不能在改名时清空快照。 + +## 数据字段与校验 + +以本仓库 `src/lib/canvas.ts` 的 `decodeCanvas` 和 `src/lib/canvas-elements.ts` 为当前前端契约: + +| 数据 | 必须保留或校验的内容 | +| --- | --- | +| 节点类型 | text/image/script 使用 material;plain-text 使用 plainText;icon 使用 icon | +| 几何 | position.x/y 为有限数、绝对值不超过 1,000,000;width/height 成对、20–3000;rotation 为 [0,360) | +| 文本 | title 最长 300 字符,text 最长 100,000 字符;fontSize 8–160 | +| 样式 | color 为六位 hex;background 为六位 hex 或 transparent;bold/italic/underline 为布尔;align=left/center/right;fontFamily=sans/serif/mono;lineHeight=1–3 | +| 图标 | icon 为 Lucide PascalCase 标识,当前例如 Star/Rocket/Heart;颜色及尺寸由相同样式/几何字段确定 | +| 图片 | src 为 HTTPS 图片 URL 或当前前端支持的 PNG/JPEG/WebP/GIF data URL;单次本地选图最大 2 MB | +| 连线 | id/source/target/sourceHandle/targetHandle;四侧 handle=top/right/bottom/left;保留旧无名连接点兼容;引用必须存在,不允许连接纯文本或图标 | +| 视图 | viewport.x/y 为有限数,zoom=0.1–4 | + +最多 500 个节点、2000 条连线;节点与连线 ID 各自唯一。selected/dragging/measured 等 UI 临时状态不入库。节点 style.width/height 是前端尺寸镜像,可不持久化,恢复时用 width/height 生成。连线按贝塞尔曲线渲染。 + +快照请求体建议支持至少 5 MiB(目前 Core 和前端代理都是 1 MiB,需要同步调整该路由的限制),否则一张 2 MB 图片转为 data URL 后就无法保存。限制应按 UTF-8 字节计算。若 Core 选择使用独立图片上传接口代替 data URL,请提供上传和持久 URL 返回契约,前端再替换图片保存方式。 + +## 图标是否需要专门的后端服务 + +本次图标由已有 npm 包 `lucide-react@1.69.0` 本地渲染,前端提供 45 个图标和搜索。添加、搜索与显示不需要图标下载或目录 API;数据库只需通过 Canvas 快照保存 icon 标识、颜色、位置、尺寸和旋转角度,不保存 SVG 源码。后续如支持多个图标库,再为节点增加图标库及版本标识。 + +## Core 交付验收 + +1. 写入含中文文本、全部 textStyle 字段、旋转尺寸、图标和四向连线的快照,重新 GET 完全还原。 +2. 两个客户端持相同版本写入,只允许一个成功,另一个收到 412。 +3. viewer、其他 workspace 成员及未登录用户不能越权写入;非法引用、无效样式、超限请求不能留下部分数据。 +4. 改名不清空快照,保存快照不改变名称;旧 board 可读取空快照;包含一张 2 MB 图片的合法快照能够保存和还原。 +5. 接口上线后前端接入:优先读取服务端快照、提供本地旧草稿迁移、串行自动保存与冲突处理,并验证跨设备恢复;在此之前不能将前端本地持久化称为数据库保存。 diff --git a/src/components/canvas/branch-menu.tsx b/src/components/canvas/branch-menu.tsx new file mode 100644 index 0000000..6bb2cc4 --- /dev/null +++ b/src/components/canvas/branch-menu.tsx @@ -0,0 +1,28 @@ +"use client"; +import { useEffect, useRef } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { createPortal } from "react-dom"; +import { FileText, ImagePlus, StickyNote, X } from "lucide-react"; +import type { XYPosition } from "@xyflow/react"; +import type { NodeDirection } from "@/lib/canvas"; +import styles from "./canvas.module.css"; +export type BranchRequest = { id: string; direction: NodeDirection; point: XYPosition; at?: XYPosition }; +export function BranchMenu({ request, close, choose }: { request: BranchRequest | null; close: () => void; choose: (kind: "text" | "image" | "script") => void }) { + const ref = useRef(null); + const reduce = useReducedMotion(); + useEffect(() => { + if (!request) return; + const focus = document.activeElement as HTMLElement | null; + ref.current?.querySelector("button[data-choice]")?.focus(); + const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) close(); }; + const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); focus?.focus(); } }; + window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true); + return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); }; + }, [request, close]); + if (typeof document === "undefined") return null; + return createPortal({request && { event.stopPropagation(); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const buttons = Array.from(ref.current?.querySelectorAll("button") || []); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus(); } }}> +
添加连接节点
+ {([["text", StickyNote, "文本"], ["image", ImagePlus, "图片"], ["script", FileText, "脚本"]] as const).map(([kind, Icon, label]) => )} +
}
, document.body); +} diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index fa20d1c..50e290a 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -3,19 +3,18 @@ .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; } +.node { position: relative; padding: 0; 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%; flex: 1; min-height: 0; height: 100%; padding: 0; resize: none; 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; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; max-height: none; margin: auto; border-radius: 5px; object-fit: contain; } -.node :global(.react-flow__handle) { width: 8px; height: 8px; background: #a47764; border: 2px solid #fff; } +.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; } +.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; } +.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; } +.cardText::placeholder, .plainText::placeholder { color: #b2a99e; } +.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; } .toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; } .toolbar button:disabled { cursor: not-allowed; } .topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; } .toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; } .toolbar .zoomValue:hover { background: #e9e7e3; } -.saveStatus { padding-right: 5px; font-size: 10px; color: #a59b90; } .bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; } .helpPopup { position: fixed; inset: auto 12px 62px 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; } @@ -32,34 +31,53 @@ .topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; } .node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; } -.branchButton { position: absolute; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid #cbb8a8; border-radius: 50%; background: #fffefb; color: #78523f; box-shadow: 0 2px 6px #32251a0a; cursor: pointer; z-index: 5; } -.branchButton:hover { background: #f1e6df; border-color: #a47764; } -.branch_top { top: -48px; left: 50%; transform: translateX(-50%); } -.branch_right { right: -48px; top: 50%; transform: translateY(-50%); } -.branch_bottom { bottom: -48px; left: 50%; transform: translateX(-50%); } -.branch_left { left: -48px; top: 50%; transform: translateY(-50%); } -.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; } -.nodeHeading { flex: 0 0 auto; } -.plainFrame { padding: 4px; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; } +.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; } +.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; } .plainFrame.selectedFrame { border-color: #a47764; } -.plainText { display: block; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; padding: 0; border: 0; outline: none; background: transparent; color: #37352f; font-family: inherit; line-height: 1.25; resize: none; } -.plainText::placeholder { color: #b3a79c; } -.resizeHandle { position: absolute; box-sizing: border-box; width: 8px; height: 8px; padding: 0; border: 1px solid #a47764; border-radius: 2px; background: white; z-index: 8; touch-action: none; } -.resize_top { top: -4px; left: calc(50% - 4px); cursor: ns-resize; } -.resize_bottom { bottom: -4px; left: calc(50% - 4px); cursor: ns-resize; } -.resize_left { left: -4px; top: calc(50% - 4px); cursor: ew-resize; } -.resize_right { right: -4px; top: calc(50% - 4px); cursor: ew-resize; } -.resize_top-left { left: -4px; top: -4px; cursor: nwse-resize; } -.resize_bottom-right { right: -4px; bottom: -4px; cursor: nwse-resize; } -.resize_top-right { right: -4px; top: -4px; cursor: nesw-resize; } -.resize_bottom-left { left: -4px; bottom: -4px; cursor: nesw-resize; } -.rotationStem { position: absolute; left: 50%; top: -62px; width: 1px; height: 62px; background: #d5c7bb; pointer-events: none; } -.rotationHandle { position: absolute; top: -88px; left: calc(50% - 12px); display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 1px solid #cbb8a8; border-radius: 50%; background: white; color: #78523f; z-index: 8; cursor: grab; touch-action: none; } -.rotationHandle:active { cursor: grabbing; } -.rotationValue { position: absolute; top: -85px; left: calc(50% + 18px); color: #92877c; background: #fffefbe6; padding: 0 4px; border-radius: 3px; font-size: 10px; pointer-events: none; } -.plainFrame .rotationHandle { top: -38px; } -.plainFrame .rotationStem { top: -14px; height: 14px; } -.plainFrame .rotationValue { top: -35px; } +.plainText { padding: 4px; } +.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 2px solid #fff; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; transition: width 160ms ease, height 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; } +.centerHandle svg { opacity: 0; scale: 0.4; pointer-events: none; transition: opacity 140ms ease, scale 160ms ease; } +.node :global(.react-flow__handle).plusHandle { width: 28px; height: 28px; background: #fffefb; border: 1px solid #cbb8a8; box-shadow: 0 2px 6px #32251a0a; } +.plusHandle svg { opacity: 1; scale: 1; } +.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; } +.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 0; scale: 0.8; pointer-events: none; transition: opacity 160ms ease, scale 160ms ease; } +.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; } +.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; } +.rotatingFrame { cursor: grabbing; touch-action: none; } +.rotatingFrame textarea, .rotatingFrame .centerHandle { pointer-events: none; } +.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; } +.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; } +.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; } +.styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; } +.styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; } +.styleToolbar input[type="number"] { width: 48px; } +.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; } +.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; } +.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } +.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; } +.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; } +.branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; } +.branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; } +.branchMenu button:hover { background: #f1e6df; } +.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; } +.iconContent svg { pointer-events: none; } +.iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; } +.iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; } +.iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; } +.iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; } +.iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; } +.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; } +.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; } +.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; } .handMode .plainText { pointer-events: none; } -.handMode .resizeHandle, .handMode .rotationHandle, .handMode .rotationStem, .handMode .rotationValue, .handMode .branchButton { display: none; } +.handMode .cornerResize, .handMode .rotationHint, .handMode .centerHandle { visibility: hidden; } +.handMode .styleToolbar { display: none; } +@media (prefers-reduced-motion: reduce) { + .transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle, .centerHandle svg { transition: none; } +} + +.textDragHandle { position: absolute; top: -23px; left: 0; display: grid; place-items: center; width: 20px; height: 20px; color: #92877c; cursor: grab; opacity: 0; pointer-events: none; transition: opacity 160ms ease; } +.selectedFrame .textDragHandle { opacity: 1; pointer-events: auto; } +.handMode .textDragHandle { visibility: hidden; } +@media (prefers-reduced-motion: reduce) { .textDragHandle { transition: none; } } diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 2d7adf1..5058ce6 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -2,21 +2,25 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react"; import { - Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode, - addEdge, useReactFlow, useViewport, type Connection, + Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode, + addEdge, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition, } from "@xyflow/react"; -import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2, Upload } from "lucide-react"; +import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Type, Undo2, Upload } from "lucide-react"; import { useReducedMotion } from "motion/react"; import toolbar from "@/components/ui/toolbar.module.css"; import { IconButton } from "@/components/ui/icon-button"; import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode, type NodeDirection } from "@/lib/canvas"; import type { Board } from "@/lib/workspace"; +import { BranchMenu, type BranchRequest } from "./branch-menu"; +import { IconNode } from "./icon-node"; +import { IconPicker } from "./icon-picker"; +import type { TextStyle } from "@/lib/canvas-elements"; import { PlainTextNode } from "./plain-text-node"; import type { NodeTransform } from "@/lib/canvas-geometry"; import { MaterialNode, NodeEditing } from "./material-node"; import styles from "./canvas.module.css"; -const nodeTypes = { material: MaterialNode, plainText: PlainTextNode }; +const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode }; function CanvasEditor({ board }: { board: Board }) { const flow = useReactFlow(); @@ -32,20 +36,29 @@ function CanvasEditor({ board }: { board: Board }) { 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 [branchRequest, setBranchRequest] = useState(null); + const [iconPicker, setIconPicker] = useState(false); + const [rotating, setRotating] = useState(null); + const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null); + const pendingImage = useRef(null); + const closeBranch = useCallback(() => setBranchRequest(null), []); const imageInput = useRef(null); const projectInput = useRef(null); const firstSave = useRef(true); + useEffect(() => { + const input = imageInput.current; + const cancel = () => { pendingImage.current = null; }; + input?.addEventListener("cancel", cancel); + return () => input?.removeEventListener("cancel", cancel); + }, []); 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); @@ -65,16 +78,38 @@ function CanvasEditor({ board }: { board: Board }) { const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []); const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []); - const branch = useCallback((id: string, direction: NodeDirection) => { - try { - const document = branchCanvasNode(state.present, id, direction); - dispatch({ type: "replace", document }); - const child = document.nodes[document.nodes.length - 1]; - void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration }); - } catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); } - }, [state.present, flow, duration]); + const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { + setBranchRequest({ id, direction, point }); + setRotating(null); + }, []); const transform = useCallback((id: string, transform: NodeTransform) => dispatch({ type: "transform", id, transform }), []); - const editing = useMemo(() => ({ begin: beginEdit, edit, branch, transform }), [beginEdit, edit, branch, transform]); + const style = useCallback((id: string, style: TextStyle, fontSize?: number) => dispatch({ type: "style", id, style, fontSize }), []); + const editing = useMemo(() => ({ begin: beginEdit, edit, branch, transform, style, rotating, rotate: setRotating }), [beginEdit, edit, branch, transform, style, rotating]); + function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) { + try { + const document = branchCanvasNode(state.present, request.id, request.direction, kind, src, request.at); + dispatch({ type: "replace", document }); + const child = document.nodes.at(-1)!; + void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration }); + setBranchRequest(null); + } catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); } + } + function chooseBranch(kind: "text" | "image" | "script") { + if (!branchRequest) return; + if (kind === "image") { pendingImage.current = branchRequest; setBranchRequest(null); imageInput.current?.click(); } + else createBranch(branchRequest, kind); + } + const connectionEnd: OnConnectEnd = (event, connection) => { + const start = connectionStart.current; + connectionStart.current = null; + if (!start || connection.isValid) return; + const point = "changedTouches" in event ? event.changedTouches[0] : event; + if (!point || Math.hypot(point.clientX - start.point.x, point.clientY - start.point.y) < 5) return; + if (!document.elementFromPoint(point.clientX, point.clientY)?.closest(".react-flow__pane")) return; + if (!flow.getNode(start.id)?.selected) return; + const at = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }); + setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at }); + }; const center = () => { const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); @@ -96,7 +131,7 @@ function CanvasEditor({ board }: { board: Board }) { 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: brief.id, target: idea.id, type: "default" }, { id: crypto.randomUUID(), source: idea.id, target: script.id }, ] } }); setTool("select"); @@ -107,9 +142,9 @@ function CanvasEditor({ board }: { board: Board }) { async function readImage(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; - if (!file) return; + if (!file) { pendingImage.current = null; 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; + pendingImage.current = null; setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return; } try { const src = await new Promise((resolve, reject) => { @@ -118,8 +153,9 @@ function CanvasEditor({ board }: { board: Board }) { reader.onerror = () => reject(new Error("无法读取图片。")); reader.readAsDataURL(file); }); - addNode("image", src); - } catch { setError("无法读取图片,请重新选择。"); } + const request = pendingImage.current; pendingImage.current = null; + if (request) createBranch(request, "image", src); else addNode("image", src); + } catch { pendingImage.current = null; setError("无法读取图片,请重新选择。"); } } async function importProject(event: ChangeEvent) { const file = event.target.files?.[0]; @@ -167,7 +203,13 @@ function CanvasEditor({ board }: { board: Board }) { nodes={nodes} edges={edges} nodeTypes={nodeTypes} onNodesChange={(changes) => dispatch({ type: "nodes", changes })} onEdgesChange={(changes) => dispatch({ type: "edges", changes })} - onNodeDragStart={beginEdit} onConnect={connect} connectionMode={ConnectionMode.Loose} + connectionLineType={ConnectionLineType.Bezier} + onNodeDragStart={() => { beginEdit(); closeBranch(); setRotating(null); }} onConnect={connect} connectionMode={ConnectionMode.Loose} + connectOnClick={false} connectionDragThreshold={5} + onConnectStart={(event, params) => { closeBranch(); const point = "touches" in event ? event.touches[0] : event; if (params.nodeId && params.handleId && point) connectionStart.current = { id: params.nodeId, direction: params.handleId as NodeDirection, point: { x: point.clientX, y: point.clientY } }; }} + onConnectEnd={connectionEnd} + onPaneClick={() => { closeBranch(); setRotating(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); }} + onMoveStart={closeBranch} onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })} defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4} panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]} @@ -191,7 +233,6 @@ function CanvasEditor({ board }: { board: Board }) {