docs(canvas): explain node editing scopes and display interactions
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
|
||||
}
|
||||
|
||||
@@ -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<NonNullable<EdgeData['path']>> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() };
|
||||
/** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */
|
||||
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
|
||||
|
||||
@@ -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<T> = { preview(value: T): void; commit(): void; cancel(): void };
|
||||
|
||||
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */
|
||||
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
|
||||
return Object.freeze({
|
||||
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
|
||||
preview(value: T) { if (isCurrent()) actions.preview(value); },
|
||||
// 提交与取消共用同一有效性判断;原actions负责释放手势与处理重复结束。
|
||||
commit() { if (isCurrent()) actions.commit(); },
|
||||
cancel() { if (isCurrent()) actions.cancel(); },
|
||||
});
|
||||
|
||||
@@ -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<string, string>(), counts: new Map<string, number>(), drops: [] as ReturnType<typeof frameDropFeedback> });
|
||||
|
||||
/** 优先展示拖动候选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 && <div className={styles.membershipBadge} title={label}><Frame size={11} />{label}</div>;
|
||||
}
|
||||
|
||||
@@ -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 <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
|
||||
const delta = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (delta !== undefined || event.key === "Home" || event.key === "End") {
|
||||
|
||||
@@ -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<CanvasNode> & { 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 <>
|
||||
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
||||
<motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
|
||||
@@ -59,6 +75,7 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
|
||||
{directAnchors && <ContentHandles selected={!!selected} />}
|
||||
{data.ports?.map((port) => {
|
||||
// 只在同方向端口之间均分高度,index+1与length+1让首尾都留出边距。
|
||||
const peers = data.ports!.filter((item) => item.direction === port.direction);
|
||||
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
|
||||
})}
|
||||
|
||||
@@ -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<CanvasNode>) {
|
||||
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><CanvasIcon name={props.data.icon} aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
|
||||
}
|
||||
|
||||
@@ -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<CanvasNode>) {
|
||||
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 (
|
||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} renderedTarget={data.__headlessTarget} title={data.title} selected={selected} /></header>
|
||||
|
||||
@@ -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<NodeTransform>), 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<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(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]);
|
||||
}
|
||||
|
||||
@@ -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<object,ReturnType<typeof createNodeInternalsBatch>>();
|
||||
/** 返回稳定的invalidate回调;注册尚未建立或已卸载时调用无效,不直接触发布局读取。 */
|
||||
export function useNodeInternalsUpdate(id:string){
|
||||
// useStoreApi的包装对象可能每次不同,稳定getState函数作为store身份;update交给批处理器实际刷新。
|
||||
const scope=useStoreApi().getState,update=useUpdateNodeInternals();
|
||||
const lease=useRef<ReturnType<ReturnType<typeof createNodeInternalsBatch>['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(),[]);
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>(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 <input ref={input} className={`${styles.nodeTitleInput} nodrag nopan nowheel`} value={value} maxLength={300} aria-label="节点名称" onChange={(event) => 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 <span className={styles.nodeTitle} title="双击编辑名称" role="button" tabIndex={selected ? 0 : -1} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); actions.select(id); setEditing(true); }}
|
||||
|
||||
@@ -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<CanvasNode>) {
|
||||
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 <TransformableNode {...props} className={styles.plainImageFrame}><CanvasImage id={props.id} className={styles.plainImage} src={managedImageSource(props.data.src, preview)} title={props.data.title} /></TransformableNode>;
|
||||
}
|
||||
|
||||
@@ -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<CanvasNode>) {
|
||||
return (
|
||||
<TransformableNode {...props}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user