Files
Endoleap/src/components/canvas/headless-graph-canvas.tsx
T

288 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache";
import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint";
import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames";
import {routeCanvasKey} 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 { projectCanvas, 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";
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers;fitLoading?:boolean }) {
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
const root = useSyncExternalStore(subscribe, engine.getSnapshot, engine.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);
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>();
const canCreate=!loading||loading.version>=4&&engine.editor.getQueries(root.document).coverage.completeCanvasIds.includes(view.canvasId);
useEffect(()=>{if(!needsLoadingFit.current||!flow.viewportInitialized||!root.document.elements.size)return;needsLoadingFit.current=false;void flow.fitView({padding:.2,maxZoom:1});},[flow,root.document]);
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
const [editorFocused, setEditorFocused] = useState(false), focusEpoch = useRef(0);
useEffect(() => { focusEpoch.current++; setEditorFocused(false); return () => { focusEpoch.current++; }; }, [engine,viewId]);
const [copying, setCopying] = useState(false);
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
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("");
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);
const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
projectionCache.current ??= createProjectionCache();
const geometryCache=useRef<ReturnType<typeof createGeometryProjectionCache>|null>(null);geometryCache.current??=createGeometryProjectionCache();
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id, Id>());
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
const projection = useMemo(() => {
const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture,catalog,{queries:engine.editor.getQueries(root.document),geometry:geometryCache.current!,owner:engine});
// 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, sessions, view,renderers,catalog]);
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 paintVisible=paint.unknown.length>0||!surfaceWidth||!surfaceHeight||paint.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0;
const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions };
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)));
}, []);
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
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]);
}
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]:[]);
}
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;
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') });
}
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));
}
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 });
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 } : {}) } };
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;
}
function connect(connection: Connection) {
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 } }));
}
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
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)]);
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
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]);
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' })); }
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;
useEffect(() => {
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
const escape = (event: KeyboardEvent) => { const state=engine.sessions.getSnapshot(),element=event.target instanceof Element?event.target:null;if(event.key==='Escape'&&!event.defaultPrevented&&!event.isComposing&&!element?.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"],[role="dialog"],[role="menu"]')&&(state.gesture?.viewId===viewId||state.connectionGesture?.viewId===viewId)){cancel();} };
window.addEventListener('blur', cancel); window.addEventListener('keydown', escape, true);
return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape, true); };
}, [engine, viewId]);
const modes = view.modes;
// Native culling keeps the full graph in React Flow. Retain every mounted editor/lease
// while an interaction is active so viewport changes cannot discard IME or capture.
const cullVisible = !paintVisible && !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId;
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;
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
begin: id => { if (id) { groups.current.set(id, 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)), 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'); },
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'); },
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]);
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)!;
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'); },
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;
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]);
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]);
if (!liveView) return <p role="status">画布已关闭</p>;
return <div 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++; setEditorFocused(true); } }}
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setEditorFocused(false); }); }}
onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }}
onTouchStartCapture={event => { if (event.touches.length > 1) { engine.sessions.cancel('multi_touch'); drag.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 }); }}
onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }}
onPointerCancelCapture={() => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); drag.current = null; }}
onKeyDown={event => {
const element=event.target instanceof Element?event.target:null;
const claim=routeCanvasKey({key:event.key,composing:event.nativeEvent.isComposing,textEditing:!!element?.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'),overlay:!!element?.closest('[role="dialog"],[role="menu"]'),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')&&current.current.view.selection.size){removeSelected();return true;}return false;},
});
if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();}
}}>
<ImagePreviews.Provider value={imagePreviews}>
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
<NodeEditing.Provider value={nodeEditing}>
<EdgeEditing.Provider value={edgeEditing}>
<ReactFlow<ProjectionNode> elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
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, 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 => {
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>
<div 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={() => {
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 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=window.document.querySelector('[data-headless-canvas]')?.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 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 engine={engine} canvasId={view.canvasId} />}
{resourcePicker&&resourcePicker.canvasId===view.canvasId&&<HeadlessResourcePicker 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 key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
{showRecovery && <HeadlessRecoveryDialog 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>;
}
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);
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>;
}