diff --git a/src/components/canvas/canvas-overlays.tsx b/src/components/canvas/canvas-overlays.tsx index c157b54..4bca157 100644 --- a/src/components/canvas/canvas-overlays.tsx +++ b/src/components/canvas/canvas-overlays.tsx @@ -1,20 +1,35 @@ +/** + * 将Portal中的节点浮层注册到视图OverlayIndex,提供屏幕边界、焦点及输入法组合态。 + * 索引用于输入路由与浮层归属判断;DOM标记与事件监听在ref更换或卸载时清理,不写入文档。 + */ "use client"; import {createContext,useContext,useCallback,useLayoutEffect,useId,useRef} from 'react'; import {useViewport} from '@xyflow/react'; import type {OverlayIndex} from '@/lib/canvas-bindings/overlays'; import type {ProjectEngine} from '@/lib/canvas-runtime/workspace'; +/** 新版Provider提供视图索引与engine;null允许旧编辑器或无engine的展示组件直接复用Hook。 */ export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null); /** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */ +/** 返回DOM ref回调。注册目标包含id/incarnation,避免同ID元素重建后仍沿用旧浮层身份。 + * useId区分组件实例,placement区分用途;Portal位置不影响目标归属。 */ export function useCanvasOverlay(targetId:string,placement:string){ const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{}); + // 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。 useViewport(); const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation; + // 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。 const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return; + // 随机owner标记区分同一DOM上的新旧绑定,旧清理不能删除新绑定写入的数据属性。 const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement; + // getBoundingClientRect给出屏幕坐标,四周扩大8px作为输入命中边界;焦点读取当前activeElement。 let composing=false;const value=()=>{const rect=element.getBoundingClientRect();return{placement,target:{id:targetId,incarnation},bounds:{x:rect.x-8,y:rect.y-8,width:rect.width+16,height:rect.height+16},focused:element.contains(document.activeElement),composing};}; + // 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。 const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure; + // focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。 const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure); + // 清除本绑定拥有的DOM标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。 cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();}; },[scope,instance,targetId,incarnation,placement]); + // 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。 useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref; } diff --git a/src/components/canvas/content-handles.tsx b/src/components/canvas/content-handles.tsx index e9d26eb..b950fcd 100644 --- a/src/components/canvas/content-handles.tsx +++ b/src/components/canvas/content-handles.tsx @@ -1,6 +1,11 @@ +/** + * 通用内容节点的四向连接点。只提供稳定方向ID和几何位置,实际连线命令由画布层接收。 + */ "use client"; import { Handle, Position } from "@xyflow/react"; import styles from "./canvas.module.css"; +/** 四边均使用source Handle以配合画布连接模式;selected控制能否开始连线以及读屏可见性。 + * 不在这里创建端点记录,也不判断跨分区连线的归属。 */ export function ContentHandles({ selected }: { selected: boolean }) { return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => )}; } diff --git a/src/components/canvas/edge-editing.tsx b/src/components/canvas/edge-editing.tsx index fdcee67..538e58f 100644 --- a/src/components/canvas/edge-editing.tsx +++ b/src/components/canvas/edge-editing.tsx @@ -1,6 +1,13 @@ +/** + * 连线编辑的React动作上下文。让连线组件调用begin/patch/finish/cancel,并读取当前活动集合。 + * 真正的命令、事务和history由Provider注入,默认空实现仅支持未接入编辑器的渲染环境。 + */ "use client"; import { createContext } from "react"; import type { EditingLease } from "./editing-lease"; import type { EdgeData } from "@/lib/canvas-v2"; +/** begin可拒绝、使用旧版无返回值模式,或返回绑定手势的EditingLease;patch的transient区分临时预览。 + * finish/cancel可选,active为当前活动连线ID集合;默认Set不代表全项目状态仓库。 */ const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set() }; +/** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */ export const EdgeEditing = createContext & Partial>>(defaultEditing); diff --git a/src/components/canvas/editing-lease.ts b/src/components/canvas/editing-lease.ts index 8ebf5a2..2fceebc 100644 --- a/src/components/canvas/editing-lease.ts +++ b/src/components/canvas/editing-lease.ts @@ -1,9 +1,17 @@ +/** + * 编辑手势的能力对象:一次交互取得preview/commit/cancel,再由有效性护栏限制其后续调用。 + * 这里只校验调用当刻是否仍有效,不维护手势状态机,也不自行保证commit只能调用一次。 + */ /** A captured pointer may only finish the exact interaction that acquired it. */ +/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */ export type EditingLease = { preview(value: T): void; commit(): void; cancel(): void }; +/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */ export function guardEditingLease(isCurrent: () => boolean, actions: EditingLease): EditingLease { return Object.freeze({ + // 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。 preview(value: T) { if (isCurrent()) actions.preview(value); }, + // 提交与取消共用同一有效性判断;原actions负责释放手势与处理重复结束。 commit() { if (isCurrent()) actions.commit(); }, cancel() { if (isCurrent()) actions.cancel(); }, }); diff --git a/src/components/canvas/frame-feedback.tsx b/src/components/canvas/frame-feedback.tsx index 164b47b..e90e31a 100644 --- a/src/components/canvas/frame-feedback.tsx +++ b/src/components/canvas/frame-feedback.tsx @@ -1,3 +1,6 @@ +/** + * 分区成员拖放的展示上下文。提供名称、成员计数与当前候选归属变化,供节点显示提示;不负责执行成员迁移。 + */ "use client"; import { createContext, useContext } from "react"; @@ -5,14 +8,18 @@ import { Frame } from "lucide-react"; import type { frameDropFeedback } from "@/lib/canvas-frames"; import styles from "./canvas.module.css"; +/** 缺少Provider时使用空展示数据;drops是frameDropFeedback产生的当前预览,不能代替持久归属。 */ export const FrameFeedback = createContext({ names: new Map(), counts: new Map(), drops: [] as ReturnType }); +/** 优先展示拖动候选to,否则使用既有frameId;只有被选中或有拖放变化时才考虑显示徽标。 */ export function MembershipBadge({ id, frameId, selected }: { id: string; frameId?: string; selected: boolean }) { const { names, drops } = useContext(FrameFeedback); const drop = drops.find((item) => item.id === id); + // drop.to可能为空,表示准备移出;不能用“drop.to || frameId”退回原分区,否则会隐藏移出意图。 const target = drop ? drop.to : frameId; const name = target ? names.get(target) : undefined; if (!drop && !selected) return null; + // 目标有名称时区分“松开加入”与“属于”;没有目标且原来有归属则提示移出,否则不绘制。 const label = name ? `${drop && drop.to !== drop.from ? "松开加入" : "属于"}:${name}` : drop?.from ? `松开移出:${names.get(drop.from) || "分区"}` : null; return label &&
{label}
; } diff --git a/src/components/canvas/frame-label-menu.tsx b/src/components/canvas/frame-label-menu.tsx index 2e6ea26..547d78f 100644 --- a/src/components/canvas/frame-label-menu.tsx +++ b/src/components/canvas/frame-label-menu.tsx @@ -1,16 +1,25 @@ +/** + * 分区名称显示模式的受控单选控件。显示当前值并通过change请求修改,组件本身不拥有分区模型。 + */ "use client"; import { useRef } from "react"; import { EyeOff, Scaling, Type } from "lucide-react"; import type { FrameLabelMode } from "@/lib/canvas-elements"; import styles from "./canvas.module.css"; +/** 固定三种模式,数组顺序同时决定视觉滑块位置与键盘导航顺序。 */ const options = [ { value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称", Icon: EyeOff }, { value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小", Icon: Type }, { value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小", Icon: Scaling }, ] as const; +/** 鼠标点击切换模式;方向键循环移动,Home/End跳到首尾,并把焦点移至目标按钮。 + * radio角色、aria-checked和单一Tab入口表达单选语义;SVG仅提供视觉提示。 */ export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) { + // 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。 const items = useRef<(HTMLButtonElement | null)[]>([]); const current = Math.max(0, options.findIndex((option) => option.value === value)); + // 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。 + // 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。 return
{ 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>(); +/** 返回稳定的invalidate回调;注册尚未建立或已卸载时调用无效,不直接触发布局读取。 */ export function useNodeInternalsUpdate(id:string){ + // useStoreApi的包装对象可能每次不同,稳定getState函数作为store身份;update交给批处理器实际刷新。 const scope=useStoreApi().getState,update=useUpdateNodeInternals(); const lease=useRef['acquire']>|undefined>(undefined); + // 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。 useEffect(()=>{ let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);} const owner=batch.acquire(id);lease.current=owner; return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;}; },[scope,update,id]); + // 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。 return useCallback(()=>lease.current?.invalidate(),[]); } diff --git a/src/components/canvas/node-title.tsx b/src/components/canvas/node-title.tsx index adbc93b..8177aa5 100644 --- a/src/components/canvas/node-title.tsx +++ b/src/components/canvas/node-title.tsx @@ -1,18 +1,28 @@ +/** + * 节点标题的局部编辑交互。展示态与输入态切换,提交时调用rename;本地value只是输入草稿。 + */ "use client"; import { useEffect, useRef, useState } from "react"; import {useScopedNodeEditing} from "./node-editing"; import styles from "./canvas.module.css"; +/** 双击先选择节点再编辑,键盘F2/Enter也可进入;选中时标题才加入正常Tab顺序。 + * renderedTarget把后续rename绑定到当前渲染的元素生命周期与位置。 */ export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; title: string; selected: boolean;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) { const actions = useScopedNodeEditing(id,renderedTarget); const [editing, setEditing] = useState(false), [value, setValue] = useState(title); + // cancelled用于Escape触发blur时抑制保存;input用于进入编辑后聚焦并选中全部文字。 const input = useRef(null), cancelled = useRef(false); + // 展示态跟随外部标题,输入态保留当前草稿;失去选择会退出编辑,进入编辑则集中处理焦点。 useEffect(() => { if (!editing) setValue(title); }, [title, editing]); useEffect(() => { if (!selected) setEditing(false); }, [selected]); useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]); + /** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */ function save() { if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); } cancelled.current = false; setEditing(false); } + // Enter在非输入法组合态触发blur保存,Escape先置取消标记再blur;按键不继续冒泡到画布。 + // 标题长度限制300字符,nodrag/nopan/nowheel避免文本操作被当成画布手势。 if (editing) return setValue(event.target.value)} onBlur={save} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />; return { event.preventDefault(); event.stopPropagation(); actions.select(id); setEditing(true); }} diff --git a/src/components/canvas/plain-image-node.tsx b/src/components/canvas/plain-image-node.tsx index 0fa42db..5bfbe05 100644 --- a/src/components/canvas/plain-image-node.tsx +++ b/src/components/canvas/plain-image-node.tsx @@ -1,3 +1,6 @@ +/** + * 无卡片标题的图片节点。用通用变换容器承载图片,随视口缩放选择预览或正式资源地址。 + */ "use client"; import { CanvasImage } from "./canvas-image"; import { managedImageSource } from "@/lib/canvas-v2"; @@ -5,8 +8,12 @@ import { useViewport, type NodeProps } from "@xyflow/react"; import type { CanvasNode } from "@/lib/canvas"; import { TransformableNode } from "./transformable-node"; import styles from "./canvas.module.css"; +/** 依赖视口zoom估算图片在屏幕上的像素宽度;CanvasImage负责本地预览与正式图片的显示交接。 */ export function PlainImageNode(props: NodeProps) { const { zoom } = useViewport(); + // 节点宽度未提供时按320估算,再乘zoom与设备像素比;不超过1024时请求预览源。 + // 这是显示宽度启发式,不检查图片原始尺寸;无window时像素比按1处理。 const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024; + // managedImageSource按资源格式选择可用地址,移动/缩放等编辑行为仍由TransformableNode处理。 return ; } diff --git a/src/components/canvas/plain-text-node.tsx b/src/components/canvas/plain-text-node.tsx index 8e8e9f1..9b2e68e 100644 --- a/src/components/canvas/plain-text-node.tsx +++ b/src/components/canvas/plain-text-node.tsx @@ -1,3 +1,6 @@ +/** + * 纯文本节点的显示装配。外层复用节点变换与选择交互,内层使用文本编辑器的plain模式;不在此持有第二份文本数据。 + */ "use client"; import type { NodeProps } from "@xyflow/react"; @@ -5,6 +8,7 @@ import type { CanvasNode } from "@/lib/canvas"; import { TransformableNode } from "./transformable-node"; import { CanvasTextEditor } from "./canvas-text-editor"; +/** NodeProps整体交给TransformableNode;文本编辑器取得同一个id/data/selected,plain决定其纯文本呈现方式。 */ export function PlainTextNode(props: NodeProps) { return ( diff --git a/src/components/canvas/text-appearance.ts b/src/components/canvas/text-appearance.ts index ddb726b..50136a1 100644 --- a/src/components/canvas/text-appearance.ts +++ b/src/components/canvas/text-appearance.ts @@ -1,5 +1,11 @@ +/** + * 将节点文本数据转换为React内联CSS的纯函数。统一卡片与纯文本的默认外观,不修改原数据或编辑状态。 + */ import type { CSSProperties } from "react"; import type { CanvasNodeData } from "@/lib/canvas-elements"; +/** 字号优先data.fontSize,纯文本默认24、卡片默认13;布尔样式映射为明确的CSS值。 + * 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。 + * 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */ 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,