From ce02e439587e70e7eadb6c5312a1a81e01a79a1e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 15:40:10 +0800 Subject: [PATCH] feat(canvas): expose native import progress and controls --- src/components/canvas/canvas.module.css | 9 ++++ .../canvas/headless-graph-canvas.tsx | 2 + src/components/canvas/headless-imports.tsx | 41 +++++++++++++++++++ tests/headless/renderer.integration.mjs | 16 +++++++- 4 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/components/canvas/headless-imports.tsx diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index 823de3b..50cd115 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -192,3 +192,12 @@ .memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; } .memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; } + +.importPanel { position: absolute; top: 84px; right: 12px; z-index: 12; width: max-content; max-width: min(310px, calc(100vw - 28px)); max-height: 45vh; overflow: auto; border: 1px solid #ded7ce; border-radius: 10px; padding: 6px 9px; background: #fffefb; box-shadow: 0 4px 18px #32251a10; color: #49372e; font-size: 11px; } +.importPanel summary { cursor: pointer; padding: 5px 0; } +.importPanel ul { padding-left: 16px; overflow-wrap: anywhere; } +.importPanel p { margin: 5px 0; } +.importPanel small { color: #92877c; } +.importJob { border-top: 1px solid #eee7de; padding-top: 5px; margin-top: 5px; } +.importActions { display: flex; align-items: center; gap: 8px; } +.importActions > button:not(:first-child), .importJob button { color: #78523f; padding: 4px 0; } diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 1793c55..06c6493 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -16,6 +16,7 @@ import { guardEditingLease } from "./editing-lease"; import { NodeEditing } from "./transformable-node"; import { EdgeEditing } from "./edge-editing"; import { HeadlessAIPanel } from "./headless-ai-panel"; +import { HeadlessImports } from "./headless-imports"; import { HeadlessRecoveryDialog } from "./headless-recovery-dialog"; import { CanvasEdge } from "./canvas-edge"; import { MaterialNode } from "./material-node"; @@ -202,6 +203,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B 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 })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} /> + {showAI && aiElement && setShowAI(false)} />} {showRecovery && setShowRecovery(false)} />} {error &&
{error}
} diff --git a/src/components/canvas/headless-imports.tsx b/src/components/canvas/headless-imports.tsx new file mode 100644 index 0000000..d6df2ce --- /dev/null +++ b/src/components/canvas/headless-imports.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useCallback, useRef, useState, useSyncExternalStore } from "react"; +import { FolderInput } from "lucide-react"; +import type { ProjectEngine } from "@/lib/canvas-runtime/workspace"; +import { IconButton } from "@/components/ui/icon-button"; +import { errorText } from "@/lib/api"; +import styles from "./canvas.module.css"; + +const reason = (code: string) => ({ 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_unavailable: "导入源文件不可用", import_source_changed: "导入源或解析规则已变化", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", invalid_import_source: "文件过大或格式不支持" } as Record)[code] ?? "这条记录暂时无法加入"; + +export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; canvasId: string }) { + const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]); + const snapshot = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot); + const input = useRef(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 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 ; +} diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 2633280..5d9b4ba 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -15,6 +15,7 @@ documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a' documents.set(other, createDocument(other, [{ id: other, parent: null }], [])); 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 releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map(); let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;}); let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; }); const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] }); @@ -35,13 +36,15 @@ const upstream = createServer(async (request, response) => { const write = path.match(/\/materials\/([^/]+)\/canvas\/model\/operations$/); if (write) { const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]); + if(batch.operations.some(operation=>operation.changes.some(change=>change.kind==='element'&&change.after?.data?.title==='Imported NDJSON node'))){importWriteSeen=true;await importGate;} assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id); if(rejectNextEdit){rejectNextEdit=false;rejectedEdit=batch;const before=previous.elements.get('b');documents.set(write[1],applyChanges(previous,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:before.data.text,after:'Remote reviewed',beforePresent:true,afterPresent:true}]));versions.set(root,versions.get(root)+1);return reply(409,{protocol_version:2,request_id:batch.request_id,status:'not-applied',terminal:true,error:'version_conflict'});} for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id)); let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes); documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,versions.get(id)+1); - return reply(200, { protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false,Object.keys(batch.expected_versions)) }); + const receipt={ protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false,Object.keys(batch.expected_versions)) };editReceipts.set(batch.request_id,receipt);return reply(200,receipt); } + const editQuery=path.match(/\/canvas\/model\/operations\/([^/]+)$/);if(editQuery)return editReceipts.has(editQuery[1])?reply(200,editReceipts.get(editQuery[1])):reply(404,{request_id:editQuery[1],status:'unknown'}); if(path.endsWith('/canvas/ai/models'))return reply(200,{models:[{id:'fixture-text',label:'Fixture text',available:true,input_modalities:['text'],output_modalities:['text']}]}); if(path.endsWith('/canvas/ai/runs')&&request.method==='POST'){ const input=JSON.parse(bytes.toString()),owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');assert.deepEqual(input.expected_versions,Object.fromEntries([...documents.get(root).canvases.keys()].map(id=>[id,versions.get(id)]))); @@ -228,11 +231,20 @@ try { assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false); console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity'); console.log('PASS canvas floating controls remain visible while the sidebar is expanded'); + const beforeImport=documents.get(root).elements.size,importRecord={kind:'element',key:'ui-import',record:{id:'ui-import',incarnation:1,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:'Imported NDJSON node',text:'Visible before ACK'},geometry:{x:700,y:450,width:240,height:160,rotation:0}}},nativeText=JSON.stringify(importRecord)+'\n{bad}'; + await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(nativeText)}],'canvas.ndjson',{type:'application/x-ndjson'}));const input=document.querySelector('[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); + await until(`document.querySelectorAll('.react-flow__node').length===6&&document.body.innerText.includes('部分加入')`,'native import did not show its successful prefix and partial report'); + for(let i=0;i<100&&!importWriteSeen;i++)await pause(20);assert.ok(importWriteSeen);assert.equal(documents.get(root).elements.size,beforeImport,'import waited for its backend before displaying content'); + await evaluate(`[...document.querySelectorAll('summary')].find(summary=>summary.textContent.includes('条未加入记录')).click()`);await until(`document.body.innerText.includes('记录格式错误')`,'import failure reason was hidden'); + const importScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-integration.png',Buffer.from(importScreenshot.data,'base64'));releaseImport(); + for(let i=0;i<200&&documents.get(root).elements.size!==beforeImport+1;i++)await pause(20);assert.equal(documents.get(root).elements.size,beforeImport+1);await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('部分加入')`,'import task and content did not recover after reload'); + await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import undo did not remove its successful content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import recovered an undone record'); + console.log('PASS actual NDJSON import displays before backend ACK, reports a failed record, restores task/source state and never revives undone content'); 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'); console.log('PASS logout retires the runtime before dropping its view without a render error'); } finally { - releaseApply();releaseUpload(); socket?.close(); + 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));