Files
Endoleap/src/components/canvas/use-image-uploads.ts
T

72 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧编辑器的内存上传任务管理。原文件、Blob预览URL和AbortController随Hook挂载存活,资源上传成功后由commit绑定到本地节点。
* 这是可取消的页面内流程,没有持久文件仓库;卸载会中止上传并清理URL,不能承诺跨刷新继续上传。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api, errorText } from "@/lib/api";
import { uploadCanvasImage, type CanvasAsset } from "@/lib/canvas-assets";
import type { CanvasDocument, CanvasNode } from "@/lib/canvas";
/** draft指定节点归属与可选关联边,调用方负责先创建可编辑占位并正确处理提交时归属。 */
export type ImageUploadDraft = { owner: string; node: CanvasNode; edge?: CanvasDocument["edges"][number] };
/** file保存原字节、localURL用于即时预览、key用于同任务重试幂等;ready可等待本地图片尺寸解码。 */
export type ImageUploadJob = ImageUploadDraft & { file: File; localURL: string; key: string; status: "uploading" | "failed" | "uploaded"; error?: string; controller: AbortController; ready?: Promise<void>; asset?: CanvasAsset };
/** Uploads attach file references to existing editable local nodes; transient URLs never enter autosaves. */
/** commit通过ref读取最新回调;pending按节点ID存任务,jobs数组只作React显示发布。 */
export function useImageUploads(workspace: string, commit: (draft: ImageUploadDraft, asset: CanvasAsset) => void) {
const pending = useRef(new Map<string, ImageUploadJob>());
const [jobs, setJobs] = useState<ImageUploadJob[]>([]);
const mounted = useRef(true), onCommit = useRef(commit); onCommit.current = commit;
/** 挂载期间发布新的任务数组;任务对象仍是同一引用,字段更新后需调用changed触发显示。 */
const changed = () => { if (mounted.current) setJobs([...pending.current.values()]); };
/** 中止任务、释放预览URL并移出Map;并不在这里删除文档节点或服务端资源。 */
function remove(id: string) { const job = pending.current.get(id); if (!job) return; job.controller.abort(); URL.revokeObjectURL(job.localURL); pending.current.delete(id); changed(); }
// 卸载统一abort并回收URL,清Map防止迟到上传结果提交;StrictMode重新挂载effect时复位mounted。
useEffect(() => { mounted.current = true; return () => { mounted.current = false; for (const job of pending.current.values()) { job.controller.abort(); URL.revokeObjectURL(job.localURL); } pending.current.clear(); }; }, []);
/** uploading状态防并发重试;开始新尝试前换controller并清错误,保留原幂等key。
* 上传与ready并行,二者都成功后才commit;commit抛错也会进入failed状态,允许再次处理。 */
async function run(job: ImageUploadJob) {
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(); } }
}
/** 先创建Blob URL并让调用方建立草稿,草稿构造失败立即释放URL;任务初始failed以便run统一转uploading。 */
function start(file: File, draft: (localURL: string) => ImageUploadDraft) {
const localURL = URL.createObjectURL(file);
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尺寸,不重编码上传文件。
const scale = Math.min(1,480/image.naturalWidth,480/image.naturalHeight);
const size = { width: Math.max(24,Math.round(image.naturalWidth*scale)), height: Math.max(24,Math.round(image.naturalHeight*scale)) };
job.node = { ...job.node, ...size, style: { ...job.node.style, ...size } }; changed(); resolve();
}; image.onerror = () => reject(new Error("无法读取图片尺寸。")); image.src = localURL;
});
}
// start同步返回任务对象,异步上传由run内部捕获失败,不要求调用方等待才显示占位。
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); } };
}