Scope host UI focus and curve-budget fallback to their own lifetimes
This commit is contained in:
+12
-3
@@ -1008,8 +1008,14 @@ nodes/edges stay in React Flow's lookup; geometry, dragging, model data and hidd
|
||||
state are not fabricated. A view-local weak cache preserves tagged row identity,
|
||||
and ending an owner returns the original row. Detached focus is retired even
|
||||
when removing a renderer emits no blur. Flags never enter a Root or file.
|
||||
Unscoped focused inputs/portals and exhausted/unknown paint-budget sentinels retain
|
||||
the conservative whole-graph fallback; their missing ownership is not guessed.
|
||||
Application panels/toolbars explicitly mark `data-canvas-ui-view` with their
|
||||
current view ID, including portalled dialogs. Their inputs have a lifetime
|
||||
independent of node culling; focusing them does not pin native nodes. Registered
|
||||
node portals still declare their explicit target. Only unscoped third-party
|
||||
inputs/portals keep the conservative whole-graph compatibility fallback; missing
|
||||
ownership is not guessed. Exhausting curve-inspection work retains the edge
|
||||
layer and its endpoints, while unrelated nodes remain culled. This can still
|
||||
retain many endpoints/edges; it is not a constant-cost or complete geometry proof.
|
||||
|
||||
React Flow 12.12.1 does not expose a retained-ID selector. The app-only
|
||||
`scripts/loaders/xyflow-retained.cjs` extends its two ESM visibility selectors at
|
||||
@@ -1026,7 +1032,10 @@ native empty-pane click. Reports include `inputCheck.beforeEditing` and
|
||||
`inputCheck.duringEditing`, `inputCheck.offscreenEditing` and
|
||||
`inputCheck.offscreenPortal`; this preflight is outside the copy timing window.
|
||||
The screen-pan probes preserve native IME and an unsaved Portal title, bound
|
||||
mounted owners, check release after blur, and remount saved content. Add
|
||||
mounted owners, check release after blur, and remount saved content. Independent
|
||||
AI-panel focus/pan is recorded as `panelFocusedNodes`/`offscreenPanel`; blur saves
|
||||
settings without starting a generation task. The actual-page suite also exceeds
|
||||
the 4096-edge inspection limit and checks unrelated offscreen cards stay culled. Add
|
||||
`--turbopack` to the page/performance runner to use the app's default compiler.
|
||||
Turbopack fixtures live temporarily under the physical dependency workspace so
|
||||
its filesystem fence can resolve Next; the exact unique directory is removed in
|
||||
|
||||
@@ -11,7 +11,7 @@ import { managedImageSource } from "@/lib/canvas-v2";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
export function HeadlessAIPanel({ engine, element, close }: { engine: ProjectEngine; element: ElementRecord; close(): void }) {
|
||||
export function HeadlessAIPanel({ engine, element, close, viewId }: { engine: ProjectEngine; element: ElementRecord; close(): void; viewId:string }) {
|
||||
const saved = object(element.data.ai), root = engine.getSnapshot();
|
||||
const [models, setModels] = useState<readonly RunModel[]>([]), [loading, setLoading] = useState(true), [error, setError] = useState('');
|
||||
const [model, setModel] = useState(typeof saved.modelId === 'string' ? saved.modelId : '');
|
||||
@@ -43,7 +43,7 @@ export function HeadlessAIPanel({ engine, element, close }: { engine: ProjectEng
|
||||
}
|
||||
const appliedOutput=typeof output.output_id==='string'&&run?.appliedOutputIds?.includes(output.output_id);
|
||||
const message = cancelling ? '取消请求已保存,等待确认…' : applying ? '正在应用生成结果…' : rejectedStart ? `生成未受理:${rejectedStart.blockers.join('、')}` : appliedOutput ? '此生成结果已应用' : pending.length ? pending.some(record => record.phase === 'blocked') ? '生成受理需要处理' : '正在等待同步并受理生成…' : run?.blocked ? '生成结果已保留,当前编辑需要协调' : run?.error ? '任务跟踪需要处理,可重试' : run?.hasMore ? '正在同步任务状态…' : run ? ({queued:'已受理,等待生成',running:'正在生成…',succeeded:'已更新当前元素',failed:'生成未完成,原内容已保留',conflict:'内容已改变,生成结果已保留',cancelled:'已取消生成',partially_succeeded:'部分步骤已完成'}[run.status]) : '结果将显示在当前元素';
|
||||
return <section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
|
||||
return <section data-canvas-ui-view={viewId} className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具" style={{position:'absolute',right:24,bottom:90,zIndex:110,maxWidth:'calc(100% - 48px)',maxHeight:'70%',overflow:'auto'}} onPointerDown={event=>event.stopPropagation()} onKeyDown={event=>event.stopPropagation()}>
|
||||
<header><Sparkles size={14}/><strong>{image ? '图像创作' : '文本创作'}</strong><span>{element.data.title as string}</span><button aria-label="关闭 AI 工具" onClick={close}><X size={14}/></button></header>
|
||||
<textarea aria-label="AI 指令" placeholder="希望如何处理当前内容?" maxLength={16000} disabled={busy} value={prompt} onChange={event=>setPrompt(event.target.value)} onBlur={save}/>
|
||||
<footer><select aria-label="AI 模型" value={model} disabled={loading || busy} onChange={event=>setModel(event.target.value)}>{!model && <option value="">{loading ? '正在读取模型…' : '暂无可用模型'}</option>}{models.map(model=><option key={model.id} value={model.id} disabled={!model.available}>{model.label || model.display_name || model.id}</option>)}</select>
|
||||
|
||||
@@ -114,7 +114,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
const nativeNodes=useStore(state=>state.nodes);
|
||||
const connectionPaintCache=useMemo(()=>createConnectionPaintIndex(),[engine,view.canvasId]);
|
||||
const connectionPaint=useMemo(()=>connectionPaintCache.sync(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),[connectionPaintCache,projection,engine,flow,nativeNodes]);
|
||||
const paintedEdges=[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
|
||||
// Exhausted geometry work cannot prove any edge invisible. Retain the edge
|
||||
// layer and its endpoints, not unrelated nodes or a synthetic sentinel owner.
|
||||
const paintedEdges=connectionPaint.unknown.includes('paint_budget')?projection.edges.map(edge=>edge.id):[...connectionPaint.unknown,...connectionPaint.query(sceneViewport)];
|
||||
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 }) => {
|
||||
@@ -212,12 +214,14 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
if(sessions.connectionGesture?.viewId===viewId)retained.add(sessions.connectionGesture.target.id);
|
||||
const activeEditor=focusedEditor?.isConnected?focusedEditor:null;
|
||||
const focusedOverlay=activeEditor?.closest<HTMLElement>('[data-canvas-overlay-target]');
|
||||
const focusedUI=activeEditor?.closest<HTMLElement>('[data-canvas-ui-view]');
|
||||
const independentFocus=focusedUI?.dataset.canvasUiView===viewId;
|
||||
const focusedNative=activeEditor?.closest<HTMLElement>('.react-flow__node,.react-flow__edge');
|
||||
const focusedId=focusedOverlay?.dataset.canvasOverlayView===viewId?focusedOverlay.dataset.canvasOverlayTarget:focusedNative?.closest('[data-headless-canvas]')===surface.current?focusedNative?.dataset.id:undefined;
|
||||
if(focusedId)retained.add(focusedId);
|
||||
// Retained connections also keep their measured endpoint owners alive.
|
||||
for(const id of [...retained]){const element=root.document.elements.get(id);if(element?.structure==='connection'){retained.add(element.source.elementId);retained.add(element.target.elementId);}}
|
||||
const scoped=!(activeEditor&&!focusedId)&&[...retained].every(id=>root.document.elements.has(id));
|
||||
const scoped=!(activeEditor&&!focusedId&&!independentFocus)&&[...retained].every(id=>root.document.elements.has(id));
|
||||
const cullVisible=!!surfaceWidth&&!!surfaceHeight&&scoped;
|
||||
const retainedProjection=useMemo(()=>createRetainedProjection(),[engine,viewId]);
|
||||
const rendered=retainedProjection(projection.nodes,projection.edges,retained);
|
||||
@@ -311,7 +315,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color="#ded7ce" />
|
||||
</ReactFlow>
|
||||
</EdgeEditing.Provider></NodeEditing.Provider></CanvasRendererCommandsContext.Provider></ImagePreviews.Provider></CanvasOverlays.Provider>
|
||||
<div className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<div data-canvas-ui-view={viewId} className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
||||
const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation}));
|
||||
@@ -321,17 +325,17 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
<IconButton icon={Minus} label="缩小" onClick={() => flow.zoomOut()} /><IconButton icon={Plus} label="放大" onClick={() => flow.zoomIn()} /><IconButton icon={Maximize} label="适应画布" onClick={() => flow.fitView({ padding: .2 })} />
|
||||
<IconButton icon={Download} label="导出图谱" disabled={!!loading} onClick={() => { const snapshot = engine.getSnapshot(), url = URL.createObjectURL(new Blob([JSON.stringify({ documentFormatVersion: 1, projectId: snapshot.document.projectId, canvases: [...snapshot.document.canvases.values()], elements: [...snapshot.document.elements.values()], tombstones: Object.fromEntries(snapshot.document.tombstones) })], { type: 'application/json' })); const a = window.document.createElement('a'); a.href = url; a.download = board.title + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }} />
|
||||
</div><p role="status" style={{ fontSize: 11 }}>{blocked ? '有修改需要处理' : loading ? `正在加载… ${loading.receivedElements}/${loading.totalElements},${loading.version>=3?"按已就绪范围同步":"修改已在本地排队"}` : pending.length ? '正在同步…' : '已同步'}{blocked && <><button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button><button aria-label="处理同步修改" onClick={() => setShowRecovery(true)}>处理同步修改</button></>}</p></div>
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<div data-canvas-ui-view={viewId} className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
|
||||
<IconButton icon={Ratio} label="按图片比例调整" disabled={!!loading||!canFitImage} onClick={()=>{if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
|
||||
<IconButton icon={Sparkles} label="AI 创作" disabled={!!loading||!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
|
||||
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={() => imageInput.current?.click()} /><IconButton icon={Images} label="复用其它画布图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={()=>{const rect=window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();setResourcePicker({canvasId:view.canvasId,position:flow.screenToFlowPosition({x:(rect?.left??0)+(rect?.width??window.innerWidth)/2,y:(rect?.top??0)+(rect?.height??window.innerHeight)/2})});}} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
|
||||
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={() => imageInput.current?.click()} /><IconButton icon={Images} label="复用其它画布图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={()=>{const rect=surface.current?.getBoundingClientRect();setResourcePicker({canvasId:view.canvasId,position:flow.screenToFlowPosition({x:(rect?.left??0)+(rect?.width??window.innerWidth)/2,y:(rect?.top??0)+(rect?.height??window.innerHeight)/2})});}} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
|
||||
</div>
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
|
||||
{(!loading||loading.version>=5)&&<HeadlessImports engine={engine} canvasId={view.canvasId} />}
|
||||
{resourcePicker&&resourcePicker.canvasId===view.canvasId&&<HeadlessResourcePicker engine={engine} workspaceId={board.workspace_id} canvasId={resourcePicker.canvasId} position={resourcePicker.position} close={()=>setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />}
|
||||
{showAI && aiElement && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
|
||||
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
|
||||
<input data-canvas-ui-view={viewId} ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
|
||||
{(!loading||loading.version>=5)&&<HeadlessImports viewId={viewId} engine={engine} canvasId={view.canvasId} />}
|
||||
{resourcePicker&&resourcePicker.canvasId===view.canvasId&&<HeadlessResourcePicker viewId={viewId} engine={engine} workspaceId={board.workspace_id} canvasId={resourcePicker.canvasId} position={resourcePicker.position} close={()=>setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />}
|
||||
{showAI && aiElement && <HeadlessAIPanel viewId={viewId} key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
|
||||
{showRecovery && <HeadlessRecoveryDialog viewId={viewId} engine={engine} close={() => setShowRecovery(false)} />}
|
||||
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import styles from "./canvas.module.css";
|
||||
|
||||
const reason = (code: string) => ({ import_asset_authorization_failed: "资源访问授权失败,此记录未加入", import_asset_capacity: "本组资源过大,无法一次准备", asset_depot_unavailable: "资源仓库暂不可用", import_resource_authorization_required: "资源需要重新授权,此记录尚未加入", invalid_document_import: "导出文档结构不合法", invalid_json_array: "JSON 记录数组格式错误", incomplete_json_array: "JSON 数组尾部不完整", json_nesting_limit: "JSON 嵌套层数过多", invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_read_failed: "暂时无法读取源文件,可重试;已加入内容保留", import_source_verification_failed: "源文件校验失败,可重试;已加入内容保留", invalid_import_read_proof: "源文件读取证据不一致,任务已暂停", import_source_unavailable: "导入源文件不可用", import_source_changed: "源文件内容与保存时不一致,任务已暂停", import_source_integrity_unavailable: "旧任务缺少源内容校验,无法安全继续;已加入内容保留", import_source_integrity_missing: "源校验记录缺失,任务已暂停", import_source_integrity_interpreter_unavailable: "源校验版本暂不支持", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_copy_snapshot: "复制快照不可用", invalid_copy_preparation: "复制准备记录不可用", copy_interpreter_changed: "复制规则已变化,需要重新复制", invalid_import_source: "文件过大或格式不支持" } as Record<string,string>)[code] ?? "这条记录暂时无法加入";
|
||||
|
||||
export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; canvasId: string }) {
|
||||
export function HeadlessImports({ engine, canvasId, viewId }: { engine: ProjectEngine; canvasId: string; viewId:string }) {
|
||||
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
|
||||
const snapshot = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
|
||||
const input = useRef<HTMLInputElement>(null), [open, setOpen] = useState(false), [error, setError] = useState("");
|
||||
const scope = useRef({ engine, canvasId }); scope.current = { engine, canvasId };
|
||||
const jobs = [...(snapshot.runtime.imports?.values() ?? [])].filter(job => job.source?.targetCanvasId === canvasId).slice(-3).reverse();
|
||||
const control = (work: Promise<void>) => { void work.then(() => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(""); }).catch(error => { if (scope.current.engine === engine && scope.current.canvasId === canvasId) setError(errorText(error)); }); };
|
||||
return <aside className={styles.importPanel} aria-label="画布导入">
|
||||
return <aside data-canvas-ui-view={viewId} className={styles.importPanel} aria-label="画布导入">
|
||||
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入画布记录" disabled={!engine.imports.canStart(canvasId).enabled} onClick={() => input.current?.click()} /><span>NDJSON / JSON 记录数组</span></div>
|
||||
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,.json,application/json,application/x-ndjson" hidden onChange={async event => {
|
||||
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
|
||||
|
||||
@@ -52,7 +52,7 @@ function PlanStructureReview({plan}:{plan:ConflictPlan}){
|
||||
</details>;
|
||||
}
|
||||
|
||||
export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;close():void}){
|
||||
export function HeadlessRecoveryDialog({engine,close,viewId}:{engine:ProjectEngine;close():void;viewId:string}){
|
||||
const owner=useRef<RecoverySession|undefined>(undefined),[session,setSession]=useState<RecoverySession>(),[records,setRecords]=useState<readonly OperationIntent[]>([]);
|
||||
const [choices,setChoices]=useState(new Map<Id,RecoveryChoice>()),[plan,setPlan]=useState<ConflictPlan>(),[error,setError]=useState(''),[busy,setBusy]=useState(true),[frozen,setFrozen]=useState(false);
|
||||
useEffect(()=>{
|
||||
@@ -63,7 +63,7 @@ export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;clos
|
||||
async function cancel(){if(busy&&session)return;setBusy(true);try{await engine.cancelRecovery();close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||
async function apply(){if(!session||busy)return;setBusy(true);setError('');setFrozen(true);try{const result=plan?await session.resolve(plan):await session.apply();if(result.status==='rejected'){setFrozen(false);setError(message({code:result.code}));return;}close();}catch(error){setError(message(error));}finally{setBusy(false);}}
|
||||
const canonical=session?.canonical.document;
|
||||
return <Dialog open onOpenChange={open=>{if(!open)void cancel();}}><DialogContent aria-label="处理同步修改" style={{zIndex:300,maxHeight:'80vh',overflowY:'auto',background:'var(--background, #fffdf9)',color:'var(--foreground, #302d29)'}} onEscapeKeyDown={event=>{if(busy&&session)event.preventDefault();}}>
|
||||
return <Dialog open onOpenChange={open=>{if(!open)void cancel();}}><DialogContent data-canvas-ui-view={viewId} aria-label="处理同步修改" style={{zIndex:300,maxHeight:'80vh',overflowY:'auto',background:'var(--background, #fffdf9)',color:'var(--foreground, #302d29)'}} onEscapeKeyDown={event=>{if(busy&&session)event.preventDefault();}}>
|
||||
<DialogTitle>处理同步修改</DialogTitle>
|
||||
<DialogDescription>检查期间暂停编辑。逐项选择要保留的修改,预览后再应用;原内容和撤销记录会保留备份。</DialogDescription>
|
||||
{busy&&!session&&<p role="status">正在等待同步并读取最新内容…</p>}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { errorText } from "@/lib/api";
|
||||
|
||||
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
|
||||
type Candidate = { id: Id; title: string };
|
||||
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void}) {
|
||||
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded,viewId}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void;viewId:string}) {
|
||||
const host=useCanvasRuntimeHost(),[items,setItems]=useState<Candidate[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState<Id>(),[error,setError]=useState("");
|
||||
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
|
||||
const dismiss=()=>{lifecycle.current?.abort();close();};
|
||||
@@ -54,7 +54,7 @@ export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,clo
|
||||
}catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));}
|
||||
finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);}
|
||||
}
|
||||
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent aria-describedby={descriptionId}>
|
||||
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent data-canvas-ui-view={viewId} aria-describedby={descriptionId}>
|
||||
<DialogHeader><DialogTitle>从其它画布添加图片</DialogTitle><DialogDescription id={descriptionId}>选择同一工作区已打开画布中的图片,无需再次上传。</DialogDescription></DialogHeader>
|
||||
{loading?<p role="status">正在查找图片…</p>:items.length===0?<p>暂无可复用的图片。先打开有已上传图片的画布,再回到这里。</p>:<div className="grid max-h-80 grid-cols-2 gap-3 overflow-y-auto">{items.map(item=><button key={item.id} type="button" aria-label={`添加图片:${item.title}`} disabled={!!busy} onClick={()=>void insert(item)} className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border p-3 text-sm hover:bg-muted disabled:opacity-50">
|
||||
<span className="flex h-20 w-full items-center justify-center overflow-hidden rounded bg-muted"><Preview src={`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(item.id)}?variant=preview`}/></span><span className="w-full truncate">{item.title}</span>{busy===item.id&&<span role="status">正在添加…</span>}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {writeFile} from 'node:fs/promises';
|
||||
import {setTimeout as pause} from 'node:timers/promises';
|
||||
import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts';
|
||||
|
||||
export async function measureRenderer({call,evaluate,documents,root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',bundler='webpack',buildMs}){
|
||||
export async function measureRenderer({call,evaluate,documents,calls=[],root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',bundler='webpack',buildMs}){
|
||||
const target=2*count+3,started=Date.now(),heapSamples=[];let copying=false,nextHeap=0;
|
||||
const wait=async(expression,label)=>{const deadline=Date.now()+240000;while(Date.now()<deadline){if(exceptions.length)throw Error(exceptions.join('\n'));if(copying&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'&&Date.now()>=nextHeap){const heap=await call('Runtime.getHeapUsage'),retention=await evaluate('window.__fixtureReceiptRetention');heapSamples.push({wallMs:Date.now()-started,serverElements:documents.get(root)?.elements.size,heap,retention});nextHeap=Date.now()+10000;await writeFile(reportPath+'.heap.json',JSON.stringify({baseline,heapSamples},null,2));}if(await evaluate(expression))return;await pause(100);}throw Error(label);};
|
||||
let report,duringCopyInput;
|
||||
@@ -41,6 +41,26 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
|
||||
await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'native input did not synchronize before copy measurement');
|
||||
const inputCheck={beforeEditing,duringEditing,offscreenEditing};
|
||||
console.log('PERF native editing '+JSON.stringify(inputCheck));
|
||||
const runStartsBefore=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length;
|
||||
await wait(`!!document.querySelector('[aria-label="AI 创作"]')&&!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI panel action did not become available');
|
||||
await evaluate(`document.querySelector('[aria-label="AI 创作"]').click()`);
|
||||
await wait(`document.querySelector('[aria-label="AI 模型"]')?.value==='fixture-text'`,'AI model did not load');
|
||||
await evaluate(`(()=>{window.__performancePanel=document.querySelector('[aria-label="AI 指令"]');window.__performancePanel.focus();window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);})()`);
|
||||
await call('Input.insertText',{text:'Stable host panel draft'});
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
inputCheck.panelFocusedNodes=await evaluate('document.querySelectorAll(".react-flow__node").length');
|
||||
if(count>=100)assert.ok(inputCheck.panelFocusedNodes<=beforeEditing+10,'host-panel focus mounted the complete canvas');
|
||||
await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`);
|
||||
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
assert.equal(await evaluate('window.__performancePanel.isConnected&&document.activeElement===window.__performancePanel&&window.__performancePanel.value==="Stable host panel draft"'),true,'independent panel lost its focus or draft during pan');
|
||||
inputCheck.offscreenPanel=await evaluate('document.querySelectorAll(".react-flow__node").length');
|
||||
assert.equal(inputCheck.offscreenPanel,0,'host-panel input pinned unrelated offscreen nodes');
|
||||
await evaluate('window.__performancePanel.blur();new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
|
||||
await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'AI settings did not save before copy');
|
||||
assert.equal(documents.get(root).elements.get('perf-0').data.ai?.prompt,'Stable host panel draft','host-panel blur lost saved settings');
|
||||
await evaluate(`(()=>{document.querySelector('[aria-label="关闭 AI 工具"]').click();const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__performancePanel;delete window.__editViewport;})()`);
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,runStartsBefore,'editing settings started a generation task');
|
||||
|
||||
const panePoint=await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect();for(let y=rect.y+5;y<rect.bottom;y+=20)for(let x=rect.x+5;x<rect.right;x+=20)if(document.elementFromPoint(x,y)===pane)return{x,y};throw Error('no empty native pane point');})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...panePoint,button:'left',buttons:1,clickCount:1});
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...panePoint,button:'left',buttons:0,clickCount:1});
|
||||
@@ -85,6 +105,8 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
|
||||
assert.equal(copiedFirst?.data.text,sourceText,'copy snapshot absorbed a later original edit');
|
||||
assert.equal(document.elements.get('perf-0').data.text,duringCopyInput.value,'ACK overwrote concurrent original text');
|
||||
}
|
||||
const runStartsDuringScenario=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length-runStartsBefore;
|
||||
assert.equal(runStartsDuringScenario,0,'deep copy or settings unexpectedly started generation');
|
||||
const visibleEdges=await evaluate('document.querySelectorAll(".react-flow__edge").length');assert.equal(visibleEdges,2,'frame connections did not render for the offscreen source');
|
||||
// Completion is a model/progress check. Verify an offscreen copied card actually mounts after panning.
|
||||
const originalLast=document.elements.get(`perf-${count-1}`),copiedLast=[...document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId===copiedCanvas.id&&element.geometry.x===originalLast.geometry.x&&element.geometry.y===originalLast.geometry.y);assert.ok(copiedLast);
|
||||
@@ -92,7 +114,7 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co
|
||||
await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect(),matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(${world.x}*matrix.a+matrix.e-rect.width/2)/.5,deltaY:(${world.y}*matrix.a+matrix.f-rect.height/2)/.5,bubbles:true,cancelable:true}));})()`);
|
||||
await wait(`!!document.querySelector('.react-flow__node[data-id="${copiedLast.id}"] textarea')`,'offscreen copied card did not mount after viewport pan');
|
||||
const viewportCheck={visibleEdgesBeforePan:visibleEdges,copiedId:copiedLast.id,mountedAfterPan:await evaluate('document.querySelectorAll(".react-flow__node").length')};
|
||||
report={inputCheck,duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} ${bundler}; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks`,count,initialReadyMs,buildMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started};
|
||||
report={inputCheck,duringCopyInput,runStartsDuringScenario,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} ${bundler}; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks`,count,initialReadyMs,buildMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started};
|
||||
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile(reportPath.endsWith('.json')?reportPath.slice(0,-5)+'.png':reportPath+'.png',Buffer.from(screenshot.data,'base64'));
|
||||
}catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',count,buildMode,buildMs,error:error.message,measurements,serverElements:documents.get(root)?.elements.size,serverCanvases:documents.get(root)?.canvases.size,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;}
|
||||
await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath);
|
||||
|
||||
@@ -169,7 +169,7 @@ try {
|
||||
const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
|
||||
await call('Page.navigate', { url: route });
|
||||
if(performanceMode){
|
||||
await measureRenderer({call,evaluate,documents,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
|
||||
await measureRenderer({call,evaluate,documents,calls,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
|
||||
}else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report);
|
||||
}else{
|
||||
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');
|
||||
@@ -419,6 +419,14 @@ try {
|
||||
console.log('PASS deleting a focused renderer releases detached ownership without requiring blur and history restores its content');
|
||||
|
||||
|
||||
// Exceed the curve-inspection budget without retaining unrelated child cards.
|
||||
const budgetRoot=id(870),budgetChild=id(871),budgetFrame={...node('budget-frame',budgetRoot,0),componentId:'frame',geometry:{x:0,y:400,width:1000,height:600,rotation:0}},budgetNodes=[node('budget-a',budgetRoot,80),node('budget-b',budgetRoot,450),budgetFrame,...Array.from({length:200},(_,index)=>node('budget-hidden-'+index,budgetChild,12000+index*280))],budgetEdges=Array.from({length:4097},(_,index)=>({id:'budget-edge-'+index,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:budgetRoot,source:{elementId:'budget-a'},target:{elementId:'budget-b'},data:{},path:null}));
|
||||
documents.set(budgetRoot,createDocument(budgetRoot,[{id:budgetRoot,parent:null},{id:budgetChild,parent:{canvasId:budgetRoot,entryElementId:budgetFrame.id}}],[...budgetNodes,...budgetEdges]));versions.set(budgetRoot,1);versions.set(budgetChild,1);boards.push({id:budgetRoot,workspace_id:workspace,kind:'board',version:1,title:'Curve budget board'});
|
||||
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${budgetRoot}`});
|
||||
await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&document.querySelectorAll('.react-flow__edge').length===4097&&document.querySelectorAll('.react-flow__node').length===3`,'paint budget fallback hid edges or mounted unrelated child nodes');
|
||||
assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="budget-hidden-0"]')`),false);assert.equal(documents.get(budgetRoot).elements.size,4300);assert.equal(await evaluate('window.__fixtureActualEngine.getSnapshot().document.elements.size'),4300);assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get('budget-hidden-199')?.ownerCanvasId`),budgetChild);
|
||||
console.log('PASS exhausted curve inspection retains all 4097 edges and their endpoint owners while 200 unrelated offscreen cards stay culled');
|
||||
|
||||
// Read a fresh project whose known envelope contains opaque component/version payloads.
|
||||
const opaqueRoot=id(800),opaqueChild=id(801),opaqueU={...node('opaque-u',opaqueRoot,50),componentId:'plugin:future',dataVersion:9,data:{title:'Opaque source',ports:{find:'future field'},presentation:{unitScale:'future'},assetId:'opaque-asset',__headlessFallback:{handles:'untrusted'},opaque:{preserve:true}}},opaqueV={...node('opaque-v',opaqueRoot,400),dataVersion:99,data:{title:'Future card',text:{preserve:true},ports:'future field'}},opaqueFrame={...node('opaque-frame',opaqueRoot,100),componentId:'frame',dataVersion:99,geometry:{x:100,y:450,width:350,height:220,rotation:0},data:{title:'Future partition',ports:{preserve:true},presentation:{unitScale:'future'}}},opaqueKnown=node('opaque-known',opaqueRoot,800),opaqueMember=node('opaque-member',opaqueChild,20),opaqueEdge={id:'opaque-edge',incarnation:1,structure:'connection',componentId:'plugin:connection',dataVersion:9,ownerCanvasId:opaqueRoot,source:{elementId:opaqueU.id,portId:'custom-out'},target:{elementId:opaqueV.id,portId:'custom-in'},path:{type:'future',preserve:true},data:{appearance:{color:{preserve:true}}}},regularEdge={...opaqueEdge,id:'opaque-regular',componentId:'connection',dataVersion:1,target:{elementId:opaqueKnown.id,portId:'left'},path:null,data:{}},partitionEdge={...regularEdge,id:'opaque-partition',source:{elementId:opaqueKnown.id,portId:'bottom'},target:{elementId:opaqueMember.id}};
|
||||
const opaqueOriginal=createDocument(opaqueRoot,[{id:opaqueRoot,parent:null},{id:opaqueChild,parent:{canvasId:opaqueRoot,entryElementId:opaqueFrame.id}}],[opaqueU,opaqueV,opaqueFrame,opaqueKnown,opaqueMember,opaqueEdge,regularEdge,partitionEdge]);documents.set(opaqueRoot,opaqueOriginal);versions.set(opaqueRoot,1);versions.set(opaqueChild,1);boards.push({id:opaqueRoot,workspace_id:workspace,kind:'board',version:1,title:'Compatibility board'});
|
||||
|
||||
Reference in New Issue
Block a user