Make uploading images fully editable local canvas nodes

This commit is contained in:
2026-11-06 23:57:32 +08:00
parent 3b9a2c7525
commit 20d877a346
12 changed files with 181 additions and 46 deletions
+2
View File
@@ -102,3 +102,5 @@ Core PR #16 已由用户合并为 2207c85;push #946、PR #947、主线 #949、
能力协商 2 秒截止与约 1,989ms 的服务端路由耗时接近,且真实三次移动前仍有仅 position 变化的 PATCH。现移除短截止并禁止把网络错误永久缓存为“不支持”;慢响应和失败预取回归均保持原子成员移动。后端三笔事务各移动 1 个节点,总数、非位置内容与版本递增均正确。
首次上传改为本地即时预览 + 后台原图上传,服务端确认后保存引用,远端预览加载完才切换显示。该改动消除空画布等待,不将即时显示误报为网络上传已完成或服务端延迟已消失。实际交互复测待用户操作后补充。
用户随后指出只读预览不等于已添加元素。已修正为选图时就创建可编辑的本地节点,后台上传仅补齐引用;位置、尺寸、名称、删除及撤销状态都以本地最新状态为准。未准备好的文件节点和关联边不传给 Core;其他编辑仍可保存。该修正不依赖公网延迟降低,也不将本地添加误报成后台文件已上传。
+2
View File
@@ -136,3 +136,5 @@ Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVA
Core `subgraphs.member_positions` 能力启用时,最终落点(源画布局部坐标)与归属通过一次 members POST 原子保存,单纯拖放不再先 PATCH 坐标。旧后端仍使用先保存坐标再迁移的兼容流程,但不显示整屏等待遮罩。Core 配套 PR #16 已合并并部署为 2207c85;刷新页面后会重新检测并启用新接口能力。
上传图片时立即在画布显示本地预览,并在后台上传原始文件;上传成功才把稳定附件引用加入快照。服务端预览加载完成前继续显示本地图片,避免网络等待期间空白。失败保留文件供重试/取消,重试沿用同一幂等键;本地 blob URL 不保存到服务端。
上传中的图片也是正常本地节点,可立即选中、拖动、缩放、改名、删除和撤销;上传完成只补齐附件引用,不重置节点。上传前改变分区归属会先在本地完成,文件准备好后保存到当时的实际所属画布。临时上传标记和 blob 地址由传输层排除,不发送给 Core;文件仍未可用时,依赖服务端文件的 AI 生成需等待上传完成。
+9
View File
@@ -68,3 +68,12 @@
生产复核发现 capabilities 请求约 1,989ms,原先 2 秒客户端截止可能误将慢响应缓存为不支持。已删除该短截止,网络错误清除缓存并重试;只有成功响应明确未声明能力才走旧流程。DOM 回归增加 2.1 秒能力响应、预取失败后重试的场景,均保持原子移动、无前置 PATCH。
上传 DOM 回归覆盖:网络未确认前本地图片已显示、可添加其他内容、快照没有 blob URL、原始附件引用与 assetId 正确、远端预览加载后释放本地 URL、失败保留文件、同键重试只创建一个节点、取消/卸载清理与原图比例。TypeScript、83 项单元测试与隔离生产构建通过。生产新上传的交互体验及修正后的能力协商仍需用户刷新后实测。
## 上传期间的真实本地节点
改为在选图时创建可编辑的本地 CanvasNode,上传确认只更新 src/assetId,并同步修正本地撤销记录,不新增无意义的撤销步骤。传输过滤临时节点与关联边,写入基线使用实际发送的干净快照。
- 84 项单元测试通过,新增上传节点/关联边不会进入网络快照、其他内容仍能保存、附件可用后正常保存的覆盖。
- DOM/Hook 回归通过:上传期间拖动/缩放/改名在确认后保留;确认前删除不会被重新插入;删除可撤销;尺寸初始化与引用补齐不消耗撤销;本地跨分区移动与复合撤销、文件确认到当前所属画布;已持久化后的归属撤销走真实事务。
- 并发回归通过:版本号型响应的重新读取、包含重复 parent 的事务快照,都保留本地上传节点;其他事务与附件完成同时发生不会覆盖新图片。AI 在文件上传完成后可执行,编辑无需等待。
- 最终 TypeScript、84 项单元测试、完整 DOM 回归和隔离生产构建均通过;未访问用户浏览器,不将 DOM 测试描述为用户已完成浏览器验收。
+2 -2
View File
@@ -40,11 +40,11 @@ export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string
<textarea aria-label="AI 指令" placeholder={image ? "描述你希望生成或修改的画面…" : "希望如何处理当前内容?"} value={prompt} maxLength={16000} onChange={(event) => setPrompt(event.target.value)} onBlur={persist} />
<footer><select aria-label="AI 模型" disabled={loading || !ai.live || busy} value={model} onChange={(event) => { setModel(event.target.value); if (ai.live) save({ ...config(), modelId: event.target.value }); }}>{!model && <option value="">{loading ? "正在读取模型…" : "暂无可用模型"}</option>}{models.map((model) => <option key={model.id} value={model.id} disabled={!model.available}>{model.label || model.display_name || model.id}{!model.available ? " · 不可用" : ""}</option>)}</select>
<label><input type="checkbox" aria-label="继续向下传播" disabled={!!busy} checked={propagate} onChange={(event) => { setPropagate(event.target.checked); if (model && ai.live) save({ ...config(), propagate: event.target.checked }); }} />继续向下传播</label>
{busy && run ? <button aria-label="取消生成" title="取消生成" onClick={() => void ai.cancel(run).catch((error) => setError(errorText(error)))}><Square size={15} /></button> : <button aria-label="执行 AI" title="在当前元素生成结果" disabled={starting || !ai.live || !model || loading || !models.some((item) => item.id === model && item.available)} onClick={async () => {
{busy && run ? <button aria-label="取消生成" title="取消生成" onClick={() => void ai.cancel(run).catch((error) => setError(errorText(error)))}><Square size={15} /></button> : <button aria-label="执行 AI" title="在当前元素生成结果" disabled={!!node.pendingUpload || starting || !ai.live || !model || loading || !models.some((item) => item.id === model && item.available)} onClick={async () => {
setStarting(true); setError(""); try { await ai.run(boardId, node.id, model, prompt, propagate); } catch (error) { setError(errorText(error)); } finally { setStarting(false); }
}}><ArrowUp size={18} /></button>}
</footer>
<p role="status">{!ai.live ? "演示画布暂不执行生成" : ai.conflicts.has(boardId) ? "生成已完成,本地草稿存在冲突,尚未替换当前内容" : busy ? "正在生成,结果将显示在当前元素…" : step ? ({ succeeded: "已更新当前元素", conflict: "内容已改变,生成结果已保留", failed: "生成未完成,原内容已保留", cancelled: "已取消生成", skipped: "此步骤未执行" }[step.status] || step.status) : "结果将显示在当前元素"}</p>
<p role="status">{node.pendingUpload ? "图片正在上传,可继续编辑;上传完成后即可生成" : !ai.live ? "演示画布暂不执行生成" : ai.conflicts.has(boardId) ? "生成已完成,本地草稿存在冲突,尚未替换当前内容" : busy ? "正在生成,结果将显示在当前元素…" : step ? ({ succeeded: "已更新当前元素", conflict: "内容已改变,生成结果已保留", failed: "生成未完成,原内容已保留", cancelled: "已取消生成", skipped: "此步骤未执行" }[step.status] || step.status) : "结果将显示在当前元素"}</p>
{(step?.status === "conflict" || ai.conflicts.has(boardId)) && output && run && <div className={styles.aiConflict}>{textOutput && <pre>{textOutput}</pre>}{output.src && <img src={managedImageSource(output.src)} alt="待应用的生成结果" />}<button onClick={() => void ai.apply(run, node.id, output.output_id, boardId).catch((error) => setError(errorText(error)))}>使用此结果替换当前内容</button></div>}
{(error || ai.error) && <p role="alert">{error || errorText(new Error(ai.error))}</p>}
</section></NodeToolbar>;
+25 -21
View File
@@ -84,16 +84,18 @@ 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;
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
const imageUploading = demoImageUploading;
useEffect(() => {
const current = treeRef.current;
for (const job of uploads.jobs) {
const owner = [...current.current.current].find(([,doc])=>doc.nodes.some((node)=>node.pendingUpload===job.node.id));
if (!owner) continue;
if (job.asset) { current.resolveImageUpload(job.node.id,job.asset,{width:job.node.width!,height:job.node.height!}); continue; }
const node=owner[1].nodes.find((node)=>node.id===job.node.id)!;
if (node.data.kind==="plain-image"&&node.width===320&&node.height===240&&(job.node.width!==320||job.node.height!==240)) current.resolveImageUpload(job.node.id,undefined,{width:job.node.width!,height:job.node.height!});
}
}, [uploads.jobs,tree.documents]);
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);
@@ -105,12 +107,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
return () => input?.removeEventListener("cancel", cancel);
}, []);
const { nodes: displayNodes, edges } = state.present;
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 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 uploadDisplayID = (job: typeof uploads.jobs[number]) => [...tree.views.nodeRefs].find(([,ref])=>ref.id===job.node.id)?.[0] || 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(() => {
@@ -249,6 +247,9 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const inMovingSubtree = (child: string) => { const seen = new Set<string>(); let cursor: string | undefined = child; while (cursor && !seen.has(cursor)) { if (cursor === item.data.subgraph?.boardId) return true; seen.add(cursor); cursor = parents.get(cursor)?.boardId; } return false; };
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
if (target !== ref.boardId && tree.current.current.get(ref.boardId)?.nodes.find((node)=>node.id===ref.id)?.pendingUpload) {
tree.movePendingImage(ref.boardId,target,ref.id);continue;
}
if (target !== ref.boardId) {
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
}
@@ -312,7 +313,7 @@ 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) => {
try { const job = uploads.start(file, (src) => {
const current = treeRef.current;
if (request) {
const ref = current.views.nodeRefs.get(request.id); if (!ref) throw new Error("源节点已移动或删除,请重新添加图片。");
@@ -322,7 +323,11 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
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));}
});
const current=treeRef.current,doc=current.current.current.get(job.owner)!;
const node={...job.node,pendingUpload:job.node.id};
current.dispatch({type:"replace",document:{...doc,nodes:[...doc.nodes,node],edges:[...doc.edges,...(job.edge?[job.edge]:[])]}},job.owner);
setTool("select"); } catch(error) {setError(errorText(error));}
return;
}
setImageUploading(true);
@@ -362,7 +367,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
}
async function exportProject() {
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
try { documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
try { if([...tree.current.current.values()].some((doc)=>doc.nodes.some((node)=>node.pendingUpload))) throw new Error("图片仍在上传,完成后即可导出。"); documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
const content = JSON.stringify(documents.size > 1 ? { format: "endoleap-canvas-bundle", root: board.id, documents: Object.fromEntries(documents) } : { ...tree.rootDocument, viewport: flow.getViewport() }, null, 2);
const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
const link = document.createElement("a");
@@ -399,7 +404,6 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<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) => {
@@ -513,7 +517,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<p>可通过右上角导出 JSON 备份。</p>
</div>
{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>)}
{uploads.jobs.filter((job)=>job.status!=="uploaded").map((job)=><NodeToolbar key={job.node.id} nodeId={uploadDisplayID(job)} 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={()=>{const current=treeRef.current;const owner=[...current.current.current].find(([,doc])=>doc.nodes.some((node)=>node.pendingUpload===job.node.id));if(owner)current.dispatch({type:"nodes",changes:[{type:"remove",id:job.node.id}]},owner[0]);}}>取消</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; }
+64 -12
View File
@@ -3,13 +3,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EdgeChange, NodeChange } from "@xyflow/react";
import { api, ApiError } from "@/lib/api";
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
import { CanvasSync, canvasFingerprint, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
type LocalHistory = { boardId: string; before: CanvasDocument };
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
type Entry = LocalHistory | TransactionHistory;
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
@@ -184,7 +185,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [boardId, document] of current.current) try {
if (pendingRecovery.current.has(boardId)) continue;
const writer = writers.current.get(boardId);
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
} catch { setError("local_backup_failed"); }
}, 250);
const save = setTimeout(() => {
@@ -198,7 +199,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (pause.current) return;
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) continue;
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
}
};
window.addEventListener("pagehide", backup);
@@ -224,9 +225,23 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
function accept(result: CanvasCommandResult) {
const next = new Map(current.current);
const values = [...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])];
for (const value of values) { const decoded = decodeSnapshot(value); install(value.board_id, decoded); next.set(value.board_id, keepSelection(decoded.document, current.current.get(value.board_id))); }
for (const id of result.archived_board_ids || []) { next.delete(id); writers.current.delete(id); }
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
for (const value of values) {
const decoded=decodeSnapshot(value),local=current.current.get(value.board_id),base=writers.current.get(value.board_id)?.document;
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
}
for (const id of result.archived_board_ids || []) {
const local=current.current.get(id);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
if(local&&owner){
const frame=owner[1].nodes.find((node)=>node.data.subgraph?.boardId===id)!,target=next.get(owner[0])!;
const scale=frame.data.presentation?.contentScale||1,ids=new Set(local.nodes.filter((node)=>node.pendingUpload).map((node)=>node.id));
const moved=local.nodes.filter((node)=>ids.has(node.id)).map((node)=>({...node,position:{x:frame.position.x+24+node.position.x*scale,y:frame.position.y+36+node.position.y*scale},width:node.width!*scale,height:node.height!*scale}));
next.set(owner[0],{...target,nodes:[...target.nodes,...moved],edges:[...target.edges,...local.edges.filter((edge)=>ids.has(edge.source)||ids.has(edge.target)).map((edge)=>({...edge,data:transformEdgeData(edge.data,{boardId:id,id:edge.id,origin:{x:frame.position.x+24,y:frame.position.y+36},scale})}))]});
}
next.delete(id);writers.current.delete(id);
}
publish(next);
return [...new Set(values.map((value) => value.board_id))];
}
@@ -244,7 +259,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
const changed = accept(result);
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
if (!changed.length && result.canvas_version) { const target = typeof result.board_id === "string" ? result.board_id : boardId; const remote = await read(target); install(target, remote); publish(new Map(current.current).set(target, keepSelection(remote.document, current.current.get(target)))); }
if (!changed.length && result.canvas_version) {
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
}
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
retryOperation.current = null; setPendingCommand(false); setError("");
return result;
@@ -294,7 +313,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
const rebasedHistory = undo.current.map((entry, index) => index < historyIndex || "id" in entry || !values.has(entry.boardId) ? entry : { ...entry, before: mergeCanvasDocuments(bases.get(entry.boardId)!, normalize(entry.boardId, entry.before), values.get(entry.boardId)!.document) });
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
const next = new Map(current.current);
for (const [id, value] of values) {
const base = bases.get(id)!; if (!base) throw new Error("invalid_canvas_snapshot");
@@ -330,6 +350,26 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
});
}
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
if (node.pendingUpload !== id) return node;
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
const {pendingUpload,...ready}=node;
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
}) });
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
}
function movePendingImage(source: string, target: string, id: string) {
const from = current.current.get(source)!, to = current.current.get(target)!;
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
if (!a || !b || from.edges.some((edge)=>edge.source===id||edge.target===id)) throw new Error("图片上传完成后即可跨分区移动连线。");
const moved={...node,position:{x:(a.origin.x+node.position.x*a.scale-b.origin.x)/b.scale,y:(a.origin.y+node.position.y*a.scale-b.origin.y)/b.scale},width:node.width!*a.scale/b.scale,height:node.height!*a.scale/b.scale};
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
}
async function refresh(boardId: string) {
return exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) return;
@@ -358,7 +398,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const original = stack.at(-1); if (!original || locked.current || retryOperation.current || members.current.length) return;
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before) } : original;
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
if (!("id" in entry) && entry.pendingMove) {
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
if(!owner)throw new Error("invalid_reference");
const writer=writers.current.get(owner[0]);await writer?.settled();
if(writer?.document.nodes.some((node)=>node.id===move.id)){
if(owner[0]!==desired)for(const hop of subgraphMovePath(current.current,owner[0],desired))await command(hop.parent,`subgraphs/${hop.frame}/members`,{source_board_id:hop.source,target_board_id:hop.target,node_ids:[move.id]},subgraphMoveParticipants(current.current,hop.parent,hop.source,hop.target,[move.id]),false);
stack.pop();destination.push(entry);tick((v)=>v+1);return;
}
}
if ("id" in entry) {
if (isRedo) {
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
@@ -373,10 +423,12 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
} else {
const before = current.current.get(entry.boardId); if (!before) return;
publish(new Map(current.current).set(entry.boardId, entry.before)); stack.pop(); destination.push({ ...entry, before });
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
for(const [id,doc] of entry.related || []){const currentDoc=current.current.get(id);if(currentDoc)related.set(id,currentDoc);next.set(id,doc);}
publish(next); stack.pop(); destination.push({ ...entry, before, ...(entry.related?{related}:{}) });
}
tick((v) => v + 1);
}
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
return { documents, current, views, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
return { documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
}
+4 -3
View File
@@ -4,9 +4,9 @@ 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> };
export type ImageUploadJob = ImageUploadDraft & { file: File; localURL: string; key: string; status: "uploading" | "failed" | "uploaded"; error?: string; controller: AbortController; ready?: Promise<void>; asset?: CanvasAsset };
/** Local previews never enter the canvas document or an autosave payload. */
/** 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[]>([]);
@@ -20,7 +20,7 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
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();
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) {
@@ -41,6 +41,7 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
});
}
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); } };
}
+1 -1
View File
@@ -22,7 +22,7 @@ export type CanvasNodeData = {
presentation?: FramePresentation;
viewScale?: number;
};
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon" | "frame" | "plainImage">;
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon" | "frame" | "plainImage"> & { pendingUpload?: string };
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
/** Business inheritance only: React views reuse these capabilities by composition. */
+3 -2
View File
@@ -8,7 +8,8 @@ export function canvasPath(workspace: string, board: string) {
return `workspaces/${encodeURIComponent(workspace)}/materials/${encodeURIComponent(board)}/canvas`;
}
export function canvasPayload(document: CanvasDocument) {
const clean = decodeCanvas(JSON.stringify(document));
const pending = new Set(document.nodes.filter((node) => node.pendingUpload).map((node) => node.id));
const clean = decodeCanvas(JSON.stringify({ ...document, nodes: document.nodes.filter((node) => !pending.has(node.id)), edges: document.edges.filter((edge) => !pending.has(edge.source) && !pending.has(edge.target)) }));
return { ...clean, nodes: clean.nodes.map(({ id, type, position, width, height, data }) => ({ id, type, position, ...(width !== undefined ? { width, height } : {}), data })) };
}
export function canvasFingerprint(document: CanvasDocument, includeViewport = true) {
@@ -80,7 +81,7 @@ export class CanvasSync {
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body });
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false);
this.document = document;
this.document = canvasPayload(document);
this.notify({ status: "saved", version: this.version });
} catch (error) {
this.pending ||= document;
+31 -1
View File
@@ -7,7 +7,7 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
const other=makeCanvasNode('text',{x:950,y:100});other.id='other';
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;}
function Fixture(){const seed=structuredClone(records.get(parent));tree=useCanvasTree(parent,'w',seed.document,seed);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)]}}]]);
@@ -26,6 +26,14 @@ export async function canvasMemberScenario({root,act,h,useCanvasTree,makeCanvasN
assert.equal(options.headers['If-Match'],`"${records.get(id).version}"`);
const document=JSON.parse(options.body).document;saves.push({id,document});records.set(id,{version:records.get(id).version+1,document});return json({version:records.get(id).version});
}
if(suffix==='ai/runs'&&method==='POST'){const next=structuredClone(records.get(id));next.version++;records.set(id,next);return json({canvas_version:next.version,board_id:id});}
if(suffix==='subgraphs'&&method==='POST'){
const input=JSON.parse(options.body).frame,next=structuredClone(records.get(parent)),childID='66666666-6666-4666-8666-666666666666';
const added=makeCanvasNode('frame',input.position);added.id=input.id;added.data.subgraph={boardId:childID};next.document.nodes.push(added);next.version++;records.set(parent,next);
const empty={version:1,document:{...emptyCanvas(),schema_version:2}};records.set(childID,empty);
if(mode==='create-delayed')await new Promise(resolve=>{release=resolve;});
return json({transaction_id:crypto.randomUUID(),parent:{board_id:parent,...structuredClone(next)},snapshots:[{board_id:parent,...structuredClone(next)}],child:{board_id:childID,...empty}});
}
assert.equal(suffix,'subgraphs/frame/members');posts.push({body:options.body,key:options.headers['Idempotency-Key']});
if(receipt && options.headers['Idempotency-Key']===key){assert.equal(options.body,body);return json(receipt);}
receipt=null;
@@ -82,6 +90,28 @@ 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();
const pendingImage=makeCanvasNode('image',{x:200,y:200},'blob:editable-upload');pendingImage.id='pending-image';pendingImage.pendingUpload=pendingImage.id;
await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:pendingImage}]});tree.movePendingImage(parent,child,pendingImage.id);await tick();});
assert.equal(posts.length,0);assert.equal(saves.length,0);assert.deepEqual(tree.current.current.get(child).nodes.find(n=>n.id===pendingImage.id).position,{x:76,y:64});
await act(async()=>{await tree.undo();await tick();});assert.ok(tree.rootDocument.nodes.some(n=>n.id===pendingImage.id));assert.ok(!tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));
await act(async()=>{await tree.redo();await tick();});
await act(async()=>{tree.nodeAction(child+'::'+pendingImage.id,id=>({type:'rename-node',id,title:'上传期间编辑'}));tree.resolveImageUpload(pendingImage.id,{id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777'});await tick();});
await wait(()=>records.get(child).document.nodes.some(n=>n.id===pendingImage.id),'upload completion at current owner');assert.equal(posts.length,0,'Local ownership before upload does not require a server move');
await act(async()=>{await tree.undo();await tick();});assert.equal(tree.current.current.get(child).nodes.find(n=>n.id===pendingImage.id).data.title,pendingImage.data.title,'Metadata acknowledgement does not consume an undo step');
await act(async()=>{await tree.undo();await tick();});assert.ok(tree.rootDocument.nodes.some(n=>n.id===pendingImage.id));assert.ok(!tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));
await act(async()=>{await tree.redo();await tick();});assert.ok(tree.current.current.get(child).nodes.some(n=>n.id===pendingImage.id));assert.equal(posts.length,2,'Undo/redo after persistence use real ownership transactions');
console.log('PASS pending image ownership: editable local moves, atomic local undo, current-owner upload resolution and transactional undo after persistence');
await reset('create-delayed');
const asyncImage=makeCanvasNode('image',{x:800,y:500},'blob:async-upload');asyncImage.id='async-image';asyncImage.pendingUpload=asyncImage.id;
await act(async()=>{tree.dispatch({type:'nodes',changes:[{type:'add',item:asyncImage}]});await tick();});
await act(async()=>{await tree.command(parent,'ai/runs',{node_id:'other',model_id:'test',prompt:'x',parameters:{}},undefined,false);await tick();});
assert.ok(tree.rootDocument.nodes.some(n=>n.pendingUpload===asyncImage.id),'Version-only command refresh retains local images');
let creation;await act(async()=>{creation=tree.command(parent,'subgraphs',{frame:{id:'new-frame',title:'Frame',position:{x:500,y:500},width:640,height:400,labelMode:'fixed'}});await tick();});
await wait(()=>release,'delayed frame command');
await act(async()=>{tree.resolveImageUpload(asyncImage.id,{id:'77777777-7777-4777-8777-777777777777',url:'https://api.foundry.entroia.com/projects/endoleap/workspaces/w/assets/77777777-7777-4777-8777-777777777777'});release();await creation;await tick();});
assert.ok(tree.rootDocument.nodes.some(n=>n.id===asyncImage.id&&!n.pendingUpload),'Concurrent command response cannot overwrite the resolved image');assert.equal(tree.rootDocument.nodes.filter(n=>n.id==='new-frame').length,1,'Repeated parent snapshots are adopted only once');
console.log('PASS upload command races: version-only refresh and duplicate transaction snapshots retain pending/ready local images');
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');
+13
View File
@@ -102,3 +102,16 @@ test("viewport restoration roundoff does not create an autosave", async () => {
edited.present.nodes[0]={...edited.present.nodes[0],data:{...edited.present.nodes[0].data,text:"changed"}};
await writer.enqueue(edited.present);assert.equal(writes,1);
});
test("editable local uploads and touching edges never enter transport snapshots",async()=>{
const pending={...makeCanvasNode("image",{x:30,y:40},"blob:local-file"),pendingUpload:"upload-one"};pending.id="image-local";
const initial=emptyCanvas(),calls=[];
const writer=new CanvasSync(async(_path,options)=>{calls.push(JSON.parse(options.body).document);return {version:calls.length+1};},"canvas",{version:1,document:initial},()=>{});
const local={...initial,nodes:[pending]};await writer.enqueue(local);assert.equal(calls.length,0);
const other=makeCanvasNode("text",{x:1,y:2});
const edited={...local,nodes:[pending,other],edges:[{id:"pending-edge",source:pending.id,target:other.id}]};await writer.enqueue(edited);
assert.equal(calls.length,1);assert.equal(calls[0].nodes.length,1);assert.equal(calls[0].edges.length,0);assert.ok(!JSON.stringify(calls[0]).includes("blob:"));assert.ok(!writer.document.nodes.some(node=>node.pendingUpload));
const {pendingUpload,...ready}=pending;ready.data={...ready.data,src:"https://example.com/original.png"};ready.position={x:300,y:400};
await writer.enqueue({...edited,nodes:[ready,other]});assert.equal(calls.length,2);assert.equal(calls[1].nodes.find(node=>node.id===ready.id).position.x,300);assert.equal(calls[1].edges.length,1);
});
+25 -4
View File
@@ -39,11 +39,26 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC
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'));
const imageNodeID=local.closest('.react-flow__node').dataset.id;
const editable=()=>document.querySelector(`.react-flow__node[data-id="${imageNodeID}"]`);
assert.ok(editable().classList.contains('draggable'),'Uploading images are real draggable nodes');
await event(editable(),'mousedown',{clientX:200,clientY:200,button:0,buttons:1});await event(window,'mousemove',{clientX:210,clientY:200,buttons:1});await event(window,'mousemove',{clientX:280,clientY:250,buttons:1});await event(window,'mouseup',{clientX:280,clientY:250,button:0});
const editedPosition=editable().style.transform;
await event(editable().querySelector('[title="双击编辑名称"]'),'dblclick');
const titleInput=document.querySelector('[aria-label="节点名称"]');
await act(async()=>{Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set.call(titleInput,'上传前改名');titleInput.dispatchEvent(new window.Event('input',{bubbles:true}));await tick();});
await event(titleInput,'keydown',{key:'Enter'});
assert.ok(editable().textContent.includes('上传前改名'),'Uploading images can be renamed');
await event(editable(),'click');await event(document.querySelector('[aria-label="八向拉伸模式"]'),'click');
const resize=document.querySelector('[data-resize-handle="bottom-right"]'),surface=editable().querySelector('[style*="rotate("]');
await event(resize,'pointerdown',{clientX:400,clientY:400,button:0});await event(surface,'pointermove',{clientX:460,clientY:440,buttons:1});await event(surface,'pointerup',{clientX:460,clientY:440});await event(window,'keydown',{key:'Escape'});
const editedWidth=editable().style.width,editedHeight=editable().style.height;
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');
const persisted=snapshot.document.nodes.find(n=>n.id===imageNodeID);assert.equal(persisted.data.title,'上传前改名');assert.equal(editable().style.transform,editedPosition);assert.equal(editable().style.width,editedWidth);assert.equal(editable().style.height,editedHeight);
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');
@@ -52,10 +67,16 @@ export async function canvasUploadScenario({root,act,h,GraphCanvas,window,emptyC
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 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.equal(saves.length,0);await event(document.querySelector('[aria-label="撤销"]'),'click');assert.ok(document.querySelector(`img[src="${cancelledURL}"]`),'Undo restores a locally deleted upload');
await reset();await choose();const deletedID=document.querySelector('.react-flow__node').dataset.id;
await event(document.querySelector('.react-flow__node'),'click');await event(document,'keydown',{key:'Backspace',code:'Backspace',keyCode:8});await wait(()=>!document.querySelector('.react-flow__node'),'delete before acknowledgement');
await act(async()=>{release();await tick();});await act(async()=>{await new Promise(r=>setTimeout(r,900));});assert.equal(snapshot.document.nodes.length,0,'Late upload completion must not resurrect a deleted node');
await event(document.querySelector('[aria-label="撤销"]'),'click');await wait(()=>snapshot.document.nodes.length===1,'undo after upload completed');assert.equal(snapshot.document.nodes[0].id,deletedID);assert.equal(snapshot.document.nodes[0].data.src,asset.url);
console.log('PASS editable uploads: move/resize/rename persist through acknowledgement, late completion respects deletion and undo');
window.Image=class {naturalWidth=640;naturalHeight=960;set src(_){setTimeout(()=>this.onload?.(),0);}};
await reset('immediate');await choose('添加纯图片');await wait(()=>snapshot.document.nodes.length===1,'fast upload dimensions');assert.equal(snapshot.document.nodes[0].width,320);assert.equal(snapshot.document.nodes[0].height,480);await event(document.querySelector('[aria-label="撤销"]'),'click');assert.equal(document.querySelector('.react-flow__node'),null,'Intrinsic size and attachment acknowledgement do not add undo steps');
await reset();await choose('添加纯图片');assert.equal(document.querySelector('.react-flow__node').style.width,'320px');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');
console.log('PASS upload cleanup: undoable deletion, unmount revocation/abort and plain-image aspect ratio');
} finally {window.Image=NativeImage;URL.revokeObjectURL=originalRevoke;await act(async()=>{root.render(null);await tick();});}
}