docs(canvas): 补充加载预览与旧任务 hooks 注释 #3

Merged
Zayden-Jung merged 1 commits from codex/canvas-panel-comments into main 2026-11-11 18:10:51 +00:00
5 changed files with 82 additions and 0 deletions
@@ -1,3 +1,7 @@
/**
* 模型读取前缀的只读预览。直接把已校验document投影给ReactFlow,允许浏览视口,不创建可写engine或临时history/ledger。
* 这个组件不负责持久化前缀;后续可编辑partial engine的建立与交接由路由及runtime处理。
*/
"use client";
import {useEffect,useMemo,useRef} from 'react';
@@ -11,26 +15,36 @@ import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/c
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
import styles from './canvas.module.css';
/** 已知类型缺少专用preview renderer时展示准备占位;保留已投影连线需要的Handle,禁止新连接。 */
function PreparingNode({data}:NodeProps){return <div style={{width:'100%',height:'100%',padding:14,border:'1px solid #ded7ce',borderRadius:8,background:'#fffdfa'}} aria-label="正在准备元素"><span>{typeof data.title==='string'?data.title:'正在准备元素…'}</span>{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=><Handle key={index} type={handle.type} id={handle.id} position={handle.type==='source'?Position.Right:Position.Left} isConnectable={false}/>)}</div>;}
/** 所有预览命令统一拒绝为model_loading,避免复用的renderer意外修改数据。 */
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
/** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
/** 首次存在节点且视口初始化后fitView一次,限制最大缩放为1,并把结果交给外层用于视图交接。 */
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
// fitted是本组件挂载期标记;清理只禁止迟到通知,不取消已经发起的fitView,也不重置fitted。
const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
return null;
}
/** Render the validated read prefix directly. No temporary editor, ACK, ledger or
* writable engine is constructed, and no partial model enters durable storage. */
/** renderer catalog限制加载期可用渲染路径;无法解释引用关系时沿用opaque占位,不能猜测缺失内容。 */
export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){
// Without a declared reference schema, use the existing opaque placeholder
// until the complete document is available to the installed renderer.
const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]);
// 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。
const projection=useMemo(()=>{
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
const handles=new Map<string,{id?:string;type:'source'|'target'}[]>();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);}
// 节点禁用拖动/选择/焦点/连接及指针,边禁用选择/焦点;已知组件声明assetId资源字段才设置预览URL。
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&&registry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
},[preview.document,canvasId,workspaceId,registry,catalog]);
// 普通rendererId优先使用专用preview,否则节点PreparingNode、边UnsupportedEdge;节点再套inert隔离内部控件。
const nodeTypes=useMemo<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...Object.fromEntries(Object.keys(renderers.nodeTypes).map(id=>[id,renderers.previewNodeTypes[id]??PreparingNode])),unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]);
// 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
<CanvasRendererCommandsContext.Provider value={commands}>
<ReactFlow nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} nodesFocusable={false} edgesFocusable={false} panOnDrag panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} onMoveEnd={(_,viewport)=>onViewport(viewport)}>
@@ -1,15 +1,25 @@
/**
* ReactFlow适配层的保留标记缓存。为需要保留的节点/边复制一份带__endoleapRetain的投影记录,原对象不变。
* 标记供应用固定版本的编译扩展使用,不是模型属性,也不应进入history、存储或业务renderer数据。
*/
/** Adapter-only flags consumed by the app's pinned React Flow compiler extension.
* They never enter the document, history, persistence or renderer business data. */
/** 每个投影使用者创建一份缓存:WeakMap复用同一原对象的带标记副本,previous复用最近一次整个输出。 */
export function createRetainedProjection(){
// 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。
const tagged=new WeakMap<object,object>();
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
/** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{nodes:N[];edges:E[]}=>{
if(!ids.size){previous=undefined;return{nodes,edges};}
// 数组引用及ID成员都相同才复用;快照复制ids,避免调用方之后原地修改Set影响比较基准。
if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]};
/** 未命中的记录保留原引用;命中时浅复制顶层加标记,嵌套数据仍与原投影共享。 */
const retain=<T extends {id:string}>(row:T):T=>{
if(!ids.has(row.id))return row;
let value=tagged.get(row);if(!value){value={...row,__endoleapRetain:true};tagged.set(row,value);}return value as T;
};
// 非空集合下会映射两张数组,即使没有命中也生成数组;下一次相同输入再复用该result。
const result={nodes:nodes.map(retain),edges:edges.map(retain)};
previous={nodes,edges,ids:new Set(ids),result};return result;
};
+26
View File
@@ -1,10 +1,16 @@
/**
* 旧画布树的AI任务跟踪Hook。发现根及后代画布的活动任务,按事件游标增量读取,协调结果刷新与本地冲突显示。
* 此路径使用内存Map/Set和定时轮询,不具备新版runtime ledger的持久请求恢复;卸载会停止本Hook跟踪。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { CANVAS_ACCEPT } from "@/lib/canvas-v2";
import { canvasPath } from "@/lib/canvas-sync";
import type { useCanvasTree } from "./use-canvas-tree";
/** 服务端模型目录条目:输入/输出模态、可用性与可选参数schema;本Hook只传递模型选择。 */
export type CanvasModel = { id: string; label?: string; display_name?: string; output_modalities: string[]; input_modalities: string[]; available: boolean; unavailable_reason?: string; parameters_schema?: unknown };
/** 旧AI接口的任务分页结果;next_seq/has_more跟踪事件页,steps/outputs/events供面板和活动连线显示。 */
export type AIRun = {
run_id: string; board_id: string; status: string; next_seq: number; has_more?: boolean; error?: string;
steps?: { step_id: string; board_id: string; node_id: string; status: string; error?: string }[];
@@ -12,7 +18,9 @@ export type AIRun = {
events?: { seq: number; type: string; board_id?: string; node_id?: string; logical_edge_id?: string; canvas_version?: number; output?: { output_id: string; text?: string; src?: string } }[];
applied_versions?: Record<string, number>;
};
/** 只有queued/running视为执行中;终态任务仍可能因has_more继续拉取剩余事件。 */
const active = (status: string) => status === "queued" || status === "running";
/** enabled仅控制画布AI模式显示;live才启用服务请求。tree通过ref保持最新,轮询effect按作用域重建。 */
export function useCanvasAI(workspace: string, root: string, live: boolean, tree: ReturnType<typeof useCanvasTree>) {
const [enabled, setEnabled] = useState(false);
const [runs, setRuns] = useState(new Map<string, AIRun>());
@@ -21,16 +29,22 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
const [activeEdges, setActiveEdges] = useState(new Set<string>());
const reference = useRef(tree); reference.current = tree;
const wake = useRef<() => void>(() => {});
// cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。
const cache = useRef(new Map<string, AIRun>());
const edgeStates = useRef(new Map<string, Set<string>>());
// 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。
const pendingRefresh = useRef(new Map<string, number>());
/** 发布新的Map/Set引用触发React更新;不把可变ref集合原样暴露成受控state。 */
const changed = () => { setRuns(new Map(cache.current)); setActiveEdges(new Set([...edgeStates.current.values()].flatMap((values) => [...values]))); };
// 单个effect内polling防并发轮询,dirty合并UI发布;AbortController统一中止这次作用域的读取。
useEffect(() => {
if (!live) return;
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
let polling = false, dirty = false, nextDiscovery = 0;
const controller = new AbortController();
/** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
/** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */
async function discover() {
let cursor = ""; const seen = new Set<string>();
do {
@@ -39,7 +53,9 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
} while (cursor && alive);
}
/** 是否仍需较快跟踪,包含终态但事件尚未读完的任务。 */
const hasActive = () => [...cache.current.values()].some((run) => active(run.status) || run.has_more);
/** 隐藏页面延后30秒;可见时串行发现、读事件、刷新已应用画布,再按活跃程度安排下一次。 */
async function poll() {
if (!alive || polling) return;
if (document.visibilityState === "hidden") { timer = setTimeout(poll, 30000); return; }
@@ -49,9 +65,11 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
for (const [id, previous] of cache.current) {
if (!active(previous.status) && !previous.has_more) continue;
let current = previous;
// 每页100事件,游标必须安全且不倒退,has_more时必须前进;每run最多保留最近1000条事件。
do {
const page = await request<Omit<AIRun, "board_id">>(canvasPath(workspace, previous.board_id) + `/ai/runs/${id}?after_seq=${current.next_seq}&limit=100`);
if (!Number.isSafeInteger(page.next_seq) || page.next_seq < current.next_seq || page.has_more && page.next_seq === current.next_seq) throw new Error("invalid_run_cursor");
// edge.active加入动画集合,completed/failed/skipped移除;任务终态清空剩余活动边。
const edges = edgeStates.current.get(id) || new Set<string>();
for (const event of page.events || []) {
if (event.logical_edge_id) { if (event.type === "edge.active") edges.add(event.logical_edge_id); else if (["edge.completed", "edge.failed", "edge.skipped"].includes(event.type)) edges.delete(event.logical_edge_id); }
@@ -63,6 +81,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
cache.current.set(id, current);
} while (current.has_more && alive);
}
// 等树不忙且不在本地事务中才刷新;版本冲突记录到conflicts,不直接用生成快照覆盖本地草稿。
for (const [board, version] of pendingRefresh.current) {
if (reference.current.busy || reference.current.transactionBusy) break;
const writer = reference.current.writers.current.get(board);
@@ -79,27 +98,34 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
polling = false;
if (alive) timer = setTimeout(poll, hasActive() || pendingRefresh.current.size ? 1200 : 30000);
}
/** 被唤醒时清计时器并强制下一次重新发现;已有poll运行时由其收尾继续调度。 */
const resume = () => { if (timer) clearTimeout(timer); nextDiscovery = 0; void poll(); };
wake.current = resume;
/** 页面重新可见时立即唤醒,避免继续等待后台长间隔。 */
const visible = () => { if (document.visibilityState === "visible") resume(); };
document.addEventListener("visibilitychange", visible);
void poll();
// effect清理中止GET、定时器和可见性监听,wake退为空实现;此处不清除缓存或自动取消服务端任务。
return () => { alive = false; wake.current = () => {}; controller.abort(); if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", visible); };
}, [workspace, root, live]);
/** 真实画布经tree.command启动任务,先插queued占位供UI显示,再唤醒跟踪;演示模式明确拒绝。 */
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
if (!live) throw new Error("demo_ai_unavailable");
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
}
/** 以新幂等键发送取消命令后继续查询终态;暂记queued用于保持轮询,不表示服务端重新执行任务。 */
async function cancel(run: AIRun) {
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
}
/** 先prepareApply协调目标本地状态,再发显式采用命令;成功后移除冲突提示并继续跟踪服务端最终状态。 */
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
await reference.current.prepareApply(targetBoard, node);
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); } wake.current();
}
// 对外只暴露显示状态和三个动作;调用者不直接修改cache、分页游标或刷新等待表。
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
}
@@ -1,17 +1,26 @@
/**
* 旧版单画布的自动保存Hook:短延迟备份本地草稿,较长延迟把最新快照交给CanvasSync。
* 它使用旧快照writer,不是新版冻结请求ledger;本地recovery记录也不是可靠保存文件字节的资源仓库。
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { canvasFingerprint, canvasPath, CanvasSync, type CanvasSnapshot, type SyncState } from "@/lib/canvas-sync";
import { decodeCanvas, type CanvasDocument } from "@/lib/canvas";
/** snapshot存在才建立远端writer,无snapshot仍可本地备份;writer在挂载时初始化一次,调用方应按画布重挂载。
* key区分草稿作用域,paused阻断这里的新备份/入队,不在此取消已交给writer的请求。 */
export function useCanvasPersistence(document: CanvasDocument, workspace: string, board: string, key: string, snapshot: CanvasSnapshot | undefined, paused: boolean) {
const [sync, setSync] = useState<SyncState>({ status: "saved", version: snapshot?.version || 1 });
// alive限制writer通知更新已卸载组件;latest和pausedRef让异步回调读取当前文档/暂停值。
const alive = useRef(false);
const latest = useRef(document); latest.current = document;
const pausedRef = useRef(paused); pausedRef.current = paused;
// 同一挂载复用writer;状态变化时刷新sync并补本地备份,不随每次文档渲染新建同步器。
const [writer] = useState(() => snapshot ? new CanvasSync(api, canvasPath(workspace, board), snapshot, (next) => {
if (alive.current) { setSync(next); backup(); }
}) : null);
/** 保存规范化document、writer当前版本及内容指纹dirty;回调路径吞掉存储异常,定时effect路径会展示错误。 */
function backup() {
if (pausedRef.current) return;
try {
@@ -19,6 +28,7 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
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. */ }
}
// 文档变化重置250ms本地/800ms远端计时器,形成防抖;暂停不建立计时器,清理只取消尚未触发的任务。
useEffect(() => {
if (paused) return;
const local = setTimeout(() => {
@@ -28,13 +38,16 @@ export function useCanvasPersistence(document: CanvasDocument, workspace: string
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
}, [document, writer, key, paused]);
// pagehide与卸载只执行同步本地backup,不保证远端请求在离页前完成;卸载后不再接收writer的UI通知。
useEffect(() => {
alive.current = true;
/** 页面隐藏/离开时尽量保存当前内存文档,暂停状态由backup内部再次检查。 */
const flush = () => backup();
window.addEventListener("pagehide", flush);
return () => { window.removeEventListener("pagehide", flush); backup(); alive.current = false; };
// Writer and key are stable for this board's mounted editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writer, key]);
// retry先补本地备份再委托writer重试;没有远端writer时返回undefined,不伪造同步成功。
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
}
@@ -1,28 +1,42 @@
/**
* 旧编辑器的内存上传任务管理。原文件、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;
@@ -30,18 +44,23 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
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); } };
}