Share scoped display leases and retain incremental resource views

This commit is contained in:
2026-11-09 00:46:34 +08:00
parent 5919eb0453
commit 03c3a0dc66
9 changed files with 333 additions and 32 deletions
+12 -12
View File
@@ -13,6 +13,7 @@ import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cach
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors";
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";
@@ -42,7 +43,9 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
useEffect(() => { focusEpoch.current++; setEditorFocused(false); return () => { focusEpoch.current++; }; }, [engine,viewId]);
const [copying, setCopying] = useState(false);
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
const [error, setError] = useState(""), [sources, setSources] = useState(new Map<string, string>());
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();
@@ -122,18 +125,15 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
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 => node.data.assetId).map(node => [node.id, node.data.assetId]));
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;
let active = true;
const handles: { release(): void }[] = [], entries: [Id, string][] = [];
void Promise.all((JSON.parse(resourceKey) as [Id, Id][]).map(async ([id, resource]) => {
const handle = await engine.displayResource(resource);
if (!active) { handle.release(); return; }
handles.push(handle); entries.push([id, handle.url]);
})).then(() => { if (active) setSources(new Map(entries)); }).catch(e => { if (active) setError(errorText(e)); });
return () => { active = false; for (const handle of handles) handle.release(); };
}, [engine, resourceKey, Boolean(liveView)]);
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;
@@ -258,7 +258,7 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
<HeadlessImports engine={engine} canvasId={view.canvasId} />
{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} <button onClick={() => setError('')}>关闭</button></div>}
{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 }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers }) {