feat(canvas): expose native import progress and controls
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
<IconButton icon={Type} label="添加纯文本" onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" onClick={() => add('frame')} />
|
||||
</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 })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
|
||||
<HeadlessImports engine={engine} canvasId={view.canvasId} />
|
||||
{showAI && aiElement && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
|
||||
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
|
||||
{error && <div className={styles.error} role="alert">{error} <button onClick={() => setError('')}>关闭</button></div>}
|
||||
|
||||
@@ -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<string,string>)[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<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="画布导入">
|
||||
<div className={styles.importActions}><IconButton icon={FolderInput} label="导入 NDJSON 画布数据" onClick={() => input.current?.click()} /><span>逐行画布数据</span></div>
|
||||
<input data-canvas-import ref={input} type="file" accept=".ndjson,.jsonl,application/x-ndjson" hidden onChange={event => {
|
||||
const file = event.target.files?.[0]; event.target.value = ""; if (!file) return;
|
||||
try { engine.imports.startNdjson(file, canvasId); setOpen(true); setError(""); } catch (error) { setError(errorText(error)); }
|
||||
}} />
|
||||
{jobs.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}><summary>导入任务 · {jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入</summary>
|
||||
{jobs.map((job, index) => <section className={styles.importJob} key={job.id} aria-label={`导入任务 ${index + 1}`}>
|
||||
<strong>{job.source?.name || `导入任务 ${index + 1}`}</strong>
|
||||
<p role="status">{job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}</p>
|
||||
{job.error && <p>{reason(job.error)}</p>}
|
||||
<div className={styles.importActions}>
|
||||
{job.status === "running" && <button onClick={() => control(engine.imports.pause(job.id))}>暂停导入</button>}
|
||||
{job.status === "paused" && <button onClick={() => control(engine.imports.resume(job.id))}>继续导入</button>}
|
||||
{["running","paused"].includes(job.status) && <button onClick={() => control(engine.imports.cancel(job.id))}>停止导入</button>}
|
||||
</div>
|
||||
{job.status === "cancelled" && <small>已加入的内容保留并继续同步。</small>}
|
||||
{job.failures.length > 0 && <details><summary>{job.failures.length} 条未加入记录</summary><ul>{job.failures.slice(0, 10).map((failure, item) => <li key={`${failure.key}:${item}`}>{failure.key.startsWith("byte:") ? `文件位置 ${failure.cursor}` : failure.key}:{reason(failure.code)}</li>)}</ul></details>}
|
||||
</section>)}
|
||||
</details>}
|
||||
{error && <p role="alert">{error}{jobs.length > 0 && <button onClick={() => control(engine.retry())}>重试保存</button>}</p>}
|
||||
</aside>;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user