428 lines
52 KiB
TypeScript
428 lines
52 KiB
TypeScript
/**
|
||
* 新版主画布的React/ReactFlow装配。engine拥有文档、历史和运行事实,sessions拥有每视图选择/工具/手势;组件只保存显示缓存与面板状态。
|
||
* 文档提交先推进场景索引,再按事件类型立即或逐帧发布;几何预览通过session进入投影,提交才产生文档修改。
|
||
* 画布适配层负责DOM测量、命中、裁剪、焦点和资源显示,core不读取DOM;渲染组件通过动作上下文调用editor/runtime。
|
||
* 外层只借用项目engine:挂载attach视图,卸载detach,不关闭工作区持有的项目或持久任务。
|
||
*/
|
||
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
|
||
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, useViewport, useStore, type Connection,type Viewport } from "@xyflow/react";
|
||
import { Copy, Download, Frame, Hand, ImagePlus, Images, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
|
||
import type { Board } from "@/lib/workspace";
|
||
import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow";
|
||
import { CanvasProvider, CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow";
|
||
import { defaultCanvasRenderers } from "./canvas-renderers";
|
||
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
|
||
import { equal } from "@/lib/canvas-core/values";
|
||
import {createSceneProjection} from "@/lib/canvas-bindings/scene-projection";
|
||
import {createConnectionPaintIndex} from "@/lib/canvas-bindings/connection-paint";
|
||
import {createRetainedProjection} from "./retained-projection";
|
||
import {createOverlayIndex} from "@/lib/canvas-bindings/overlays";
|
||
import {CanvasOverlays} from "./canvas-overlays";
|
||
import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint";
|
||
import {createFrameSource} from '@/lib/canvas-bindings/frame-queue';
|
||
import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames";
|
||
import {routeCanvasKey,canvasKeyContext} from "@/lib/canvas-bindings/input-router";
|
||
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
|
||
import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors";
|
||
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
|
||
import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache";
|
||
import { createResourceView, type ResourceViewSource } from "@/lib/canvas-bindings/resource-view";
|
||
import { ancestors, commonCanvas } from "@/lib/canvas-core/document";
|
||
import { toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection";
|
||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||
import { errorText } from "@/lib/api";
|
||
import { IconButton } from "@/components/ui/icon-button";
|
||
import toolbar from "@/components/ui/toolbar.module.css";
|
||
import { guardEditingLease } from "./editing-lease";
|
||
import { NodeEditing } from "./transformable-node";
|
||
import { EdgeEditing } from "./edge-editing";
|
||
import { HeadlessAIPanel } from "./headless-ai-panel";
|
||
import { HeadlessResourcePicker } from "./headless-resource-picker";
|
||
import { HeadlessImports } from "./headless-imports";
|
||
import { HeadlessRecoveryDialog } from "./headless-recovery-dialog";
|
||
import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
|
||
import { ImagePreviews } from "./canvas-image";
|
||
import styles from "./canvas.module.css";
|
||
|
||
/** 把适配层样式等可序列化值规范化成JSON Data,去掉undefined等JSON不保留的属性;不处理循环或任意非JSON对象。 */
|
||
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
||
/** 已attach视图的内容组件;需要外层ReactFlowProvider与CanvasProvider。root/session是发布快照,current供回调读取最新投影。 */
|
||
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
|
||
// 场景索引按engine+view复用,提交订阅先advance变更,再通知显示,避免每次渲染重建整图索引。
|
||
const sceneCache=useMemo(()=>createSceneProjection(),[engine,viewId]);
|
||
/** 用户字段修改/替换删除及undo/redo立即发布,保证受控输入及时回显;批量创建和后台修改允许合并到下一帧。 */
|
||
const subscribe = useCallback((notify: (immediate?:boolean) => void) => {
|
||
let before=engine.getSnapshot().document;
|
||
return engine.editor.subscribe((commit,next)=>{sceneCache.advance(before,next.document,commit.changes);before=next.document;
|
||
// Controlled inputs must publish their new value before the native event
|
||
// finishes. Batch creation/background work may share a presentation frame.
|
||
const immediate=commit.origin==='undo'||commit.origin==='redo'||commit.origin==='user'&&commit.changes.some(change=>change.kind==='field'||change.before!==null);
|
||
notify(immediate);
|
||
});
|
||
}, [engine,sceneCache]);
|
||
// rootFrames只调度呈现通知,不延迟editor真实提交;sessionFrames独立合并手势/选择等视图变化。
|
||
const rootFrames=useMemo(()=>createFrameSource({subscribe,getSnapshot:engine.getSnapshot},{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine,subscribe]);
|
||
const root = useSyncExternalStore(rootFrames.subscribe, rootFrames.getSnapshot, rootFrames.getSnapshot);
|
||
const sessionFrames=useMemo(()=>createSessionFrameSource(engine.sessions,{request:callback=>requestAnimationFrame(callback),cancel:handle=>cancelAnimationFrame(handle as number)}),[engine]);
|
||
const sessions = useSyncExternalStore(sessionFrames.subscribe, sessionFrames.getSnapshot, sessionFrames.getSnapshot);
|
||
// 视图被detach后保留最近view供本轮Hook计算,最终返回“画布已关闭”;不以lastView恢复已关闭会话。
|
||
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
|
||
const loading=root.runtime.loading,needsLoadingFit=useRef(!!loading&&fitLoading);
|
||
|
||
if (liveView) lastView.current = liveView;
|
||
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
|
||
// 完整加载后允许普通创建;加载中要求loading版本支持且当前canvas覆盖完整,最终命令层仍再校验准入。
|
||
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
|
||
// 加载预览首次适配只取当前canvas直接节点,避免展开后代把视口拉得过远;有交接视口时不再自动fit。
|
||
useEffect(()=>{
|
||
if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.canvases.has(view.canvasId))return;
|
||
// Expanded descendants can extend far beyond their partition entry. Initial
|
||
// framing belongs to the direct members of this canvas.
|
||
const nodes=engine.editor.getQueries(root.document).members(view.canvasId).filter(id=>root.document.elements.get(id)?.structure==='node').map(id=>({id}));
|
||
if(!nodes.length)return;
|
||
needsLoadingFit.current=false;void flow.fitView({nodes,padding:.2,maxZoom:1});
|
||
},[engine,flow,root.document,view.canvasId]);
|
||
// 复制准备任务来自runtime状态;copying防本地重复点击,两者共同禁用复制入口。
|
||
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
|
||
// 焦点与epoch用于保留活动输入和排除迟到blur;切engine/view复位,移除renderer未触发blur时另检查isConnected。
|
||
const [focusedEditor, setFocusedEditor] = useState<HTMLElement | null>(null), focusEpoch = useRef(0);
|
||
useEffect(() => { focusEpoch.current++; setFocusedEditor(null); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
||
// Removing an active renderer need not dispatch a native blur. Retire only
|
||
// that detached input, without clearing a newer focus acquired in the meantime.
|
||
useEffect(()=>{if(focusedEditor&&!focusedEditor.isConnected)setFocusedEditor(current=>current===focusedEditor?null:current);});
|
||
const [copying, setCopying] = useState(false);
|
||
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
|
||
// 选择转成id/incarnation后查询深复制可用性;渲染时的可用性仅用于UI,点击时再次捕获实际选择交runtime。
|
||
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
|
||
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
||
const [error, setError] = useState("");
|
||
// resource显示状态带engine/view身份,旧显示器迟到回调不能为当前画布提供错误图片;sources不是文档资源表。
|
||
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
||
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
|
||
// 拖动保存起始场景坐标、指针ID与会话token,positions只含顶层选中节点,防分区与成员重复位移。
|
||
const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
|
||
// 复用投影对象与测量结果,避免ReactFlow把纯元数据变化误当节点卸载,影响输入及连接点。
|
||
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
|
||
projectionCache.current ??= createProjectionCache();
|
||
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
||
// groups按元素生命周期保存一次文本编辑的history分组ID,元素重建后清旧分组,不能跨incarnation合并编辑。
|
||
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
|
||
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
|
||
// 加载期使用受限renderer catalog,完整后回到正常定义;sceneCache把文档、会话预览与查询组成显示场景。
|
||
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
|
||
const scene=sceneCache.project(engine,root.document,view,sessions,engine.editor.registry,catalog,engine.editor.getQueries(root.document));
|
||
// 仅在尺寸一致且投影缓存确认身份/变换允许时复用measured;离开场景的节点清测量条目。
|
||
const projection = useMemo(() => {
|
||
const result = scene;
|
||
// Preserve renderer measurements across metadata/preview updates. Dropping them
|
||
// makes React Flow discard handle bounds and unmount active edge controls.
|
||
const visibleIds = new Set(result.nodes.map(node => node.id));
|
||
for (const id of measurements.current.keys()) if (!visibleIds.has(id)) measurements.current.delete(id);
|
||
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
|
||
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
|
||
}, [engine, root.document, scene,view.canvasId]);
|
||
// 每个view独立OverlayIndex,Portal控件通过索引贡献边界与焦点;不会把浮层几何存入项目文档。
|
||
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
|
||
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
|
||
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
|
||
const surfaceBounds=surface.current?.getBoundingClientRect();
|
||
|
||
// 绘制范围索引用于可见性筛选;sceneViewport把ReactFlow平移/缩放反算为场景矩形。
|
||
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
|
||
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
|
||
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
|
||
const sceneViewport={x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom};
|
||
// 过滤元素生命周期已变的浮层;缺surface矩形时只按身份保守保留,不凭缺失测量断言不可见。
|
||
const visibleOverlays=overlaySnapshot.length?(surfaceBounds?overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation):overlaySnapshot.filter(entry=>root.document.elements.get(entry.target.id)?.incarnation===entry.target.incarnation)):[];
|
||
// 未知绘制范围必须保留,加上矩形查询命中;不能把unknown当成屏幕外。
|
||
const paintedNodes=[...paint.unknown,...paint.query(sceneViewport)];
|
||
const nativeNodes=useStore(state=>state.nodes);
|
||
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
|
||
// 连线范围依赖真实Handle测量:新版/未知连线使用measuredConnector,兼容节点使用旧几何适配。
|
||
// 订阅nativeNodes让测量更新后重算连线路径范围,不把DOM测量要求下放core。
|
||
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
|
||
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
|
||
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
|
||
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
|
||
// 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。
|
||
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||
// 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。
|
||
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
|
||
/** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */
|
||
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
||
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
||
}, []);
|
||
/** 发命令前读取当前incarnation,元素缺失即抛错;渲染侧更早的身份检查由useScopedNodeEditing等负责。 */
|
||
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
|
||
/** 统一元素data补丁入口,可携history groupId;schema、能力和事务合法性留editor验证。 */
|
||
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
|
||
/** 把ReactFlow选择变化合到session当前Set,成员完全相同则不重复发布;不把selected写入文档。 */
|
||
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
|
||
const current = engine.sessions.getSnapshot().views.get(viewId)!;
|
||
const ids = new Set(current.selection);
|
||
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
|
||
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
|
||
}
|
||
/** 空白点击补充core投影命中:12px容差、预览目标及实际Handle/layer适配;只选择confirmed命中。
|
||
* 命中计算后再次比较document/session快照身份,过期帧不能选中新incarnation;抓手模式点击清选择。 */
|
||
function selectAtPane(event: {clientX:number;clientY:number}) {
|
||
const captured=current.current;
|
||
if(captured.view.tool!=='select'){engine.sessions.select(viewId,[]);return;}
|
||
// Native handles stay in the renderer adapter; the core never reads DOM or guesses custom ports.
|
||
const result=hitProjectedCanvas(captured.projection,engine.editor.getQueries(captured.root.document),engine.editor.registry,viewId,{
|
||
space:'scene',point:flow.screenToFlowPosition({x:event.clientX,y:event.clientY}),zoom:flow.getViewport().zoom,tolerancePx:12,
|
||
},{
|
||
previewIds:new Set(captured.sessions.gesture?.targets.map(target=>target.id)),
|
||
anchor:(node,handle,end,edge)=>{
|
||
const measured=flow.getInternalNode(node.id);if(!measured)return undefined;
|
||
return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector({...measured,position:measured.internals.positionAbsolute},handle,end==='source'?'right':'left');
|
||
},
|
||
layer:(id,kind)=>kind==='node'?flow.getInternalNode(id)?.internals.z:undefined,
|
||
});
|
||
// A possible hit is deliberately not selected; a stale frame must not select a new incarnation.
|
||
if(engine.getSnapshot().document!==captured.root.document||engine.sessions.getSnapshot()!==captured.sessions)return;
|
||
const hit=result.hits[0];
|
||
engine.sessions.select(viewId,hit?.certainty==='confirmed'?[hit.target.id]:[]);
|
||
}
|
||
/** session.begin成功后捕获gesture token,以guardEditingLease约束后续preview/commit/cancel只作用于该交互。 */
|
||
function beginTransform(id: Id) {
|
||
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
|
||
const token = engine.sessions.getSnapshot().gesture!.token;
|
||
/** 每次租约动作实时核对token,旧pointerup不能结束后来新建的手势。 */
|
||
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
|
||
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||
}
|
||
/** 将显示空间位置/尺寸转成owner局部Geometry:尺寸除scale,以中心toLocal后再求左上角,旋转减去父旋转。
|
||
* 这里只更新session预览,不直接dispatch文档;后续commit统一形成history与同步修改。 */
|
||
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
|
||
const { root, projection } = current.current, element = root.document.elements.get(id);
|
||
if (element?.structure !== 'node') return;
|
||
const t = projection.transforms.get(element.ownerCanvasId)!;
|
||
const displayed = projectionCache.current!.getNode(id)!;
|
||
const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale;
|
||
const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 });
|
||
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
||
report(engine.sessions.update(viewId, target(id), patch));
|
||
}
|
||
/** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。
|
||
* 默认位置从首个data-headless-canvas的DOM矩形取屏幕中心再转场景坐标,at可显式覆盖。 */
|
||
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
|
||
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
|
||
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
|
||
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
|
||
// 各类型使用固定默认尺寸及初始incarnation/dataVersion;资源引用由已stage/adopt的ref注入,不在这里上传。
|
||
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
|
||
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
||
/** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */
|
||
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
||
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
||
return id;
|
||
}
|
||
/** 选择工具才可建边;拒绝unsupported端点,将owner设为两端所属canvas的共同上级,端口ID来自连接事件。 */
|
||
function connect(connection: Connection) {
|
||
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
|
||
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
|
||
if (!source || !destination) return;
|
||
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
|
||
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
||
}
|
||
// 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。
|
||
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||
// 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。
|
||
useEffect(() => {
|
||
if (!liveView) return;
|
||
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
|
||
resourceView.current=view;
|
||
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
|
||
}, [engine,viewId,Boolean(liveView)]);
|
||
// 资源目标集合变化才sync,映射失败在本面板报错,不擅自修改文档中的assetId。
|
||
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
|
||
// 再按engine/view及当前assetId过滤结果,节点切换资源时不显示上一个资源的迟到URL。
|
||
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
|
||
/** 一次事务删除当前选择中仍存在的元素;关联结构处理由editor命令实现,不循环修改投影数组。 */
|
||
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
||
// 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。
|
||
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
||
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
||
// 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。
|
||
useEffect(() => {
|
||
/** 核对viewId后取消session并清本地拖动记录;window blur不要求指针事件。 */
|
||
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
|
||
// Bubble after the target/React handlers: an input owner can claim Escape first.
|
||
/** 冒泡阶段让输入控件优先认领Escape,再按统一路由决定是否取消手势。 */
|
||
const escape = (event: KeyboardEvent) => {
|
||
if(event.key!=='Escape')return;
|
||
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{gesture:()=>{const state=engine.sessions.getSnapshot();if(state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;cancel();return true;}});
|
||
if(claim==='gesture'){event.preventDefault();event.stopPropagation();}
|
||
};
|
||
window.addEventListener('blur', cancel); window.addEventListener('keydown', escape);
|
||
return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape); };
|
||
}, [engine, viewId]);
|
||
const modes = view.modes;
|
||
// Retain explicit owners in the native renderer, leaving the full graph in its
|
||
// lookup for edges/queries. No synthetic dragging or altered geometry is used.
|
||
// 保留模式目标、可见绘制对象、浮层目标和本视图手势;完整图仍留ReactFlow查询表,不以伪造拖动避裁剪。
|
||
const retained=new Set<Id>([...modes.keys(),...paintedNodes,...paintedEdges]);
|
||
for(const entry of visibleOverlays)retained.add(entry.target.id);
|
||
if(sessions.gesture?.viewId===viewId)for(const target of sessions.gesture.targets)retained.add(target.id);
|
||
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
|
||
// 活动输入可来自节点、连线、Portal浮层或独立UI;无法证明其归属时保守关闭可见性裁剪。
|
||
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
|
||
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
|
||
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
|
||
const independentFocus=focusedUI?.dataset.canvasUiView===viewId;
|
||
const focusedNative=activeEditor?.closest<HTMLElement>('.react-flow__node,.react-flow__edge');
|
||
const focusedId=focusedOverlay?.dataset.canvasOverlayView===viewId?focusedOverlay.dataset.canvasOverlayTarget:focusedNative?.closest('[data-headless-canvas]')===surface.current?focusedNative?.dataset.id:undefined;
|
||
if(focusedId)retained.add(focusedId);
|
||
// Retained connections also keep their measured endpoint owners alive.
|
||
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
|
||
// 所有保留对象仍须存在于文档,且视口尺寸已知,才启用native onlyRenderVisibleElements。
|
||
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
|
||
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
|
||
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
|
||
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
|
||
// 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。
|
||
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
|
||
const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
||
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
||
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
|
||
// Runtime-only publications must not invalidate every card's editing context.
|
||
// Actions use current.current / engine snapshots instead of captured document projections.
|
||
const rotating = [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null;
|
||
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
|
||
// 新版资源URL寿命由resourceView管理,CanvasImage的loaded回调这里无需删除旧版上传任务。
|
||
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
|
||
// 给旧节点renderer注入新版动作:begin/end维护文本history分组与editing mode,正文按同incarnation使用groupId。
|
||
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
|
||
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
|
||
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
|
||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
||
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||
// 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。
|
||
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
||
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
||
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
||
// 分支直接在当前canvas按方向偏移创建文本,再同一事务创建连线;与旧版弹类型菜单的行为不同。
|
||
branch: (id, direction) => {
|
||
const node = projectionCache.current!.getNode(id); if (!node) return;
|
||
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
|
||
report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' }));
|
||
},
|
||
|
||
}),[engine,viewId,board.workspace_id,flow,report,rotating,resizing]);
|
||
// 曲线编辑同样捕获connectionGesture token;active暂为空集合,连线renderer在此不从run状态获取执行动画。
|
||
const edgeEditing = useMemo<ContextType<typeof EdgeEditing>>(() => ({ active: new Set(), begin: id => {
|
||
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
|
||
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
||
const transform = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||
/** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */
|
||
const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
||
return guardEditingLease(ownsGesture, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||
},
|
||
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||
// 临时path只更新session;正式修改把data和path分开命令,在同一个事务提交,避免把路径写进业务data。
|
||
patch: (id, value, transient) => {
|
||
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
|
||
const t = current.current.projection.transforms.get(element.ownerCanvasId)!;
|
||
const path = transformPath((value.path ?? null) as Json, t, true);
|
||
const displayedPath = current.current.projection.edges.find(edge => edge.id === id)?.data?.path;
|
||
// 工具栏可能传回整个显示data;只有显式含path且与显示路径不同才提交connection.path,避免无意重写。
|
||
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
|
||
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
|
||
const { path: ignored, ...rest } = value; void ignored;
|
||
report(engine.editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target(id), patch: json(rest) }); if (changedPath) tx.dispatch({ type: 'connection.path', target: target(id), path }); }, { label: 'Edit connection' }));
|
||
},
|
||
|
||
}),[engine,viewId,report]);
|
||
// 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。
|
||
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
||
// 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。
|
||
if (!liveView) return <p role="status">画布已关闭</p>;
|
||
// surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。
|
||
// 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。
|
||
// ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。
|
||
// 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。
|
||
// 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。
|
||
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
||
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }}
|
||
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setFocusedEditor(null); }); }}
|
||
onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:pointerStart.current?.pointerId}; }}
|
||
onTouchStartCapture={event => { if (event.touches.length > 1) { const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId||state.connectionGesture?.viewId===viewId)engine.sessions.cancel('multi_touch');drag.current = null;pointerStart.current=null;return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }),pointerId:pointerStart.current?.pointerId}; }}
|
||
onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:event.pointerId}; }}
|
||
onPointerCancelCapture={event => { const currentDrag=drag.current;if(!currentDrag||currentDrag.pointerId!==undefined&¤tDrag.pointerId!==event.pointerId)return;const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId&&state.gesture.token===currentDrag.token)engine.sessions.cancel('pointer_cancel');drag.current=null; }}
|
||
onKeyDown={event => {
|
||
const claim=routeCanvasKey({key:event.key,composing:event.nativeEvent.isComposing,...canvasKeyContext(event.nativeEvent),defaultPrevented:event.defaultPrevented,meta:event.metaKey,ctrl:event.ctrlKey,alt:event.altKey,shift:event.shiftKey},{
|
||
gesture:input=>{const state=engine.sessions.getSnapshot();if(input.key!=='Escape'||state.gesture?.viewId!==viewId&&state.connectionGesture?.viewId!==viewId)return false;engine.sessions.cancel('escape');drag.current=null;return true;},
|
||
tool:input=>{if(input.ctrl||input.meta||input.alt||input.shift)return false;const key=input.key.toLowerCase();if(key!=='h'&&key!=='v')return false;engine.sessions.tool(viewId,key==='h'?'pan':'select');return true;},
|
||
shortcut:input=>{if(input.alt)return false;if((input.meta||input.ctrl)&&input.key.toLowerCase()==='z'){report(input.shift?engine.editor.history.redo():engine.editor.history.undo());return true;}if(!input.meta&&!input.ctrl&&!input.shift&&(input.key==='Delete'||input.key==='Backspace')&¤t.current.view.selection.size){removeSelected();return true;}return false;},
|
||
});
|
||
if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();}
|
||
}}>
|
||
<CanvasOverlays.Provider value={overlayScope}><ImagePreviews.Provider value={imagePreviews}>
|
||
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
|
||
<NodeEditing.Provider value={nodeEditing}>
|
||
<EdgeEditing.Provider value={edgeEditing}>
|
||
<ReactFlow<ProjectionNode> elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={rendered.nodes} edges={rendered.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
|
||
nodesConnectable={view.tool === 'select'} nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport}
|
||
|
||
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
||
onNodeDragStart={(event, node, nodes) => {
|
||
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
||
// 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。
|
||
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
||
const point = 'clientX' in event ? event : event.changedTouches[0];
|
||
drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
|
||
const result = engine.sessions.begin(viewId, top.map(target)); report(result);
|
||
if (result.enabled) drag.current.token = engine.sessions.getSnapshot().gesture!.token; else drag.current = null;
|
||
}}
|
||
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
|
||
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
|
||
onNodeDragStop={event => {
|
||
// 仅结束匹配本视图和token的拖动,迟到dragStop不提交当前其它手势。
|
||
const saved = drag.current; drag.current = null; pointerStart.current = null;
|
||
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
|
||
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
||
for (const [id, start] of saved.positions) transform(id, { position: { x: start.x + end.x - saved.start.x, y: start.y + end.y - saved.start.y } });
|
||
report(engine.sessions.commit(viewId));
|
||
}}
|
||
onConnect={connect} onPaneClick={selectAtPane}>
|
||
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color="#ded7ce" />
|
||
</ReactFlow>
|
||
</EdgeEditing.Provider></NodeEditing.Provider></CanvasRendererCommandsContext.Provider></ImagePreviews.Provider></CanvasOverlays.Provider>
|
||
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
||
// 深复制点击时捕获选择身份,异步完成只更新同engine/view的面板;runtime负责进度与已加入内容。
|
||
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
|
||
setCopying(true); setError("");
|
||
void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); });
|
||
}} />
|
||
<IconButton icon={Minus} label="缩小" onClick={() => flow.zoomOut()} /><IconButton icon={Plus} label="放大" onClick={() => flow.zoomIn()} /><IconButton icon={Maximize} label="适应画布" onClick={() => flow.fitView({ padding: .2 })} />
|
||
<IconButton icon={Download} label="导出图谱" disabled={!!loading} onClick={() => { const snapshot = engine.getSnapshot(), url = URL.createObjectURL(new Blob([JSON.stringify({ documentFormatVersion: 1, projectId: snapshot.document.projectId, canvases: [...snapshot.document.canvases.values()], elements: [...snapshot.document.elements.values()], tombstones: Object.fromEntries(snapshot.document.tombstones) })], { type: 'application/json' })); const a = window.document.createElement('a'); a.href = url; a.download = board.title + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }} />
|
||
</div><p role="status" style={{ fontSize: 11 }}>{blocked ? '有修改需要处理' : loading ? `正在加载… ${loading.receivedElements}/${loading.totalElements},${loading.version>=3?"按已就绪范围同步":"修改已在本地排队"}` : pending.length ? '正在同步…' : '已同步'}{blocked && <><button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button><button aria-label="处理同步修改" onClick={() => setShowRecovery(true)}>处理同步修改</button></>}</p></div>
|
||
<div data-canvas-ui-view={viewId} className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
|
||
<IconButton icon={Ratio} label="按图片比例调整" disabled={!!loading||!canFitImage} onClick={()=>{if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
|
||
<IconButton icon={Sparkles} label="AI 创作" disabled={!!loading||!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
|
||
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={() => imageInput.current?.click()} /><IconButton icon={Images} label="复用其它画布图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={()=>{const rect=surface.current?.getBoundingClientRect();setResourcePicker({canvasId:view.canvasId,position:flow.screenToFlowPosition({x:(rect?.left??0)+(rect?.width??window.innerWidth)/2,y:(rect?.top??0)+(rect?.height??window.innerHeight)/2})});}} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
|
||
</div>
|
||
<input data-canvas-ui-view={viewId} ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
|
||
{(!loading||loading.version>=5)&&<HeadlessImports viewId={viewId} engine={engine} canvasId={view.canvasId} />}
|
||
{resourcePicker&&resourcePicker.canvasId===view.canvasId&&<HeadlessResourcePicker viewId={viewId} engine={engine} workspaceId={board.workspace_id} canvasId={resourcePicker.canvasId} position={resourcePicker.position} close={()=>setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />}
|
||
{showAI && aiElement && <HeadlessAIPanel viewId={viewId} key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
|
||
{showRecovery && <HeadlessRecoveryDialog viewId={viewId} engine={engine} close={() => setShowRecovery(false)} />}
|
||
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||
</div>;
|
||
}
|
||
/** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。
|
||
* 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */
|
||
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) {
|
||
const [handoffViewport]=useState(initialViewport);
|
||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||
// attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。
|
||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
||
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||
}
|