Keep image migration pending until save confirmation
This commit is contained in:
@@ -69,3 +69,6 @@
|
||||
- 数据库聚合确认父图含 9 个节点、2 条边、2 张内嵌图片。JSON 文本约 477,646 字节,图片 src 合计 474,073 字符,约占 99.3%。原先设计让两张图片阻塞整张画布的数据读取。
|
||||
|
||||
修复采用普通 personal SaaS 附件模式:一份原始附件、一份按需显示预览,快照仅引用附件。前端添加真实附件上传及显式旧图迁移;Core PR #15 提供单一 1024px 预览、权限检查和有界缓存。原图不替换,AI 继续取原始内容。图片独立加载后,结构读取不再受两张完整图片的传输时间阻塞。生产部署和指定画布迁移后的实际字节数/时间须重测后记录,不能把预计缩减当作已完成实测。
|
||||
|
||||
|
||||
后端 PR #15 已由用户合并为 b2ae48e,分支/PR #936/#937、主线 #940、部署 #941 均成功。Endoleap main 570cd53 已接入。业务数据尚需用户明确点击“优化图片加载”;目前未把上传接口、预览上线或模拟测试当成该画布已经完成迁移/提速。迁移前聚合与哈希基线保存在服务器 `/tmp/endoleap-canvas-diag-20261106T131903Z/migration-baseline.json`,当前版本 252、8 个父图节点、2 张内嵌图片。预期在下一次明确操作后核对哈希和真实下载字节/时间。
|
||||
|
||||
@@ -125,6 +125,6 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA
|
||||
|
||||
## 图片附件与加载
|
||||
|
||||
真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能通过 PR #15 交付,部署前服务端仍可能返回原图。
|
||||
真实画布新上传的图片存入 Foundry 附件 API,快照只保存原始附件引用,避免 Base64 随每次打开和保存传输。原图保留;画布按需读取单一最长边 1024px 的预览,放大后可读取原图。预览权限与原图一致,Core 的配套预览功能已随 PR #15 合并并部署(b2ae48e);小图或 GIF 可能直接返回原始文件。
|
||||
|
||||
已有内嵌图片通过画布提示中的“优化图片加载”明确迁移一次:上传原始字节,然后按当前版本保存相同节点、尺寸、位置与连线。打开页面不会自动迁移或 PATCH。只改变视角保存在本机;真实内容修改才自动保存到服务器。
|
||||
|
||||
+2
-1
@@ -49,5 +49,6 @@
|
||||
- 新图片使用受管附件;旧图仅在用户明确点击后迁移,字节保持原样,快照不保存预览地址。
|
||||
- 单元测试增加字节一致性、重复图片去重、幂等重试、失败不替换、V1 兼容和渲染 URL 分离;共 84 项通过。
|
||||
- DOM 回归确认只读打开/缩放零 PATCH、显式迁移一次上传、位置和边不变、画布使用预览而快照保存原图引用;原有 AI/子图/连线回归通过。
|
||||
- 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 的最新远程检查、部署及用户画布迁移后实测仍在进行。
|
||||
- 同步 TCP/数据库证据及隐私边界见 PERFORMANCE.md。Core PR #15 已由用户合并为 b2ae48e;分支/PR CI #936/#937、主线检查 #940 和部署 #941 全部成功。当前仍等待用户明确执行旧图片迁移与重开日志,尚未完成实际画布的提速验收。
|
||||
- 最终前端检查:TypeScript、84 项测试、完整 DOM 回归、隔离 Webpack 构建和 API 冒烟均通过(包括 15.2 秒慢正文、原图/预览参数及响应头)。
|
||||
- 最后补充:图片按需加载/异步解码;优化提示保持到版本保存确认后才消失,并阻止与新图片上传重叠。延迟保存的 DOM 回归和最终隔离构建通过。
|
||||
|
||||
@@ -478,7 +478,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
|
||||
<p>可通过右上角导出 JSON 备份。</p>
|
||||
</div>
|
||||
{!!snapshot && inlineImageCount(tree.rootDocument) > 0 && <div className={styles.imageMigration} role="status"><span>此画布的旧图片可优化加载,原图和内容都会保留。</span><button type="button" disabled={tree.busy || !!recovery} onClick={() => void tree.migrateImages().catch((error) => setError(errorText(error)))}>{tree.busy ? "正在处理…" : "优化图片加载"}</button></div>}
|
||||
{!!snapshot && (inlineImageCount(tree.rootDocument) > 0 || tree.migratingImages) && <div className={styles.imageMigration} role="status"><span>此画布的旧图片可优化加载,原图和内容都会保留。</span><button type="button" disabled={tree.busy || imageUploading || !!recovery} onClick={() => void tree.migrateImages().catch((error) => setError(errorText(error)))}>{tree.busy ? "正在处理…" : "优化图片加载"}</button></div>}
|
||||
{imageUploading && <p className={styles.imageMigration} role="status">正在上传图片…</p>}
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
{iconPicker && <IconPicker close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
|
||||
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
|
||||
{data.kind === "image" ? <img className={styles.image} src={managedImageSource(data.src, preview)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
{data.kind === "image" ? <img loading="lazy" decoding="async" className={styles.image} src={managedImageSource(data.src, preview)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
<CanvasTextEditor id={id} data={data} selected={selected} />
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
|
||||
|
||||
@@ -7,5 +7,5 @@ import styles from "./canvas.module.css";
|
||||
export function PlainImageNode(props: NodeProps<CanvasNode>) {
|
||||
const { zoom } = useViewport();
|
||||
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={managedImageSource(props.data.src, preview)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
|
||||
return <TransformableNode {...props} className={styles.plainImageFrame}><img loading="lazy" decoding="async" className={styles.plainImage} src={managedImageSource(props.data.src, preview)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
|
||||
}
|
||||
|
||||
@@ -276,19 +276,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
async function migrateImages() {
|
||||
return exclusive(async () => {
|
||||
await flush();
|
||||
const before = current.current.get(root)!;
|
||||
const controller = new AbortController(); migrationAbort.current = controller;
|
||||
let document: CanvasDocument;
|
||||
try { document = await prepareManagedImages(before, api, workspace, controller.signal); }
|
||||
finally { migrationAbort.current = null; }
|
||||
if (!mounted.current || controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
undo.current.push({ boardId: root, before }); redo.current = [];
|
||||
publish(new Map(current.current).set(root, document));
|
||||
const writer = writers.current.get(root)!;
|
||||
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
try {
|
||||
await flush();
|
||||
const before = current.current.get(root)!;
|
||||
const document = await prepareManagedImages(before, api, workspace, controller.signal);
|
||||
if (!mounted.current || controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
undo.current.push({ boardId: root, before }); redo.current = [];
|
||||
publish(new Map(current.current).set(root, document));
|
||||
const writer = writers.current.get(root)!;
|
||||
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
} finally { migrationAbort.current = null; }
|
||||
});
|
||||
}
|
||||
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
|
||||
return { documents, current, views, migrateImages, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
||||
return { documents, current, views, migrateImages, migratingImages: !!migrationAbort.current, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
|
||||
if(!suffix&&method==='GET')return json(structuredClone(snapshot));
|
||||
if(!suffix&&method==='PATCH'){
|
||||
if(options.headers['If-Match']!==`"${snapshot.version}"`)return json({error:'version_conflict'},412);
|
||||
const payload=JSON.parse(options.body).document;assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted');
|
||||
const payload=JSON.parse(options.body).document;
|
||||
if(payload.nodes.some(node=>node.id==='legacy-image' && node.data.assetId))await new Promise(resolve=>setTimeout(resolve,200));assert.ok(payload.nodes.every(node=>!node.id.includes('::')),'No projected IDs may be persisted');
|
||||
if(board===parent)assert.ok(!payload.nodes.some(node=>node.id==='child-text'),'Child content cannot be duplicated into parent');
|
||||
saves.push({board,document:payload});snapshot.document=structuredClone(payload);snapshot.version++;return json({version:snapshot.version});
|
||||
}
|
||||
@@ -152,6 +153,7 @@ export async function canvasV2Scenario({root,act,h,GraphCanvas,window,makeCanvas
|
||||
console.log('PASS AI images: sticky mode, modality filtering, original-node asset output and cancellation');
|
||||
const beforeMigration=structuredClone(records.get(parent).document);
|
||||
await event([...document.querySelectorAll('button')].find(button=>button.textContent==='优化图片加载'),'click');
|
||||
assert.ok([...document.querySelectorAll('button')].some(button=>button.textContent==='正在处理…'),'Migration stays visible until the versioned save is acknowledged');
|
||||
await waitUntil(()=>records.get(parent).document.nodes.find(node=>node.id==='legacy-image')?.data.src.startsWith('https://api.foundry.entroia.com/'),'explicit image migration');
|
||||
assert.equal(assetUploads,1);const migrated=records.get(parent).document.nodes.find(node=>node.id==='legacy-image');assert.deepEqual(migrated.position,beforeMigration.nodes.find(node=>node.id==='legacy-image').position);
|
||||
assert.deepEqual(records.get(parent).document.edges,beforeMigration.edges);
|
||||
|
||||
Reference in New Issue
Block a user