67 lines
5.9 KiB
TypeScript
67 lines
5.9 KiB
TypeScript
/**
|
||
* 旧编辑器的内存上传任务管理。原文件、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 {
|
||
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;
|
||
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() };
|
||
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) => {
|
||
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;
|
||
}
|
||
// 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); } };
|
||
}
|