"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(); 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;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0}); const resourceView=useRef|null>(null); const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map } | null>(null); const projectionCache = useRef | null>(null); projectionCache.current ??= createProjectionCache(); const geometryCache=useRef|null>(null);geometryCache.current??=createGeometryProjectionCache(); const measurements = useRef(new Map()); const imageInput = useRef(null), groups = useRef(new Map()); 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[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 = { 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[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) => { 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();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>(() => ({sources,loaded:()=>{}}),[sources]); const nodeEditing = useMemo>(() => ({ 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>(() => ({ 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) => 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(()=>({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

画布已关闭

; return
{ 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')&¤t.current.view.selection.size){removeSelected();return true;}return false;}, }); if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();} }}> 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}>
report(engine.editor.history.undo())} /> report(engine.editor.history.redo())} /> { 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); }); }} /> flow.zoomOut()} /> flow.zoomIn()} /> flow.fitView({ padding: .2 })} /> { 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); }} />

{blocked ? '有修改需要处理' : loading ? `正在加载… ${loading.receivedElements}/${loading.totalElements},${loading.version>=3?"按已就绪范围同步":"修改已在本地排队"}` : pending.length ? '正在同步…' : '已同步'}{blocked && <>}

engine.sessions.tool(viewId, 'select')} /> engine.sessions.tool(viewId, 'pan')} /> {if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} /> setShowAI(value => !value)} /> add('plain-text')} /> add('text')} /> imageInput.current?.click()} />{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})});}} /> add('frame')} />
{ 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)&&} {resourcePicker&&resourcePicker.canvasId===view.canvasId&&setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />} {showAI && aiElement && setShowAI(false)} />} {showRecovery && setShowRecovery(false)} />} {error &&
{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&}
}
; } 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 ? :

正在准备画布…

; }