diff --git a/src/components/canvas/use-canvas-persistence.ts b/src/components/canvas/use-canvas-persistence.ts index 8fc87ab..375d36a 100644 --- a/src/components/canvas/use-canvas-persistence.ts +++ b/src/components/canvas/use-canvas-persistence.ts @@ -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(); } }; } diff --git a/src/components/canvas/use-image-uploads.ts b/src/components/canvas/use-image-uploads.ts index 7fdfb0a..2a06d1d 100644 --- a/src/components/canvas/use-image-uploads.ts +++ b/src/components/canvas/use-image-uploads.ts @@ -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((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); } };