feat(canvas): refine connections, text styles and icon editing
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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)
|
||||
|
||||
- 新增业务元素类继承,卡片与纯文本共享基础变换;纯文本不提供连接点或四向分支。
|
||||
|
||||
@@ -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. 接口上线后前端接入:优先读取服务端快照、提供本地旧草稿迁移、串行自动保存与冲突处理,并验证跨设备恢复;在此之前不能将前端本地持久化称为数据库保存。
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
const focus = document.activeElement as HTMLElement | null;
|
||||
ref.current?.querySelector<HTMLButtonElement>("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(<AnimatePresence>{request && <motion.div ref={ref} key={request.id + request.direction} initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : 4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: reduce ? 1 : 0.96 }} transition={{ duration: reduce ? 0 : 0.14 }} role="dialog" aria-label="选择连接节点类型" className={styles.branchMenu} style={{ left: Math.max(12, Math.min(request.point.x + 12, window.innerWidth - 222)), top: Math.max(12, Math.min(request.point.y + 12, window.innerHeight - 208)) }}
|
||||
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const buttons = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>("button") || []); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus(); } }}>
|
||||
<header><span>添加连接节点</span><button aria-label="关闭节点菜单" onClick={close}><X size={14} /></button></header>
|
||||
{([["text", StickyNote, "文本"], ["image", ImagePlus, "图片"], ["script", FileText, "脚本"]] as const).map(([kind, Icon, label]) => <button data-choice key={kind} onClick={() => choose(kind)}><Icon size={17} /><span>{label}</span></button>)}
|
||||
</motion.div>}</AnimatePresence>, document.body);
|
||||
}
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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<CanvasNode>();
|
||||
@@ -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<BranchRequest | null>(null);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
const [rotating, setRotating] = useState<string | null>(null);
|
||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||
const pendingImage = useRef<BranchRequest | null>(null);
|
||||
const closeBranch = useCallback(() => setBranchRequest(null), []);
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const projectInput = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
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<string>((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<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -167,7 +203,13 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<ReactFlow<CanvasNode> 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 }) {
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
|
||||
</div>
|
||||
<span className={styles.saveStatus} role="status">{saved ? "保存在此浏览器" : "正在保存到此浏览器…"}</span>
|
||||
</div>
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
|
||||
@@ -199,8 +240,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Type} label="添加纯文本" onClick={() => addNode("plain-text")} />
|
||||
<IconButton icon={StickyNote} label="添加文本卡片" onClick={() => addNode("text")} />
|
||||
<IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} />
|
||||
<IconButton icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageInput.current?.click(); }} />
|
||||
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
|
||||
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
|
||||
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
|
||||
@@ -213,12 +255,20 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<p>拖动空白处平移,拖动节点移动位置。</p>
|
||||
<p>触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。</p>
|
||||
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
|
||||
<p>选中后拖动边角调整大小,拖动顶部旋转柄旋转;Shift 吸附 15°,双击复位。</p>
|
||||
<p>纯文本缩放会同步改变字号,不显示卡片边框、连接点和四向加号。</p>
|
||||
<p>拖动卡片连接点创建连线;Delete 删除所选内容。</p>
|
||||
<p>拖动右下角调整大小;在样式栏开启旋转,拖动元素旋转,点击外部退出。Shift 吸附 15°。</p>
|
||||
<p>纯文本可独立调整宽高,文字随宽度换行,字号在样式栏设置;不显示卡片连接点或四向加号。</p>
|
||||
<p>拖动四边中心点或选中后的加号创建贝塞尔连线;点击加号选择新节点类型。Delete 删除所选内容。</p>
|
||||
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
|
||||
<p>当前编辑保存在此浏览器,可导出 JSON 备份。</p>
|
||||
<p>可通过右上角导出 JSON 备份。</p>
|
||||
</div>
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
<IconPicker open={iconPicker} close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
const node = makeCanvasNode("icon", center());
|
||||
node.data = { ...node.data, icon: name, title: label };
|
||||
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
setTool("select");
|
||||
}} />
|
||||
{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 />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ArrowRight, BookOpen, Briefcase, Calendar, Camera, Check, Circle, Cloud, Code, Coffee, Compass, Diamond, FileText, Flag, Flame, Folder, Globe, Heart, Image, Layers, Leaf, Lightbulb, Link, MapPin, MessageCircle, Moon, Music, Palette, Play, Rocket, Search, Settings, Shield, Smile, Sparkles, Square, Star, Sun, Target, ThumbsUp, Triangle, Users, Video, WandSparkles, Zap } from "lucide-react";
|
||||
export const iconCatalog = [
|
||||
{ name: "Star", label: "星星", Icon: Star }, { name: "Heart", label: "爱心", Icon: Heart }, { name: "Lightbulb", label: "灵感", Icon: Lightbulb },
|
||||
{ name: "Sparkles", label: "闪光", Icon: Sparkles }, { name: "Zap", label: "闪电", Icon: Zap }, { name: "Flame", label: "火焰", Icon: Flame },
|
||||
{ name: "Rocket", label: "火箭", Icon: Rocket }, { name: "Target", label: "目标", Icon: Target }, { name: "Flag", label: "旗帜", Icon: Flag },
|
||||
{ name: "Check", label: "完成", Icon: Check }, { name: "ThumbsUp", label: "点赞", Icon: ThumbsUp }, { name: "Smile", label: "笑脸", Icon: Smile },
|
||||
{ name: "Coffee", label: "咖啡", Icon: Coffee }, { name: "Leaf", label: "叶子", Icon: Leaf }, { name: "Sun", label: "太阳", Icon: Sun },
|
||||
{ name: "Moon", label: "月亮", Icon: Moon }, { name: "Cloud", label: "云", Icon: Cloud }, { name: "Globe", label: "地球", Icon: Globe },
|
||||
{ name: "MapPin", label: "位置", Icon: MapPin }, { name: "Compass", label: "指南针", Icon: Compass }, { name: "ArrowRight", label: "箭头", Icon: ArrowRight },
|
||||
{ name: "BookOpen", label: "书本", Icon: BookOpen }, { name: "FileText", label: "文件", Icon: FileText }, { name: "Folder", label: "文件夹", Icon: Folder },
|
||||
{ name: "Calendar", label: "日历", Icon: Calendar }, { name: "Briefcase", label: "公文包", Icon: Briefcase }, { name: "Users", label: "用户", Icon: Users },
|
||||
{ name: "MessageCircle", label: "对话", Icon: MessageCircle }, { name: "Link", label: "链接", Icon: Link }, { name: "Code", label: "代码", Icon: Code },
|
||||
{ name: "Settings", label: "设置", Icon: Settings }, { name: "Shield", label: "盾牌", Icon: Shield }, { name: "Search", label: "搜索", Icon: Search },
|
||||
{ name: "Image", label: "图片", Icon: Image }, { name: "Camera", label: "相机", Icon: Camera }, { name: "Video", label: "视频", Icon: Video },
|
||||
{ name: "Music", label: "音乐", Icon: Music }, { name: "Play", label: "播放", Icon: Play }, { name: "Palette", label: "调色板", Icon: Palette },
|
||||
{ name: "WandSparkles", label: "魔法", Icon: WandSparkles }, { name: "Layers", label: "图层", Icon: Layers }, { name: "Circle", label: "圆形", Icon: Circle },
|
||||
{ name: "Square", label: "方形", Icon: Square }, { name: "Triangle", label: "三角形", Icon: Triangle }, { name: "Diamond", label: "菱形", Icon: Diamond },
|
||||
];
|
||||
export function canvasIcon(name?: string) { return iconCatalog.find((icon) => icon.name === name)?.Icon || Star; }
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
import { canvasIcon } from "./icon-catalog";
|
||||
import styles from "./canvas.module.css";
|
||||
export function IconNode(props: NodeProps<CanvasNode>) {
|
||||
const Icon = canvasIcon(props.data.icon);
|
||||
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><Icon aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { iconCatalog } from "./icon-catalog";
|
||||
import styles from "./canvas.module.css";
|
||||
export function IconPicker({ open, close, choose }: { open: boolean; close: () => void; choose: (name: string, label: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
useEffect(() => { if (open) search.current?.focus(); }, [open]);
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(<AnimatePresence>{open && <motion.aside key="icons" initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: reduce ? 0 : -12 }} transition={{ duration: reduce ? 0 : 0.16 }} className={styles.iconPicker} role="dialog" aria-label="添加图标" onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") close(); }}>
|
||||
<header><strong>图标</strong><button aria-label="关闭图标面板" onClick={close}><X size={16} /></button></header>
|
||||
<input ref={search} type="search" aria-label="搜索图标" placeholder="搜索图标…" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<div className={styles.iconGrid}>{iconCatalog.filter((icon) => (icon.name + icon.label).toLowerCase().includes(query.toLowerCase())).map(({ name, label, Icon }) => <button key={name} title={label} aria-label={`添加${label}图标`} onClick={() => choose(name, label)}><Icon size={24} /></button>)}</div>
|
||||
{!iconCatalog.some((icon) => (icon.name + icon.label).toLowerCase().includes(query.toLowerCase())) && <p>未找到图标</p>}
|
||||
<footer>Lucide · {iconCatalog.length} 个图标</footer>
|
||||
</motion.aside>}</AnimatePresence>, document.body);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { useContext, useRef } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { FileText, Image as ImageIcon, Plus, Type } from "lucide-react";
|
||||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import { nodeDirections, type CanvasNode, type NodeDirection } from "@/lib/canvas";
|
||||
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
|
||||
import { textAppearance } from "./text-appearance";
|
||||
import styles from "./canvas.module.css";
|
||||
import { TransformableNode, NodeEditing } from "./transformable-node";
|
||||
export { NodeEditing } from "./transformable-node";
|
||||
@@ -15,26 +16,29 @@ const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧
|
||||
export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const pointerStart = useRef({ x: 0, y: 0 });
|
||||
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
||||
return (
|
||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><span>{data.title}</span></header>
|
||||
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
|
||||
{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" ? "写下你的脚本…" : "留住一个想法…"}
|
||||
<textarea style={textAppearance(data)} className={`${styles.cardText} 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} className={styles.legacyHandle} isConnectable={false} />
|
||||
{nodeDirections.map((direction) => <Handle key={direction} id={direction} type="source" position={positions[direction]}
|
||||
style={direction === "left" || direction === "right" ? { top: "calc(50% + 20px)" } : { left: "calc(50% + 20px)" }} />)}
|
||||
{selected && elementCapabilities(data.kind).branchable && nodeDirections.map((direction) => (
|
||||
<button key={direction} type="button" className={`${styles.branchButton} ${styles["branch_" + direction]} nodrag nopan`}
|
||||
aria-label={`在${labels[direction]}添加节点`} title={`在${labels[direction]}添加节点并连线`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); editing.branch(id, direction); }}>
|
||||
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
className={`${styles.centerHandle} ${selected ? styles.plusHandle : ""}`}
|
||||
aria-label={selected ? `在${labels[direction]}添加或连接节点` : `从${labels[direction]}连接节点`} role="button" tabIndex={selected ? 0 : -1}
|
||||
onPointerDown={(event) => { pointerStart.current = { x: event.clientX, y: event.clientY }; }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (selected && elementCapabilities(data.kind).branchable && Math.hypot(event.clientX - pointerStart.current.x, event.clientY - pointerStart.current.y) < 5)
|
||||
editing.branch(id, direction, { x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
onKeyDown={(event) => { if (selected && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); editing.branch(id, direction, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); } }}>
|
||||
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</Handle>)}
|
||||
</TransformableNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { NodeToolbar, Position, type NodeProps } from "@xyflow/react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, RotateCw, Underline } from "lucide-react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import type { TextStyle } from "@/lib/canvas-elements";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
const reduce = useReducedMotion();
|
||||
const editing = useContext(NodeEditing);
|
||||
const style = data.textStyle || {};
|
||||
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
|
||||
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
|
||||
return <NodeToolbar isVisible={true} position={Position.Top} offset={34}>
|
||||
<AnimatePresence>{selected && <motion.div initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
|
||||
onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
|
||||
{data.kind !== "image" && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
|
||||
{text && <>
|
||||
<select aria-label="字体" value={style.fontFamily || "sans"} onChange={(event) => patch({ fontFamily: event.target.value as TextStyle["fontFamily"] })}><option value="sans">正文</option><option value="serif">衬线</option><option value="mono">等宽</option></select>
|
||||
<input aria-label="字号" title="字号" type="number" min={8} max={160} value={data.fontSize || (data.kind === "plain-text" ? 24 : 13)} onChange={(event) => { const size = Number(event.target.value); if (size >= 8 && size <= 160) patch({}, size); }} />
|
||||
{([["bold", Bold, "加粗"], ["italic", Italic, "斜体"], ["underline", Underline, "下划线"]] as const).map(([key, Icon, label]) => <button key={key} aria-label={label} aria-pressed={!!style[key]} title={label} onClick={() => patch({ [key]: !style[key] })}><Icon size={16} /></button>)}
|
||||
{([["left", AlignLeft, "左对齐"], ["center", AlignCenter, "居中"], ["right", AlignRight, "右对齐"]] as const).map(([align, Icon, label]) => <button key={align} aria-label={label} aria-pressed={(style.align || "left") === align} title={label} onClick={() => patch({ align })}><Icon size={16} /></button>)}
|
||||
<select aria-label="行距" title="行距" value={style.lineHeight || 1.5} onChange={(event) => patch({ lineHeight: Number(event.target.value) })}><option value={1}>1</option><option value={1.25}>1.25</option><option value={1.5}>1.5</option><option value={1.8}>1.8</option><option value={2}>2</option></select>
|
||||
</>}
|
||||
{data.kind !== "image" && <label className={styles.colorPicker} title="背景色"><span style={{ background: style.background && style.background !== "transparent" ? style.background : "#ffffff" }} /><input aria-label="背景色" type="color" value={style.background && style.background !== "transparent" ? style.background : "#ffffff"} onChange={(event) => patch({ background: event.target.value })} /></label>}
|
||||
{data.kind !== "image" && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
|
||||
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
|
||||
</motion.div>}</AnimatePresence>
|
||||
</NodeToolbar>;
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { useContext } from "react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { NodeEditing, TransformableNode } from "./transformable-node";
|
||||
import { textAppearance } from "./text-appearance";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function PlainTextNode(props: NodeProps<CanvasNode>) {
|
||||
const editing = useContext(NodeEditing);
|
||||
return (
|
||||
<TransformableNode {...props}>
|
||||
<span className={styles.textDragHandle} title="拖动移动纯文本" aria-label="拖动移动纯文本"><GripVertical size={14} /></span>
|
||||
<textarea className={`${styles.plainText} nodrag nowheel`} aria-label="纯文本" placeholder="写下文字…"
|
||||
style={{ fontSize: props.data.fontSize || 24 }} value={props.data.text} maxLength={100000}
|
||||
style={textAppearance(props.data)} value={props.data.text} maxLength={100000}
|
||||
onFocus={editing.begin} onChange={(event) => editing.edit(props.id, event.target.value)} />
|
||||
</TransformableNode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CanvasNodeData } from "@/lib/canvas-elements";
|
||||
export function textAppearance(data: CanvasNodeData): CSSProperties {
|
||||
const style = data.textStyle || {};
|
||||
return { fontSize: data.fontSize || (data.kind === "plain-text" ? 24 : 13), color: style.color || "#37352f", fontWeight: style.bold ? 700 : 400, fontStyle: style.italic ? "italic" : "normal", textDecoration: style.underline ? "underline" : "none", textAlign: style.align || "left", lineHeight: style.lineHeight || 1.5,
|
||||
fontFamily: style.fontFamily === "serif" ? 'Georgia, "Songti SC", serif' : style.fontFamily === "mono" ? 'ui-monospace, "SFMono-Regular", monospace' : "inherit", backgroundColor: style.background || "transparent" };
|
||||
}
|
||||
@@ -2,32 +2,46 @@
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useReactFlow, useUpdateNodeInternals, type NodeProps, type XYPosition } from "@xyflow/react";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import { Grip, RotateCw } from "lucide-react";
|
||||
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
|
||||
import { normalizeRotation, resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform } from "@/lib/canvas-geometry";
|
||||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const NodeEditing = createContext({
|
||||
begin: () => {}, edit: (_id: string, _text: string) => {},
|
||||
branch: (_id: string, _direction: NodeDirection) => {},
|
||||
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
|
||||
transform: (_id: string, _transform: NodeTransform) => {},
|
||||
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
|
||||
rotating: null as string | null, rotate: (_id: string | null) => {},
|
||||
});
|
||||
const resizeDirections: ResizeDirection[] = ["top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left", "top-left"];
|
||||
const resizeLabels = { top: "上边", right: "右边", bottom: "下边", left: "左边", "top-right": "右上角", "bottom-right": "右下角", "bottom-left": "左下角", "top-left": "左上角" };
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate" };
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: "bottom-right" | "rotate" };
|
||||
|
||||
export function TransformableNode({ id, data, selected, width, height, children, className = "" }: NodeProps<CanvasNode> & { children: ReactNode; className?: string }) {
|
||||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string }) {
|
||||
const { id, data, selected, width, height, children, className = "" } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const update = useUpdateNodeInternals();
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const rotation = data.rotation || 0;
|
||||
const plain = data.kind === "plain-text";
|
||||
const plain = data.kind === "plain-text" || data.kind === "icon";
|
||||
const capabilities = elementCapabilities(data.kind);
|
||||
useEffect(() => { update(id); }, [id, rotation, width, height, update]);
|
||||
const rotating = capabilities.rotatable && editing.rotating === id && selected;
|
||||
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
|
||||
useEffect(() => {
|
||||
if (!rotating) return;
|
||||
const outside = (event: globalThis.PointerEvent) => {
|
||||
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"]')) editing.rotate(null);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") editing.rotate(null); };
|
||||
window.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
|
||||
}, [rotating, id, editing]);
|
||||
|
||||
function start(event: PointerEvent<HTMLButtonElement>, direction: Gesture["direction"]) {
|
||||
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const node = flow.getNode(id);
|
||||
@@ -36,7 +50,7 @@ export function TransformableNode({ id, data, selected, width, height, children,
|
||||
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<HTMLButtonElement>) {
|
||||
function move(event: PointerEvent<HTMLElement>) {
|
||||
if (!gesture.current) return;
|
||||
event.stopPropagation();
|
||||
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
@@ -45,38 +59,24 @@ export function TransformableNode({ id, data, selected, width, height, children,
|
||||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }));
|
||||
}
|
||||
function finish(event: PointerEvent<HTMLButtonElement>) {
|
||||
event.stopPropagation();
|
||||
gesture.current = null;
|
||||
function finish(event: PointerEvent<HTMLElement>) {
|
||||
event.stopPropagation(); gesture.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
function rotateBy(step: number) {
|
||||
editing.begin();
|
||||
editing.transform(id, { rotation: normalizeRotation(rotation + step) });
|
||||
}
|
||||
return (
|
||||
<div className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${className}`}
|
||||
style={{ width: width || (plain ? 240 : 260), height: height || (plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
|
||||
return <>
|
||||
<NodeStyleToolbar {...props} />
|
||||
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
|
||||
onPointerDownCapture={rotating ? (event) => start(event, "rotate") : undefined}
|
||||
onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}
|
||||
onLostPointerCapture={() => { gesture.current = null; }}
|
||||
style={{ width: width || (data.kind === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
|
||||
{children}
|
||||
{selected && <>
|
||||
{capabilities.resizable && resizeDirections.map((direction) => <button key={direction} type="button"
|
||||
className={`${styles.resizeHandle} ${styles["resize_" + direction]} nodrag nopan`}
|
||||
aria-label={`缩放${resizeLabels[direction]}`} title={`拖动${resizeLabels[direction]}调整大小`}
|
||||
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={() => { gesture.current = null; }} />)}
|
||||
{capabilities.rotatable && <>
|
||||
<span className={styles.rotationStem} />
|
||||
<button type="button" className={`${styles.rotationHandle} nodrag nopan`} aria-label="旋转节点" title="拖动旋转;Shift 吸附 15°;双击复位"
|
||||
onPointerDown={(event) => start(event, "rotate")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={() => { gesture.current = null; }}
|
||||
onDoubleClick={(event) => { event.stopPropagation(); editing.begin(); editing.transform(id, { rotation: 0 }); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); event.stopPropagation(); rotateBy((event.key === "ArrowLeft" ? -1 : 1) * (event.shiftKey ? 15 : 1)); }
|
||||
if (event.key === "Home") { event.preventDefault(); event.stopPropagation(); editing.begin(); editing.transform(id, { rotation: 0 }); }
|
||||
}}>
|
||||
<RotateCw size={14} strokeWidth={1.6} />
|
||||
</button>
|
||||
<span className={styles.rotationValue}>{Math.round(rotation)}°</span>
|
||||
</>}
|
||||
</>}
|
||||
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={selected && !rotating ? 0 : -1}
|
||||
aria-hidden={!selected || rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
|
||||
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}>
|
||||
<Grip size={14} />
|
||||
</button>}
|
||||
{rotating && <span className={styles.rotationHint}><RotateCw size={12} /> {Math.round(rotation)}° · 拖动旋转,点击外部退出</span>}
|
||||
</div>
|
||||
);
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Node, XYPosition } from "@xyflow/react";
|
||||
|
||||
export type ElementKind = "text" | "image" | "script" | "plain-text";
|
||||
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon";
|
||||
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
|
||||
export type CanvasNodeData = {
|
||||
kind: ElementKind;
|
||||
title: string;
|
||||
@@ -8,8 +9,10 @@ export type CanvasNodeData = {
|
||||
src?: string;
|
||||
rotation?: number;
|
||||
fontSize?: number;
|
||||
textStyle?: TextStyle;
|
||||
icon?: string;
|
||||
};
|
||||
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText">;
|
||||
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon">;
|
||||
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
|
||||
|
||||
/** Business inheritance only: React views reuse these capabilities by composition. */
|
||||
@@ -47,7 +50,14 @@ export class ImageCardElement extends CardElement { readonly kind = "image"; rea
|
||||
export class ScriptCardElement extends CardElement { readonly kind = "script"; readonly title = "脚本"; }
|
||||
export class PlainTextElement extends CanvasElement { readonly kind = "plain-text"; readonly title = "纯文本"; }
|
||||
|
||||
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement };
|
||||
export class IconElement extends CanvasElement {
|
||||
readonly kind = "icon"; readonly title = "图标";
|
||||
protected override readonly nodeType = "icon" as const;
|
||||
protected override readonly size = { width: 80, height: 80 };
|
||||
override toNode(): CanvasNode { const node = super.toNode(); return { ...node, data: { ...node.data, icon: "Star" } }; }
|
||||
}
|
||||
|
||||
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement };
|
||||
export function elementCapabilities(kind: ElementKind) { return elementClasses[kind].capabilities; }
|
||||
export function createCanvasElement(kind: ElementKind, position: XYPosition, src?: string): CanvasNode {
|
||||
return new elementClasses[kind](position, "", src).toNode();
|
||||
|
||||
@@ -5,8 +5,8 @@ export type ResizeDirection = "top" | "right" | "bottom" | "left" | "top-left" |
|
||||
export type NodeTransform = { position?: XYPosition; width?: number; height?: number; rotation?: number; fontSize?: number };
|
||||
export function nodeSize(node: CanvasNode) {
|
||||
return {
|
||||
width: node.width || Number(node.style?.width) || node.measured?.width || (node.data.kind === "plain-text" ? 240 : 260),
|
||||
height: node.height || Number(node.style?.height) || node.measured?.height || (node.data.kind === "plain-text" ? 72 : 200),
|
||||
width: node.width || Number(node.style?.width) || node.measured?.width || (node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 240 : 260),
|
||||
height: node.height || Number(node.style?.height) || node.measured?.height || (node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 72 : 200),
|
||||
};
|
||||
}
|
||||
export function normalizeRotation(angle: number) { return ((angle % 360) + 360) % 360; }
|
||||
@@ -33,23 +33,21 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
|
||||
const horizontal = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
|
||||
const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
|
||||
const plain = node.data.kind === "plain-text";
|
||||
const icon = node.data.kind === "icon";
|
||||
let nextWidth = Math.max(plain ? 60 : 140, Math.min(3000, width + horizontal * local.x));
|
||||
let nextHeight = Math.max(plain ? 24 : 100, Math.min(3000, height + vertical * local.y));
|
||||
let fontSize: number | undefined;
|
||||
if (plain) {
|
||||
if (icon) {
|
||||
const sx = 1 + horizontal * local.x / width;
|
||||
const sy = 1 + vertical * local.y / height;
|
||||
let scale = !horizontal ? sy : !vertical ? sx : Math.abs(sx - 1) > Math.abs(sy - 1) ? sx : sy;
|
||||
const font = node.data.fontSize || 24;
|
||||
scale = Math.max(60 / width, 24 / height, 8 / font, Math.min(scale, 3000 / width, 3000 / height, 160 / font));
|
||||
scale = Math.max(24 / width, 24 / height, Math.min(scale, 3000 / width, 3000 / height));
|
||||
nextWidth = width * scale;
|
||||
nextHeight = height * scale;
|
||||
fontSize = font * scale;
|
||||
}
|
||||
const center = nodeCenter(node);
|
||||
const shift = rotateVector({ x: horizontal * (nextWidth - width) / 2, y: vertical * (nextHeight - height) / 2 }, rotation);
|
||||
return {
|
||||
position: { x: center.x + shift.x - nextWidth / 2, y: center.y + shift.y - nextHeight / 2 },
|
||||
width: nextWidth, height: nextHeight, ...(fontSize !== undefined ? { fontSize } : {}),
|
||||
width: nextWidth, height: nextHeight,
|
||||
};
|
||||
}
|
||||
|
||||
+27
-7
@@ -1,5 +1,5 @@
|
||||
import { applyEdgeChanges, applyNodeChanges } from "@xyflow/react";
|
||||
import type { Edge, EdgeChange, Node, NodeChange, Viewport, XYPosition } from "@xyflow/react";
|
||||
import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from "@xyflow/react";
|
||||
|
||||
import { createCanvasElement, elementCapabilities, type CanvasNode } from "./canvas-elements.ts";
|
||||
import { nodeSize, rotateVector, nodeCenter, type NodeTransform } from "./canvas-geometry.ts";
|
||||
@@ -15,6 +15,7 @@ export type CanvasAction =
|
||||
| { type: "edges"; changes: EdgeChange[] }
|
||||
| { type: "edit"; id: string; text: string }
|
||||
| { type: "transform"; id: string; transform: NodeTransform }
|
||||
| { type: "style"; id: string; style: import("./canvas-elements.ts").TextStyle; fontSize?: number }
|
||||
| { type: "viewport"; viewport: Viewport }
|
||||
| { type: "undo" | "redo" };
|
||||
|
||||
@@ -48,6 +49,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
? 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 "style": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, textStyle: { ...node.data.textStyle, ...action.style }, ...(action.fontSize !== undefined ? { fontSize: action.fontSize } : {}) } } : node) });
|
||||
case "transform": {
|
||||
const patch = action.transform;
|
||||
return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id !== action.id ? node : {
|
||||
@@ -76,11 +78,11 @@ export function makeCanvasNode(kind: CanvasNode["data"]["kind"], position: XYPos
|
||||
return createCanvasElement(kind, position, src);
|
||||
}
|
||||
|
||||
export function branchCanvasNode(document: CanvasDocument, id: string, direction: NodeDirection): CanvasDocument {
|
||||
export function branchCanvasNode(document: CanvasDocument, id: string, direction: NodeDirection, kind: "text" | "script" | "image" = "text", src?: string, at?: XYPosition): CanvasDocument {
|
||||
const source = document.nodes.find((node) => node.id === id);
|
||||
if (!source) throw new Error("节点不存在。");
|
||||
if (document.nodes.length >= 500) throw new Error("当前图谱已达到 500 个节点。");
|
||||
if (!elementCapabilities(source.data.kind).branchable) throw new Error("纯文本不支持分支节点。");
|
||||
if (!elementCapabilities(source.data.kind).branchable) throw new Error("纯文本和图标不支持分支节点。");
|
||||
const { width, height } = nodeSize(source);
|
||||
const gap = 80;
|
||||
const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } };
|
||||
@@ -96,7 +98,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
|
||||
if (direction === "left" || direction === "right") position.y += 240;
|
||||
else position.x += 300;
|
||||
}
|
||||
const child = { ...makeCanvasNode("text", position), selected: true };
|
||||
const child = { ...makeCanvasNode(kind, at || position, src), selected: true };
|
||||
const opposite: Record<NodeDirection, NodeDirection> = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
||||
return {
|
||||
...document,
|
||||
@@ -111,6 +113,21 @@ function validImage(src: unknown): src is string {
|
||||
try { const url = new URL(src); return url.protocol === "https:" && !url.username && !url.password && src.length < 2048; }
|
||||
catch { return false; }
|
||||
}
|
||||
export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").TextStyle | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("文本样式无效。");
|
||||
const style = value as Record<string, unknown>;
|
||||
const valid = Object.entries(style).every(([key, entry]) => {
|
||||
if (["color", "background"].includes(key)) return typeof entry === "string" && (/^#[0-9a-f]{6}$/i.test(entry) || (key === "background" && entry === "transparent"));
|
||||
if (["bold", "italic", "underline"].includes(key)) return typeof entry === "boolean";
|
||||
if (key === "align") return typeof entry === "string" && ["left", "center", "right"].includes(entry);
|
||||
if (key === "fontFamily") return typeof entry === "string" && ["sans", "serif", "mono"].includes(entry);
|
||||
if (key === "lineHeight") return typeof entry === "number" && Number.isFinite(entry) && entry >= 1 && entry <= 3;
|
||||
return false;
|
||||
});
|
||||
if (!valid) throw new Error("文本样式无效。");
|
||||
return { ...style };
|
||||
}
|
||||
export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (raw.length > 5_000_000) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
const value = JSON.parse(raw);
|
||||
@@ -120,7 +137,7 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
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", "plain-text"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
|
||||
!node.data || !["text", "image", "script", "plain-text", "icon"].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("图谱节点格式无效。");
|
||||
const width = node.width ?? node.style?.width;
|
||||
@@ -130,9 +147,12 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
(node.data.rotation !== undefined && (!Number.isFinite(node.data.rotation) || node.data.rotation < 0 || node.data.rotation >= 360)) ||
|
||||
(node.data.fontSize !== undefined && (!Number.isFinite(node.data.fontSize) || node.data.fontSize < 8 || node.data.fontSize > 160)))
|
||||
throw new Error("节点大小或旋转角度无效。");
|
||||
if (node.data.kind === "icon" && (typeof node.data.icon !== "string" || !/^[A-Z][A-Za-z0-9]{0,63}$/.test(node.data.icon))) throw new Error("图标标识无效。");
|
||||
const textStyle = decodeTextStyle(node.data.textStyle);
|
||||
ids.add(node.id);
|
||||
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : "material",
|
||||
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : node.data.kind === "icon" ? "icon" : "material",
|
||||
...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), position: { x: node.position.x, y: node.position.y }, data: {
|
||||
...(textStyle ? { textStyle } : {}), ...(node.data.kind === "icon" ? { icon: node.data.icon } : {}),
|
||||
kind: node.data.kind, title: node.data.title, text: node.data.text,
|
||||
rotation: node.data.rotation || 0, fontSize: node.data.fontSize || (node.data.kind === "plain-text" ? 24 : 13), ...(node.data.kind === "image" ? { src: node.data.src } : {}),
|
||||
} };
|
||||
@@ -143,7 +163,7 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
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("图谱连线格式无效。");
|
||||
if (!elementCapabilities(nodeKinds.get(edge.source)!).connectable || !elementCapabilities(nodeKinds.get(edge.target)!).connectable)
|
||||
throw new Error("纯文本不支持节点连线。");
|
||||
throw new Error("纯文本和图标不支持节点连线。");
|
||||
if ([edge.sourceHandle, edge.targetHandle].some((handle) => handle != null && !nodeDirections.includes(handle as NodeDirection)))
|
||||
throw new Error("图谱连接方向无效。");
|
||||
edgeIds.add(edge.id);
|
||||
|
||||
@@ -49,12 +49,16 @@ test("all eight handles resize cards while retaining the opposite local anchor,
|
||||
}
|
||||
});
|
||||
|
||||
test("plain text resizing keeps aspect ratio and scales the font", () => {
|
||||
test("plain text width and height resize independently without changing the font", () => {
|
||||
const node = new PlainTextElement({ x: 0, y: 0 }).toNode();
|
||||
const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 });
|
||||
close(patch.width, 480); close(patch.height, 144); close(patch.fontSize, 48);
|
||||
close(patch.width, 480); close(patch.height, 144); assert.equal(patch.fontSize, undefined);
|
||||
const widthOnly = resizeFromPointer(node, "bottom-right", { x: 120, y: 0 });
|
||||
close(widthOnly.width, 360); close(widthOnly.height, 72);
|
||||
const heightOnly = resizeFromPointer(node, "bottom-right", { x: 0, y: 60 });
|
||||
close(heightOnly.width, 240); close(heightOnly.height, 132);
|
||||
const min = resizeFromPointer(node, "bottom-right", { x: -10000, y: -10000 });
|
||||
assert(min.width >= 60); assert(min.height >= 24); assert(min.fontSize >= 8);
|
||||
assert(min.width >= 60); assert(min.height >= 24); assert.equal(min.fontSize, undefined);
|
||||
});
|
||||
|
||||
test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => {
|
||||
@@ -73,8 +77,8 @@ test("geometry and plain-text type survive save/restore and transforms undo as o
|
||||
state = canvasReducer(state, { type: "checkpoint" });
|
||||
state = canvasReducer(state, { type: "transform", id: node.id, transform: { ...resizeFromPointer(node, "right", { x: 120, y: 0 }), rotation: 45 } });
|
||||
const restored = decodeCanvas(JSON.stringify(state.present)).nodes[0];
|
||||
assert.equal(restored.type, "plainText"); close(restored.width, 360); close(restored.height, 108);
|
||||
close(restored.data.rotation, 45); close(restored.data.fontSize, 36);
|
||||
assert.equal(restored.type, "plainText"); close(restored.width, 360); close(restored.height, 72);
|
||||
close(restored.data.rotation, 45); close(restored.data.fontSize, 24);
|
||||
const undo = canvasReducer(state, { type: "undo" });
|
||||
assert.deepEqual(nodeSize(undo.present.nodes[0]), { width: 240, height: 72 });
|
||||
close(undo.present.nodes[0].data.rotation, 0);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
|
||||
import { CanvasElement, CardElement, IconElement } from "../src/lib/canvas-elements.ts";
|
||||
import { resizeFromPointer } from "../src/lib/canvas-geometry.ts";
|
||||
|
||||
function seeded() { return { ...emptyCanvas(), nodes: [makeCanvasNode("text", { x: 100, y: 200 })] }; }
|
||||
|
||||
test("branch type selection creates the chosen text, script or uploaded image and keeps the link in one undo step", () => {
|
||||
for (const kind of ["text", "script", "image"]) {
|
||||
const original = seeded();
|
||||
const src = kind === "image" ? "data:image/png;base64,YQ==" : undefined;
|
||||
const result = branchCanvasNode(original, original.nodes[0].id, "bottom", kind, src);
|
||||
assert.equal(result.nodes[1].data.kind, kind);
|
||||
assert.equal(result.nodes[1].data.src, src);
|
||||
assert.equal(result.edges[0].sourceHandle, "bottom");
|
||||
assert.equal(result.edges[0].targetHandle, "top");
|
||||
const restored = decodeCanvas(JSON.stringify(result));
|
||||
assert.equal(restored.nodes[1].data.kind, kind);
|
||||
const history = canvasReducer({ past: [], present: original, future: [] }, { type: "replace", document: result });
|
||||
assert.deepEqual(canvasReducer(history, { type: "undo" }).present, original);
|
||||
}
|
||||
});
|
||||
|
||||
test("a connection released on the canvas creates the chosen node at the release point", () => {
|
||||
const original = seeded();
|
||||
const at = { x: -300, y: 700 };
|
||||
const result = branchCanvasNode(original, original.nodes[0].id, "left", "script", undefined, at);
|
||||
assert.deepEqual(result.nodes[1].position, at);
|
||||
assert.equal(result.edges.length, 1);
|
||||
});
|
||||
|
||||
test("all text styles and font size survive serialization, undo and redo", () => {
|
||||
const original = seeded();
|
||||
const style = { color: "#123456", background: "#fedcba", bold: true, italic: true, underline: true, align: "right", fontFamily: "serif", lineHeight: 1.8 };
|
||||
const edited = canvasReducer({ past: [], present: original, future: [] }, { type: "style", id: original.nodes[0].id, style, fontSize: 32 });
|
||||
const restored = decodeCanvas(JSON.stringify(edited.present));
|
||||
assert.deepEqual(restored.nodes[0].data.textStyle, style);
|
||||
assert.equal(restored.nodes[0].data.fontSize, 32);
|
||||
const undo = canvasReducer(edited, { type: "undo" });
|
||||
assert.equal(undo.present.nodes[0].data.textStyle, undefined);
|
||||
assert.equal(canvasReducer(undo, { type: "redo" }).present.nodes[0].data.textStyle.bold, true);
|
||||
const cleared = canvasReducer(edited, { type: "style", id: original.nodes[0].id, style: { bold: false, background: "transparent" } });
|
||||
assert.equal(cleared.present.nodes[0].data.textStyle.color, style.color);
|
||||
assert.equal(decodeCanvas(JSON.stringify(cleared.present)).nodes[0].data.textStyle.bold, false);
|
||||
});
|
||||
|
||||
test("unsafe CSS, unknown styles and invalid icon identifiers cannot enter a restored canvas", () => {
|
||||
for (const style of [{ color: "url(https://example.com)" }, { background: "red" }, { fontFamily: "custom" }, { lineHeight: Infinity }, { bold: "true" }, { align: "justify" }, { align: ["left"] }, { fontFamily: ["sans"] }, { position: "fixed" }, []]) {
|
||||
const doc = seeded(); doc.nodes[0].data.textStyle = style;
|
||||
assert.throws(() => decodeCanvas(JSON.stringify(doc)), /样式/);
|
||||
}
|
||||
const icon = makeCanvasNode("icon", { x: 0, y: 0 });
|
||||
icon.data.icon = "<svg onload=evil>";
|
||||
assert.throws(() => decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })), /图标/);
|
||||
});
|
||||
|
||||
test("icon elements inherit transforms without card branching, preserve their identity and resize proportionally", () => {
|
||||
const element = new IconElement({ x: 10, y: 20 });
|
||||
assert(element instanceof CanvasElement);
|
||||
assert.equal(element instanceof CardElement, false);
|
||||
const icon = element.toNode(); icon.data.icon = "Rocket";
|
||||
icon.data.textStyle = { color: "#cc6633" }; icon.data.rotation = 45;
|
||||
const result = decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [icon] })).nodes[0];
|
||||
assert.equal(result.type, "icon"); assert.equal(result.data.icon, "Rocket");
|
||||
assert.equal(result.data.textStyle.color, "#cc6633"); assert.equal(result.data.rotation, 45);
|
||||
icon.data.rotation = 0;
|
||||
const larger = resizeFromPointer(icon, "bottom-right", { x: 80, y: 80 });
|
||||
assert.equal(larger.width, 160); assert.equal(larger.height, 160);
|
||||
const smaller = resizeFromPointer(icon, "bottom-right", { x: -1000, y: -1000 });
|
||||
assert.equal(smaller.width, 24); assert.equal(smaller.height, 24);
|
||||
assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [icon] }, icon.id, "right"));
|
||||
});
|
||||
Reference in New Issue
Block a user