48 lines
3.7 KiB
TypeScript
48 lines
3.7 KiB
TypeScript
"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";
|
|
export type ImageUploadDraft = { owner: string; node: CanvasNode; edge?: CanvasDocument["edges"][number] };
|
|
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. */
|
|
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;
|
|
const changed = () => { if (mounted.current) setJobs([...pending.current.values()]); };
|
|
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(); }
|
|
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(); }; }, []);
|
|
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()]);
|
|
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(); } }
|
|
}
|
|
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.
|
|
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; }
|
|
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;
|
|
});
|
|
}
|
|
void run(job);
|
|
return job;
|
|
}
|
|
return { jobs, 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); } };
|
|
}
|