From 838d3e5c469a8998a0d0d9a6a92c8a0c612cb552 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 01:59:22 +0800 Subject: [PATCH] docs(canvas): explain text tools and geometry gesture lifecycles --- src/components/canvas/branch-menu.tsx | 9 +++++ src/components/canvas/canvas-edge.tsx | 36 ++++++++++++++++++++ src/components/canvas/canvas-text-editor.tsx | 14 ++++++++ src/components/canvas/edge-toolbar.tsx | 12 +++++++ src/components/canvas/icon-catalog.ts | 10 ++++++ src/components/canvas/icon-picker.tsx | 16 +++++++++ src/components/canvas/node-style-toolbar.tsx | 11 ++++++ src/components/canvas/transformable-node.tsx | 28 +++++++++++++++ 8 files changed, 136 insertions(+) diff --git a/src/components/canvas/branch-menu.tsx b/src/components/canvas/branch-menu.tsx index 6bb2cc4..ca7aa4c 100644 --- a/src/components/canvas/branch-menu.tsx +++ b/src/components/canvas/branch-menu.tsx @@ -1,3 +1,6 @@ +/** + * 从节点连接点发起的分支类型选择菜单。Portal挂在body,使用屏幕坐标定位;选择结果交给choose创建内容与连线。 + */ "use client"; import { useEffect, useRef } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; @@ -6,10 +9,14 @@ 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"; +/** id/direction标识发起节点和边,point是菜单屏幕位置,at可由调用方保存拟创建位置;菜单不解释at。 */ export type BranchRequest = { id: string; direction: NodeDirection; point: XYPosition; at?: XYPosition }; +/** request为空时不显示,类型固定为文本/图片/脚本;这里只派发选择,不执行上传或文档修改。 */ export function BranchMenu({ request, close, choose }: { request: BranchRequest | null; close: () => void; choose: (kind: "text" | "image" | "script") => void }) { const ref = useRef(null); const reduce = useReducedMotion(); + // 菜单打开后保存原焦点并聚焦首个类型按钮;点击外部关闭,Escape关闭且恢复原焦点。 + // 仅在request存在时安装全局监听,变化/卸载时拆除;普通选择或外部关闭不在此强制恢复焦点。 useEffect(() => { if (!request) return; const focus = document.activeElement as HTMLElement | null; @@ -19,6 +26,8 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true); return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); }; }, [request, close]); + // 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。 + // 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。 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(); } }}> diff --git a/src/components/canvas/canvas-edge.tsx b/src/components/canvas/canvas-edge.tsx index 4640fe4..e574176 100644 --- a/src/components/canvas/canvas-edge.tsx +++ b/src/components/canvas/canvas-edge.tsx @@ -1,3 +1,7 @@ +/** + * 画布连线显示与贝塞尔链编辑。根据端点生成路径,叠加箭头、选中轮廓和动画,选中时提供曲线点及控制柄。 + * 指针拖动通过租约预览后提交;旧接口保留transient patch兼容路径。组件不自行同步服务端或管理撤销栈。 + */ "use client"; import { useContext, useEffect, useRef, useState, type PointerEvent } from "react"; @@ -15,11 +19,15 @@ import { EdgeToolbarControls } from "./edge-toolbar"; import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture"; import styles from "./canvas.module.css"; +/** 旧节点用绝对位置替换局部position后,交compatConnector计算旋转/缩放等兼容端点。 */ function connector(node:InternalNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{return compatConnector({...node,position:node.internals.positionAbsolute},handle,fallback);} +/** 新投影已经给出端点坐标,只需按连接边补朝外法向;避免再应用一遍旧节点几何变换。 */ function nativeConnector(x:number,y:number,side:ConnectorSide):Connector{ const normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}}; return {point:{x,y},normal:normals[side]}; } +/** 生成可复用SVG端部标记;none不绘制,开放箭头不填充,其余按filled使用线色或画布底色。 + * userSpaceOnUse使大小不随线宽缩放,auto-start-reverse支持起终两端朝向。 */ function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) { const type = value?.type || "none"; if (type === "none") return null; @@ -27,6 +35,7 @@ function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: s ; } +/** 读取两端内部节点、视口与编辑上下文;selectedKnot仅是当前控件选择,不进入连线数据。 */ export function CanvasEdge(props: EdgeProps) { const source = useInternalNode(props.source), target = useInternalNode(props.target); const editing = useContext(EdgeEditing); @@ -34,37 +43,49 @@ export function CanvasEdge(props: EdgeProps) { const { zoom } = useViewport(); const reduce = useReducedMotion(); const [selectedKnot, setSelectedKnot] = useState(null); + // 手势记录控制点id/part与捕获目标;事件监听通过AbortController统一拆除,lease可缺省以兼容旧编辑器。 const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null); + // 原生监听通过ref使用最新move/stop/cancel,避免投影更新后继续用过期path或端点闭包。 const pointerHandlers = useRef<{ move(event: PointerEvent): void; stop(event: PointerEvent): void; cancel(event: PointerEvent): void } | null>(null); + // 只挂载一次的失焦/卸载监听经ref调用最新cancel,释放未完成手势。 const interrupt=useRef(()=>{});interrupt.current=()=>cancel(); useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]); + // 任一端点节点未就绪就不绘制;新版端点使用ReactFlow计算结果,旧版由兼容几何计算。 if (!source || !target) return null; const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : connector(target, props.targetHandleId, "left"); const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {}; + // 贝塞尔链生成可绘制path;工具栏锚点取中间一段的t=0.5,属于近似中心而非整条曲线的弧长中点。 const segments = curveSegments(from, to, data.path), path = curvePath(segments); const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5); const color = appearance.color || "#aa968b", width = appearance.width || 1.5; + // 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。 const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`; const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id)); const knots = data.path?.knots || []; + /** 最多64个中间曲线点;insertCurveKnot按给定点或默认位置拆分曲线,提交后选择新点。 */ function add(point?: { x: number; y: number }) { if (knots.length >= 64) return; const next = insertCurveKnot(from, to, data.path, point); editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id); } + /** 仅未处理的主指针左键可开始。已有手势不被重复接管,另一指针到来先取消原手势。 + * 捕获优先放在稳定的画布surface上,避免投影替换SVG控制点后丢掉后续事件。 */ function start(event: PointerEvent, id: string, part: NonNullable["part"]) { if(event.button!==0||event.defaultPrevented)return;if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancel();return;}if(event.isPrimary===false)return; // The SVG control can be replaced by a projection update. The view surface survives it. const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget; + // begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。 const lease = editing.begin(props.id); if (lease === false) return; const controller = new AbortController(); gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id); + /** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */ const owned = (input: Event) => (input as globalThis.PointerEvent).pointerId === event.pointerId; capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent); }, { signal: controller.signal }); capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent); }, { signal: controller.signal }); for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent); }, { signal: controller.signal }); if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation(); } + /** 屏幕点转画布点,并克隆当前path后更新选中部分;只接受当前捕获指针的事件。 */ function move(event: PointerEvent) { const current = gesture.current; if(!current)return;event.stopPropagation(); @@ -72,15 +93,18 @@ export function CanvasEdge(props: EdgeProps) { if(!current)return; const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); + // 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。 if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point); else { const knot = next.knots.find((knot) => knot.id === current.id); if (!knot) return; if (current.part === "position") knot.position = point; else { + // corner两柄独立;smooth保持另一柄长度且方向共线反向;symmetric两柄长度也相同。 const vector = minus(point, knot.position), other = current.part === "in" ? "out" : "in"; const length = Math.hypot(knot[other].x, knot[other].y), nextLength = Math.hypot(vector.x, vector.y); knot[current.part] = vector; + // Shift把该曲线点模式改为symmetric,这个mode会随本次path修改一起提交。 if (event.shiftKey) knot.mode = "symmetric"; if (knot.mode !== "corner") { const scale = knot.mode === "symmetric" ? 1 : nextLength ? length / nextLength : 0; @@ -88,14 +112,17 @@ export function CanvasEdge(props: EdgeProps) { } } } + // 优先本次租约的preview;旧版以transient=true派发path修改,结束时另行finish/cancel。 if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true); } + /** 抬起前再处理一次最后位置,随后清引用、拆监听、提交并释放捕获,避免释放事件重复结束。 */ function stop(event: PointerEvent) { if(!gesture.current)return;event.stopPropagation(); if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort(); if (current.lease) current.lease.commit(); else editing.finish?.(); releasePointerCapture(current.capture,current.pointerId); } + /** 事件取消需匹配捕获;无参数用于失焦/卸载。先清引用和监听,再取消预览并释放捕获。 */ function cancel(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) { if(event&&gesture.current)event.stopPropagation?.(); if(event&&!ownsPointerCapture(gesture.current,event))return; @@ -105,7 +132,10 @@ export function CanvasEdge(props: EdgeProps) { if (current.lease) current.lease.cancel(); else editing.cancel?.(); releasePointerCapture(current.capture,current.pointerId); } + // 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。 pointerHandlers.current = { move, stop, cancel }; + /** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。 + * 方向键一步1单位、Shift为10单位;中间in/out柄的键盘分支直接返回,不修改曲线。 */ const control = (point: { x: number; y: number }, id: string, part: NonNullable["part"], label: string) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} onLostPointerCapture={(event) => { if (gesture.current) cancel(event); }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }} onKeyDown={(event) => { @@ -117,6 +147,9 @@ export function CanvasEdge(props: EdgeProps) { editing.patch(props.id, { ...data, path: next }); } }} />; + // 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。 + // flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。 + // 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。 return { event.preventDefault(); event.stopPropagation(); add(flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })); }}> {props.selected && } @@ -124,6 +157,7 @@ export function CanvasEdge(props: EdgeProps) { {animated && (animation.effect === "flow" || !animation.effect ? : )} {props.selected && <> editing.patch(props.id, data)} count={knots.length} knotMode={(knots.find((knot) => knot.id === selectedKnot) || knots.at(-1))?.mode} changeKnotMode={(mode) => { + // 工具栏模式应用于选中knot,找不到时退到最后一点;平滑/镜像优先以out柄为方向基准重算in柄。 const next = structuredClone(data.path!); const knot = next.knots.find((knot) => knot.id === selectedKnot) || next.knots.at(-1); if (!knot) return; knot.mode = mode; if (mode !== "corner") { if (!Math.hypot(knot.out.x, knot.out.y)) knot.out = { x: -knot.in.x, y: -knot.in.y }; @@ -141,6 +175,8 @@ export function CanvasEdge(props: EdgeProps) { } ; } +/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。 + * 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */ export function CanvasConnectionLine(props: ConnectionLineComponentProps) { const from = connector(props.fromNode, props.fromHandle.id, "right"); const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } }; diff --git a/src/components/canvas/canvas-text-editor.tsx b/src/components/canvas/canvas-text-editor.tsx index 33b8656..c162eef 100644 --- a/src/components/canvas/canvas-text-editor.tsx +++ b/src/components/canvas/canvas-text-editor.tsx @@ -1,3 +1,7 @@ +/** + * 卡片与纯文本共用的正文编辑交互。展示态用覆盖层接收选择/双击,编辑态让textarea接收原生文本操作。 + * 正文直接来自data.text,每次输入交给actions.edit;本组件只保存是否正在编辑,不另存正文副本。 + */ "use client"; import { useEffect, useRef, useState } from "react"; @@ -7,21 +11,31 @@ import { textAppearance } from "./text-appearance"; import styles from "./canvas.module.css"; /** Single clicks select; native double-click timing controls entry into editing. */ +/** plain选择纯文本样式、标签和占位文案;非plain按kind区分脚本/笔记占位。 + * 对外动作经过渲染目标身份检查,具体文本事务与history分组由Provider实现。 */ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) { const actions = useScopedNodeEditing(id,data.__headlessTarget); const [editing, setEditing] = useState(false); const textarea = useRef(null); + // 按下位置和移动标记只用于区分拖动与双击;移动超过4px后不因双击事件进入编辑。 const press = useRef({ x: 0, y: 0, moved: false }); + // 失去选择退出本地编辑态;此effect不直接调用endEditing,正常输入框blur另走结束回调。 useEffect(() => { if (!selected) setEditing(false); }, [selected]); + // 进入编辑后聚焦到文本末尾,并禁止浏览器自动滚动,以免画布视口跳动。 useEffect(() => { if (!editing) return; const element = textarea.current; element?.focus({ preventScroll: true }); element?.setSelectionRange(element.value.length, element.value.length); }, [editing]); + /** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */ function begin() { actions.begin(id); setEditing(true); } + // 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。 const active = editing && selected; const label = plain ? "纯文本" : data.title; + // 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。 + // 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。 + // 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。 return