docs(canvas): explain legacy persistence and upload cleanup

This commit is contained in:
2026-11-12 16:38:57 +08:00
parent 55d70fe786
commit 62b41b79dc
2 changed files with 9 additions and 0 deletions
@@ -21,10 +21,12 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
if (alive.current) { setSync(next); backup(); }
}) : null);
/** 保存规范化document、writer当前版本及内容指纹dirty;回调路径吞掉存储异常,定时effect路径会展示错误。 */
// writer 通知捕获创建时的 backup/key;latest 与 pausedRef 保持当前值,key/workspace/board 改变须重建 Hook 所在视图。
function backup() {
if (pausedRef.current) return;
try {
const current = latest.current;
// dirty 使用包含 viewport 的指纹,而 writer.needsSave 忽略 viewport;两者用途不同,不可互换解释。
localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(current) !== writer.saved : false }));
} catch { /* Storage failure is surfaced by the effect below. */ }
}
@@ -35,6 +37,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
try { localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(latest.current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(latest.current) !== writer.saved : false })); }
catch { setSync({ status: "error", version: writer?.version || 1, error: "local_backup_failed" }); }
}, 250);
// 此处只入队,不 await/捕获 running 的拒绝;CanvasSync 的通知与回调需遵守它自身的异常约定。
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
}, [document, writer, key, paused]);
@@ -48,6 +51,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
// Writer and key are stable for this board's mounted editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writer, key]);
// 同步 localStorage 写入只保证调用返回时 API 未抛错;没有回读、版本日志或刷盘确认。
// retry先补本地备份再委托writer重试;没有远端writer时返回undefined,不伪造同步成功。
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
}
@@ -30,9 +30,11 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
if (job.status === "uploading") return;
job.status = "uploading"; job.error = undefined; job.controller = new AbortController(); changed();
try {
// Promise.all 不会因 ready 拒绝自动中止上传;失败 UI 可先出现,而底层请求仍可能完成。
const [asset] = await Promise.all([uploadCanvasImage(api, workspace, job.file, job.file.name, job.controller.signal, job.key), job.ready || Promise.resolve()]);
// 结果返回后核对挂载、Map内仍是同一个job且未取消,旧任务不能写到后来同ID的新任务。
if (!mounted.current || pending.current.get(job.node.id) !== job || job.controller.signal.aborted) return;
// commit 是同步调用,不等待网络 ACK;抛错不会自动撤销它此前已发布的本地修改。
job.asset = asset; onCommit.current(job, asset); job.status = "uploaded"; changed();
} catch (error) { if (mounted.current && pending.current.get(job.node.id) === job && !job.controller.signal.aborted) { job.status = "failed"; job.error = errorText(error); changed(); } }
}
@@ -42,11 +44,13 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
let value: ImageUploadDraft;
try { value = draft(localURL); } catch (error) { URL.revokeObjectURL(localURL); throw error; }
const job: ImageUploadJob = { ...value, file, localURL, key: crypto.randomUUID(), status: "failed", controller: new AbortController() };
// 节点 ID 必须由草稿工厂保证唯一;同 ID set 会替换旧任务,但此处不会 abort/revoke 被替换的任务。
pending.current.set(job.node.id, job); changed();
// Browser decoding runs locally, concurrently with the original-byte upload.
// 纯图片并行解码自然尺寸,限制初始长宽不超过480(不主动放大),再分别设置至少24px的显示尺寸。
if (job.node.data.kind === "plain-image") {
job.ready = new Promise<void>((resolve,reject) => {
// 这个 Image 解码没有接入 controller 的取消,也没有显式移除 onload/onerror;回调以 Map 身份拒绝已移除任务。
const image = new window.Image(); image.onload = () => {
if (pending.current.get(job.node.id) !== job) { resolve(); return; }
// 分别钳制到最小24可能改变极端长宽比;这里只初始化节点和style尺寸,不重编码上传文件。
@@ -60,6 +64,7 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
void run(job);
return job;
}
// 每次重试仍调用上传接口,不直接采用 job.asset;幂等依赖相同 key,ready 失败也不会自动回滚服务端资源。
// hasPending包含失败但不包含uploaded,active只看uploading;loaded仅在正式图片加载后移除已上传预览。
// retry复用job及幂等键;若ready曾拒绝,重试不会在这里重建尺寸解码Promise。
return { jobs, hasPending:()=>[...pending.current.values()].some(job=>job.status!=="uploaded"), active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };