Enable partial canvas editing and complete detached views in the project runtime
This commit is contained in:
+13
-2
@@ -43,5 +43,16 @@ deletions, ownership/reference changes, uploads, imports and AI admission wait.
|
||||
replacing local edits. `engine.loading.complete(document, versions, {sourceToken})`
|
||||
validates the full capture and releases scheduling only after its cut is durable.
|
||||
Use `{fresh:true}` only for an explicitly fresh complete read; conflicts require the
|
||||
existing reviewed recovery flow. Endoleap's page still uses read-only loading previews;
|
||||
page integration and foreground/background source handoff remain unfinished.
|
||||
existing reviewed recovery flow. `engine.loading.resume()` coalesces a fresh complete
|
||||
read through the project's persistence adapter and persists completion before resolving.
|
||||
`workspace.resumeLoading(projectId)` delegates to that owner and reports current-identity
|
||||
failures. Closing the project aborts the read and saves the recoverable prefix; detaching
|
||||
a view does not close its engine.
|
||||
|
||||
Endoleap's page now opens the same project engine for a validated prefix, allows the
|
||||
restricted field/geometry edits above, coalesces incoming prefixes, and preserves its
|
||||
textarea, history and viewport when the full model arrives. App navigation cancels the
|
||||
view's stream and asks the retained project owner to complete through a fresh read.
|
||||
Reload recovers the durable prefix when that project is opened again. This is not a
|
||||
persistent HTTP cursor or a workspace-wide startup scan; partial-scope ACK/chunk
|
||||
interleaving and broader structural permissions remain unfinished.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { equal } from "@/lib/canvas-core/values";
|
||||
import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache";
|
||||
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";
|
||||
@@ -31,13 +32,16 @@ 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 }: { engine: ProjectEngine; board: Board; viewId: Id; renderers:ReactRenderers }) {
|
||||
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 sessions = useSyncExternalStore(engine.sessions.subscribe, engine.sessions.getSnapshot, engine.sessions.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>();
|
||||
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]);
|
||||
@@ -52,15 +56,16 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
|
||||
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,renderers.catalog,{queries:engine.editor.getQueries(root.document),geometry:geometryCache.current!,owner:engine});
|
||||
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]);
|
||||
}, [engine, root.document, sessions, view,renderers,catalog]);
|
||||
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 }) => {
|
||||
@@ -193,7 +198,7 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
|
||||
}),[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 tabIndex={-1}
|
||||
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 }); }}
|
||||
@@ -213,7 +218,7 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
|
||||
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
|
||||
<NodeEditing.Provider value={nodeEditing}>
|
||||
<EdgeEditing.Provider value={edgeEditing}>
|
||||
<ReactFlow<ProjectionNode> onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
|
||||
<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)}
|
||||
@@ -240,22 +245,22 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
|
||||
</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 || !view.selection.size} onClick={() => {
|
||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={!!loading || copying || preparingCopy || !view.selection.size} 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="导出图谱" 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 ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <><button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button><button aria-label="处理同步修改" onClick={() => setShowRecovery(true)}>处理同步修改</button></>}</p></div>
|
||||
<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},修改已在本地排队` : 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={!canFitImage} onClick={()=>{if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
|
||||
<IconButton icon={Sparkles} label="AI 创作" disabled={!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
|
||||
<IconButton icon={Type} label="添加纯文本" onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" onClick={() => add('frame')} />
|
||||
<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={!!loading} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!!loading} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!!loading} onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" disabled={!!loading} 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)); } }} />
|
||||
<HeadlessImports engine={engine} canvasId={view.canvasId} />
|
||||
{!loading&&<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} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||||
@@ -265,5 +270,5 @@ export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCa
|
||||
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><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} /></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
return attached ? <ReactFlowProvider><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type N
|
||||
import type {Registry} from '@/lib/canvas-core/types';
|
||||
import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
|
||||
import type {ReactRenderers} from '@/lib/canvas-bindings/react-renderers';
|
||||
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
|
||||
import {projectCanvas} from '@/lib/canvas-bindings/projection';
|
||||
import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/renderer-context';
|
||||
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
|
||||
@@ -22,7 +23,7 @@ function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:View
|
||||
export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){
|
||||
// Without a declared reference schema, use the existing opaque placeholder
|
||||
// until the complete document is available to the installed renderer.
|
||||
const catalog=useMemo(()=>({...renderers.catalog,resolve(element:Parameters<ReactRenderers['catalog']['resolve']>[0]){return registry.components.get(element.componentId)?.copy?renderers.catalog.resolve(element):undefined;}}),[registry,renderers]);
|
||||
const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]);
|
||||
const projection=useMemo(()=>{
|
||||
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
|
||||
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type {Viewport} from '@xyflow/react';
|
||||
import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
|
||||
import type {LiveModelPreview} from '@/lib/canvas-runtime/model-preview';
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
@@ -27,7 +27,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
const route = workspaceId + ":" + boardId;
|
||||
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
|
||||
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||
const [preview,setPreview]=useState<{board:Board;value:ModelPreview;route:string}|null>(null);
|
||||
const [preview,setPreview]=useState<{board:Board;value:LiveModelPreview;route:string}|null>(null);
|
||||
const previewViewport=useRef<Viewport|undefined>(undefined);
|
||||
const [error, setError] = useState("");
|
||||
const [retry, setRetry] = useState(0);
|
||||
@@ -43,17 +43,28 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined;
|
||||
let latest:{value:LiveModelPreview;board:Board;accountId:string}|undefined,pumping:Promise<void>|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType<typeof runtimeHost.workspace>|undefined;
|
||||
const reportBackground=(error:unknown)=>{if(active)setError(errorText(error));};
|
||||
const completeInBackground=()=>{if(partialEngine?.loading.getState()&¤tIdentity?.())void partialRuntime?.resumeLoading(partialEngine.getSnapshot().document.projectId).catch(reportBackground);};
|
||||
function pump(){
|
||||
if(pumping)return pumping;
|
||||
const task=(async()=>{while(latest){const next=latest;latest=undefined;if(!currentIdentity?.())return;const runtime=runtimeHost.workspace(next.accountId,next.board.workspace_id);partialRuntime=runtime;const engine=await runtime.openPartial(next.value.document,next.value.versions,next.value.loading);partialEngine=engine;if(active&¤tIdentity?.()){setLoaded(previous=>previous?.route===route&&previous.engine===engine?previous:{board:next.board,engine,route});setPreview(null);}else{completeInBackground();return;}await engine.persist();}})();
|
||||
pumping=task.finally(()=>{pumping=undefined;if(latest&&active)void pump().catch(reportBackground);});return pumping;
|
||||
}
|
||||
|
||||
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current,
|
||||
onPreview:(value,board)=>{if(active&&(!currentIdentity||currentIdentity())&&value.document.canvases.has(board.id))setPreview({board,value,route});},
|
||||
onPreview:(value,board,profile)=>{if(active&¤tIdentity?.()&&value.document.canvases.has(board.id)){if(!partialEngine)setPreview({board,value,route});if(value.document.elements.size){latest={value,board,accountId:profile.id};void pump().catch(reportBackground);}}},
|
||||
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
|
||||
}).then(async result => {
|
||||
if (!active||currentIdentity&&!currentIdentity()) return;
|
||||
if (result.mode === 'legacy') { setLoaded({ board: result.board, snapshot: result.snapshot, route });setPreview(null); return; }
|
||||
while(pumping)await pumping;
|
||||
if(!active||!currentIdentity?.())return;
|
||||
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions);
|
||||
if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);}
|
||||
})
|
||||
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
|
||||
return () => { active = false; controller.abort(); };
|
||||
return () => { active = false;latest=undefined; controller.abort();completeInBackground(); };
|
||||
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
||||
const current = loaded?.route === route ? loaded : null;
|
||||
const currentPreview=preview?.route===route?preview:null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ElementRecord } from '../canvas-core/types.ts';
|
||||
import type { ElementRecord,Registry } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
export type RendererDescriptor={readonly componentId:string;readonly dataVersion:number;readonly structure:'node'|'connection';readonly rendererId:string;readonly projection?:'compat-v1'|'record-v1'};
|
||||
export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];resolve(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>):RendererDescriptor|undefined};
|
||||
@@ -17,3 +17,6 @@ export const builtinRendererDefinitions:readonly RendererDescriptor[]=Object.fre
|
||||
Object.freeze({componentId:'connection',dataVersion:1,structure:'connection',rendererId:'default',projection:'compat-v1'}),
|
||||
]);
|
||||
export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinitions);
|
||||
|
||||
/** Installed renderers without a reference declaration wait for full context. */
|
||||
export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {ModelPreview} from './canvas-runtime/model-preview.ts';
|
||||
import type {LiveModelPreview} from './canvas-runtime/model-preview.ts';
|
||||
import type { Profile } from './api.ts';
|
||||
import type { CanvasRequest } from './canvas-sync.ts';
|
||||
import { decodeSnapshot } from './canvas-sync.ts';
|
||||
@@ -8,14 +8,14 @@ import type { Registry } from './canvas-core/types.ts';
|
||||
import { loadFoundryModel, type ModelLoad, type ServiceTransport } from './canvas-service/foundry.ts';
|
||||
|
||||
/** Read canonical first: a migrated project must never be opened through snapshot writers. */
|
||||
export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:ModelPreview)=>void}):Promise<ModelLoad>;onPreview?:(preview:ModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) {
|
||||
export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:LiveModelPreview)=>void}):Promise<ModelLoad>;onPreview?:(preview:LiveModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) {
|
||||
const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`;
|
||||
const [board, profile] = await Promise.all([options.request<Board>(path, { signal: options.signal }), options.request<Profile>('profile', { signal: options.signal })]);
|
||||
options.signal?.throwIfAborted();
|
||||
if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference');
|
||||
if(!options.readModel&&!options.transport)throw new Error('model_loader_unavailable');
|
||||
try {
|
||||
const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:ModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal);
|
||||
const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:LiveModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
if (model.status === 'ready') return { mode: 'headless' as const, board, profile, model };
|
||||
// Existing projects keep their current editor until the user explicitly migrates them.
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const image = await options.storage.recover(leader), recovered = image ? decodeRecovery(image, options.registry) : undefined;
|
||||
if (recovered && recovered.document.projectId !== options.scope.projectId) throw fault('project_mismatch');
|
||||
for (const canvas of options.document.canvases.keys()) if (!Number.isSafeInteger(openingVersions.get(canvas)) || openingVersions.get(canvas)! < 1) throw fault('missing_server_version', [canvas]);
|
||||
let loadingRead:Promise<Result>|undefined,loadingReadController:AbortController|undefined;
|
||||
let acceptingLoading=false,loadingRelease:(()=>void)|undefined,loadingCompletionRevision:number|undefined;
|
||||
let closing = false, closed = false, durableRootRevision = image?.rootRevision ?? -1;
|
||||
const digestLifecycle=new AbortController();
|
||||
@@ -704,6 +705,12 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
return Object.freeze({
|
||||
editor, sessions, imports,
|
||||
loading:Object.freeze({getState:()=>editor.getSnapshot().runtime.loading,
|
||||
resume():Promise<Result>{
|
||||
if(loadingRead)return loadingRead;
|
||||
const task=(async()=>{assertWritable(false);if(!editor.getSnapshot().runtime.loading)return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:[]};if(!options.persistence.read)throw fault('canonical_reader_unavailable');const controller=new AbortController();loadingReadController=controller;
|
||||
try{const read=await network.run(controller.signal,()=>options.persistence.read!(controller.signal));assertWritable(false);controller.signal.throwIfAborted();if(!editor.getSnapshot().runtime.loading)return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:[]};const result=completeLoading(read.document,read.versions,{fresh:true});if(result.status==='applied')await persist();return result;}finally{if(loadingReadController===controller)loadingReadController=undefined;}
|
||||
})();loadingRead=task.finally(()=>{if(loadingRead===wrapped)loadingRead=undefined;});const wrapped=loadingRead;return wrapped;
|
||||
},
|
||||
accept(document:Document,versions:ReadonlyMap<Id,number>,state:LoadingState):Result{
|
||||
try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');validatePrefixExtension(previous.acknowledged,previous.versions,previous.loading,document,versions,state,options.registry);const acknowledged=createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()],document.tombstones),working=replayPending(acknowledged,previous.ledger,options.registry);acceptingLoading=true;return editor.reconcile(working,Object.freeze({...previous,loading:freeze(clone(state)),acknowledged}),root.rootRevision);}catch(error){return reject(error);}finally{acceptingLoading=false;}
|
||||
},
|
||||
@@ -913,7 +920,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]);while(sizingTask)await sizingTask; await scheduler.settle(); await runTracker.settle(); await persist(); },
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closing = true;unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
|
||||
closing = true;loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
|
||||
await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask;
|
||||
recovery?.controller.abort();recovery?.release();
|
||||
for (const release of [...displays]) release();displayCache.releaseOwner(displayOwner);if(!options.displays)displayCache.close();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {createModelPreview,type ModelPreview} from './model-preview.ts';
|
||||
import {createModelPreview,type LiveModelPreview} from './model-preview.ts';
|
||||
import type { Id, Registry } from '../canvas-core/types.ts';
|
||||
import { fault, readMap } from '../canvas-core/values.ts';
|
||||
import type { WorkspaceRuntime } from './workspace.ts';
|
||||
@@ -28,16 +28,16 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou
|
||||
bindAccount(account: Id) { activate(account); const epoch = authEpoch; return () => !disposed && accountId === account && authEpoch === epoch; },
|
||||
identity: () => Object.freeze({ accountId, authEpoch }),
|
||||
getWorkspaces: () => readMap(workspaces),
|
||||
async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:ModelPreview)=>void}={}) {
|
||||
async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) {
|
||||
activate(account);
|
||||
const readEpoch=authEpoch;
|
||||
if(!options.modelLoader)throw fault('model_loader_unavailable');
|
||||
let reader=readers.get(workspace);
|
||||
if(!reader){const epoch=authEpoch,current=()=>!disposed&&accountId===account&&authEpoch===epoch;reader=createScopedLoader({load:options.modelLoader(account,workspace,epoch,current),current:()=>current(),maxCacheEntries:0});readers.set(workspace,reader);}
|
||||
const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;
|
||||
const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;let declaredCanvasIds:readonly Id[]=[];
|
||||
const onUpdate=(state:import('./loader.ts').LoadSnapshot<CanonicalReadUpdate>)=>{
|
||||
if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return;
|
||||
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value)settings.onPreview?.(value);}}
|
||||
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({version:1 as const,sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
|
||||
catch(error){previewFailed=true;try{options.onObserverError?.(error);}catch{}}
|
||||
};
|
||||
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate});
|
||||
|
||||
@@ -24,6 +24,6 @@ export { createImageDecodePool } from './image-decoder.ts';
|
||||
export type { ImageDecoderPort, ImageDimensions, ImageDecodeState } from './image-decoder.ts';
|
||||
|
||||
export {createModelPreview} from './model-preview.ts';
|
||||
export type {ModelPreview} from './model-preview.ts';
|
||||
export type {ModelPreview,LiveModelPreview} from './model-preview.ts';
|
||||
|
||||
export type {LoadingState} from './loading.ts';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { copyReferenceIds } from '../canvas-core/copy-paths.ts';
|
||||
import { clone, equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
import type { CanonicalReadUpdate } from './contracts.ts';
|
||||
|
||||
export type LiveModelPreview=ModelPreview&{readonly loading:import('./loading.ts').LoadingState;readonly versions:ReadonlyMap<Id,number>};
|
||||
export type ModelPreview = { readonly document:Document;readonly receivedElements:number;readonly totalElements:number;readonly waitingElements:number;readonly complete:false };
|
||||
/** An ephemeral read projection. It owns neither a working document nor ACKs,
|
||||
* history, operations, file bytes or a durable recovery cut. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type {LoadingState} from './loading.ts';
|
||||
import type { Document, Id, Registry } from '../canvas-core/types.ts';
|
||||
import { fault, readMap } from '../canvas-core/values.ts';
|
||||
import { openProjectEngine, type EngineOptions } from './engine.ts';
|
||||
@@ -20,21 +21,25 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
|
||||
const projects = new Map<Id, Promise<ProjectEngine>>();
|
||||
let accepting = true, closed = false, closing: Promise<void> | undefined;
|
||||
const current = () => accepting && !closed && options.isCurrentIdentity();
|
||||
function acquire(document:Document,versions:ReadonlyMap<Id,number>,loading?:LoadingState){
|
||||
if(!current())return Promise.reject(fault('identity_changed'));
|
||||
const existing=projects.get(document.projectId);if(existing)return existing;
|
||||
const opening=openProjectEngine({scope:{accountId:options.accountId,workspaceId:options.workspaceId,projectId:document.projectId},registry:options.registry,document,versions,loading,storage:options.storage,displays,digests,images:options.images,queryBudget:options.queryBudget,...options.adapters(document.projectId,current),onError:options.onError,isCurrentIdentity:current,policy:()=>current()?{enabled:true}:{enabled:false,code:'identity_changed',targets:[]},traversalPolicy:()=>current()?{enabled:true}:{enabled:false,code:'identity_changed',targets:[]}});
|
||||
projects.set(document.projectId,opening);void opening.catch(()=>{if(projects.get(document.projectId)===opening)projects.delete(document.projectId);});return opening;
|
||||
}
|
||||
return Object.freeze({
|
||||
accountId: options.accountId, workspaceId: options.workspaceId, authEpoch: options.authEpoch, registry: options.registry,
|
||||
isCurrentIdentity: current,
|
||||
getProjects: () => readMap(projects),
|
||||
open(document: Document, versions: ReadonlyMap<Id, number>) {
|
||||
if (!current()) return Promise.reject(fault('identity_changed'));
|
||||
const existing = projects.get(document.projectId);
|
||||
if (existing) return existing.then(engine => { if (!current()) throw fault('identity_changed'); engine.refresh(document, versions); return engine; });
|
||||
const opening = openProjectEngine({ scope: { accountId: options.accountId, workspaceId: options.workspaceId, projectId: document.projectId }, registry: options.registry, document, versions, storage: options.storage, displays, digests, images:options.images, queryBudget:options.queryBudget, ...options.adapters(document.projectId, current), onError: options.onError,isCurrentIdentity:current,
|
||||
traversalPolicy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
|
||||
policy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
|
||||
});
|
||||
projects.set(document.projectId, opening);
|
||||
void opening.catch(() => { if (projects.get(document.projectId) === opening) projects.delete(document.projectId); });
|
||||
return opening.then(engine => { if (!current()) throw fault('identity_changed'); engine.refresh(document, versions); return engine; });
|
||||
return acquire(document,versions).then(engine=>{if(!current())throw fault('identity_changed');const previous=engine.getSnapshot().runtime.versions;if(![...versions].some(([id,value])=>(previous.get(id)??0)>value))engine.refresh(document,versions);return engine;});
|
||||
},
|
||||
openPartial(document:Document,versions:ReadonlyMap<Id,number>,loading:LoadingState){
|
||||
return acquire(document,versions,loading).then(engine=>{if(!current())throw fault('identity_changed');const previous=engine.loading.getState();if(previous?.sourceToken===loading.sourceToken){const result=engine.loading.accept(document,versions,loading);if(result.status==='rejected')throw fault(result.code,result.targets);}return engine;});
|
||||
},
|
||||
async resumeLoading(projectId:Id){
|
||||
try{if(!current())throw fault('identity_changed');const opening=projects.get(projectId);if(!opening)throw fault('project_not_open');const engine=await opening;if(!current())throw fault('identity_changed');const result=await engine.loading.resume();if(result.status==='rejected')throw fault(result.code,result.targets);return result;}
|
||||
catch(error){if(current()){try{options.onError?.(error);}catch{}}throw error;}
|
||||
},
|
||||
close() {
|
||||
accepting = false; // Fence commands/transports before awaiting pending opens or storage.
|
||||
|
||||
@@ -99,3 +99,21 @@ test('fresh full read merges unrelated remote fields and geometry undo does not
|
||||
test('identity fencing rejects edits, history traversal and late prefix publication without changing the root',async()=>{
|
||||
const f=fixture();let current=true;const engine=await f.open({isCurrentIdentity:()=>current});try{engine.editor.dispatch({type:'element.data',target,patch:{text:'saved'}});await engine.settle();current=false;const before=engine.getSnapshot();assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{text:'late'}}).code,'identity_changed');assert.equal(engine.editor.history.undo().code,'identity_changed');assert.equal(engine.loading.accept(prefix,versions,state).code,'identity_changed');assert.equal(engine.refresh(full,versions).code,'identity_changed');assert.strictEqual(engine.getSnapshot(),before);assert.equal(f.calls.length,0);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
test('background full reads are shared, preserve edits and cannot downgrade after completion',async()=>{
|
||||
let release;const gate=new Promise(resolve=>release=resolve);const f=fixture();let reads=0;const persistence={...f.persistence,async read(){reads++;await gate;return{document:f.server(),versions:f.currentVersions()};}},engine=await f.open({persistence});
|
||||
try{engine.editor.dispatch({type:'element.data',target,patch:{text:'background edit'}});await engine.persist();const first=engine.loading.resume(),second=engine.loading.resume();assert.strictEqual(first,second);while(!reads)await new Promise(resolve=>setImmediate(resolve));assert.equal(reads,1);release();assert.equal((await first).status,'applied');await engine.settle();assert.equal(f.server().elements.get('A').data.text,'background edit');assert.equal(engine.loading.accept(prefix,versions,state).code,'loading_not_active');assert.equal((await engine.loading.resume()).status,'applied');assert.equal(reads,1);}finally{release();await engine.close();}
|
||||
});
|
||||
test('project close aborts a background read and persists its partial edits for the next owner',async()=>{
|
||||
const f=fixture();let aborted=false,started;const reading=new Promise(resolve=>started=resolve);const persistence={...f.persistence,read:signal=>new Promise((resolve,reject)=>{started();signal.addEventListener('abort',()=>{aborted=true;reject(signal.reason);},{once:true});})};let engine=await f.open({persistence});
|
||||
engine.editor.dispatch({type:'element.data',target,patch:{text:'survives source close'}});await engine.persist();const rejected=assert.rejects(engine.loading.resume());await reading;await engine.close();await rejected;assert.equal(aborted,true);assert.equal(f.storage.image().formatVersion,7);engine=await f.open();try{assert.equal(engine.getSnapshot().document.elements.get('A').data.text,'survives source close');assert.equal(f.calls.length,0);}finally{await engine.close();}
|
||||
});
|
||||
test('Workspace opens one partial owner and ignores an older foreground result after background synchronization',async()=>{
|
||||
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture();const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>true,adapters:()=>({persistence:f.persistence})});
|
||||
try{const [first,second]=await Promise.all([workspace.openPartial(prefix,versions,state),workspace.openPartial(prefix,versions,state)]);assert.strictEqual(first,second);first.editor.dispatch({type:'element.data',target,patch:{title:'kept background'}});await first.loading.resume();await first.settle();const before=first.getSnapshot();assert.strictEqual(await workspace.open(full,versions),first);assert.strictEqual(first.getSnapshot(),before);assert.equal(first.getSnapshot().runtime.conflicts.size,0);assert.equal(workspace.getProjects().size,1);}finally{await workspace.close();}
|
||||
});
|
||||
|
||||
test('Workspace reports a background source failure while preserving the pending partial cut and fences retired reports',async()=>{
|
||||
const {createWorkspaceRuntime}=await import('../../src/lib/canvas-runtime/workspace.ts'),f=fixture(),reports=[];let current=true;const workspace=createWorkspaceRuntime({accountId:scope.accountId,workspaceId:scope.workspaceId,authEpoch:1,registry,storage:f.storage,isCurrentIdentity:()=>current,onError:error=>reports.push(error),adapters:()=>({persistence:{...f.persistence,async read(){throw Error('offline');}}})});
|
||||
try{const engine=await workspace.openPartial(prefix,versions,state);engine.editor.dispatch({type:'element.data',target,patch:{text:'held while offline'}});await engine.persist();await assert.rejects(workspace.resumeLoading(root),/offline/);assert.equal(reports.length,1);assert.ok(engine.loading.getState());assert.equal(f.calls.length,0);assert.equal(f.storage.image().formatVersion,7);current=false;await assert.rejects(workspace.resumeLoading(root),/identity_changed/);assert.equal(reports.length,1);}finally{await workspace.close();}
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ if(performanceMode){
|
||||
}
|
||||
const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' }));
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
|
||||
let brokenStreamRoot=null;
|
||||
let brokenStreamRoot=null,abandonedRoot=null,releaseAbandoned;const abandonedGate=new Promise(resolve=>releaseAbandoned=resolve);
|
||||
let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode;
|
||||
let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map();
|
||||
let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;});
|
||||
@@ -48,6 +48,7 @@ const upstream = createServer(async (request, response) => {
|
||||
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length});
|
||||
if(read[1]===brokenStreamRoot){line({kind:'elements',elements:value.elements.slice(0,1)});response.end('{broken tail}\n');return;}
|
||||
let start=0;
|
||||
if(read[1]===abandonedRoot){line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await abandonedGate;if(response.destroyed)return;}
|
||||
if(gatedFirstModel&&read[1]===root&&value.elements.length){gatedFirstModel=false;line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await firstModelGate;}
|
||||
for(let offset=start;offset<value.elements.length;offset+=64)line({kind:'elements',elements:value.elements.slice(offset,offset+64)});
|
||||
line({kind:'complete',elementCount:value.elements.length});return response.end();
|
||||
@@ -131,21 +132,26 @@ try {
|
||||
if(performanceMode){
|
||||
await measureRenderer({call,evaluate,documents,root,partition,count:performanceCount,exceptions,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
|
||||
}else{
|
||||
await until('!!document.querySelector("[data-canvas-loading-preview] .react-flow__node[data-id=a]")','first model node did not display before the gated tail');
|
||||
assert.equal(await evaluate('!!document.querySelector("[data-canvas-loading-preview] .react-flow__node[data-id=b]")'),false);
|
||||
assert.equal(await evaluate('!!document.querySelector("[data-headless-canvas]")'),false,'partial read opened the writable engine');
|
||||
await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');
|
||||
assert.equal(await evaluate('!!document.querySelector(".react-flow__node[data-id=b]")'),false);
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
|
||||
assert.equal(await evaluate('document.querySelector("[data-canvas-loading-preview] textarea").readOnly'),true);
|
||||
await until('document.querySelector("[data-canvas-loading-preview] .react-flow__node[data-id=a]").getBoundingClientRect().x>300','initial preview node remained under the sidebar');
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1});
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:1070,y:630,button:'left',buttons:1});
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1070,y:630,button:'left',buttons:0});await pause(100);
|
||||
const previewTransform=await evaluate('document.querySelector("[data-canvas-loading-preview] .react-flow__viewport").style.transform');
|
||||
await writeFile('/private/tmp/headless-loading-preview.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
|
||||
assert.equal(await evaluate('document.querySelector("[aria-label=添加文本卡片]").disabled'),true);
|
||||
await until('document.querySelector(".react-flow__node[data-id=a]").getBoundingClientRect().x>300','initial partial node remained under the sidebar');
|
||||
const partialTextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=a] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...partialTextPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...partialTextPoint,button:'left',buttons:0,clickCount:2});
|
||||
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=a] textarea')&&!document.activeElement.readOnly`,'loaded prefix text did not enter edit mode');
|
||||
await call('Input.insertText',{text:' partial-local'});const partialText=await evaluate('document.activeElement.value');assert.ok(partialText.endsWith(' partial-local'));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
|
||||
await evaluate(`window.partialTextarea=document.activeElement;void 0`);
|
||||
const previewTransform=await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform');
|
||||
await writeFile('/private/tmp/headless-loading-editable.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
|
||||
releaseFirstModel();
|
||||
console.log('PASS actual GraphPage displays the first legal model prefix before the gated tail, without opening a writable engine or issuing an edit');
|
||||
await until('!!document.querySelector("[data-headless-canvas]") && document.querySelectorAll(".react-flow__node").length === 2', 'actual headless canvas did not open');
|
||||
assert.equal(await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform'),previewTransform,'preview viewport was lost during editor handoff');
|
||||
await until('!!document.querySelector("[data-headless-canvas]:not([data-model-loading])") && document.querySelectorAll(".react-flow__node").length === 2','full model did not finish the partial editor');
|
||||
assert.equal(await evaluate('window.partialTextarea.isConnected&&document.activeElement===window.partialTextarea'),true,'tail completion remounted the active text editor');
|
||||
assert.equal(await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform'),previewTransform,'partial viewport was lost');
|
||||
for(let i=0;i<100&&documents.get(root).elements.get('a').data.text!==partialText;i++)await pause(20);assert.equal(documents.get(root).elements.get('a').data.text,partialText);
|
||||
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});
|
||||
await evaluate(`document.querySelector('[aria-label="撤销"]').click()`);for(let i=0;i<100&&documents.get(root).elements.get('a').data.text!=='Editable content';i++)await pause(20);assert.equal(documents.get(root).elements.get('a').data.text,'Editable content');
|
||||
console.log('PASS actual GraphPage edits the first prefix before the gated tail, keeps its active textarea/history/viewport and sends only after durable completion');
|
||||
assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot');
|
||||
assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true);
|
||||
const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`);
|
||||
@@ -194,8 +200,10 @@ try {
|
||||
await click('撤销');
|
||||
for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Editable content';i++)await pause(20);
|
||||
assert.equal(documents.get(root).elements.get('b').data.text,'Editable content');assert.equal(documents.get(root).elements.get('b').geometry.width,300);
|
||||
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'edits did not settle before hit-test');
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
// Hit from the pane beyond the native edge interaction stroke, using screen-space tolerance.
|
||||
const nearCurve=await evaluate(`(()=>{const path=document.querySelector('.react-flow__edge[data-id="edge"] .react-flow__edge-path');if(!path)return null;const length=path.getTotalLength(),matrix=path.getScreenCTM();for(let t=.15;t<.9;t+=.07){const a=path.getPointAtLength(length*t),b=path.getPointAtLength(length*t+1),p=new DOMPoint(a.x,a.y).matrixTransform(matrix),q=new DOMPoint(b.x,b.y).matrixTransform(matrix),dx=q.x-p.x,dy=q.y-p.y,d=Math.hypot(dx,dy);for(const sign of [-1,1]){const x=p.x-sign*dy/d*11,y=p.y+sign*dx/d*11;if(document.elementFromPoint(x,y)?.classList.contains('react-flow__pane'))return{x,y};}}return null;})()`);
|
||||
const nearCurve=await evaluate(`(()=>{const path=document.querySelector('.react-flow__edge[data-id="edge"] .react-flow__edge-path');if(!path)return null;const length=path.getTotalLength(),matrix=path.getScreenCTM();for(let t=.1;t<.99;t+=.01){const a=path.getPointAtLength(length*t),b=path.getPointAtLength(length*t+1),p=new DOMPoint(a.x,a.y).matrixTransform(matrix),q=new DOMPoint(b.x,b.y).matrixTransform(matrix),dx=q.x-p.x,dy=q.y-p.y,d=Math.hypot(dx,dy);for(const sign of [-1,1]){const x=p.x-sign*dy/d*11,y=p.y+sign*dx/d*11;if(document.elementFromPoint(x,y)?.classList.contains('react-flow__pane')&&![...document.querySelectorAll('.react-flow__node')].some(node=>{const r=node.getBoundingClientRect();return x>=r.left-13&&x<=r.right+13&&y>=r.top-13&&y<=r.bottom+13;}))return{x,y};}}return null;})()`);
|
||||
assert.ok(nearCurve,'no pane point beyond the SVG interaction stroke');
|
||||
const beforeHit=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...nearCurve,button:'left',buttons:1,clickCount:1});
|
||||
@@ -204,7 +212,7 @@ try {
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeHit,'read-only hit query created a document operation');
|
||||
console.log('PASS screen-tolerance pane hit selects the displayed curve beyond native SVG stroke without a model operation');
|
||||
const curveHandle=await evaluate(`(()=>{const r=document.querySelector('[aria-label="曲线点 1"]').getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2};})()`);
|
||||
assert.equal(await evaluate(`document.elementFromPoint(${curveHandle.x},${curveHandle.y})?.getAttribute('aria-label')`),'曲线点 1','curve drag handle is not hit-testable');
|
||||
assert.equal(await evaluate(`document.elementFromPoint(${curveHandle.x},${curveHandle.y})?.getAttribute('aria-label')`),'曲线点 1','selected curve control must remain hit-testable where a card overlaps');
|
||||
const beforeCurve = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
|
||||
await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...curveHandle, button: 'left', buttons: 1, clickCount: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: curveHandle.x+20, y: curveHandle.y+10, button: 'left', buttons: 1 });
|
||||
@@ -350,11 +358,21 @@ try {
|
||||
brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot;
|
||||
documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'});
|
||||
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${brokenRoot}`});
|
||||
await until(`document.querySelector('[data-canvas-loading-preview] textarea')?.value==='prefix-kept'&&!!document.querySelector('[role="alert"]')`,'malformed model tail hid the legal prefix or failed to report');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas]')`),false);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+brokenRoot+'/canvas/model/operations')).length,0);
|
||||
await until(`document.querySelector('[data-headless-canvas][data-model-loading] textarea')?.value==='prefix-kept'&&!!document.querySelector('[role="alert"]')`,'malformed model tail hid the legal prefix or failed to report');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+brokenRoot+'/canvas/model/operations')).length,0);
|
||||
assert.ok((await evaluate(`document.querySelector('[role="alert"]').textContent`)).includes('画布返回的数据无法解析'));
|
||||
brokenStreamRoot=null;await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='重新加载').click()`);
|
||||
await until(`!!document.querySelector('[data-headless-canvas]')&&document.querySelectorAll('.react-flow__node').length===2&&!document.querySelector('[role="alert"]')`,'retry did not replace the failed prefix with a complete editor');
|
||||
console.log('PASS malformed model tail retains its legal read-only prefix, reports a readable error and retries into the complete editor without model writes');
|
||||
await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&document.querySelectorAll('.react-flow__node').length===2&&!document.querySelector('[role="alert"]')`,'retry did not replace the failed prefix with a complete editor');
|
||||
console.log('PASS malformed model tail retains its legal partial editor, reports a readable error and retries into the complete editor without model writes');
|
||||
|
||||
abandonedRoot=id(840);const backgroundRoot=abandonedRoot;
|
||||
documents.set(backgroundRoot,createDocument(backgroundRoot,[{id:backgroundRoot,parent:null}],[node('background-node',backgroundRoot,80),node('background-tail',backgroundRoot,450)]));versions.set(backgroundRoot,1);boards.push({id:backgroundRoot,workspace_id:workspace,kind:'board',version:1,title:'Background loading board'});
|
||||
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${backgroundRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="background-node"]')`,'background fixture did not open the prefix');
|
||||
const backgroundPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="background-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...backgroundPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...backgroundPoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="background-node"] textarea')&&!document.activeElement.readOnly`,'background prefix did not enter editing');await call('Input.insertText',{text:' background-local'});const backgroundText=await evaluate('document.activeElement.value');
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+backgroundRoot+'/canvas/model/operations')).length,0);
|
||||
await evaluate(`(()=>{const link=[...document.querySelectorAll('a')].find(link=>link.pathname.endsWith('/${other}'));if(!link)throw Error('other board navigation link missing');link.click();})()`);await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'navigation away from partial loading failed');
|
||||
for(let i=0;i<150&&documents.get(backgroundRoot).elements.get('background-node').data.text!==backgroundText;i++)await pause(20);assert.equal(documents.get(backgroundRoot).elements.get('background-node').data.text,backgroundText,'leaving the view stranded a local edit behind the loading hold');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+backgroundRoot+'/canvas/model')&&call.headers.accept!=='application/vnd.foundry.canvas-model+ndjson;version=1'));
|
||||
releaseAbandoned();await pause(100);assert.ok((await evaluate('location.pathname')).endsWith(other));console.log('PASS leaving a partial editor hands completion to the project owner, persists and synchronizes its edit through a fresh read without reopening the view');
|
||||
|
||||
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
|
||||
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
|
||||
@@ -363,7 +381,7 @@ try {
|
||||
console.log('PASS logout retires the runtime before dropping its view without a render error');
|
||||
}
|
||||
} finally {
|
||||
releaseFirstModel();releaseImport();releaseApply();releaseUpload(); socket?.close();
|
||||
releaseAbandoned();releaseFirstModel();releaseImport();releaseApply();releaseUpload(); socket?.close();
|
||||
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await exit; }
|
||||
if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} }
|
||||
upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));
|
||||
|
||||
Reference in New Issue
Block a user