diff --git a/headless/README.md b/headless/README.md index ee02f1a..e3ae4e0 100644 --- a/headless/README.md +++ b/headless/README.md @@ -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. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 1444865..ea1624d 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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(); + 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|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,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(()=>({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 }); }} @@ -213,7 +218,7 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin - onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose} + 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
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 ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <>}

+ { 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},修改已在本地排队` : 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()} /> add('frame')} /> + {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()} /> 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&&} {showAI && aiElement && setShowAI(false)} />} {showRecovery && setShowRecovery(false)} />} {error &&
{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&}
} @@ -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 ? :

正在准备画布…

; + return attached ? :

正在准备画布…

; } diff --git a/src/components/canvas/loading-graph-canvas.tsx b/src/components/canvas/loading-graph-canvas.tsx index 525256d..18a46b1 100644 --- a/src/components/canvas/loading-graph-canvas.tsx +++ b/src/components/canvas/loading-graph-canvas.tsx @@ -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[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}))}; diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx index ab6f68b..54f4092 100644 --- a/src/components/workspace/graph-page.tsx +++ b/src/components/workspace/graph-page.tsx @@ -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(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(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|undefined,partialEngine:ProjectEngine|undefined,partialRuntime:ReturnType|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; diff --git a/src/lib/canvas-bindings/renderers.ts b/src/lib/canvas-bindings/renderers.ts index a14c1b8..35c75be 100644 --- a/src/lib/canvas-bindings/renderers.ts +++ b/src/lib/canvas-bindings/renderers.ts @@ -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):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[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});} diff --git a/src/lib/canvas-entry.ts b/src/lib/canvas-entry.ts index bbe983f..5bedf59 100644 --- a/src/lib/canvas-entry.ts +++ b/src/lib/canvas-entry.ts @@ -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;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;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(path, { signal: options.signal }), options.request('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. diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 294e00f..f6cc8fa 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -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|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{ + 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,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(); diff --git a/src/lib/canvas-runtime/host.ts b/src/lib/canvas-runtime/host.ts index 1296e6d..4a2436d 100644 --- a/src/lib/canvas-runtime/host.ts +++ b/src/lib/canvas-runtime/host.ts @@ -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)=>{ if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return; - try{for(;seencanvas.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}); diff --git a/src/lib/canvas-runtime/index.ts b/src/lib/canvas-runtime/index.ts index 84de54c..d1ba2ff 100644 --- a/src/lib/canvas-runtime/index.ts +++ b/src/lib/canvas-runtime/index.ts @@ -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'; diff --git a/src/lib/canvas-runtime/model-preview.ts b/src/lib/canvas-runtime/model-preview.ts index fd80510..4b6b475 100644 --- a/src/lib/canvas-runtime/model-preview.ts +++ b/src/lib/canvas-runtime/model-preview.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}; 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. */ diff --git a/src/lib/canvas-runtime/workspace.ts b/src/lib/canvas-runtime/workspace.ts index e8374a7..8f3b397 100644 --- a/src/lib/canvas-runtime/workspace.ts +++ b/src/lib/canvas-runtime/workspace.ts @@ -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>(); let accepting = true, closed = false, closing: Promise | undefined; const current = () => accepting && !closed && options.isCurrentIdentity(); + function acquire(document:Document,versions:ReadonlyMap,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) { - 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,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. diff --git a/tests/headless/loading-engine.test.mjs b/tests/headless/loading-engine.test.mjs index f796c55..6b4fe88 100644 --- a/tests/headless/loading-engine.test.mjs +++ b/tests/headless/loading-engine.test.mjs @@ -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();} +}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 2a2a423..28bdeb5 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -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;offsetcall.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));