Fix canvas transfer timeouts and move images out of snapshots
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { fetchCanvasResponse } from "@/lib/canvas-response";
|
||||
import { sessionCookie, sessionMaxAge, SESSION_EXPIRY_HEADER } from "@/lib/session-cookie";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
@@ -36,6 +37,8 @@ async function handler(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const requestStarted = performance.now();
|
||||
const requestStartedAt = new Date().toISOString();
|
||||
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
|
||||
const json = (body: unknown, status: number) =>
|
||||
NextResponse.json(body, { status, headers: responseHeaders });
|
||||
@@ -61,6 +64,7 @@ async function handler(
|
||||
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
|
||||
}
|
||||
}
|
||||
const bodyReceived = performance.now();
|
||||
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
|
||||
const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
const headers: Record<string, string> = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" };
|
||||
@@ -72,20 +76,31 @@ async function handler(
|
||||
if (value !== null) headers[h] = value;
|
||||
}
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor"]) {
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor", "variant", "width"]) {
|
||||
const values = req.nextUrl.searchParams.getAll(key);
|
||||
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
|
||||
}
|
||||
const encodingStarted = performance.now();
|
||||
const outboundBody = req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {});
|
||||
const encodeMs = performance.now() - encodingStarted;
|
||||
const started = performance.now();
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
const snapshotRead = canvas && path.endsWith("/canvas") && req.method === "GET";
|
||||
const streamedRead = snapshotRead || asset && req.method === "GET";
|
||||
const snapshotWrite = canvas && path.endsWith("/canvas") && req.method === "PATCH";
|
||||
const traceId = streamedRead || snapshotWrite ? crypto.randomUUID() : "";
|
||||
const requestUpstream = streamedRead ? (url: string, options: RequestInit) => fetchCanvasResponse(url, options, undefined, fetch, (trace) => {
|
||||
console.info(snapshotRead ? "[canvas-transfer]" : "[asset-transfer]", JSON.stringify({ trace_id: traceId, resource: path, ...trace }));
|
||||
}) : fetch;
|
||||
if (streamedRead || snapshotWrite) responseHeaders.set("X-Canvas-Trace-Id", traceId);
|
||||
const upstream = await requestUpstream(
|
||||
origin + "/projects/" + encodeURIComponent(project) + "/" + path +
|
||||
(query.size ? "?" + query.toString() : ""),
|
||||
{
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {}),
|
||||
signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
|
||||
body: outboundBody,
|
||||
signal: streamedRead ? req.signal : AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
},
|
||||
@@ -103,8 +118,11 @@ async function handler(
|
||||
}
|
||||
if (asset && upstream.ok && req.method === "GET") {
|
||||
const type = upstream.headers.get("content-type") || "";
|
||||
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) return json({ error: "invalid_asset" }, 502);
|
||||
if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) { await upstream.body?.cancel(); return json({ error: "invalid_asset" }, 502); }
|
||||
responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff");
|
||||
const variant = upstream.headers.get("x-asset-variant");
|
||||
if (variant === "original" || variant === "preview") responseHeaders.set("X-Asset-Variant", variant);
|
||||
responseHeaders.set("Server-Timing", `upstream;dur=${(performance.now() - started).toFixed(1)}`);
|
||||
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
const headersReceived = performance.now();
|
||||
@@ -116,6 +134,15 @@ async function handler(
|
||||
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
const body = upstream.status === 204 ? null : await upstream.json();
|
||||
if (snapshotWrite) console.info("[canvas-save]", JSON.stringify({
|
||||
trace_id: traceId, resource: path, status: upstream.status, started_at: requestStartedAt, finished_at: new Date().toISOString(),
|
||||
client_body_ms: Math.round((bodyReceived - requestStarted) * 10) / 10,
|
||||
encode_ms: Math.round(encodeMs * 10) / 10,
|
||||
request_bytes: typeof outboundBody === "string" ? new TextEncoder().encode(outboundBody).length : 0,
|
||||
upload_and_server_ms: Math.round((headersReceived - started) * 10) / 10,
|
||||
response_body_ms: Math.round((performance.now() - headersReceived) * 10) / 10,
|
||||
total_ms: Math.round((performance.now() - requestStarted) * 10) / 10,
|
||||
}));
|
||||
responseHeaders.set(
|
||||
"Server-Timing",
|
||||
`upstream;dur=${(headersReceived - started).toFixed(1)}, total;dur=${(performance.now() - started).toFixed(1)}`,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
+24
-4
@@ -6,7 +6,12 @@ export type Profile = {
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
export class ApiError extends Error {
|
||||
constructor(code: string, readonly status: number, readonly details?: unknown, readonly retryAfter?: string | null) { super(code); this.name = "ApiError"; }
|
||||
readonly status: number;
|
||||
readonly details?: unknown;
|
||||
readonly retryAfter?: string | null;
|
||||
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
|
||||
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
@@ -16,10 +21,20 @@ export async function api<T>(
|
||||
...options,
|
||||
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
let body: unknown;
|
||||
if (res.status === 204) body = null;
|
||||
else {
|
||||
try { body = await res.json(); }
|
||||
catch (error) {
|
||||
if (options.signal?.aborted) throw options.signal.reason;
|
||||
const malformed = error instanceof SyntaxError;
|
||||
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
|
||||
}
|
||||
}
|
||||
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
|
||||
if (!res.ok) {
|
||||
const code = body?.error?.code || body?.error;
|
||||
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, body?.details, res.headers.get("retry-after"));
|
||||
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
|
||||
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
@@ -55,6 +70,11 @@ export const errorText = (error: unknown) => {
|
||||
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
|
||||
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
|
||||
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
|
||||
response_interrupted: "画布数据传输中断或超时,请重新加载;当前内容未被覆盖。",
|
||||
invalid_api_response: "接口返回了不完整或无法解析的数据,请重新加载。",
|
||||
invalid_asset: "图片格式无法识别,请选择有效的 PNG、JPEG、WebP 或 GIF。",
|
||||
asset_too_large: "图片超出上传限制,请选择小于 20 MiB 的文件。",
|
||||
invalid_asset_response: "图片上传结果无效,画布内容未替换,请重试。",
|
||||
invalid_canvas_snapshot: "服务端画布数据格式无效,请重新读取或联系维护者。",
|
||||
canvas_schema_upgrade_required: "这张画布已升级,请刷新以加载新版客户端。",
|
||||
undo_receipt_expired: "撤销记录已过期,当前内容已保留。",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CanvasDocument } from "./canvas.ts";
|
||||
|
||||
type Request = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
export type CanvasAsset = { id: string; url: string; width: number; height: number; bytes: number; mime_type: string };
|
||||
export const ASSET_LIMIT = 20 * 1024 * 1024;
|
||||
export function inlineImageCount(document: CanvasDocument) {
|
||||
return document.nodes.filter((node) => ["image", "plain-image"].includes(node.data.kind) && node.data.src?.startsWith("data:image/")).length;
|
||||
}
|
||||
export async function uploadCanvasImage(request: Request, workspace: string, file: Blob, name: string, signal?: AbortSignal): Promise<CanvasAsset> {
|
||||
if (!file.size || file.size > ASSET_LIMIT) throw new Error("asset_too_large");
|
||||
const form = new FormData(); form.append("image", file, name);
|
||||
const key = crypto.randomUUID();
|
||||
const send = () => request<CanvasAsset>(`workspaces/${encodeURIComponent(workspace)}/assets`, { method: "POST", headers: { "Idempotency-Key": key }, body: form, signal });
|
||||
let asset: CanvasAsset;
|
||||
try { asset = await send(); } catch (error) {
|
||||
if (signal?.aborted || !(error instanceof TypeError || typeof error === "object" && error && "status" in error && Number(error.status) >= 500)) throw error;
|
||||
asset = await send(); // The same bytes/key retry a possibly committed upload.
|
||||
}
|
||||
if (!asset || typeof asset.url !== "string") throw new Error("invalid_asset_response");
|
||||
let url: URL;
|
||||
try { url = new URL(asset.url); } catch { throw new Error("invalid_asset_response"); }
|
||||
if (!/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(asset.id) || url.origin !== "https://api.foundry.entroia.com" || url.pathname !== `/projects/${process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"}/workspaces/${workspace}/assets/${asset.id}` || url.search || !Number.isFinite(asset.width) || asset.width < 1 || !Number.isFinite(asset.height) || asset.height < 1) throw new Error("invalid_asset_response");
|
||||
return asset;
|
||||
}
|
||||
export function inlineImageBlob(source: string) {
|
||||
const match = /^data:image\/(png|jpeg|webp|gif);base64,([A-Za-z0-9+/]+={0,2})$/.exec(source);
|
||||
if (!match || match[2].length > Math.ceil(ASSET_LIMIT / 3) * 4) throw new Error("invalid_asset");
|
||||
const bytes = Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0));
|
||||
return { file: new Blob([bytes], { type: "image/" + match[1] }), extension: match[1] };
|
||||
}
|
||||
/** Explicit one-time action: preserve image bytes and all node/edge identities. */
|
||||
export async function prepareManagedImages(document: CanvasDocument, request: Request, workspace: string, signal?: AbortSignal) {
|
||||
const assets = new Map<string, CanvasAsset>();
|
||||
const nodes = [];
|
||||
for (const node of document.nodes) {
|
||||
const source = node.data.src;
|
||||
if (!["image", "plain-image"].includes(node.data.kind) || !source?.startsWith("data:image/")) { nodes.push(node); continue; }
|
||||
let asset = assets.get(source);
|
||||
if (!asset) { const { file, extension } = inlineImageBlob(source); asset = await uploadCanvasImage(request, workspace, file, `image-${node.id}.${extension}`, signal); assets.set(source, asset); }
|
||||
nodes.push({ ...node, data: { ...node.data, src: asset.url, ...(document.schema_version === 2 ? { assetId: asset.id } : {}) } });
|
||||
}
|
||||
return { ...document, nodes };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Separate the response-header deadline from a large snapshot's transfer.
|
||||
* A client disconnect still aborts the upstream; bodies are never cached. */
|
||||
export type CanvasTransferTrace = { started_at: string; finished_at: string; headers_ms: number; first_chunk_ms?: number; transfer_ms: number; total_ms: number; bytes: number; chunks: number; outcome: "complete" | "cancelled" | "error"; error?: string };
|
||||
export async function fetchCanvasResponse(url: string, options: RequestInit, limits = { headersMs: 15000, bodyMs: 90000, idleMs: 30000 }, request = fetch, report?: (trace: CanvasTransferTrace) => void) {
|
||||
const started = performance.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
const abort = new AbortController();
|
||||
const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
|
||||
const timeout = () => abort.abort(new DOMException("Canvas transfer timed out", "TimeoutError"));
|
||||
let timer = setTimeout(timeout, limits.headersMs);
|
||||
let upstream: Response;
|
||||
try { upstream = await request(url, { ...options, signal }); }
|
||||
catch (error) {
|
||||
clearTimeout(timer);
|
||||
const elapsed = Math.round((performance.now() - started) * 10) / 10;
|
||||
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: elapsed, transfer_ms: 0, total_ms: elapsed, bytes: 0, chunks: 0, outcome: "error", ...(error instanceof Error ? { error: error.name } : {}) });
|
||||
throw error;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
if (!upstream.body) return upstream;
|
||||
const headersAt = performance.now();
|
||||
const reader = upstream.body.getReader();
|
||||
let bytes = 0, chunks = 0, firstChunk: number | undefined;
|
||||
const record = (outcome: CanvasTransferTrace["outcome"], error?: unknown) => {
|
||||
const now = performance.now(), rounded = (value: number) => Math.round(value * 10) / 10;
|
||||
report?.({ started_at: startedAt, finished_at: new Date().toISOString(), headers_ms: rounded(headersAt - started), ...(firstChunk === undefined ? {} : { first_chunk_ms: rounded(firstChunk - headersAt) }), transfer_ms: rounded(now - headersAt), total_ms: rounded(now - started), bytes, chunks, outcome, ...(error instanceof Error ? { error: error.name } : {}) });
|
||||
};
|
||||
let idle: ReturnType<typeof setTimeout> | undefined;
|
||||
let finished = false;
|
||||
const cleanup = () => { finished = true; clearTimeout(timer); clearTimeout(idle); signal.removeEventListener("abort", interrupted); };
|
||||
let interrupt: (reason: unknown) => void;
|
||||
const interrupted = () => interrupt(signal.reason);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
interrupt = (reason) => { if (finished) return; cleanup(); record("error", reason); controller.error(reason); void reader.cancel(reason).catch(() => {}); };
|
||||
signal.addEventListener("abort", interrupted, { once: true });
|
||||
timer = setTimeout(timeout, limits.bodyMs);
|
||||
if (signal.aborted) interrupted();
|
||||
},
|
||||
async pull(controller) {
|
||||
if (finished) return;
|
||||
idle = setTimeout(timeout, limits.idleMs);
|
||||
try {
|
||||
const next = await reader.read(); clearTimeout(idle);
|
||||
if (finished) return;
|
||||
if (next.done) { cleanup(); record("complete"); controller.close(); } else { firstChunk ??= performance.now(); bytes += next.value.byteLength; chunks++; controller.enqueue(next.value); }
|
||||
} catch (error) { if (!finished) { cleanup(); record("error", error); controller.error(error); } }
|
||||
},
|
||||
async cancel(reason) { if (!finished) { cleanup(); record("cancelled", reason); } abort.abort(reason); await reader.cancel(reason).catch(() => {}); },
|
||||
});
|
||||
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers: upstream.headers });
|
||||
}
|
||||
+28
-8
@@ -11,8 +11,25 @@ export function canvasPayload(document: CanvasDocument) {
|
||||
const clean = decodeCanvas(JSON.stringify(document));
|
||||
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) {
|
||||
return JSON.stringify(canvasPayload(document), (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
||||
export function canvasFingerprint(document: CanvasDocument, includeViewport = true) {
|
||||
const { viewport, ...content } = canvasPayload(document);
|
||||
return JSON.stringify(includeViewport ? { ...content, viewport } : content, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
||||
}
|
||||
export function canvasChanges(before: CanvasDocument, after: CanvasDocument) {
|
||||
const a = canvasPayload(before), b = canvasPayload(after);
|
||||
const changed = (left: unknown, right: unknown) => JSON.stringify(left) !== JSON.stringify(right);
|
||||
return {
|
||||
viewport: changed(a.viewport, b.viewport),
|
||||
nodes: b.nodes.flatMap((node) => {
|
||||
const prior = a.nodes.find((item) => item.id === node.id);
|
||||
if (!prior) return [{ id: node.id, fields: ["added"] }];
|
||||
const fields = ["position", "width", "height"].filter((key) => changed(prior[key as keyof typeof prior], node[key as keyof typeof node]));
|
||||
for (const key of new Set([...Object.keys(prior.data), ...Object.keys(node.data)])) if (changed((prior.data as Record<string, unknown>)[key], (node.data as Record<string, unknown>)[key])) fields.push("data." + key);
|
||||
return fields.length ? [{ id: node.id, fields }] : [];
|
||||
}),
|
||||
removed_nodes: a.nodes.filter((node) => !b.nodes.some((item) => item.id === node.id)).map((node) => node.id),
|
||||
edges: changed(a.edges, b.edges),
|
||||
};
|
||||
}
|
||||
export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot {
|
||||
if (!Number.isSafeInteger(value?.version) || value.version < 1) throw new Error("invalid_canvas_snapshot");
|
||||
@@ -23,6 +40,7 @@ export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot {
|
||||
export class CanvasSync {
|
||||
version: number;
|
||||
saved: string;
|
||||
private savedContent: string;
|
||||
error = "";
|
||||
document: CanvasDocument;
|
||||
private pending: CanvasDocument | null = null;
|
||||
@@ -32,9 +50,10 @@ export class CanvasSync {
|
||||
private notify: (state: SyncState) => void;
|
||||
constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) {
|
||||
this.request = request; this.path = path; this.version = initial.version;
|
||||
this.saved = canvasFingerprint(initial.document); this.notify = notify;
|
||||
this.saved = canvasFingerprint(initial.document); this.savedContent = canvasFingerprint(initial.document, false); this.notify = notify;
|
||||
this.document = initial.document;
|
||||
}
|
||||
needsSave(document: CanvasDocument) { return canvasFingerprint(document, false) !== this.savedContent; }
|
||||
enqueue(document: CanvasDocument): Promise<void> {
|
||||
this.pending = document;
|
||||
if (this.error) return Promise.resolve();
|
||||
@@ -46,7 +65,7 @@ export class CanvasSync {
|
||||
adopt(snapshot: CanvasSnapshot) {
|
||||
if (this.running) throw new Error("canvas_busy");
|
||||
this.pending = null; this.error = ""; this.version = snapshot.version; this.document = snapshot.document;
|
||||
this.saved = canvasFingerprint(snapshot.document); this.notify({ status: "saved", version: this.version });
|
||||
this.saved = canvasFingerprint(snapshot.document); this.savedContent = canvasFingerprint(snapshot.document, false); this.notify({ status: "saved", version: this.version });
|
||||
}
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
@@ -54,12 +73,13 @@ export class CanvasSync {
|
||||
this.notify({ status: "saving", version: this.version });
|
||||
try {
|
||||
const fingerprint = canvasFingerprint(document);
|
||||
if (fingerprint === this.saved) { this.notify({ status: "saved", version: this.version }); continue; }
|
||||
if (!this.needsSave(document)) { this.notify({ status: "saved", version: this.version }); continue; }
|
||||
if (process.env.NODE_ENV === "development") console.info("[canvas-dirty]", JSON.stringify({ resource: this.path, ...canvasChanges(this.document, document) }));
|
||||
const body = JSON.stringify({ document: canvasPayload(document) });
|
||||
if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large");
|
||||
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.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false);
|
||||
this.document = document;
|
||||
this.notify({ status: "saved", version: this.version });
|
||||
} catch (error) {
|
||||
@@ -77,13 +97,13 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
|
||||
if (record) {
|
||||
const value = JSON.parse(record);
|
||||
const doc = decodeCanvas(JSON.stringify(value.document));
|
||||
if (!snapshot || (value.dirty && canvasFingerprint(doc) !== canvasFingerprint(snapshot.document))) return doc;
|
||||
if (!snapshot || (value.dirty && canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false))) return doc;
|
||||
return null;
|
||||
}
|
||||
const legacy = storage.getItem(key);
|
||||
if (!legacy) return null;
|
||||
const doc = decodeCanvas(legacy);
|
||||
return !snapshot || canvasFingerprint(doc) !== canvasFingerprint(snapshot.document) ? doc : null;
|
||||
return !snapshot || canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false) ? doc : null;
|
||||
}
|
||||
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
|
||||
if (draft.schema_version < remote.schema_version) return false;
|
||||
|
||||
@@ -82,10 +82,10 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
export function managedImageSource(src?: string) {
|
||||
export function managedImageSource(src?: string, preview = false) {
|
||||
if (!src) return src;
|
||||
try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/);
|
||||
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1];
|
||||
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1] + (preview ? "?variant=preview" : "");
|
||||
} catch { /* inline and demo images are already browser-readable */ }
|
||||
return src;
|
||||
}
|
||||
|
||||
+5
-1
@@ -77,7 +77,11 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
data: { ...node.data, ...(node.data.kind === "frame" && node.data.subgraph && patch.width !== undefined && patch.height !== undefined ? { presentation: { ...node.data.presentation, [node.data.presentation?.collapsed ? "collapsedSize" : "expandedSize"]: { width: patch.width, height: patch.height } } } : {}), ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
|
||||
}) } };
|
||||
}
|
||||
case "viewport": return { ...state, present: { ...state.present, viewport: action.viewport } };
|
||||
case "viewport": {
|
||||
const before = state.present.viewport;
|
||||
if (Math.abs(before.x - action.viewport.x) < 1e-6 && Math.abs(before.y - action.viewport.y) < 1e-6 && Math.abs(before.zoom - action.viewport.zoom) < 1e-9) return state;
|
||||
return { ...state, present: { ...state.present, viewport: action.viewport } };
|
||||
}
|
||||
case "undo": {
|
||||
const last = state.past.at(-1);
|
||||
return last ? { past: state.past.slice(0, -1), present: { ...last, viewport: state.present.viewport }, future: [state.present, ...state.future] } : state;
|
||||
|
||||
Reference in New Issue
Block a user