diff --git a/README.md b/README.md index a03a026..564f6a7 100644 --- a/README.md +++ b/README.md @@ -105,4 +105,13 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、 左上和底部工具栏按右上视图控件的分组节奏增加横向留白(间隙 6px、横向内边距 6px),按钮仍为 32×32px,竖向内边距仍为 4px。三处分隔线统一;左侧标题不再额外叠加外边距,右上现有样式保持不变。 -选中节点后,上、下、左、右各显示一个加号。点击会在对应方向新增文字节点并连线,视图跟随新节点;重复添加会避开已有节点,整个操作可一步撤销。方向连线也会保存在本地草稿和导出的 JSON 中,旧版左右连接继续兼容。 +选中卡片后,上、下、左、右各显示一个加号。点击会在对应方向新增文字卡片并连线,视图跟随新节点;重复添加会避开已有节点,整个操作可一步撤销。方向连线也会保存在本地草稿和导出的 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`。 diff --git a/VALIDATION.md b/VALIDATION.md index dfbead4..e689332 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -1,5 +1,13 @@ # 本地验收记录 +## 元素继承、缩放、旋转与纯文本(2026-11-06) + +- 新增业务元素类继承,卡片与纯文本共享基础变换;纯文本不提供连接点或四向分支。 +- 选中元素提供八向缩放与旋转柄;几何计算使用旋转后的局部坐标,保持对侧锚点。纯文本按比例缩放字号,支持大小、角度、字号的草稿/JSON 持久化和单次手势撤销。 +- 类型检查通过;24 项自动测试通过,包含八个方向在多个角度下的锚点不变、纯文本缩放限制、旋转吸附、继承能力、导入兼容与撤销。 +- 独立临时目录的 Webpack 生产构建通过;完整 API/路由/88 个品牌资源冒烟回归通过,未改写正在使用的开发服务缓存。 +- 遵循用户安排未访问项目浏览器,实际拖动手感和旋转后的连线视觉仍由用户试用反馈。 + - 生产构建:Next.js 16.4.5,TypeScript 检查及 6 个页面静态生成成功。 - API 会话测试:HttpOnly / SameSite Cookie、令牌不返回前端、带会话代理、401、跨站拒绝、非法 Origin、路径白名单、OAuth challenge 校验通过。 - 线上轻量检查:profile 401;空登录参数 400;Slack 与 Google 启动登录均返回 302,目标分别为 slack.com、accounts.google.com。未执行真人账户登录。 diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index 73b7d66..fa20d1c 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -6,9 +6,9 @@ .node { position: relative; width: 260px; padding: 14px; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; } .selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; } .nodeHeading { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; font-size: 11px; color: #92877c; } -.node textarea { display: block; width: 100%; min-height: 140px; padding: 0; resize: vertical; border: 0; background: none; color: #37352f; font-family: inherit; font-size: 13px; line-height: 1.8; outline: none; } +.node textarea { 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; max-width: 100%; max-height: 280px; margin: auto; border-radius: 5px; object-fit: contain; } +.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; } .toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; } .toolbar button:disabled { cursor: not-allowed; } @@ -38,3 +38,28 @@ .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; } +.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; } +.handMode .plainText { pointer-events: none; } +.handMode .resizeHandle, .handMode .rotationHandle, .handMode .rotationStem, .handMode .rotationValue, .handMode .branchButton { display: none; } diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index 1551f12..2d7adf1 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -5,16 +5,18 @@ import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode, addEdge, useReactFlow, useViewport, type Connection, } from "@xyflow/react"; -import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, Type, Undo2, Upload } from "lucide-react"; +import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, 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 { 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 }; +const nodeTypes = { material: MaterialNode, plainText: PlainTextNode }; function CanvasEditor({ board }: { board: Board }) { const flow = useReactFlow(); @@ -71,7 +73,8 @@ function CanvasEditor({ board }: { board: Board }) { 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 editing = useMemo(() => ({ begin: beginEdit, edit, branch }), [beginEdit, edit, branch]); + 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 center = () => { const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); @@ -194,7 +197,8 @@ function CanvasEditor({ board }: { board: Board }) { setTool("select")} /> setTool("hand")} />