{
const delta = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
if (delta !== undefined || event.key === "Home" || event.key === "End") {
diff --git a/src/components/canvas/frame-node.tsx b/src/components/canvas/frame-node.tsx
index 5752c23..e175c7d 100644
--- a/src/components/canvas/frame-node.tsx
+++ b/src/components/canvas/frame-node.tsx
@@ -1,3 +1,7 @@
+/**
+ * 分区节点:展示名称、边框、成员拖入/移出反馈,以及子画布折叠与概览设置。
+ * 局部状态只存名称输入草稿;保存与分区属性修改都交给作用域编辑接口,成员归属不由本组件直接变更。
+ */
"use client";
import { useContext, useEffect, useRef, useState } from "react";
@@ -13,25 +17,37 @@ import {useCanvasOverlay} from "./canvas-overlays";
import {useScopedNodeEditing} from "./node-editing";
import styles from "./canvas.module.css";
+/** directAnchors用于新版分区四向连接点;子画布ports则按输入/输出方向单独布局。
+ * 分区使用自己的设置浮层,因此隐藏TransformableNode的通用样式栏,仍保留公共变换行为。 */
export function FrameNode(props: NodeProps
& { directAnchors?: boolean }) {
const { directAnchors = false, ...nodeProps } = props;
const { id, data, selected } = nodeProps;
+ // 设置浮层注册到当前视图的overlay索引,让Portal中的控件也能正确参与焦点与输入路由。
const overlay=useCanvasOverlay(id,"frame-settings");
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const feedback = useContext(FrameFeedback);
+ // 反馈来自当前拖动预计算,只统计跨分区的进入/离开;这两个计数不是持久成员列表。
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length;
const { zoom } = useViewport();
const reduce = useReducedMotion();
+ // 名称是受控输入草稿;外部data.title变化时同步覆盖,实际提交发生在blur。
const [title, setTitle] = useState(data.title);
useEffect(() => setTitle(data.title), [data.title]);
+ // Escape恢复模型标题并标记跳过随后的blur保存;Enter排除输入法组合态后通过blur统一提交。
const cancelBlur = useRef(false);
const mode = data.labelMode || "fixed";
+ /** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */
function saveTitle() {
const name = title.trim() || "未命名分区";
if (name !== data.title) editing.frame(id, { title: name });
setTitle(name);
}
+ // 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。
+ // 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。
+ // 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。
+ // fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。
+ // 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。
return <>
{selected &&
& { directAnchors?: boole
{(["top", "right", "bottom", "left"] as const).map((side) => )}
{directAnchors && }
{data.ports?.map((port) => {
+ // 只在同方向端口之间均分高度,index+1与length+1让首尾都留出边距。
const peers = data.ports!.filter((item) => item.direction === port.direction);
return ;
})}
diff --git a/src/components/canvas/icon-node.tsx b/src/components/canvas/icon-node.tsx
index 2a6f690..b4df97e 100644
--- a/src/components/canvas/icon-node.tsx
+++ b/src/components/canvas/icon-node.tsx
@@ -1,9 +1,14 @@
+/**
+ * 图标节点的轻量显示装配。图标名称与颜色来自节点data,交互与几何变换委托公共节点容器。
+ */
"use client";
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import { CanvasIcon } from "./canvas-icon";
import styles from "./canvas.module.css";
+/** 前景色默认棕色、背景默认透明;SVG填满容器并用节点title命名,实际路径由CanvasIcon异步加载。
+ * 此处的100%宽高跟随外层尺寸;固定strokeWidth传给图标层,不在本组件修改节点几何。 */
export function IconNode(props: NodeProps) {
return
;
}
diff --git a/src/components/canvas/material-node.tsx b/src/components/canvas/material-node.tsx
index 7665c59..ac15077 100644
--- a/src/components/canvas/material-node.tsx
+++ b/src/components/canvas/material-node.tsx
@@ -1,3 +1,7 @@
+/**
+ * 文本、脚本、图片卡片共用的节点组件。组合标题、内容、通用变换容器与四向连线入口。
+ * 编辑通过作用域动作接口发出,不直接写文档;旧连线的无名连接点仍保留用于兼容定位。
+ */
"use client";
import { CanvasImage } from "./canvas-image";
import { managedImageSource } from "@/lib/canvas-v2";
@@ -14,16 +18,26 @@ import styles from "./canvas.module.css";
import { TransformableNode } from "./transformable-node";
export { NodeEditing } from "./transformable-node";
+/** 方向字符串到ReactFlow几何方位的映射,与nodeDirections的稳定Handle ID配套。 */
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
+/** 四向连接入口的中文可访问名称,不参与端点数据编码。 */
const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧" };
+/** 按kind选择图片或文本编辑器,并显示对应标题图标;selected影响样式及连接操作入口。
+ * 指针点击连接点可打开分支菜单,拖动则留给ReactFlow创建连接。 */
export function MaterialNode(props: NodeProps) {
const { id, data, selected } = props;
+ // Headless目标携带元素生命周期与归属;旧渲染回调经过作用域检查,避免迟到事件编辑已替换的对象。
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const { zoom } = useViewport();
+ // 以卡片宽度(默认260)×视口缩放×设备像素比估算显示成本;1024以内优先预览源。
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
+ // 仅记录连接点按下的屏幕坐标,用小于5px的位移区分点击与拖动;不把它写进文档或history。
const pointerStart = useRef({ x: 0, y: 0 });
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
+ // 旧target/source Handle禁用新连接但保留定位;四向Handle使用方向作ID,选中时允许开始连线。
+ // 指针点击还检查branchable能力和位移;键盘Enter/空格使用Handle中心打开菜单,由动作层再处理合法性。
+ // 点击/按键停止冒泡,避免分支操作同时触发节点或画布交互;文本内容的编辑生命周期由子组件负责。
return (
diff --git a/src/components/canvas/node-editing.ts b/src/components/canvas/node-editing.ts
index 47696b7..124d635 100644
--- a/src/components/canvas/node-editing.ts
+++ b/src/components/canvas/node-editing.ts
@@ -1,3 +1,8 @@
+/**
+ * 节点组件与画布控制器之间的动作上下文。Provider实现文本、名称、样式、几何、分区及编辑模式修改。
+ * useScopedNodeEditing为新版渲染回调追加目标身份/归属检查,旧版无Overlay上下文时沿用原接口。
+ * 本模块不拥有文档、history或队列;它只把界面动作交给正确的控制器。
+ */
"use client";
import {createContext,useContext,useMemo} from 'react';
import type {XYPosition} from '@xyflow/react';
@@ -7,39 +12,59 @@ import type {NodeTransform} from '@/lib/canvas-geometry';
import type {EditingLease} from './editing-lease';
import type {RenderedTarget} from '@/lib/canvas-bindings/compat-types';
import {CanvasOverlays} from './canvas-overlays';
+/** 默认动作为空实现,便于节点展示独立挂载;可选手势钩子兼容旧版调用与新版EditingLease。
+ * rotating/resizing是Provider提供的当前模式标记,workspace用于构造导航,schema区分适配路径。 */
const defaultEditing = {
beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void),
endEditing: undefined as undefined | ((id: string) => void),
workspace: "", schema: 1 as 1 | 2,
+ // begin/select/edit分别供编辑会话、选择与正文更新使用;branch取得屏幕坐标以定位分支菜单。
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
+ // 几何、文字样式、标题和分区属性分别走独立动作,避免组件直接合并整份节点数据。
transform: (_id: string, _transform: NodeTransform) => {},
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
rename: (_id: string, _title: string) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
+ // 传元素id开启相应模式,null请求退出;是否确属当前元素在作用域包装中进一步检查。
rotating: null as string | null, rotate: (_id: string | null) => {},
resizing: null as string | null, resize: (_id: string | null) => {},
};
+/** 这些生命周期钩子可缺省,其余基础动作保留默认接口形状。 */
type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing';
+/** 由具体画布注入实现;默认上下文不会自行创建一个编辑器实例。 */
export const NodeEditing = createContext & Partial>>(defaultEditing);
/** Bind legacy renderer actions to the lifetime and placement that rendered them. */
+/** 捕获本次渲染目标的incarnation、ownerCanvasId与视图canvasId,再为每次动作读取最新状态。
+ * 捕获值代表界面当时展示的对象,最新值用来拒绝过期回调;不能只凭相同元素id就认为仍是同一对象。 */
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
+ // 只有rendered.id匹配当前节点才采用incarnation;有新版scope却缺少合法rendered身份时拒绝编辑。
const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId;
+ // 无新版scope直接返回旧接口;否则只在接口、视图作用域或目标身份变化时重建包装。
return useMemo(()=>{if(!scope)return editing;
+ // 动作当刻检查engine未关闭、视图仍存在且仍在原canvas、元素生命周期与owner未变。
+ // candidate必须是本节点(或允许的空参数),阻止一个节点的包装替其它目标发起操作。
const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);};
+ // finish/cancel没有id参数,额外确认当前手势属于本视图,且目标列表含本id/incarnation。
const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation);
+ // 拷贝展示信息,仅替换动作入口;文本/样式/几何等动作逐次检查,不缓存“曾经有效”的结论。
const scoped:typeof editing={...editing,
begin:candidate=>{if(current(candidate))editing.begin(candidate??id);},select:candidate=>{if(current(candidate))editing.select(candidate);},
edit:(candidate,text)=>{if(current(candidate))editing.edit(candidate,text);},rename:(candidate,title)=>{if(current(candidate))editing.rename(candidate,title);},
style:(candidate,style,size)=>{if(current(candidate))editing.style(candidate,style,size);},frame:(candidate,patch)=>{if(current(candidate))editing.frame(candidate,patch);},
transform:(candidate,change)=>{if(current(candidate))editing.transform(candidate,change);},branch:(candidate,direction,point)=>{if(current(candidate))editing.branch(candidate,direction,point);},
+ // 退出模式用null表示,但只准退出本节点当前拥有的rotate/resize模式,避免关闭其它节点的模式。
rotate:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='rotate'))editing.rotate(candidate);},resize:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='resize'))editing.resize(candidate);},
};
+ // 保持可选钩子的存在性:失效时beginTransform返回false,其余动作静默忽略。
+ // begin取得的租约由创建者管理;这里不把旧事件转给当前另一个手势。
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
+ // 结束手势要同时通过对象有效性与手势归属检查,防止迟到的pointerup结束新交互。
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
+ // 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。
return scoped;
},[editing,scope,id,incarnation,owner,canvas]);
}
diff --git a/src/components/canvas/node-internals.ts b/src/components/canvas/node-internals.ts
index 4d0af3e..aa630b0 100644
--- a/src/components/canvas/node-internals.ts
+++ b/src/components/canvas/node-internals.ts
@@ -1,3 +1,6 @@
+/**
+ * ReactFlow节点内部几何更新的Hook适配。将同一个store内的更新交给共享批处理器合并,卸载时释放节点租约。
+ */
"use client";
import {useCallback,useEffect,useRef} from 'react';
@@ -6,14 +9,19 @@ import {createNodeInternalsBatch} from '@/lib/canvas-bindings/node-internals';
// useStoreApi wraps the API per hook. Its shared getState function identifies the
// actual store, separating canvases even when they render the same project.
+/** WeakMap按实际store的getState函数区分画布;不按projectId共享,避免多个视图误用另一视图的更新器。 */
const batches=new WeakMap