From f657052fe971764773a295fcd99ad8384b2250c0 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 02:07:50 +0800 Subject: [PATCH] docs(canvas): explain loading previews and legacy task hooks --- .../canvas/loading-graph-canvas.tsx | 14 ++++++++++ src/components/canvas/retained-projection.ts | 10 +++++++ src/components/canvas/use-canvas-ai.ts | 26 +++++++++++++++++++ .../canvas/use-canvas-persistence.ts | 13 ++++++++++ src/components/canvas/use-image-uploads.ts | 19 ++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/src/components/canvas/loading-graph-canvas.tsx b/src/components/canvas/loading-graph-canvas.tsx index 24d7295..253c966 100644 --- a/src/components/canvas/loading-graph-canvas.tsx +++ b/src/components/canvas/loading-graph-canvas.tsx @@ -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
{typeof data.title==='string'?data.title:'正在准备元素…'}{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=>)}
;} +/** 所有预览命令统一拒绝为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();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'&®istry.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(()=>{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=>
;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]); + // 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。 return
onViewport(viewport)}> diff --git a/src/components/canvas/retained-projection.ts b/src/components/canvas/retained-projection.ts index 115772a..94ddffa 100644 --- a/src/components/canvas/retained-projection.ts +++ b/src/components/canvas/retained-projection.ts @@ -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(); let previous:{nodes:readonly object[];edges:readonly object[];ids:Set;result:{nodes:object[];edges:object[]}}|undefined; + /** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */ return (nodes:N[],edges:E[],ids:ReadonlySet):{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=(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; }; diff --git a/src/components/canvas/use-canvas-ai.ts b/src/components/canvas/use-canvas-ai.ts index a5568f1..6b57fc2 100644 --- a/src/components/canvas/use-canvas-ai.ts +++ b/src/components/canvas/use-canvas-ai.ts @@ -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; }; +/** 只有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) { const [enabled, setEnabled] = useState(false); const [runs, setRuns] = useState(new Map()); @@ -21,16 +29,22 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree const [activeEdges, setActiveEdges] = useState(new Set()); const reference = useRef(tree); reference.current = tree; const wake = useRef<() => void>(() => {}); + // cache是跟踪结果缓存,runs是发布给React的副本;每个run有自己的活动边集合,合并后供连线动画使用。 const cache = useRef(new Map()); const edgeStates = useRef(new Map>()); + // 每个board只保留最高待刷版本,避免同一批step.applied触发多次重复整画布读取。 const pendingRefresh = useRef(new Map()); + /** 发布新的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 | undefined; let polling = false, dirty = false, nextDiscovery = 0; const controller = new AbortController(); + /** 所有发现/分页GET共享取消信号并协商旧v2响应格式。 */ const request = (path: string) => api(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } }); + /** 分页发现根和后代活动run,每页50;已跟踪run不覆盖,重复cursor判为协议异常避免无限循环。 */ async function discover() { let cursor = ""; const seen = new Set(); 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>(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(); 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 }; } diff --git a/src/components/canvas/use-canvas-persistence.ts b/src/components/canvas/use-canvas-persistence.ts index 1f597d7..8fc87ab 100644 --- a/src/components/canvas/use-canvas-persistence.ts +++ b/src/components/canvas/use-canvas-persistence.ts @@ -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({ 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(); } }; } diff --git a/src/components/canvas/use-image-uploads.ts b/src/components/canvas/use-image-uploads.ts index bc793f4..7fdfb0a 100644 --- a/src/components/canvas/use-image-uploads.ts +++ b/src/components/canvas/use-image-uploads.ts @@ -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; 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()); const [jobs, setJobs] = useState([]); 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((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); } }; } -- 2.52.0