Fix canvas transfer timeouts and move images out of snapshots
This commit is contained in:
@@ -187,3 +187,7 @@
|
||||
.framePort:global(.connectionindicator) { pointer-events: auto; }
|
||||
.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
|
||||
.canvasBusy button { margin: 8px 5px; padding: 6px 9px; border: 1px solid #ded7ce; border-radius: 5px; background: #fffefb; cursor: pointer; }
|
||||
|
||||
.imageMigration { 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; }
|
||||
.imageMigration button { flex-shrink: 0; border: 0; border-radius: 6px; padding: 6px 9px; background: #eee9e2; color: #51483d; cursor: pointer; }
|
||||
.imageMigration button:disabled { opacity: .5; cursor: wait; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { inlineImageCount, uploadCanvasImage } from "@/lib/canvas-assets";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
@@ -46,13 +47,19 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const [initial] = useState(() => {
|
||||
try {
|
||||
const local = readLocalRecovery(localStorage, storageKey, snapshot);
|
||||
return { document: snapshot?.document || local || emptyCanvas(), recovery: snapshot ? local : null, error: "" };
|
||||
let document = snapshot?.document || local || emptyCanvas();
|
||||
try {
|
||||
const view = JSON.parse(localStorage.getItem(storageKey + ":viewport") || "null");
|
||||
if (view && [view.x, view.y, view.zoom].every(Number.isFinite) && Math.abs(view.x) <= 1e6 && Math.abs(view.y) <= 1e6 && view.zoom >= .1 && view.zoom <= 4) document = { ...document, viewport: view };
|
||||
} catch { /* A broken local view never blocks the server document. */ }
|
||||
return { document, recovery: snapshot ? local : null, error: "" };
|
||||
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
|
||||
});
|
||||
|
||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||
const [error, setError] = useState(initial.error);
|
||||
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
const [resizing, setResizing] = useState<string | null>(null);
|
||||
const [rotating, setRotating] = useState<string | null>(null);
|
||||
@@ -98,6 +105,13 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return () => clearTimeout(timer);
|
||||
}, [membershipNotice]);
|
||||
const duration = reduceMotion ? 0 : 180;
|
||||
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
|
||||
treeRef.current.dispatch({ type: "viewport", viewport });
|
||||
try { localStorage.setItem(storageKey + ":viewport", JSON.stringify(viewport)); } catch { /* View changes do not require server writes. */ }
|
||||
};
|
||||
const changeView = (operation: () => Promise<boolean>) => {
|
||||
void operation().then(() => rememberViewport(flow.getViewport())).catch((error) => setError(error.message));
|
||||
};
|
||||
|
||||
const latest = useRef(state.present);
|
||||
latest.current = state.present;
|
||||
@@ -272,13 +286,15 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) { pendingImage.current = null; return; }
|
||||
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > 2_000_000) {
|
||||
pendingImage.current = null; setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
|
||||
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
|
||||
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
|
||||
}
|
||||
const request = pendingImage.current; pendingImage.current = null;
|
||||
const kind = imageKind.current;
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const src = await new Promise<string>((resolve, reject) => {
|
||||
const uploaded = snapshot ? await uploadCanvasImage(api, board.workspace_id, file, file.name) : null;
|
||||
const src = uploaded?.url || await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error("无法读取图片。"));
|
||||
@@ -288,13 +304,16 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
if (kind === "plain-image" && !request) {
|
||||
size = await new Promise((resolve, reject) => {
|
||||
const image = new window.Image();
|
||||
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
|
||||
image.onerror = () => reject(new Error("无法读取图片尺寸。"));
|
||||
image.src = src;
|
||||
const localSource = uploaded ? URL.createObjectURL(file) : src;
|
||||
const clean = () => { if (uploaded) URL.revokeObjectURL(localSource); };
|
||||
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); clean(); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
|
||||
image.onerror = () => { clean(); reject(new Error("无法读取图片尺寸。")); };
|
||||
image.src = localSource;
|
||||
});
|
||||
}
|
||||
if (request) createBranch(request, "image", src); else addNode(kind, src, size, kind === "plain-image" ? file.name : undefined);
|
||||
} catch { pendingImage.current = null; setError("无法读取图片,请重新选择。"); }
|
||||
} catch (error) { pendingImage.current = null; setError(errorText(error)); }
|
||||
finally { setImageUploading(false); }
|
||||
}
|
||||
async function importProject(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -396,7 +415,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
onConnectEnd={connectionEnd}
|
||||
onPaneClick={() => { closeBranch(); setRotating(null); setResizing(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); if (node.id !== resizing) setResizing(null); }}
|
||||
onMoveStart={closeBranch}
|
||||
onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })}
|
||||
onMoveEnd={(event, viewport) => { if (event) rememberViewport(viewport); }}
|
||||
defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
|
||||
panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
|
||||
nodesDraggable={tool === "select" && !tree.busy} nodesConnectable={tool === "select" && !tree.busy}
|
||||
@@ -420,10 +439,10 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!tree.canUndo || tree.busy} onClick={() => dispatch({ type: "undo" })} />
|
||||
<IconButton icon={Redo2} label="重做" disabled={!tree.canRedo || tree.busy} onClick={() => dispatch({ type: "redo" })} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Minus} label="缩小" onClick={() => void flow.zoomOut({ duration })} />
|
||||
<button className={styles.zoomValue} onClick={() => void flow.zoomTo(1, { duration })} title="恢复 100% 缩放">{Math.round(zoom * 100)}%</button>
|
||||
<IconButton icon={Plus} label="放大" onClick={() => void flow.zoomIn({ duration })} />
|
||||
<IconButton icon={Maximize} label="适应画布" onClick={() => nodes.length ? void flow.fitView({ padding: 0.2, duration }) : void flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration })} />
|
||||
<IconButton icon={Minus} label="缩小" onClick={() => changeView(() => flow.zoomOut({ duration }))} />
|
||||
<button className={styles.zoomValue} onClick={() => changeView(() => flow.zoomTo(1, { duration }))} title="恢复 100% 缩放">{Math.round(zoom * 100)}%</button>
|
||||
<IconButton icon={Plus} label="放大" onClick={() => changeView(() => flow.zoomIn({ duration }))} />
|
||||
<IconButton icon={Maximize} label="适应画布" onClick={() => changeView(() => nodes.length ? flow.fitView({ padding: 0.2, duration }) : flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration }))} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
|
||||
</div>
|
||||
@@ -434,8 +453,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Type} label="添加纯文本" onClick={() => addNode("plain-text")} />
|
||||
<IconButton icon={StickyNote} label="添加文本卡片" onClick={() => addNode("text")} />
|
||||
<IconButton icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageKind.current = "image"; imageInput.current?.click(); }} />
|
||||
<IconButton icon={ImageIcon} label="添加纯图片" onClick={() => { pendingImage.current = null; imageKind.current = "plain-image"; imageInput.current?.click(); }} />
|
||||
<IconButton disabled={imageUploading || tree.busy} icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageKind.current = "image"; imageInput.current?.click(); }} />
|
||||
<IconButton disabled={imageUploading || tree.busy} icon={ImageIcon} label="添加纯图片" onClick={() => { pendingImage.current = null; imageKind.current = "plain-image"; imageInput.current?.click(); }} />
|
||||
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
|
||||
<IconButton icon={Frame} label="添加框选区" onClick={() => addNode("frame")} />
|
||||
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
|
||||
@@ -459,6 +478,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
|
||||
<p>可通过右上角导出 JSON 备份。</p>
|
||||
</div>
|
||||
{!!snapshot && inlineImageCount(tree.rootDocument) > 0 && <div className={styles.imageMigration} role="status"><span>此画布的旧图片可优化加载,原图和内容都会保留。</span><button type="button" disabled={tree.busy || !!recovery} onClick={() => void tree.migrateImages().catch((error) => setError(errorText(error)))}>{tree.busy ? "正在处理…" : "优化图片加载"}</button></div>}
|
||||
{imageUploading && <p className={styles.imageMigration} role="status">正在上传图片…</p>}
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
{iconPicker && <IconPicker close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
|
||||
import { useContext, useRef } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { Handle, Position, useViewport, type NodeProps } from "@xyflow/react";
|
||||
import { FileText, Image as ImageIcon, Type } from "lucide-react";
|
||||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
|
||||
@@ -18,13 +18,15 @@ const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧
|
||||
export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const { zoom } = useViewport();
|
||||
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||
const pointerStart = useRef({ x: 0, y: 0 });
|
||||
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
||||
return (
|
||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
|
||||
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
|
||||
{data.kind === "image" ? <img className={styles.image} src={managedImageSource(data.src)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
{data.kind === "image" ? <img className={styles.image} src={managedImageSource(data.src, preview)} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
<CanvasTextEditor id={id} data={data} selected={selected} />
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import { useViewport, type NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
import styles from "./canvas.module.css";
|
||||
export function PlainImageNode(props: NodeProps<CanvasNode>) {
|
||||
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={managedImageSource(props.data.src)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
|
||||
const { zoom } = useViewport();
|
||||
const preview = (props.width || 320) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||
return <TransformableNode {...props} className={styles.plainImageFrame}><img className={styles.plainImage} src={managedImageSource(props.data.src, preview)} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { EdgeChange, NodeChange } from "@xyflow/react";
|
||||
import { prepareManagedImages } from "@/lib/canvas-assets";
|
||||
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";
|
||||
@@ -24,6 +25,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
|
||||
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
|
||||
const writers = useRef(new Map<string, CanvasSync>());
|
||||
const migrationAbort = useRef<AbortController | null>(null);
|
||||
const loading = useRef(new Set<string>());
|
||||
const [retryLoad, setRetryLoad] = useState(0);
|
||||
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
|
||||
@@ -54,7 +56,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
// Initialization never writes a snapshot to the server.
|
||||
if (snapshot && !writers.current.has(root)) install(root, snapshot);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; migrationAbort.current?.abort(); }; }, []);
|
||||
const update = (boardId: string, action: CanvasAction) => {
|
||||
if (locked.current || retryOperation.current) return;
|
||||
const before = current.current.get(boardId); if (!before) return;
|
||||
@@ -74,7 +76,12 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
|
||||
const next = { ...change, id: ref.id };
|
||||
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
|
||||
if (next.type === "dimensions" && next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
|
||||
if (next.type === "dimensions") {
|
||||
// ResizeObserver measurements are editor state. Only an explicit resize
|
||||
// gesture may write node dimensions back to the owning snapshot.
|
||||
if (!next.resizing) next.setAttributes = false;
|
||||
if (next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
|
||||
}
|
||||
groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), next]);
|
||||
}
|
||||
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
|
||||
@@ -148,7 +155,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.saved !== canvasFingerprint(document) : false }));
|
||||
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 { setError("local_backup_failed"); }
|
||||
}, 250);
|
||||
const save = setTimeout(() => {
|
||||
@@ -162,7 +169,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.saved !== canvasFingerprint(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: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
|
||||
}
|
||||
};
|
||||
window.addEventListener("pagehide", backup);
|
||||
@@ -267,6 +274,21 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
tick((v) => v + 1);
|
||||
}
|
||||
async function migrateImages() {
|
||||
return exclusive(async () => {
|
||||
await flush();
|
||||
const before = current.current.get(root)!;
|
||||
const controller = new AbortController(); migrationAbort.current = controller;
|
||||
let document: CanvasDocument;
|
||||
try { document = await prepareManagedImages(before, api, workspace, controller.signal); }
|
||||
finally { migrationAbort.current = null; }
|
||||
if (!mounted.current || controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
undo.current.push({ boardId: root, before }); redo.current = [];
|
||||
publish(new Map(current.current).set(root, document));
|
||||
const writer = writers.current.get(root)!;
|
||||
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
});
|
||||
}
|
||||
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, migrateImages, 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user