From 55d70fe78604ee0d75d77d35c2e061e4ace26755 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:36:27 +0800 Subject: [PATCH] docs(canvas): explain pointer editing and capture cleanup --- src/components/canvas/canvas-edge.tsx | 7 +++++++ src/components/canvas/transformable-node.tsx | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/src/components/canvas/canvas-edge.tsx b/src/components/canvas/canvas-edge.tsx index e574176..4dc649c 100644 --- a/src/components/canvas/canvas-edge.tsx +++ b/src/components/canvas/canvas-edge.tsx @@ -59,6 +59,7 @@ export function CanvasEdge(props: EdgeProps) { 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集合含本边时播放。 + // marker 命名未加入 viewId/实例 ID,且字符替换可能碰撞;调用方须维持当前 SVG 文档内标识可用。 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 || []; @@ -76,6 +77,7 @@ export function CanvasEdge(props: EdgeProps) { 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; + // begin 已执行后才安装监听/取得捕获;后续动作实现应不抛错,否则这里没有总括 try/finally 回滚。 const controller = new AbortController(); gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id); /** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */ @@ -92,6 +94,7 @@ export function CanvasEdge(props: EdgeProps) { if (!ownsPointerCapture(current,event)) return; if(!current)return; const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); + // 读的是最新 render 的 path,不是 pointerdown 快照;需要父层把 preview 更新投影回来以便后续事件继续编辑。 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); @@ -119,6 +122,7 @@ export function CanvasEdge(props: EdgeProps) { 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(); + // 提交/取消动作先于 release,且未用 finally 包裹;动作抛错可能跳过捕获释放,不能称无条件清理。 if (current.lease) current.lease.commit(); else editing.finish?.(); releasePointerCapture(current.capture,current.pointerId); } @@ -132,6 +136,7 @@ export function CanvasEdge(props: EdgeProps) { if (current.lease) current.lease.cancel(); else editing.cancel?.(); releasePointerCapture(current.capture,current.pointerId); } + // 工具栏/键盘 patch 不是拖动 lease 流程,是否形成一次 history 单元仍由 Provider 决定。 // 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。 pointerHandlers.current = { move, stop, cancel }; /** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。 @@ -147,6 +152,7 @@ export function CanvasEdge(props: EdgeProps) { editing.patch(props.id, { ...data, path: next }); } }} />; + // selectedKnot 不随外部 knots 删除自动重置;工具栏显示模式可退到最后一点,删除回调仍优先使用旧选中 ID。 // 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。 // flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。 // 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。 @@ -178,6 +184,7 @@ export function CanvasEdge(props: EdgeProps) { /** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。 * 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */ export function CanvasConnectionLine(props: ConnectionLineComponentProps) { + // 本临时线仍使用兼容端点算法,与上方已建连线的 __headlessRecord 原生坐标分流不同。 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 } }; return ; diff --git a/src/components/canvas/transformable-node.tsx b/src/components/canvas/transformable-node.tsx index 921f571..0e67ded 100644 --- a/src/components/canvas/transformable-node.tsx +++ b/src/components/canvas/transformable-node.tsx @@ -76,6 +76,7 @@ export function TransformableNode(props: NodeProps & { children: Rea const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); if (!node.selected) editing.select(id); + // node 是 flow.getNode 返回的引用,不深克隆;“起点快照”依赖外层不可变更新约定,不能原地修改该节点。 gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) }; if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;} event.preventDefault();event.stopPropagation(); @@ -87,6 +88,7 @@ export function TransformableNode(props: NodeProps & { children: Rea if (!ownsPointerCapture(gesture.current,event)) return; const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); const { node, start, direction } = gesture.current!; + // resizing 模式显式传 Shift 布尔值,未按 Shift 即解锁比例;普通角落传 undefined 沿用图片/图标默认比例规则。 const change = direction === "rotate" ? { rotation: rotationFromPointer(node, start, current, event.shiftKey) } : resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined); @@ -100,6 +102,7 @@ export function TransformableNode(props: NodeProps & { children: Rea if(event&&!ownsPointerCapture(gesture.current,event))return; const current = gesture.current; if (!current) return; gesture.current = null; + // cancel/commit 调用与释放间没有 finally;Provider/lease 抛错时可能跳过 release,需要遵守动作异常契约。 if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height }); releasePointerCapture(current.capture,current.pointerId); } @@ -107,9 +110,11 @@ export function TransformableNode(props: NodeProps & { children: Rea function finish(event: PointerEvent) { if(!gesture.current)return;event.stopPropagation(); if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; + // 当前手势引用已清空;若 commit 抛错,不会自动恢复引用或执行后面的 capture 释放。 if (current.lease) current.lease.commit(); else editing.finishTransform?.(); releasePointerCapture(current.capture,current.pointerId); } + // 模式关闭/取消选中本身不调用 cancelGesture;实际手势结束还依赖 pointerup/cancel、失焦或卸载。 // 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。 // schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。 // 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。 @@ -139,6 +144,7 @@ export function TransformableNode(props: NodeProps & { children: Rea const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return; event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return; const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1); + // 键盘按键没有 pointer capture,也不经过 move;preview/commit 同步连用,失败清理由租约实现承担。 const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey); if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); } }} />;