Keep member drops interactive and use atomic position moves

This commit is contained in:
2026-11-06 22:59:03 +08:00
parent 34e7ae48e2
commit a240507379
9 changed files with 252 additions and 22 deletions
+3
View File
@@ -189,3 +189,6 @@
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
.imageUploadStatus { position: absolute; bottom: 88px; left: 50%; transform: translateX(-50%); z-index: 120; display: flex; align-items: center; gap: 12px; max-width: min(600px,90%); padding: 10px 14px; background: #fffdf9; border: 1px solid #ded5cb; border-radius: 10px; color: #796e63; font-size: 12px; box-shadow: 0 4px 18px #372f2410; }
.memberSync { padding: 5px 9px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #796e63; font-size: 11px; box-shadow: 0 2px 8px #372f2410; }
.memberSync button { margin-left: 6px; border: 0; background: transparent; color: #78523f; text-decoration: underline; cursor: pointer; }
+21 -12
View File
@@ -4,7 +4,7 @@ import { uploadCanvasImage } from "@/lib/canvas-assets";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import {
Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
addEdge, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition,
addEdge, NodeToolbar, Position, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition,
} from "@xyflow/react";
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Frame, Image as ImageIcon, Bot, Type, Undo2, Upload } from "lucide-react";
import { useReducedMotion } from "motion/react";
@@ -92,7 +92,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
input?.addEventListener("cancel", cancel);
return () => input?.removeEventListener("cancel", cancel);
}, []);
const { nodes, edges } = state.present;
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 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>();
@@ -198,7 +199,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
setTool("select");
}
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "idle") return; dragSession.current = "active";
if (dragSession.current !== "idle") return; dragSession.current = "active"; tree.setDragging(true);
beginEdit(node.id); closeBranch(); setRotating(null); setResizing(null);
draggingFrame.current = node.data.kind === "frame" || dragged.some((item) => item.data.kind === "frame");
const before = new Map(latest.current.nodes.map((item) => [item.id, item.position]));
@@ -207,10 +208,12 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
setMembershipNotice("");
}
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (dragSession.current !== "active") return; dragSession.current = "finishing";
if (dragSession.current !== "active") return;
const origins = new Map(dragOrigins.current);
dragSession.current = "idle"; dragOrigins.current.clear(); setDraggedIds([]); tree.setDragging(false);
if (!draggingFrame.current || snapshot && tree.rootDocument.schema_version === 2) {
const items = dragged.length ? dragged : [node];
const ids = items.filter((item) => { const start = dragOrigins.current.get(item.id); return (item.data.kind !== "frame" || snapshot && tree.rootDocument.schema_version === 2) && start && (start.x !== item.position.x || start.y !== item.position.y); }).map((item) => item.id);
const ids = items.filter((item) => { const start = origins.get(item.id); return (item.data.kind !== "frame" || snapshot && tree.rootDocument.schema_version === 2) && start && (start.x !== item.position.x || start.y !== item.position.y); }).map((item) => item.id);
if (ids.length) {
const positions = new Map(items.map((item) => [item.id, item.position]));
const dropped = latest.current.nodes.map((item) => positions.has(item.id) ? { ...item, position: positions.get(item.id)! } : item);
@@ -221,7 +224,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
try {
const byOwner = new Map<string, { ids: string[]; target: string }>();
const parents = subgraphParents(tree.current.current);
for (const item of items) {
for (const item of items.filter((item) => ids.includes(item.id))) {
const ref = tree.views.nodeRefs.get(item.id); if (!ref) continue;
const center = { x: item.position.x + (item.width || 260) / 2, y: item.position.y + (item.height || 200) / 2 };
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; };
@@ -231,22 +234,20 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
const key = ref.boardId + "/" + target, group = byOwner.get(key) || { ids: [], target }; group.ids.push(ref.id); byOwner.set(key, group);
}
}
for (const [key, group] of byOwner) {
await Promise.all([...byOwner].map(async ([key, group]) => {
let source = key.split("/")[0];
for (const hop of subgraphMovePath(tree.current.current, source, group.target)) {
for (const id of group.ids) { const node = tree.current.current.get(hop.source)?.nodes.find((node) => node.id === id); if (node?.data.subgraph) await tree.ensure(node.data.subgraph.boardId, true); }
const participants = subgraphMoveParticipants(tree.current.current, hop.parent, hop.source, hop.target, group.ids);
await tree.command(hop.parent, `subgraphs/${hop.frame}/members`, { source_board_id: hop.source, target_board_id: hop.target, node_ids: group.ids }, participants);
await tree.moveMembers(hop.parent, hop.frame, hop.source, hop.target, group.ids, participants);
source = hop.target;
}
}
}));
if (byOwner.size) setMembershipNotice(notice || "已更新子画布归属");
} catch (error) { setError(error instanceof Error ? error.message : "移动内容未完成"); setMembershipNotice(""); }
} else { dispatch({ type: "drop", ids }); if (notice) setMembershipNotice(notice); }
}
}
setDraggedIds([]);
dragOrigins.current.clear(); dragSession.current = "idle";
}
async function connect(connection: Connection) {
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
@@ -366,7 +367,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
<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) return false;
if (tree.busy || tree.transactionBusy) return false;
const linked = removing.filter((node) => node.data.subgraph);
const parents = subgraphParents(tree.current.current);
const referenced = removing.filter((node) => {
@@ -494,6 +495,14 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
</div>}
{tree.memberJobs.map((job, index) => job.ids.map((id) => {
const node = [...tree.views.nodeRefs].find(([, ref]) => ref.id === id && [job.source, job.target].includes(ref.boardId));
return node && <NodeToolbar key={`${job.source}/${id}`} nodeId={node[0]} isVisible position={Position.Top}>
<div className={`${styles.memberSync} nodrag nopan`} role="status" aria-label="元素同步状态" data-node-id={node[0]}>
{job.error ? <>移动未完成 <button onClick={tree.retryMembers}>重试</button></> : index ? "等待同步…" : "同步中…"}
</div>
</NodeToolbar>;
}))}
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : "正在同步画布…"}</div>}
{error && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
+1 -1
View File
@@ -64,7 +64,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
} while (current.has_more && alive);
}
for (const [board, version] of pendingRefresh.current) {
if (reference.current.busy) break;
if (reference.current.busy || reference.current.transactionBusy) break;
const writer = reference.current.writers.current.get(board);
if (writer && writer.version < version) {
try { await reference.current.refresh(board); } catch (error) {
+102 -7
View File
@@ -12,6 +12,7 @@ export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnaps
type LocalHistory = { boardId: string; before: CanvasDocument };
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 };
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
@@ -20,6 +21,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const [status, setStatus] = useState("saved");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
const memberCapability = useRef<Promise<boolean> | null>(null);
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
function protectedNodes(boardId: string) {
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
return ids;
}
const [pendingCommand, setPendingCommand] = useState(false);
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
@@ -59,6 +70,13 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (locked.current || retryOperation.current) return;
const before = current.current.get(boardId); if (!before) return;
const result = canvasReducer(history(before), action);
const protectedIDs = protectedNodes(boardId);
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
const prior = before.nodes.find((item) => item.id === node.id);
if (!prior || !protectedIDs.has(node.id)) return node;
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
publish(new Map(current.current).set(boardId, result.present));
};
@@ -99,6 +117,9 @@ 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 }));
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);
}, [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");
let document = current.current.get(boardId);
@@ -157,11 +178,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
} catch { setError("local_backup_failed"); }
}, 250);
const save = setTimeout(() => {
if (locked.current || retryOperation.current || pause.current) return;
if (locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
for (const [boardId, document] of current.current) if (!pendingRecovery.current.has(boardId)) void writers.current.get(boardId)?.enqueue(document);
}, 800);
return () => { clearTimeout(backup); clearTimeout(save); };
}, [documents, paused, workspace]);
}, [documents, paused, workspace, memberJobs]);
useEffect(() => {
const backup = () => {
if (pause.current) return;
@@ -173,15 +194,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
window.addEventListener("pagehide", backup);
return () => { window.removeEventListener("pagehide", backup); backup(); };
}, [workspace]);
async function flush() {
async function flush(deferMemberPositions = false) {
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
const writer = writers.current.get(boardId); if (!writer) continue;
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
const deferred = deferMemberPositions ? new Set(members.current.filter((job) => job.source === boardId).flatMap((job) => job.ids)) : new Set<string>();
const saving = deferred.size ? { ...document, nodes: document.nodes.map((node) => deferred.has(node.id) ? { ...node, position: writer.document.nodes.find((item) => item.id === node.id)?.position || node.position } : node) } : document;
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
}
}
async function exclusive<T>(operation: () => Promise<T>) {
if (locked.current || pause.current) throw new Error("canvas_busy");
if (locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
locked.current = true; setBusy(true);
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
finally { locked.current = false; if (mounted.current) setBusy(false); }
@@ -225,6 +248,78 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
return execute();
});
}
// Ownership changes pause autosaves, not unrelated local editing. Server
// acknowledgements merge against the persisted base, preserving intervening edits.
async function runMembers() {
if (memberRunning.current || !members.current.length) return;
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
try {
if (!job.run) {
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
if (job.refresh) {
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
const next = new Map(current.current);
for (const [id, remote] of refreshed) {
const base = writers.current.get(id)!.document, local = current.current.get(id)!;
next.set(id, mergeCanvasDocuments(base, local, remote.document));
}
for (const [id, remote] of refreshed) install(id, remote);
publish(next); job.refresh = false;
}
const atomic = await memberCapability.current;
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)!;
const positions = Object.fromEntries(job.ids.map((id) => {
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
return [id, node.position];
}));
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
const key = crypto.randomUUID(), historyIndex = undo.current.length;
job.run = async () => {
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
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 next = new Map(current.current);
for (const [id, value] of values) {
const base = bases.get(id)!; if (!base) throw new Error("invalid_canvas_snapshot");
let local = current.current.get(id)!;
// The server owns these nodes after the move. Ignore only their
// staged coordinates; all other local edits participate in the merge.
if (id === job.source) local = { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: base.nodes.find((item) => item.id === node.id)!.position } : node) };
const merged = keepSelection(mergeCanvasDocuments(base, local, value.document), local);
next.set(id, id !== job.target ? merged : { ...merged, nodes: merged.nodes.map((node) => job.ids.includes(node.id) ? { ...node, selected: current.current.get(job.source)?.nodes.find((item) => item.id === node.id)?.selected } : node) });
}
for (const [id, value] of values) install(id, value);
publish(next);
undo.current = rebasedHistory;
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
};
}
await job.run();
members.current.shift(); showMembers(); job.resolve();
} catch (error) {
job.error = error instanceof Error ? error.message : "request_failed";
// A definite rejection performed zero writes. A retry may prepare fresh
// versions; ambiguous transport failures must retain the exact body/key.
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
showMembers();
} finally {
memberRunning.current = false;
if (members.current.length && !members.current[0].error) void runMembers();
}
}
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
if (locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
return new Promise<void>((resolve) => {
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
});
}
async function refresh(boardId: string) {
return exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) return;
@@ -250,7 +345,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
async function undoAction(isRedo = false) {
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
const original = stack.at(-1); if (!original || locked.current || retryOperation.current) return;
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;
@@ -273,5 +368,5 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
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, 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: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
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 };
}