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 -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;