Show local image previews while uploads run in background

This commit is contained in:
2026-11-06 23:32:04 +08:00
parent 18b6a6e4af
commit 3b9a2c7525
13 changed files with 207 additions and 21 deletions
+8
View File
@@ -94,3 +94,11 @@
改动将 members 同步改为局部状态和串行事务队列;自动保存暂缓,其他元素本地编辑继续,成功响应与持久化基线做三方合并后再保存编辑。Core PR #16 增加源画布局部坐标的 positions 字段及 member_positions 能力,坐标和归属在一个事务保存,支持时省掉前置 PATCH。版本及幂等保护保留。DOM/Hook 延迟响应、丢失响应和版本冲突场景已验证;尚未将模拟的一次 POST 当作线上耗时改善。
Core PR #16 已由用户合并为 2207c85;push #946、PR #947、主线 #949、部署 #950 均成功。前端 a240507 已在 main。23:13:30(Asia/Shanghai)开始安排指定父图 icon 拖放的真实验收;需要刷新以重新检测能力,实测结果待记录核对后补充。
## 首次上传及能力协商复核
23:15–23:16 的真实日志中,新 JPEG 原图 393,301 字节、1280×2276,上传约 8.5 秒;其预览 125,545 字节,首响应等待 5,913.9ms,正文传输 748.1ms,总计 6,662ms。同期 profile/workspaces/小快照也出现约 5–7 秒等待首响应,不能将全部时间归因于图片处理。随后无认证公网健康探测也复现秒级等待:curl 约 1.73/4.24 秒;Node 新建连接 DNS 20ms、TCP 累计 200.4ms、TLS 累计 3,574.6ms、总计 4,808.3ms,同连接复用请求仅 275.4/187.1ms。ECS 内部健康请求约 120.35/13.92/1.81ms。该对照表明冷连接/公网路径存在明显成本,但不是历史每个请求的同期分段追踪,不能声称已定位全部 8.5 秒或修复公网丢包。
能力协商 2 秒截止与约 1,989ms 的服务端路由耗时接近,且真实三次移动前仍有仅 position 变化的 PATCH。现移除短截止并禁止把网络错误永久缓存为“不支持”;慢响应和失败预取回归均保持原子成员移动。后端三笔事务各移动 1 个节点,总数、非位置内容与版本递增均正确。
首次上传改为本地即时预览 + 后台原图上传,服务端确认后保存引用,远端预览加载完才切换显示。该改动消除空画布等待,不将即时显示误报为网络上传已完成或服务端延迟已消失。实际交互复测待用户操作后补充。
+2
View File
@@ -134,3 +134,5 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA
拖放后元素保持落位,旁边显示局部同步状态;其他元素仍可编辑和拖动。移动中的节点及分区几何暂时受保护,归属事务排队使用最新版本。期间的本地编辑在响应到达后合并,再自动保存。响应丢失时重试复用同一请求和幂等键;明确版本冲突则先重读版本,避免重复移动或覆盖其他编辑。
Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局部坐标)与归属通过一次 members POST 原子保存,单纯拖放不再先 PATCH 坐标。旧后端仍使用先保存坐标再迁移的兼容流程,但不显示整屏等待遮罩。Core 配套 PR #16 已合并并部署为 2207c85;刷新页面后会重新检测并启用新接口能力。
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
+6
View File
@@ -62,3 +62,9 @@
- TypeScript 和 83 项单元测试通过。新增 DOM/Hook 场景覆盖:原子成员移动一次 POST/无前置 PATCH、延迟响应期间编辑和拖动其他节点、快照合并保留编辑、撤销/重做保留单一归属、丢失响应以相同 body/key 重试、412 重读版本后新命令重试、连续跨分区移动排队、旧后端兼容。
- 实际 ReactFlow DOM 手势验证 icon 跨分区后出现元素旁同步提示,无整屏同步遮罩;其他元素可继续拖动,服务器确认后位置不回退。既有 AI、连线、框、图片回归通过。无浏览器访问(沿用用户要求)。
- 最终隔离生产构建通过。Core push #946、PR #947、主线 #949、部署 #950 均成功,包含 Go 测试/构建、数据库迁移和 PostgreSQL 集成测试;PR #16 已合并为 2207c85 并部署。Core 不在本机编译。真实 icon 拖放验收仍待用户操作后的记录核对,不将发布成功当作实际性能实测。
## 慢能力响应与首次上传
生产复核发现 capabilities 请求约 1,989ms,原先 2 秒客户端截止可能误将慢响应缓存为不支持。已删除该短截止,网络错误清除缓存并重试;只有成功响应明确未声明能力才走旧流程。DOM 回归增加 2.1 秒能力响应、预取失败后重试的场景,均保持原子移动、无前置 PATCH。
上传 DOM 回归覆盖:网络未确认前本地图片已显示、可添加其他内容、快照没有 blob URL、原始附件引用与 assetId 正确、远端预览加载后释放本地 URL、失败保留文件、同键重试只创建一个节点、取消/卸载清理与原图比例。TypeScript、83 项单元测试与隔离生产构建通过。生产新上传的交互体验及修正后的能力协商仍需用户刷新后实测。
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { createContext, useContext } from "react";
export const ImagePreviews = createContext<{ sources: Map<string,string>; loaded: (id: string) => void }>({ sources: new Map(), loaded: () => {} });
export function CanvasImage({ id, src, title, className }: { id: string; src?: string; title: string; className: string }) {
const previews = useContext(ImagePreviews), local = previews.sources.get(id);
const remote = <img loading="lazy" decoding="async" className={className} src={src} alt={title} draggable={false} referrerPolicy="no-referrer" onLoad={() => previews.loaded(id)} />;
if (!local || local === src) return remote;
return <div style={{position:"relative",width:"100%",height:"100%",minHeight:0,flex:1,borderRadius:"inherit"}}>{remote}<img className={className} src={local} alt="" aria-hidden draggable={false} style={{position:"absolute",inset:0,pointerEvents:"none"}} /></div>;
}
+45 -11
View File
@@ -1,5 +1,7 @@
"use client";
import { uploadCanvasImage } from "@/lib/canvas-assets";
import { useImageUploads } from "./use-image-uploads";
import { ImagePreviews } from "./canvas-image";
import { displayID } from "@/lib/canvas-tree";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import {
@@ -59,7 +61,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const [tool, setTool] = useState<"select" | "hand">("select");
const [error, setError] = useState(initial.error);
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
const [imageUploading, setImageUploading] = useState(false);
const [demoImageUploading, setImageUploading] = useState(false);
const [iconPicker, setIconPicker] = useState(false);
const [resizing, setResizing] = useState<string | null>(null);
const [rotating, setRotating] = useState<string | null>(null);
@@ -82,6 +84,16 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
const treeRef = useRef(tree); treeRef.current = tree;
const uploads = useImageUploads(board.workspace_id, (job, asset) => {
const current = treeRef.current, document = current.current.current.get(job.owner);
if (!document || current.busy || current.rootDocument.schema_version < document.schema_version) throw new Error("canvas_busy");
if (document.nodes.length >= 500) throw new Error("当前画布已达到 500 个节点。");
if (job.edge && !document.nodes.some((node) => node.id === job.edge!.source)) throw new Error("源节点已移动或删除,请重新添加图片。");
const node = { ...job.node, data: { ...job.node.data, src: asset.url, ...(document.schema_version === 2 ? { assetId: asset.id } : {}) } };
current.dispatch({ type: "replace", document: { ...document, nodes: [...document.nodes, node], edges: [...document.edges, ...(job.edge ? [job.edge] : [])] } }, job.owner);
if (!current.current.current.get(job.owner)?.nodes.some((item) => item.id === node.id)) throw new Error("canvas_busy");
});
const imageUploading = demoImageUploading || uploads.active;
function dispatch(action: CanvasAction) {
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
treeRef.current.dispatch(action);
@@ -93,7 +105,14 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
return () => input?.removeEventListener("cancel", cancel);
}, []);
const { nodes: displayNodes, edges } = state.present;
const nodes = displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; });
const pendingUploadNodes = uploads.jobs.filter((job) => job.status !== "uploaded").flatMap((job) => {
const ref = tree.views.boards.get(job.owner); if (!ref) return [];
return [{ ...job.node, id: displayID(board.id,job.owner,job.node.id), position: { x: ref.origin.x+job.node.position.x*ref.scale,y:ref.origin.y+job.node.position.y*ref.scale }, width:job.node.width!*ref.scale,height:job.node.height!*ref.scale,style:{...job.node.style,width:job.node.width!*ref.scale,height:job.node.height!*ref.scale},selected:false,draggable:false,selectable:false,connectable:false,deletable:false }];
});
const nodes = [...displayNodes.map((node) => { const ref = tree.views.nodeRefs.get(node.id); const blocked = ref && tree.protectedNodes(ref.boardId).has(ref.id); return blocked ? { ...node, draggable: false, deletable: false, connectable: false } : node; }), ...pendingUploadNodes];
const uploadDisplayID = (job: typeof uploads.jobs[number]) => job.status === "uploaded" ? [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || displayID(board.id,job.owner,job.node.id) : displayID(board.id,job.owner,job.node.id);
const imagePreviews = { sources: new Map(uploads.jobs.map((job) => [uploadDisplayID(job),job.localURL])), loaded: (id: string) => { const job = uploads.jobs.find((job) => uploadDisplayID(job)===id);if(job) uploads.loaded(job.node.id); } };
const frameFeedback = useMemo(() => {
const names = new Map(nodes.filter((node) => node.data.kind === "frame").map((node) => [node.id, node.data.title]));
const counts = new Map<string, number>();
@@ -292,10 +311,23 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
}
const request = pendingImage.current; pendingImage.current = null;
const kind = imageKind.current;
if (snapshot) {
try { uploads.start(file, (src) => {
const current = treeRef.current;
if (request) {
const ref = current.views.nodeRefs.get(request.id); if (!ref) throw new Error("源节点已移动或删除,请重新添加图片。");
const draft = branchCanvasNode(current.current.current.get(ref.boardId)!,ref.id,request.direction,"image",src,request.at ? {x:(request.at.x-ref.origin.x)/ref.scale,y:(request.at.y-ref.origin.y)/ref.scale}:undefined);
return {owner:ref.boardId,node:draft.nodes.at(-1)!,edge:draft.edges.at(-1)};
}
if(current.rootDocument.nodes.length + uploads.jobs.filter((job)=>job.owner===board.id&&job.status!=="uploaded").length>=500)throw new Error("当前画布已达到 500 个节点。");
const node=makeCanvasNode(kind,center(),src);if(kind==='plain-image')node.data.title=file.name.slice(0,300);
return {owner:board.id,node};
});setTool("select"); } catch(error) {setError(errorText(error));}
return;
}
setImageUploading(true);
try {
const uploaded = snapshot ? await uploadCanvasImage(api, board.workspace_id, file, file.name) : null;
const src = uploaded?.url || await new Promise<string>((resolve, reject) => {
const src = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("无法读取图片。"));
@@ -305,11 +337,9 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
if (kind === "plain-image" && !request) {
size = await new Promise((resolve, reject) => {
const image = new window.Image();
const localSource = uploaded ? URL.createObjectURL(file) : src;
const clean = () => { if (uploaded) URL.revokeObjectURL(localSource); };
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); clean(); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
image.onerror = () => { clean(); reject(new Error("无法读取图片尺寸。")); };
image.src = localSource;
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
image.onerror = () => reject(new Error("无法读取图片尺寸。"));
image.src = src;
});
}
if (request) createBranch(request, "image", src); else addNode(kind, src, size, kind === "plain-image" ? file.name : undefined);
@@ -362,12 +392,14 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
return (
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
<ImagePreviews.Provider value={imagePreviews}>
<NodeEditing.Provider value={editing}>
<FrameFeedback.Provider value={frameFeedback}>
<EdgeEditing.Provider value={{ begin: beginEdit, patch: tree.patchEdge, active: new Set([...tree.views.edgeRefs].filter(([id, ref]) => ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}>
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onBeforeDelete={async ({ nodes: removing }) => {
if (tree.busy || tree.transactionBusy) return false;
if (removing.some((node) => { const ref=tree.views.nodeRefs.get(node.id);return uploads.jobs.some((job)=>job.status!=="uploaded"&&(node.data.subgraph?.boardId===job.owner||ref?.boardId===job.owner&&ref?.id===job.edge?.source)); })) return false;
const linked = removing.filter((node) => node.data.subgraph);
const parents = subgraphParents(tree.current.current);
const referenced = removing.filter((node) => {
@@ -435,6 +467,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
</EdgeEditing.Provider>
</FrameFeedback.Provider>
</NodeEditing.Provider>
</ImagePreviews.Provider>
<div className={styles.topRight}>
<div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
<IconButton icon={Undo2} label="撤销" disabled={!tree.canUndo || tree.busy} onClick={() => dispatch({ type: "undo" })} />
@@ -479,7 +512,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
<p>可通过右上角导出 JSON 备份。</p>
</div>
{imageUploading && <p className={styles.imageUploadStatus} role="status">正在上传图片…</p>}
{demoImageUploading && <p className={styles.imageUploadStatus} role="status">正在读取图片…</p>}
{uploads.jobs.filter((job)=>job.status!=="uploaded").map((job)=><NodeToolbar key={job.node.id} nodeId={displayID(board.id,job.owner,job.node.id)} isVisible position={Position.Top}><div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="图片上传状态">{job.status==="failed"?<>上传未完成 <button title={job.error} onClick={()=>uploads.retry(job.node.id)}>重试</button><button onClick={()=>uploads.remove(job.node.id)}>取消</button></>:"上传中…"}</div></NodeToolbar>)}
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
{iconPicker && <IconPicker close={() => setIconPicker(false)} choose={(name, label) => {
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
+2 -1
View File
@@ -1,4 +1,5 @@
"use client";
import { CanvasImage } from "./canvas-image";
import { managedImageSource } from "@/lib/canvas-v2";
import { useContext, useRef } from "react";
@@ -26,7 +27,7 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
{data.kind === "image" ? <img loading="lazy" decoding="async" className={styles.image} src={managedImageSource(data.src, preview)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
{data.kind === "image" ? <CanvasImage id={id} className={styles.image} src={managedImageSource(data.src, preview)} title={data.title} /> : (
<CanvasTextEditor id={id} data={data} selected={selected} />
)}
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
+2 -1
View File
@@ -1,4 +1,5 @@
"use client";
import { CanvasImage } from "./canvas-image";
import { managedImageSource } from "@/lib/canvas-v2";
import { useViewport, type NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
@@ -7,5 +8,5 @@ import styles from "./canvas.module.css";
export function PlainImageNode(props: NodeProps<CanvasNode>) {
const { zoom } = useViewport();
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
return <TransformableNode {...props} className={styles.plainImageFrame}><img loading="lazy" decoding="async" className={styles.plainImage} src={managedImageSource(props.data.src, preview)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
return <TransformableNode {...props} className={styles.plainImageFrame}><CanvasImage id={props.id} className={styles.plainImage} src={managedImageSource(props.data.src, preview)} title={props.data.title} /></TransformableNode>;
}
+12 -2
View File
@@ -117,8 +117,18 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
};
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
function supportsMemberPositions(): Promise<boolean> {
if (!snapshot) return Promise.resolve(false);
if (!memberCapability.current) {
const capability: Promise<boolean> = api<{ subgraphs?: { member_positions?: boolean } }>(canvasPath(workspace, root) + "/capabilities", { headers: { Accept: CANVAS_ACCEPT } })
.then((value) => value.subgraphs?.member_positions === true)
.catch((error) => { if (memberCapability.current === capability) memberCapability.current = null; throw error; });
memberCapability.current = capability;
}
return memberCapability.current;
}
useEffect(() => {
memberCapability.current = snapshot ? api<{ subgraphs?: { member_positions?: boolean } }>(canvasPath(workspace, root) + "/capabilities", { headers: { Accept: CANVAS_ACCEPT }, signal: AbortSignal.timeout(2000) }).then((value) => value.subgraphs?.member_positions === true).catch(() => false) : Promise.resolve(false);
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
}, [root, workspace, !!snapshot]);
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
@@ -266,7 +276,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [id, remote] of refreshed) install(id, remote);
publish(next); job.refresh = false;
}
const atomic = await memberCapability.current;
const atomic = await supportsMemberPositions();
await flush(atomic === true);
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
const source = current.current.get(job.source)!;
@@ -0,0 +1,46 @@
"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> };
/** Local previews never enter the canvas document or an autosave payload. */
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;
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 { 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); } };
}
+1 -2
View File
@@ -1,10 +1,9 @@
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
export const ASSET_LIMIT = 20 * 1024 * 1024;
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal): Promise<CanvasAsset> {
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal, key = crypto.randomUUID()): Promise<CanvasAsset> {
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
const form = new FormData(); form.append("image", file, name);
const key = crypto.randomUUID();
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
let asset: CanvasAsset;
try { asset = await send(); } catch (error) {
+11 -4
View File
@@ -5,18 +5,22 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
const frame=makeCanvasNode('frame',{x:100,y:100});frame.id='frame';frame.data.subgraph={boardId:child};
const icon=makeCanvasNode('icon',{x:10,y:20});icon.id='moving';
const other=makeCanvasNode('text',{x:950,y:100});other.id='other';
let records,tree,posts,saves,release,mode,receipt,key,body,capability=true;
let records,tree,posts,saves,release,mode,receipt,key,body,capability=true,capMode="",capCalls=0;
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
function Fixture(){tree=useCanvasTree(parent,'w',records.get(parent).document,records.get(parent));return null;}
const reset=async(nextMode='success')=>{
await act(async()=>{root.render(null);await tick();});localStorage.clear();
records=new Map([[parent,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(frame),structuredClone(other)]}}],[child,{version:1,document:{...emptyCanvas(),schema_version:2,nodes:[structuredClone(icon)]}}]]);
posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null;
posts=[];saves=[];release=null;mode=nextMode;receipt=null;key=null;body=null;capMode=nextMode;capCalls=0;
if(mode==='queued'){const second=structuredClone(icon);second.id='moving-two';second.position={x:100,y:20};records.get(child).document.nodes.push(second);mode='delayed';}
mock(async(url,options={})=>{
const parts=new URL(url,'http://localhost').pathname.split('/'),id=parts[6],suffix=parts.slice(8).join('/'),method=options.method||'GET';
if(suffix==='ai/runs')return json({runs:[]});
if(suffix==='capabilities')return json({subgraphs:{member_positions:capability}});
if(suffix==='capabilities'){
capCalls++;if(capMode==='slowcap'){await new Promise(r=>setTimeout(r,2100));if(options.signal?.aborted)throw new DOMException('Aborted','AbortError');}
if(capMode==='cap-failure'&&capCalls===1)throw new TypeError('network unavailable');
return json({subgraphs:{member_positions:capability}});
}
if(!suffix&&method==='GET')return json(structuredClone(records.get(id)));
if(!suffix&&method==='PATCH'){
assert.equal(options.headers['If-Match'],`"${records.get(id).version}"`);
@@ -40,7 +44,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
});
await act(async()=>{root.render(h(Fixture));await tick();await tick();});
};
const wait=async(check,label)=>{for(let i=0;i<60;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);};
const wait=async(check,label)=>{for(let i=0;i<120;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);};
const start=async()=>{
await act(async()=>{tree.select(child+'::moving');tree.setDragging(true);tree.nodesChanged([{type:'position',id:child+'::moving',position:{x:950,y:200},dragging:false}]);tree.setDragging(false);void tree.moveMembers(parent,'frame',child,parent,['moving'],[parent,child]);await tick();});
await wait(()=>posts.length,'move admission');
@@ -78,6 +82,9 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
await act(async()=>{release();await tick();});await wait(()=>!tree.memberJobs.length,'queue drained');
assert.equal(saves.length,0);assert.equal(tree.rootDocument.nodes.filter(n=>n.id.startsWith('moving')).length,2);assert.equal(tree.current.current.get(child).nodes.length,0);
console.log('PASS member queue: unrelated cross-frame drops serialize versions without extra coordinate PATCH');
await reset('slowcap');await start();await wait(()=>!tree.memberJobs.length,'slow capability move');assert.equal(saves.length,0,'A slow capability response must not trigger legacy PATCH fallback');
await reset('cap-failure');await start();await wait(()=>!tree.memberJobs.length,'capability retry');assert.equal(capCalls,2);assert.equal(saves.length,0,'A failed prefetch is retried rather than cached as unsupported');
console.log('PASS member capabilities: responses slower than two seconds and transient prefetch errors retain atomic moves');
capability=false;await reset();await start();await wait(()=>!tree.memberJobs.length,'legacy move');assert.equal(saves.length,1,'Old backend retains the coordinate save');assert.equal(JSON.parse(posts[0].body).positions,undefined);
console.log('PASS member compatibility: capability-gated positions and legacy two-request fallback');
capability=true;await reset('delayed');
+2
View File
@@ -216,6 +216,8 @@ try {
}
const { canvasMemberScenario } = await import("./canvas-member-scenario.mjs");
await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } });
const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs");
await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
await act(async () => { root.unmount(); await tick(); });
} finally {
await rm(folder, { recursive: true, force: true }); window.close(); stop();
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyCanvas,event,tick,mock}) {
const parent='cccccccc-cccc-4ccc-8ccc-cccccccccccc',assetID='dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const asset={id:assetID,url:`https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/${assetID}`,width:640,height:1280,bytes:4,mime_type:'image/png'};
let snapshot,uploads,saves,release,mode;
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
const revoked=[],originalRevoke=URL.revokeObjectURL;URL.revokeObjectURL=(url)=>{revoked.push(url);originalRevoke(url);};
const NativeImage=window.Image;
const wait=async(check,label)=>{for(let i=0;i<100;i++){if(check())return;await act(async()=>{await new Promise(r=>setTimeout(r,30));});}throw new Error('Timeout '+label);};
async function reset(nextMode='delayed'){
await act(async()=>{root.render(null);await tick();});localStorage.clear();snapshot={version:1,document:{...emptyCanvas(),schema_version:2}};uploads=[];saves=[];release=null;mode=nextMode;
mock(async(url,options={})=>{
const parts=new URL(url,'http://localhost').pathname.split('/'),suffix=parts.slice(8).join('/'),method=options.method||'GET';
if(parts.at(-1)==='assets'&&method==='POST'){
const file=options.body.get('image');uploads.push({key:options.headers['Idempotency-Key'],bytes:Buffer.from(await file.arrayBuffer()),signal:options.signal});
if(mode==='failed')return json({error:'forbidden'},403);
if(mode==='delayed')await new Promise(resolve=>{release=resolve;});
return json(asset,201);
}
if(suffix==='capabilities')return json({subgraphs:{member_positions:true}});
if(suffix==='ai/runs')return json({runs:[]});
if(!suffix&&method==='PATCH'){
assert.ok(!options.body.includes('blob:'),'Local object URLs must never be saved');assert.equal(options.headers['If-Match'],`"${snapshot.version}"`);
snapshot.document=JSON.parse(options.body).document;snapshot.version++;saves.push(structuredClone(snapshot.document));return json({version:snapshot.version});
}
if(!suffix)return json(snapshot);
throw new Error('Unhandled upload request '+url);
});
await act(async()=>{root.render(h(GraphCanvas,{board:{id:parent,workspace_id:'w',kind:'board',title:'Upload test',version:1},snapshot:structuredClone(snapshot)}));await tick();});
}
async function choose(label='添加图片'){
await event(document.querySelector(`[aria-label="${label}"]`),'click');
const input=document.querySelector('input[type="file"][accept^="image/"]');
Object.defineProperty(input,'files',{configurable:true,value:[new File(['test'],'local.png',{type:'image/png'})]});
await act(async()=>{input.dispatchEvent(new window.Event('change',{bubbles:true}));await tick();});
await wait(()=>uploads.length,'upload started');
}
try {
await reset();await choose();
const local=document.querySelector('.react-flow__node img[src^="blob:"]');assert.ok(local,'Selected file immediately appears on the canvas before upload acknowledgement');const localURL=local.getAttribute('src');
assert.ok(document.querySelector('[aria-label="图片上传状态"]'));assert.equal(snapshot.document.nodes.length,0);assert.equal(saves.length,0);assert.deepEqual(uploads[0].bytes,Buffer.from('test'));
await event(document.querySelector('[aria-label="添加文本卡片"]'),'click');assert.equal(document.querySelectorAll('.react-flow__node').length,2,'Other content can be added during upload');
await act(async()=>{release();await tick();});await wait(()=>document.querySelector('.react-flow__node img[src$="?variant=preview"]'),'managed image rendered');
assert.ok(document.querySelector(`img[src="${localURL}"]`),'Local image remains visible while remote preview loads');
const remote=document.querySelector('.react-flow__node img[src$="?variant=preview"]');await act(async()=>{remote.dispatchEvent(new window.Event('load'));await tick();});
assert.ok(!document.querySelector(`img[src="${localURL}"]`));assert.ok(revoked.includes(localURL));await wait(()=>snapshot.document.nodes.some(n=>n.data.src===asset.url),'original reference saved');
assert.equal(snapshot.document.nodes.filter(n=>n.data.src===asset.url).length,1);assert.equal(snapshot.document.nodes.find(n=>n.data.src===asset.url).data.assetId,assetID);
console.log('PASS uploads: immediate local preview, unrelated editing, original-byte attachment and no blob URL persistence');
await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'visible upload failure');
assert.ok(document.querySelector('img[src^="blob:"]'));assert.equal(saves.length,0);const key=uploads[0].key;mode='delayed';
await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='重试'),'click');assert.equal(uploads.length,2);assert.equal(uploads[1].key,key,'Manual retry retains the original idempotency key');
await act(async()=>{release();await tick();});await wait(()=>snapshot.document.nodes.length===1,'retry saves exactly one image');assert.equal(snapshot.document.nodes[0].data.src,asset.url);
console.log('PASS upload retry: retained file/preview, stable idempotency key and exactly one persisted node');
await reset('failed');await choose();await wait(()=>document.querySelector('[aria-label="图片上传状态"]')?.textContent.includes('上传未完成'),'failed cancellation');
const cancelledURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await event([...document.querySelectorAll('[aria-label="图片上传状态"] button')].find(n=>n.textContent==='取消'),'click');assert.equal(document.querySelector('img[src^="blob:"]'),null);assert.ok(revoked.includes(cancelledURL));assert.equal(saves.length,0);
window.Image=class {naturalWidth=640;naturalHeight=1280;set src(_){setTimeout(()=>this.onload?.(),0);}};
await reset();await choose('添加纯图片');assert.equal(document.querySelector('.react-flow__node').style.width,'240px');assert.equal(document.querySelector('.react-flow__node').style.height,'480px');
const unmountedURL=document.querySelector('img[src^="blob:"]').getAttribute('src');await act(async()=>{root.render(null);await tick();});assert.ok(uploads[0].signal.aborted);assert.ok(revoked.includes(unmountedURL));release();await tick();assert.equal(saves.length,0);
console.log('PASS upload cleanup: cancellation/unmount revoke previews, abort uploads and retain plain-image aspect ratio');
} finally {window.Image=NativeImage;URL.revokeObjectURL=originalRevoke;await act(async()=>{root.render(null);await tick();});}
}