Integrate Canvas V2 subgraphs, editable edges and AI runs
This commit is contained in:
@@ -3,10 +3,12 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const allowed =
|
||||
/^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/;
|
||||
const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/;
|
||||
const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/[a-zA-Z0-9_-]+)?$/;
|
||||
const bodyLimit = 1024 * 1024;
|
||||
|
||||
// Route Handlers do not have the Pages Router bodyParser size limit.
|
||||
async function readBody(req: NextRequest, limit = bodyLimit) {
|
||||
async function readBody(req: NextRequest, limit = bodyLimit, binary = false) {
|
||||
const reader = req.body?.getReader();
|
||||
if (!reader) return {};
|
||||
const chunks: Uint8Array[] = [];
|
||||
@@ -25,8 +27,9 @@ async function readBody(req: NextRequest, limit = bodyLimit) {
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
if (!size) return {};
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!size) return binary ? new Uint8Array() : {};
|
||||
const bytes = Buffer.concat(chunks);
|
||||
return binary ? bytes : JSON.parse(bytes.toString("utf8"));
|
||||
}
|
||||
|
||||
async function handler(
|
||||
@@ -46,11 +49,13 @@ async function handler(
|
||||
}
|
||||
}
|
||||
const path = (await params).path.join("/");
|
||||
if (!allowed.test(path)) return json({ error: "not_found" }, 404);
|
||||
if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404);
|
||||
const asset = assetRoute.test(path);
|
||||
const canvas = canvasRoute.test(path);
|
||||
let requestBody: unknown;
|
||||
if (req.method !== "GET") {
|
||||
try {
|
||||
requestBody = await readBody(req, path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit);
|
||||
requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit, asset);
|
||||
} catch (error) {
|
||||
const tooLarge = error instanceof Error && error.message === "body_too_large";
|
||||
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
|
||||
@@ -58,15 +63,16 @@ async function handler(
|
||||
}
|
||||
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": "application/json" };
|
||||
const headers: Record<string, string> = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" };
|
||||
if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2";
|
||||
const session = req.cookies.get("foundry_session")?.value;
|
||||
if (session) headers.Authorization = "Bearer " + session;
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) {
|
||||
for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key"]) {
|
||||
const value = req.headers.get(h);
|
||||
if (value !== null) headers[h] = value;
|
||||
}
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset"]) {
|
||||
for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor"]) {
|
||||
const values = req.nextUrl.searchParams.getAll(key);
|
||||
if (values.length === 1 && values[0].length < 300) query.set(key, values[0]);
|
||||
}
|
||||
@@ -78,14 +84,29 @@ async function handler(
|
||||
{
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === "GET" ? undefined : JSON.stringify(requestBody || {}),
|
||||
body: req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {}),
|
||||
signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]),
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const retryAfter = upstream.headers.get("retry-after");
|
||||
if (retryAfter) responseHeaders.set("Retry-After", retryAfter);
|
||||
const etag = upstream.headers.get("etag");
|
||||
if (etag) responseHeaders.set("ETag", etag);
|
||||
const schema = upstream.headers.get("x-canvas-schema-version");
|
||||
if (schema) responseHeaders.set("X-Canvas-Schema-Version", schema);
|
||||
const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER);
|
||||
if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) {
|
||||
const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https";
|
||||
responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure));
|
||||
}
|
||||
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);
|
||||
responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff");
|
||||
return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||
}
|
||||
const headersReceived = performance.now();
|
||||
const body = upstream.status === 204 ? null : await upstream.json();
|
||||
responseHeaders.set(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import { ArrowUp, Square, Sparkles } from "lucide-react";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { canvasPath } from "@/lib/canvas-sync";
|
||||
import { CANVAS_ACCEPT, managedImageSource } from "@/lib/canvas-v2";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import type { CanvasModel, useCanvasAI } from "./use-canvas-ai";
|
||||
import styles from "./canvas.module.css";
|
||||
export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType<typeof useCanvasAI>; save: (patch: NonNullable<CanvasNode["data"]["ai"]>) => void }) {
|
||||
const [models, setModels] = useState<CanvasModel[]>([]);
|
||||
const [model, setModel] = useState(node.data.ai?.modelId || "");
|
||||
const [prompt, setPrompt] = useState(node.data.ai?.prompt || "");
|
||||
const [propagate, setPropagate] = useState(node.data.ai?.propagate || false);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const image = ["image", "plain-image"].includes(node.data.kind);
|
||||
const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id));
|
||||
const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id);
|
||||
const busy = starting || step?.status === "queued" || step?.status === "running";
|
||||
useEffect(() => {
|
||||
if (!ai.live) { setLoading(false); return; }
|
||||
const controller = new AbortController();
|
||||
setLoading(true); setError("");
|
||||
api<{ models: CanvasModel[] }>(canvasPath(workspace, boardId) + "/ai/models", { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } }).then((result) => {
|
||||
const compatible = result.models.filter((model) => model.output_modalities.includes(image ? "image" : "text"));
|
||||
setModels(compatible); setModel((id) => compatible.some((model) => model.id === id) ? id : compatible.find((model) => model.available)?.id || "");
|
||||
}).catch((error) => { if (!controller.signal.aborted) setError(errorText(error)); }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [workspace, boardId, image, ai.live]);
|
||||
const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} });
|
||||
const persist = () => { if (model && ai.live) save(config()); };
|
||||
const output = run?.outputs?.find((item) => item.step_id === step?.step_id)?.output;
|
||||
const textOutput = run?.events?.filter((event) => event.type === "step.output" && event.node_id === node.id && event.board_id === boardId).at(-1)?.output?.text;
|
||||
return <NodeToolbar nodeId={id} isVisible position={Position.Bottom} offset={26} style={{ zIndex: 110 }}><section className={`${styles.aiPanel} nodrag nopan nowheel`} aria-label="元素 AI 工具"
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<header><Sparkles size={14} /><strong>{image ? "图像创作" : "文本创作"}</strong><span>{node.data.title}</span></header>
|
||||
<textarea aria-label="AI 指令" placeholder={image ? "描述你希望生成或修改的画面…" : "希望如何处理当前内容?"} value={prompt} maxLength={16000} onChange={(event) => setPrompt(event.target.value)} onBlur={persist} />
|
||||
<footer><select aria-label="AI 模型" disabled={loading || !ai.live || busy} value={model} onChange={(event) => { setModel(event.target.value); if (ai.live) save({ ...config(), modelId: event.target.value }); }}>{!model && <option value="">{loading ? "正在读取模型…" : "暂无可用模型"}</option>}{models.map((model) => <option key={model.id} value={model.id} disabled={!model.available}>{model.label || model.display_name || model.id}{!model.available ? " · 不可用" : ""}</option>)}</select>
|
||||
<label><input type="checkbox" aria-label="继续向下传播" disabled={!!busy} checked={propagate} onChange={(event) => { setPropagate(event.target.checked); if (model && ai.live) save({ ...config(), propagate: event.target.checked }); }} />继续向下传播</label>
|
||||
{busy && run ? <button aria-label="取消生成" title="取消生成" onClick={() => void ai.cancel(run).catch((error) => setError(errorText(error)))}><Square size={15} /></button> : <button aria-label="执行 AI" title="在当前元素生成结果" disabled={starting || !ai.live || !model || loading || !models.some((item) => item.id === model && item.available)} onClick={async () => {
|
||||
setStarting(true); setError(""); try { await ai.run(boardId, node.id, model, prompt, propagate); } catch (error) { setError(errorText(error)); } finally { setStarting(false); }
|
||||
}}><ArrowUp size={18} /></button>}
|
||||
</footer>
|
||||
<p role="status">{!ai.live ? "演示画布暂不执行生成" : ai.conflicts.has(boardId) ? "生成已完成,本地草稿存在冲突,尚未替换当前内容" : busy ? "正在生成,结果将显示在当前元素…" : step ? ({ succeeded: "已更新当前元素", conflict: "内容已改变,生成结果已保留", failed: "生成未完成,原内容已保留", cancelled: "已取消生成", skipped: "此步骤未执行" }[step.status] || step.status) : "结果将显示在当前元素"}</p>
|
||||
{(step?.status === "conflict" || ai.conflicts.has(boardId)) && output && run && <div className={styles.aiConflict}>{textOutput && <pre>{textOutput}</pre>}{output.src && <img src={managedImageSource(output.src)} alt="待应用的生成结果" />}<button onClick={() => void ai.apply(run, node.id, output.output_id, boardId).catch((error) => setError(errorText(error)))}>使用此结果替换当前内容</button></div>}
|
||||
{(error || ai.error) && <p role="alert">{error || errorText(new Error(ai.error))}</p>}
|
||||
</section></NodeToolbar>;
|
||||
}
|
||||
@@ -1,27 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { BaseEdge, useInternalNode, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
|
||||
import { useContext, useRef, useState, type PointerEvent } from "react";
|
||||
import { BaseEdge, useInternalNode, useReactFlow, useViewport, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { connectorBezier, nodeConnector, type ConnectorSide } from "@/lib/canvas-geometry";
|
||||
import { connectorBezier, nodeConnector, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
|
||||
import { curvePath, curveSegments, cubicPoint, insertCurveKnot, minus, plus } from "@/lib/canvas-curves";
|
||||
import type { CanvasEdgeModel, EdgeMarker } from "@/lib/canvas-v2";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { EdgeToolbarControls } from "./edge-toolbar";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
function connector(node: InternalNode<CanvasNode>, handle: string | null | undefined, fallback: ConnectorSide) {
|
||||
const side = handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : fallback;
|
||||
return nodeConnector({ ...node, position: node.internals.positionAbsolute }, side);
|
||||
function connector(node: InternalNode<CanvasNode>, handle: string | null | undefined, fallback: ConnectorSide): Connector {
|
||||
const port = node.data.ports?.find((port) => `port:${port.id}` === handle);
|
||||
const side = handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : port?.direction === "input" ? "left" : port ? "right" : fallback;
|
||||
const result = nodeConnector({ ...node, position: node.internals.positionAbsolute }, side);
|
||||
if (port) {
|
||||
const peers = node.data.ports!.filter((item) => item.direction === port.direction);
|
||||
result.point.y += ((peers.indexOf(port) + 1) / (peers.length + 1) - .5) * (node.measured?.height || node.height || 400);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function CanvasEdge(props: EdgeProps) {
|
||||
const source = useInternalNode<CanvasNode>(props.source);
|
||||
const target = useInternalNode<CanvasNode>(props.target);
|
||||
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
|
||||
const type = value?.type || "none";
|
||||
if (type === "none") return null;
|
||||
return <marker id={id} viewBox="0 0 12 12" markerWidth={value?.size || 12} markerHeight={value?.size || 12} refX={11} refY={6} orient="auto-start-reverse" markerUnits="userSpaceOnUse">
|
||||
<path d={type === "triangle" ? "M1 1L11 6L1 11Z" : type === "open-arrow" ? "M1 1L11 6L1 11" : type === "diamond" ? "M1 6L6 1L11 6L6 11Z" : type === "square" ? "M1 1H11V11H1Z" : "M11 6a5 5 0 1 0-10 0a5 5 0 1 0 10 0"} fill={type === "open-arrow" ? "none" : value?.filled !== false ? color : "#fffefb"} stroke={color} strokeWidth={1.2} />
|
||||
</marker>;
|
||||
}
|
||||
export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
const source = useInternalNode<CanvasNode>(props.source), target = useInternalNode<CanvasNode>(props.target);
|
||||
const editing = useContext(EdgeEditing);
|
||||
const flow = useReactFlow();
|
||||
const { zoom } = useViewport();
|
||||
const reduce = useReducedMotion();
|
||||
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
|
||||
const gesture = useRef<{ id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
|
||||
if (!source || !target) return null;
|
||||
const from = connector(source, props.sourceHandleId, "right");
|
||||
const to = connector(target, props.targetHandleId, "left");
|
||||
return <BaseEdge id={props.id} path={connectorBezier(from, to)} style={{ ...props.style, strokeLinecap: "round" }} markerStart={props.markerStart} markerEnd={props.markerEnd} interactionWidth={props.interactionWidth} />;
|
||||
const from = connector(source, props.sourceHandleId, "right"), to = connector(target, props.targetHandleId, "left");
|
||||
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
|
||||
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
|
||||
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
||||
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
|
||||
const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
||||
const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id));
|
||||
const knots = data.path?.knots || [];
|
||||
function add(point?: { x: number; y: number }) {
|
||||
if (knots.length >= 64) return;
|
||||
const next = insertCurveKnot(from, to, data.path, point);
|
||||
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
|
||||
}
|
||||
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["part"]) {
|
||||
event.preventDefault(); event.stopPropagation(); editing.begin(props.id); gesture.current = { id, part }; setSelectedKnot(id);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<SVGCircleElement>) {
|
||||
const current = gesture.current;
|
||||
if (!current) return;
|
||||
event.stopPropagation();
|
||||
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
|
||||
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
|
||||
else {
|
||||
const knot = next.knots.find((knot) => knot.id === current.id);
|
||||
if (!knot) return;
|
||||
if (current.part === "position") knot.position = point;
|
||||
else {
|
||||
const vector = minus(point, knot.position), other = current.part === "in" ? "out" : "in";
|
||||
const length = Math.hypot(knot[other].x, knot[other].y), nextLength = Math.hypot(vector.x, vector.y);
|
||||
knot[current.part] = vector;
|
||||
if (event.shiftKey) knot.mode = "symmetric";
|
||||
if (knot.mode !== "corner") {
|
||||
const scale = knot.mode === "symmetric" ? 1 : nextLength ? length / nextLength : 0;
|
||||
knot[other] = { x: -vector.x * scale, y: -vector.y * scale };
|
||||
}
|
||||
}
|
||||
}
|
||||
editing.patch(props.id, { ...data, path: next }, true);
|
||||
}
|
||||
function stop(event: PointerEvent<SVGCircleElement>) {
|
||||
event.stopPropagation(); gesture.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const control = (point: { x: number; y: number }, id: string, part: NonNullable<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
|
||||
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={() => { gesture.current = null; }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation(); const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
||||
if (delta) { event.preventDefault(); const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); const vector = { x: delta[0] * (event.shiftKey ? 10 : 1), y: delta[1] * (event.shiftKey ? 10 : 1) };
|
||||
if (part === "position") { const knot = next.knots.find((knot) => knot.id === id); if (knot) knot.position = plus(knot.position, vector); }
|
||||
else if (part === "startControl" || part === "endControl") next[part] = plus(minus(point, part === "startControl" ? from.point : to.point), vector);
|
||||
else return;
|
||||
editing.patch(props.id, { ...data, path: next });
|
||||
}
|
||||
}} />;
|
||||
return <g onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); add(flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })); }}>
|
||||
<defs><Marker id={markerId + "-head"} value={appearance.head} color={color} /><Marker id={markerId + "-tail"} value={appearance.tail} color={color} /></defs>
|
||||
{props.selected && <path d={path} fill="none" stroke="#a47764" strokeOpacity={.18} strokeWidth={width + 7 / zoom} pointerEvents="none" />}
|
||||
<BaseEdge id={props.id} path={path} style={{ stroke: color, strokeWidth: width, opacity: appearance.opacity ?? 1, strokeDasharray: appearance.line === "dashed" ? "8 5" : appearance.line === "dotted" ? "1 5" : undefined, strokeLinecap: "round" }} markerStart={appearance.tail?.type && appearance.tail.type !== "none" ? `url(#${markerId}-tail)` : undefined} markerEnd={appearance.head?.type && appearance.head.type !== "none" ? `url(#${markerId}-head)` : undefined} interactionWidth={Math.max(20 / zoom, width + 8)} />
|
||||
{animated && (animation.effect === "flow" || !animation.effect ? <g pointerEvents="none"><circle r={Math.max(3, width * 1.5)} fill={animation.color || "#fff"}><animateMotion path={path} dur={`${animation.durationMs || 1600}ms`} repeatCount="indefinite" keyPoints={animation.direction === "reverse" ? "1;0" : "0;1"} keyTimes="0;1" calcMode="linear" /></circle></g> : <path d={path} fill="none" pointerEvents="none" stroke={animation.color || "#fff"} strokeWidth={width + 1} strokeDasharray={animation.effect === "dash" ? "6 14" : undefined} className={animation.effect === "dash" ? styles.edgeDashAnimation : styles.edgePulseAnimation} style={{ animationDuration: `${animation.durationMs || 1600}ms`, animationDirection: animation.direction === "reverse" ? "reverse" : "normal" }} />)}
|
||||
{props.selected && <>
|
||||
<EdgeToolbarControls id={props.id} x={midpoint.x} y={midpoint.y - 24 / zoom} data={data} change={(data) => editing.patch(props.id, data)} count={knots.length} knotMode={(knots.find((knot) => knot.id === selectedKnot) || knots.at(-1))?.mode} changeKnotMode={(mode) => {
|
||||
const next = structuredClone(data.path!); const knot = next.knots.find((knot) => knot.id === selectedKnot) || next.knots.at(-1); if (!knot) return;
|
||||
knot.mode = mode; if (mode !== "corner") {
|
||||
if (!Math.hypot(knot.out.x, knot.out.y)) knot.out = { x: -knot.in.x, y: -knot.in.y };
|
||||
const length = Math.hypot(knot.out.x, knot.out.y), scale = mode === "symmetric" ? 1 : length ? Math.hypot(knot.in.x, knot.in.y) / length : 0;
|
||||
knot.in = { x: -knot.out.x * scale, y: -knot.out.y * scale };
|
||||
}
|
||||
editing.patch(props.id, { ...data, path: next });
|
||||
}} add={() => add()} remove={() => { const id = selectedKnot || knots.at(-1)?.id; editing.patch(props.id, { ...data, path: { ...data.path!, knots: knots.filter((knot) => knot.id !== id) } }); setSelectedKnot(null); }} />
|
||||
{knots.map((knot, index) => <g key={knot.id} onDoubleClick={(event) => event.stopPropagation()}>
|
||||
{control(knot.position, knot.id, "position", `曲线点 ${index + 1}`)}
|
||||
{selectedKnot === knot.id && <><path d={`M${plus(knot.position, knot.in).x},${plus(knot.position, knot.in).y}L${knot.position.x},${knot.position.y}L${plus(knot.position, knot.out).x},${plus(knot.position, knot.out).y}`} fill="none" stroke="#a47764" strokeWidth={1 / zoom} pointerEvents="none" />{control(plus(knot.position, knot.in), knot.id, "in", "进入控制柄")}{control(plus(knot.position, knot.out), knot.id, "out", "离开控制柄")}</>}
|
||||
</g>)}
|
||||
{control(segments[0][1], "start", "startControl", "起点控制柄")}
|
||||
{control(segments.at(-1)![2], "end", "endControl", "终点控制柄")}
|
||||
</>}
|
||||
</g>;
|
||||
}
|
||||
|
||||
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
|
||||
const from = connector(props.fromNode, props.fromHandle.id, "right");
|
||||
const to = props.toNode && props.toHandle
|
||||
? connector(props.toNode, props.toHandle.id, "left")
|
||||
: { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
||||
const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
||||
return <path fill="none" className="react-flow__connection-path" d={connectorBezier(from, to)} style={{ stroke: "#aa968b", strokeWidth: 1.5, ...props.connectionLineStyle, strokeLinecap: "round" }} />;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st
|
||||
element?.focus({ preventScroll: true });
|
||||
element?.setSelectionRange(element.value.length, element.value.length);
|
||||
}, [editing]);
|
||||
function begin() { actions.begin(); setEditing(true); }
|
||||
function begin() { actions.begin(id); setEditing(true); }
|
||||
const active = editing && selected;
|
||||
const label = plain ? "纯文本" : data.title;
|
||||
return <div className={`${styles.textEditor} ${active ? "nodrag" : ""}`}>
|
||||
|
||||
@@ -135,3 +135,55 @@
|
||||
.plainImage { width: 100%; height: 100%; display: block; object-fit: contain; pointer-events: none; user-select: none; }
|
||||
.nodeTitle { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nodeTitleInput { width: 180px; min-width: 0; height: 22px; padding: 0 4px; border: 1px solid #cbb8a8; border-radius: 4px; background: #fffefb; font: inherit; color: #78523f; }
|
||||
|
||||
.frameLabelSegments { position: relative; display: flex; padding: 3px; border-radius: 6px; background: #f1eee8; isolation: isolate; }
|
||||
.frameLabelSegments button { position: relative; z-index: 1; width: 28px; height: 28px; flex: 0 0 28px; background: transparent !important; color: #92877c; }
|
||||
.frameLabelSegments button[aria-checked="true"] { color: #78523f; }
|
||||
.frameLabelSlider { position: absolute; left: 3px; top: 3px; width: 28px; height: 28px; border-radius: 4px; background: #fffefb; box-shadow: 0 1px 4px #32251a16; transition: transform 160ms ease; }
|
||||
.resizeHandle { position: absolute; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1px solid #a47764; border-radius: 2px; padding: 0; background: #fffefb; z-index: 12; touch-action: none; pointer-events: auto; }
|
||||
.handMode .resizeHandle { pointer-events: none; }
|
||||
.edgeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 7px; max-width: min(680px, calc(100vw - 24px)); padding: 10px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a14; }
|
||||
.edgeToolbar label { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||
.edgeToolbar input[type="number"] { width: 50px; }
|
||||
.edgeToolbar input[type="color"] { width: 24px; height: 24px; padding: 0; border: 0; background: transparent; }
|
||||
.edgeToolbar input[type="range"] { width: 55px; }
|
||||
.edgeToolbar select { max-width: 92px; height: 26px; padding: 0 4px; background: #f4f0eb; border-radius: 4px; font-size: 11px; }
|
||||
.edgeToolbar button { padding: 6px; border-radius: 4px; }
|
||||
.edgeToolbar button:hover { background: #f1e6df; }
|
||||
.edgeToolbar button:disabled { opacity: .4; }
|
||||
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
|
||||
.curveControl { cursor: move; touch-action: none; }
|
||||
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
||||
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
||||
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
||||
@keyframes edgePulse { 0%, 100% { opacity: .1; } 50% { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .frameLabelSlider { transition: none; } .edgeDashAnimation, .edgePulseAnimation { animation: none; } }
|
||||
.canvasBusy { position: absolute; inset: 0; display: flex; align-items: start; justify-content: center; padding-top: 80px; z-index: 200; background: #fffefb35; cursor: progress; color: #78523f; font-size: 12px; }
|
||||
.subgraphScale { display: flex; align-items: center; gap: 2px; font-size: 11px; }
|
||||
.subgraphScale input { width: 45px; }
|
||||
.collapsedSubgraph { position: absolute; inset: 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #92877c; pointer-events: auto; font-size: 12px; }
|
||||
.collapsedSubgraph small { font-size: 10px; }
|
||||
.aiPanel { width: min(440px, calc(100vw - 28px)); border: 1px solid #ded7ce; border-radius: 13px; padding: 14px; background: #fffefb; box-shadow: 0 8px 30px #32251a16; color: #49372e; }
|
||||
.aiPanel header { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 9px; }
|
||||
.aiPanel header span { margin-left: auto; color: #92877c; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
|
||||
.aiPanel textarea { width: 100%; height: 92px; resize: vertical; padding: 4px; border: 0; outline: none; background: transparent; font-size: 13px; }
|
||||
.aiPanel footer { display: flex; align-items: center; gap: 8px; border-top: 1px solid #eee7de; padding-top: 9px; }
|
||||
.aiPanel footer select { max-width: 200px; min-width: 0; flex: 1; height: 28px; background: transparent; font-size: 11px; }
|
||||
.aiPanel footer label { display: flex; align-items: center; gap: 4px; font-size: 10px; white-space: nowrap; }
|
||||
.aiPanel footer button { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; border-radius: 8px; background: #78523f; color: white; }
|
||||
.aiPanel footer button:disabled { opacity: .35; }
|
||||
.aiPanel p { margin: 7px 0 0; color: #92877c; font-size: 10px; }
|
||||
.aiPanel p[role="alert"] { color: #a25439; }
|
||||
.aiConflict { max-height: 180px; overflow: auto; padding: 8px; border-radius: 7px; background: #f4eee7; font-size: 11px; }
|
||||
.aiConflict pre { white-space: pre-wrap; }
|
||||
.aiConflict img { max-height: 100px; }
|
||||
.aiConflict button { padding: 6px; color: #78523f; }
|
||||
.transformFrame :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; border: 0; background: #a47764; opacity: 0; pointer-events: none; }
|
||||
.selectedFrame :global(.react-flow__handle).centerHandle { opacity: 1; }
|
||||
.transformFrame :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
|
||||
.framePort { width: 8px; height: 8px; border: 1px solid #fffefb; background: #a47764; pointer-events: auto; }
|
||||
.framePort { opacity: 0; pointer-events: none; }
|
||||
.selectedFrame .framePort { opacity: 1; }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import styles from "./canvas.module.css";
|
||||
export function ContentHandles({ selected }: { selected: boolean }) {
|
||||
return <>{([["top", Position.Top], ["right", Position.Right], ["bottom", Position.Bottom], ["left", Position.Left]] as const).map(([id, position]) => <Handle key={id} id={id} type="source" position={position} className={styles.centerHandle} isConnectableStart={selected} aria-hidden={!selected} aria-label={`从 ${id} 连接元素`} />)}</>;
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import styles from "./canvas.module.css";
|
||||
export function DraftRecovery({ restore, discard }: { restore: () => void; discard: () => void }) {
|
||||
export function DraftRecovery({ restore, discard, canRestore = true, exportDraft }: { restore: () => void; discard: () => void; canRestore?: boolean; exportDraft?: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => { const element = dialog.current; element?.showModal(); return () => element?.close(); }, []);
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(<dialog ref={dialog} className={styles.recoveryDialog} aria-labelledby="draft-recovery-title" onCancel={(event) => event.preventDefault()}>
|
||||
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。</p>
|
||||
<div><button type="button" onClick={restore}>恢复本地草稿</button><button type="button" onClick={discard}>使用服务端版本</button></div>
|
||||
<h2 id="draft-recovery-title">发现未同步的本地草稿</h2><p>{canRestore ? "服务端画布已读取。恢复草稿会以本地内容替换当前服务端版本,请选择要继续编辑的版本。" : "画布的子图关系已更新,这份旧草稿不能直接覆盖。可以先导出草稿,再使用服务端版本继续编辑。"}</p>
|
||||
<div>{canRestore && <button type="button" onClick={restore}>恢复本地草稿</button>}{exportDraft && <button type="button" onClick={exportDraft}>导出本地草稿</button>}<button type="button" onClick={discard}>使用服务端版本</button></div>
|
||||
</dialog>, document.body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
import { createContext } from "react";
|
||||
import type { EdgeData } from "@/lib/canvas-v2";
|
||||
export const EdgeEditing = createContext({ begin: (_id?: string) => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() });
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import { EdgeToolbar } from "@xyflow/react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { EdgeData, EdgeMarker, MarkerShape } from "@/lib/canvas-v2";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
|
||||
const a = data.appearance || {}, animation = data.animation || {};
|
||||
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
||||
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
||||
const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]];
|
||||
return <EdgeToolbar edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<label>颜色<input aria-label="连线颜色" type="color" value={a.color || "#aa968b"} onChange={(event) => appearance({ color: event.target.value })} /></label>
|
||||
<label>粗细<input aria-label="连线粗细" type="number" min={.5} max={20} step={.5} value={a.width ?? 1.5} onChange={(event) => { const value = +event.target.value; if (value >= .5 && value <= 20) appearance({ width: value }); }} /></label>
|
||||
<label>线条<select aria-label="线条类型" value={a.line || "solid"} onChange={(event) => appearance({ line: event.target.value as typeof a.line })}><option value="solid">实线</option><option value="dashed">虚线</option><option value="dotted">点线</option></select></label>
|
||||
{(["head", "tail"] as const).map((end) => <label key={end}>{end === "head" ? "箭头" : "箭尾"}<select aria-label={end === "head" ? "箭头类型" : "箭尾类型"} value={a[end]?.type || "none"} onChange={(event) => appearance({ [end]: { ...a[end], type: event.target.value as EdgeMarker["type"] } })}>{markers.map(([id, label]) => <option key={id} value={id}>{label}</option>)}</select>
|
||||
<input aria-label={end === "head" ? "箭头大小" : "箭尾大小"} type="number" min={4} max={32} value={a[end]?.size || 12} onChange={(event) => { const size = +event.target.value; if (size >= 4 && size <= 32) appearance({ [end]: { ...a[end], type: a[end]?.type || "none", size } }); }} />
|
||||
<input aria-label={end === "head" ? "填充箭头" : "填充箭尾"} type="checkbox" checked={a[end]?.filled ?? true} onChange={(event) => appearance({ [end]: { ...a[end], type: a[end]?.type || "none", filled: event.target.checked } })} /></label>)}
|
||||
<label>透明度<input aria-label="连线透明度" type="range" min={.05} max={1} step={.05} value={a.opacity ?? 1} onChange={(event) => appearance({ opacity: +event.target.value })} /></label>
|
||||
<label>动画<select aria-label="动画播放时机" value={animation.mode || "off"} onChange={(event) => animate({ mode: event.target.value as typeof animation.mode })}><option value="off">关闭</option><option value="always">始终播放</option><option value="executing">仅执行时</option></select></label>
|
||||
<label>效果<select aria-label="连线动画效果" value={animation.effect || "flow"} onChange={(event) => animate({ effect: event.target.value as typeof animation.effect })}><option value="flow">流光</option><option value="dash">流动虚线</option><option value="pulse">脉冲</option></select></label>
|
||||
<label>方向<select aria-label="动画方向" value={animation.direction || "forward"} onChange={(event) => animate({ direction: event.target.value as typeof animation.direction })}><option value="forward">正向</option><option value="reverse">反向</option></select></label>
|
||||
<label>时长<input aria-label="动画时长" title="毫秒" type="number" min={250} max={30000} step={250} value={animation.durationMs || 1600} onChange={(event) => { const durationMs = +event.target.value; if (durationMs >= 250 && durationMs <= 30000) animate({ durationMs }); }} /></label>
|
||||
<label>光色<input aria-label="动画颜色" type="color" value={animation.color || "#ffffff"} onChange={(event) => animate({ color: event.target.value })} /></label>
|
||||
<label><input aria-label="参与下游传播" type="checkbox" checked={data.execution?.enabled !== false} onChange={(event) => change({ ...data, execution: { enabled: event.target.checked } })} />传递</label>
|
||||
<label>曲线点<select aria-label="曲线点控制模式" disabled={!count} value={knotMode || "smooth"} onChange={(event) => changeKnotMode(event.target.value as "corner" | "smooth" | "symmetric")}><option value="corner">独立控制柄</option><option value="smooth">平滑</option><option value="symmetric">镜像</option></select></label>
|
||||
<button type="button" aria-label="添加曲线点" title="也可双击连线添加曲线点" disabled={count >= 64} onClick={add}><Plus size={14} /></button>
|
||||
<button type="button" aria-label="删除选中曲线点" disabled={!count} onClick={remove}><Trash2 size={14} /></button>
|
||||
<small>拖动锚点或控制柄 · 双击添加 · Shift 镜像控制柄</small>
|
||||
</div></EdgeToolbar>;
|
||||
}
|
||||
@@ -1,77 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { EyeOff, Scaling, Type } from "lucide-react";
|
||||
import type { FrameLabelMode } from "@/lib/canvas-elements";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const options = [
|
||||
{ value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称" },
|
||||
{ value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小" },
|
||||
{ value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小" },
|
||||
{ value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称", Icon: EyeOff },
|
||||
{ value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小", Icon: Type },
|
||||
{ value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小", Icon: Scaling },
|
||||
] as const;
|
||||
|
||||
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const items = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const initialIndex = useRef(0);
|
||||
const current = options.findIndex((option) => option.value === value);
|
||||
function show(index = current) { initialIndex.current = index; setOpen(true); }
|
||||
function close(focus = false) { setOpen(false); if (focus) trigger.current?.focus(); }
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const bounds = trigger.current!.getBoundingClientRect();
|
||||
const height = menu.current!.getBoundingClientRect().height;
|
||||
setPosition({ left: Math.max(8, Math.min(bounds.left, window.innerWidth - 228)), top: Math.max(8, bounds.bottom + height + 8 <= window.innerHeight ? bounds.bottom + 6 : bounds.top - height - 6) });
|
||||
items.current[initialIndex.current]?.focus({ preventScroll: true });
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!trigger.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) close();
|
||||
};
|
||||
const dismiss = (event: Event) => {
|
||||
if (event.target instanceof Node && menu.current?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
document.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("resize", dismiss);
|
||||
window.addEventListener("scroll", dismiss, true);
|
||||
window.addEventListener("wheel", dismiss, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", outside, true);
|
||||
window.removeEventListener("resize", dismiss);
|
||||
window.removeEventListener("scroll", dismiss, true);
|
||||
window.removeEventListener("wheel", dismiss, true);
|
||||
};
|
||||
}, [open]);
|
||||
return <>
|
||||
<button ref={trigger} type="button" className={styles.frameModeTrigger} aria-label="分区名称显示模式" aria-haspopup="menu" aria-expanded={open} aria-controls={open ? id : undefined}
|
||||
onClick={() => open ? close() : show()} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); show(event.key === "ArrowUp" ? options.length - 1 : current); }
|
||||
}}>{options[current].label}<ChevronDown size={13} /></button>
|
||||
{open && createPortal(<div ref={menu} id={id} className={`${styles.frameModeMenu} nodrag nopan nowheel`} style={position} role="menu" aria-label="分区名称显示模式选项"
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
|
||||
onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) close(); }}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
const index = items.current.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (event.key === "Escape") { event.preventDefault(); close(true); }
|
||||
else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length;
|
||||
items.current[next]?.focus({ preventScroll: true });
|
||||
}
|
||||
}}>
|
||||
{options.map((option, index) => <button key={option.value} ref={(element) => { items.current[index] = element; }} type="button" role="menuitemradio" aria-checked={value === option.value} tabIndex={-1}
|
||||
onClick={() => { change(option.value); close(true); }}>
|
||||
<span className={styles.frameModeCheck}>{value === option.value && <Check size={14} />}</span><span><strong>{option.label}</strong><small>{option.hint}</small></span>
|
||||
</button>)}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
const current = Math.max(0, options.findIndex((option) => option.value === value));
|
||||
return <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
|
||||
const delta = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (delta !== undefined || event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault(); event.stopPropagation(); const index = event.key === "Home" ? 0 : event.key === "End" ? 2 : (current + delta! + 3) % 3;
|
||||
change(options[index].value); items.current[index]?.focus();
|
||||
}
|
||||
}}><span className={styles.frameLabelSlider} style={{ transform: `translateX(${current * 28}px)` }} />
|
||||
{options.map(({ value: mode, label, hint, Icon }, index) => <button key={mode} ref={(element) => { items.current[index] = element; }} role="radio" aria-checked={value === mode} aria-label={label} title={`${label}:${hint}`} tabIndex={value === mode ? 0 : -1} onClick={() => change(mode)}><Icon size={15} /></button>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
|
||||
import { FoldVertical, UnfoldVertical, ExternalLink } from "lucide-react";
|
||||
import { Handle, NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { NodeEditing, TransformableNode } from "./transformable-node";
|
||||
import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node";
|
||||
import { FrameFeedback } from "./frame-feedback";
|
||||
import { FrameLabelMenu } from "./frame-label-menu";
|
||||
import styles from "./canvas.module.css";
|
||||
@@ -15,7 +16,6 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
const feedback = useContext(FrameFeedback);
|
||||
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
|
||||
const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length;
|
||||
const count = feedback.counts.get(id) || 0;
|
||||
const { zoom } = useViewport();
|
||||
const reduce = useReducedMotion();
|
||||
const [title, setTitle] = useState(data.title);
|
||||
@@ -35,13 +35,27 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
|
||||
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.currentTarget.blur(); } if (event.key === "Escape") { cancelBlur.current = true; setTitle(data.title); event.currentTarget.blur(); } }} />
|
||||
<FrameLabelMenu value={mode} change={(labelMode) => editing.frame(id, { labelMode })} />
|
||||
<span className={styles.frameMemberCount}>{count} 项内容</span>
|
||||
<ResizeToggle id={id} />
|
||||
{data.subgraph && <><button type="button" aria-label={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} title={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} onClick={() => {
|
||||
const collapsed = !data.presentation?.collapsed;
|
||||
const currentSize = { width: (props.width || 640) / (data.viewScale || 1), height: (props.height || 400) / (data.viewScale || 1) };
|
||||
const presentation = { ...data.presentation, collapsed, [collapsed ? "expandedSize" : "collapsedSize"]: currentSize };
|
||||
editing.frame(id, { presentation });
|
||||
}}>{data.presentation?.collapsed ? <UnfoldVertical size={16} /> : <FoldVertical size={16} />}</button>
|
||||
<label className={styles.subgraphScale} title="子画布概览比例,不修改内容尺寸"><input aria-label="子画布概览比例" type="number" min={5} max={400} step={5} value={Math.round((data.presentation?.contentScale || 1) * 100)} onChange={(event) => { const scale = +event.target.value / 100; if (scale >= .05 && scale <= 4) editing.frame(id, { presentation: { ...data.presentation, contentScale: scale } }); }} />%</label>
|
||||
<a href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} aria-label="打开子画布" title="打开子画布"><ExternalLink size={15} /></a></>}
|
||||
|
||||
</motion.div>}</AnimatePresence>
|
||||
</NodeToolbar>
|
||||
<TransformableNode {...props} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
|
||||
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / zoom : 1 }} title={data.title}>{data.title}<span className={styles.frameMemberCount}> · {count} 项</span></div>}
|
||||
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / zoom }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
|
||||
{data.subgraph && data.presentation?.collapsed && <a className={styles.collapsedSubgraph} href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} onPointerDown={(event) => event.stopPropagation()}><FoldVertical size={22} /><span>{data.title}</span><small>打开子画布</small></a>}
|
||||
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / (zoom * (data.viewScale || 1)) : 1 }} title={data.title}>{data.title}</div>}
|
||||
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / (zoom * (data.viewScale || 1)) }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
|
||||
{data.ports?.map((port) => {
|
||||
const peers = data.ports!.filter((item) => item.direction === port.direction);
|
||||
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
|
||||
})}
|
||||
</TransformableNode>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react";
|
||||
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,
|
||||
} from "@xyflow/react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Frame, Image as ImageIcon, Type, Undo2, Upload } from "lucide-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";
|
||||
import dynamic from "next/dynamic";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode, type NodeDirection } from "@/lib/canvas";
|
||||
import { errorText } from "@/lib/api";
|
||||
import { readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import { useCanvasPersistence } from "./use-canvas-persistence";
|
||||
import { branchCanvasNode, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasNode, type NodeDirection } from "@/lib/canvas";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { readLocalRecovery, canRestoreCanvasDraft, type CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import { useCanvasTree } from "./use-canvas-tree";
|
||||
import type { CanvasAction } from "@/lib/canvas";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import { useCanvasAI } from "./use-canvas-ai";
|
||||
import { AIPanel } from "./ai-panel";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { CanvasEdge, CanvasConnectionLine } from "./canvas-edge";
|
||||
import { BranchMenu, type BranchRequest } from "./branch-menu";
|
||||
import { IconNode } from "./icon-node";
|
||||
import { FrameFeedback } from "./frame-feedback";
|
||||
import { subgraphMovePath, subgraphMoveParticipants, subgraphParents, exposedConnection, connectionTransactionPlan } from "@/lib/canvas-subgraphs";
|
||||
import { frameDropFeedback } from "@/lib/canvas-frames";
|
||||
import { DraftRecovery } from "./draft-recovery";
|
||||
import { FrameNode } from "./frame-node";
|
||||
@@ -44,14 +49,16 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return { document: snapshot?.document || local || emptyCanvas(), recovery: snapshot ? local : null, error: "" };
|
||||
} catch { return { document: snapshot?.document || emptyCanvas(), recovery: null, error: "无法读取此浏览器的图谱草稿,可通过导入恢复备份。" }; }
|
||||
});
|
||||
const [state, dispatch] = useReducer(canvasReducer, { past: [], present: initial.document, future: [] } satisfies CanvasHistory);
|
||||
|
||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||
const [error, setError] = useState(initial.error);
|
||||
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
const [resizing, setResizing] = useState<string | null>(null);
|
||||
const [rotating, setRotating] = useState<string | null>(null);
|
||||
const [draggedIds, setDraggedIds] = useState<string[]>([]);
|
||||
const [membershipNotice, setMembershipNotice] = useState("");
|
||||
const dragSession = useRef<"idle" | "active" | "finishing">("idle");
|
||||
const dragOrigins = useRef(new Map<string, XYPosition>());
|
||||
const draggingFrame = useRef(false);
|
||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||
@@ -61,7 +68,17 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const projectInput = useRef<HTMLInputElement>(null);
|
||||
const [recovery, setRecovery] = useState(initial.recovery);
|
||||
const sync = useCanvasPersistence(state.present, board.workspace_id, board.id, storageKey, snapshot, Boolean(recovery));
|
||||
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id);
|
||||
const childRecovery = [...tree.recoveries][0];
|
||||
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
|
||||
const sync = tree;
|
||||
const ai = useCanvasAI(board.workspace_id, board.id, !!snapshot, tree);
|
||||
const state = { present: { ...tree.rootDocument, nodes: tree.views.nodes, edges: tree.views.edges } };
|
||||
const treeRef = useRef(tree); treeRef.current = tree;
|
||||
function dispatch(action: CanvasAction) {
|
||||
if (action.type === "undo" || action.type === "redo") { void treeRef.current[action.type]().catch((error) => setError(error.message)); return; }
|
||||
treeRef.current.dispatch(action);
|
||||
}
|
||||
useEffect(() => {
|
||||
const input = imageInput.current;
|
||||
const cancel = () => { pendingImage.current = null; };
|
||||
@@ -85,22 +102,33 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const latest = useRef(state.present);
|
||||
latest.current = state.present;
|
||||
|
||||
const select = useCallback((id: string) => dispatch({ type: "nodes", changes: latest.current.nodes.map((node) => ({ id: node.id, type: "select" as const, selected: node.id === id })) }), []);
|
||||
const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []);
|
||||
const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []);
|
||||
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => {
|
||||
setBranchRequest({ id, direction, point });
|
||||
setRotating(null);
|
||||
const select = useCallback((id: string) => treeRef.current.select(id), []);
|
||||
const beginEdit = useCallback((id?: string) => { const current = treeRef.current; const ref = id ? current.views.nodeRefs.get(id) || current.views.edgeRefs.get(id) : undefined; current.dispatch({ type: "checkpoint" }, ref?.boardId || board.id); }, [board.id]);
|
||||
const edit = useCallback((id: string, text: string) => treeRef.current.nodeAction(id, (id) => ({ type: "edit", id, text })), []);
|
||||
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => { setBranchRequest({ id, direction, point }); setRotating(null); setResizing(null); }, []);
|
||||
const transform = useCallback((id: string, transform: NodeTransform) => {
|
||||
const ref = treeRef.current.views.nodeRefs.get(id); if (!ref) return;
|
||||
treeRef.current.nodeAction(id, (id) => ({ type: "transform", id, transform: { ...transform,
|
||||
...(transform.position ? { position: { x: (transform.position.x - ref.origin.x) / ref.scale, y: (transform.position.y - ref.origin.y) / ref.scale } } : {}),
|
||||
...(transform.width !== undefined ? { width: transform.width / ref.scale, height: transform.height! / ref.scale } : {}) } }));
|
||||
}, []);
|
||||
const transform = useCallback((id: string, transform: NodeTransform) => dispatch({ type: "transform", id, transform }), []);
|
||||
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => dispatch({ type: "style", id, style, fontSize }), []);
|
||||
const rename = useCallback((id: string, title: string) => dispatch({ type: "rename-node", id, title }), []);
|
||||
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode }) => dispatch({ type: "frame", id, ...patch }), []);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, rotating, rotate: setRotating }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating]);
|
||||
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => treeRef.current.nodeAction(id, (id) => ({ type: "style", id, style, fontSize })), []);
|
||||
const rename = useCallback((id: string, title: string) => treeRef.current.nodeAction(id, (id) => ({ type: "rename-node", id, title })), []);
|
||||
const frame = useCallback((id: string, patch: { title?: string; labelMode?: FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {
|
||||
const current = treeRef.current, ref = current.views.nodeRefs.get(id); if (!ref) return;
|
||||
const document = current.documents.get(ref.boardId)!;
|
||||
current.dispatch({ type: "replace", document: { ...document, schema_version: 2, nodes: document.nodes.map((node) => {
|
||||
if (node.id !== ref.id) return node;
|
||||
const toggle = patch.presentation && !!patch.presentation.collapsed !== !!node.data.presentation?.collapsed;
|
||||
const size = toggle ? patch.presentation!.collapsed ? node.data.presentation?.collapsedSize || { width: 240, height: 140 } : node.data.presentation?.expandedSize || { width: 640, height: 400 } : undefined;
|
||||
return { ...node, ...(size ? { ...size, style: { ...node.style, ...size } } : {}), data: { ...node.data, ...patch } };
|
||||
}) } }, ref.boardId);
|
||||
}, []);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, select, edit, branch, transform, style, rename, frame, workspace: board.workspace_id, schema: 2 as const, rotating, resizing, resize: (id: string | null) => { setResizing(id); setRotating(null); }, rotate: (id: string | null) => { setRotating(id); setResizing(null); } }), [beginEdit, select, edit, branch, transform, style, rename, frame, rotating, resizing, board.workspace_id, state.present.schema_version]);
|
||||
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
|
||||
try {
|
||||
const document = branchCanvasNode(state.present, request.id, request.direction, kind, src, request.at);
|
||||
dispatch({ type: "replace", document });
|
||||
const document = branchCanvasNode(tree.documents.get(tree.views.nodeRefs.get(request.id)!.boardId)!, tree.views.nodeRefs.get(request.id)!.id, request.direction, kind, src, request.at ? { x: (request.at.x - tree.views.nodeRefs.get(request.id)!.origin.x) / tree.views.nodeRefs.get(request.id)!.scale, y: (request.at.y - tree.views.nodeRefs.get(request.id)!.origin.y) / tree.views.nodeRefs.get(request.id)!.scale } : undefined);
|
||||
tree.dispatch({ type: "replace", document }, tree.views.nodeRefs.get(request.id)!.boardId);
|
||||
const child = document.nodes.at(-1)!;
|
||||
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
||||
setBranchRequest(null);
|
||||
@@ -129,15 +157,19 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
|
||||
};
|
||||
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
|
||||
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
|
||||
const node = makeCanvasNode(kind, center(), src);
|
||||
if (kind === "frame" && snapshot) {
|
||||
void tree.command(board.id, "subgraphs", { frame: { id: node.id, title: node.data.title, position: node.position, width: node.width, height: node.height, labelMode: "fixed" } }).catch((error) => setError(error.message));
|
||||
setTool("select"); return;
|
||||
}
|
||||
if (size) { node.width = size.width; node.height = size.height; node.style = { ...node.style, ...size }; }
|
||||
if (title) node.data.title = title.slice(0, 300);
|
||||
dispatch({ type: "nodes", changes: [{ type: "add", item: node }] });
|
||||
setTool("select");
|
||||
}
|
||||
function addTemplate() {
|
||||
if (nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
||||
if (tree.rootDocument.nodes.length > 497) { setError("当前图谱没有足够空间插入模板。"); return; }
|
||||
const position = center();
|
||||
const brief = makeCanvasNode("text", { x: position.x - 300, y: position.y });
|
||||
brief.data = { ...brief.data, title: "创意简报", text: "你想表达什么?\n\n记录主题、受众和一个关键想法。" };
|
||||
@@ -145,38 +177,96 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
idea.data = { ...idea.data, title: "视觉参考", text: "收集颜色、光线和画面氛围。" };
|
||||
const script = makeCanvasNode("script", { x: position.x + 340, y: position.y });
|
||||
script.data.text = "从第一个镜头开始,写下你的故事。";
|
||||
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes, brief, idea, script], edges: [...edges,
|
||||
dispatch({ type: "replace", document: { ...tree.rootDocument, nodes: [...tree.rootDocument.nodes, brief, idea, script], edges: [...tree.rootDocument.edges,
|
||||
{ id: crypto.randomUUID(), source: brief.id, target: idea.id, type: "default" },
|
||||
{ id: crypto.randomUUID(), source: idea.id, target: script.id },
|
||||
] } });
|
||||
setTool("select");
|
||||
}
|
||||
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
beginEdit(); closeBranch(); setRotating(null);
|
||||
if (dragSession.current !== "idle") return; dragSession.current = "active";
|
||||
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]));
|
||||
dragOrigins.current = new Map((dragged.length ? dragged : [node]).map((item) => [item.id, before.get(item.id) || item.position]));
|
||||
setDraggedIds(draggingFrame.current ? [] : [...dragOrigins.current.keys()]);
|
||||
setMembershipNotice("");
|
||||
}
|
||||
function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
if (!draggingFrame.current) {
|
||||
async function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
|
||||
if (dragSession.current !== "active") return; dragSession.current = "finishing";
|
||||
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" && start && (start.x !== item.position.x || start.y !== item.position.y); }).map((item) => item.id);
|
||||
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);
|
||||
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);
|
||||
const changed = frameDropFeedback(dropped, ids).filter((item) => item.from !== item.to);
|
||||
const messages = changed.map((item) => item.to ? `已加入「${frameFeedback.names.get(item.to) || "分区"}」` : `已移出「${frameFeedback.names.get(item.from!) || "分区"}」`);
|
||||
if (messages.length) setMembershipNotice(`${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}`);
|
||||
dispatch({ type: "drop", ids });
|
||||
const notice = messages.length ? `${changed.length > 1 ? `${changed.length} 项内容:` : ""}${[...new Set(messages)].join(";")}` : "";
|
||||
if (snapshot && tree.rootDocument.schema_version === 2) {
|
||||
try {
|
||||
const byOwner = new Map<string, { ids: string[]; target: string }>();
|
||||
const parents = subgraphParents(tree.current.current);
|
||||
for (const item of items) {
|
||||
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; };
|
||||
const candidates = dropped.filter((candidate) => candidate.id !== item.id && candidate.data.subgraph && !inMovingSubtree(candidate.data.subgraph.boardId) && center.x >= candidate.position.x && center.y >= candidate.position.y && center.x <= candidate.position.x + (candidate.width || 640) && center.y <= candidate.position.y + (candidate.height || 400)).sort((a, b) => (a.width || 640) * (a.height || 400) - (b.width || 640) * (b.height || 400));
|
||||
const target = candidates[0]?.data.subgraph?.boardId || (ref.boardId === board.id ? board.id : parents.get(ref.boardId)?.boardId) || board.id;
|
||||
if (target !== ref.boardId) {
|
||||
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) {
|
||||
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);
|
||||
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();
|
||||
dragOrigins.current.clear(); dragSession.current = "idle";
|
||||
}
|
||||
function connect(connection: Connection) {
|
||||
dispatch({ type: "replace", document: { ...state.present, edges: addEdge({ ...connection, id: crypto.randomUUID() }, edges) } });
|
||||
async function connect(connection: Connection) {
|
||||
const source = tree.views.nodeRefs.get(connection.source), target = tree.views.nodeRefs.get(connection.target);
|
||||
if (!source || !target) return;
|
||||
try {
|
||||
const resolve = async (ref: typeof source, handle: string | null | undefined): Promise<{ boardId: string; id: string; handle: string }> => {
|
||||
let endpoint = { boardId: ref!.boardId, id: ref!.id, handle: handle || "right" };
|
||||
for (let depth = 0; depth <= 8; depth++) {
|
||||
const node = tree.current.current.get(endpoint.boardId)?.nodes.find((node) => node.id === endpoint.id);
|
||||
if (!node?.data.subgraph) return endpoint;
|
||||
const port = node.data.ports?.find((port) => `port:${port.id}` === endpoint.handle); if (!port) throw new Error("ownership_conflict");
|
||||
await tree.ensure(node.data.subgraph.boardId);
|
||||
endpoint = { boardId: node.data.subgraph.boardId, id: port.nodeId, handle: port.handle };
|
||||
}
|
||||
throw new Error("subgraph_cycle");
|
||||
};
|
||||
const realSource = await resolve(source, connection.sourceHandle), realTarget = await resolve(target, connection.targetHandle || "left");
|
||||
if (realSource.boardId === realTarget.boardId) {
|
||||
const document = tree.current.current.get(realSource.boardId)!;
|
||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ id: crypto.randomUUID(), source: realSource.id, target: realTarget.id, sourceHandle: realSource.handle, targetHandle: realTarget.handle }, document.edges) } }, realSource.boardId);
|
||||
return;
|
||||
}
|
||||
if (realSource.boardId !== realTarget.boardId) {
|
||||
const plan = connectionTransactionPlan(tree.current.current, realSource, realTarget);
|
||||
const capability = await api<{ subgraphs?: { connections?: boolean } }>(`workspaces/${board.workspace_id}/materials/${plan.boardId}/canvas/capabilities`, { headers: { Accept: "application/vnd.foundry.canvas+json;version=2" } });
|
||||
if (capability.subgraphs?.connections) {
|
||||
await tree.command(plan.boardId, "connections", { source: plan.source, target: plan.target, edge: { id: crypto.randomUUID() } }, plan.participants);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const exposed = exposedConnection(tree.current.current, realSource, realTarget);
|
||||
const document = tree.current.current.get(exposed.boardId)!;
|
||||
tree.dispatch({ type: "replace", document: { ...document, schema_version: 2, edges: addEdge({ ...connection, source: exposed.source.id, target: exposed.target.id, sourceHandle: exposed.source.handle, targetHandle: exposed.target.handle, id: crypto.randomUUID() }, document.edges) } }, exposed.boardId);
|
||||
} catch (error) { setError(error instanceof Error && error.message === "boundary_port_required" ? "跨画布连接暂不可用,请稍后重试。" : errorText(error)); }
|
||||
}
|
||||
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -212,14 +302,18 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
if (!file) return;
|
||||
try {
|
||||
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
|
||||
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
|
||||
const document = decodeCanvas(await file.text());
|
||||
if (document.nodes.some((node) => node.data.subgraph)) throw new Error("子图备份需要按原始画布关系恢复,不能直接导入到其他画布。");
|
||||
dispatch({ type: "replace", document });
|
||||
void flow.setViewport(document.viewport);
|
||||
setError("");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
|
||||
}
|
||||
function exportProject() {
|
||||
const content = JSON.stringify({ ...state.present, viewport: flow.getViewport() }, null, 2);
|
||||
async function exportProject() {
|
||||
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
||||
try { documents = await tree.exportDocuments(); } catch (error) { setError(error instanceof Error ? error.message : "导出未完成"); return; }
|
||||
const content = JSON.stringify(documents.size > 1 ? { format: "endoleap-canvas-bundle", root: board.id, documents: Object.fromEntries(documents) } : { ...tree.rootDocument, viewport: flow.getViewport() }, null, 2);
|
||||
const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -250,36 +344,81 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
|
||||
<NodeEditing.Provider value={editing}>
|
||||
<FrameFeedback.Provider value={frameFeedback}>
|
||||
<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}
|
||||
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
|
||||
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
|
||||
onBeforeDelete={async ({ nodes: removing }) => {
|
||||
if (tree.busy) return false;
|
||||
const linked = removing.filter((node) => node.data.subgraph);
|
||||
const parents = subgraphParents(tree.current.current);
|
||||
const referenced = removing.filter((node) => {
|
||||
const ref = tree.views.nodeRefs.get(node.id); if (!ref) return false;
|
||||
const parent = parents.get(ref.boardId);
|
||||
return parent && tree.current.current.get(parent.boardId)?.nodes.find((frame) => frame.id === parent.frameId)?.data.ports?.some((port) => port.nodeId === ref.id);
|
||||
});
|
||||
if (!linked.length && !referenced.length) return true;
|
||||
try {
|
||||
for (const node of linked) {
|
||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||
await tree.ensure(node.data.subgraph!.boardId);
|
||||
const child = tree.current.current.get(node.data.subgraph!.boardId)!;
|
||||
const participants = new Set([ref.boardId, node.data.subgraph!.boardId, ...child.nodes.flatMap((item) => item.data.subgraph ? [item.data.subgraph.boardId] : [])]);
|
||||
const ancestor = subgraphParents(tree.current.current).get(ref.boardId)?.boardId;
|
||||
if (ancestor && subgraphMoveParticipants(tree.current.current, ref.boardId, node.data.subgraph!.boardId, ref.boardId, child.nodes.map((item) => item.id)).includes(ancestor)) participants.add(ancestor);
|
||||
await tree.command(ref.boardId, `subgraphs/${ref.id}/dissolve`, {}, [...participants]);
|
||||
}
|
||||
for (const node of removing.filter((node) => !node.data.subgraph)) {
|
||||
const ref = tree.views.nodeRefs.get(node.id); if (!ref) continue;
|
||||
let owner = ref.boardId;
|
||||
const parent = subgraphParents(tree.current.current).get(owner);
|
||||
if (parent && tree.current.current.get(parent.boardId)?.nodes.find((frame) => frame.id === parent.frameId)?.data.ports?.some((port) => port.nodeId === ref.id)) {
|
||||
const participants = subgraphMoveParticipants(tree.current.current, parent.boardId, owner, parent.boardId, [ref.id]);
|
||||
await tree.command(parent.boardId, `subgraphs/${parent.frameId}/members`, { source_board_id: owner, target_board_id: parent.boardId, node_ids: [ref.id] }, participants); owner = parent.boardId;
|
||||
}
|
||||
if (!tree.current.current.get(owner)?.nodes.some((item) => item.id === ref.id)) {
|
||||
const match = [...tree.current.current].filter(([, document]) => document.nodes.some((item) => item.id === ref.id));
|
||||
if (match.length !== 1) continue; owner = match[0][0];
|
||||
}
|
||||
tree.dispatch({ type: "nodes", changes: [{ type: "remove", id: ref.id }] }, owner);
|
||||
}
|
||||
if (linked.length) setMembershipNotice("已解散选框,子画布内容已保留");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "删除未完成"); }
|
||||
return false;
|
||||
}}
|
||||
onNodesChange={tree.nodesChanged}
|
||||
onEdgesChange={tree.edgesChanged}
|
||||
connectionLineType={ConnectionLineType.Bezier} connectionLineComponent={CanvasConnectionLine}
|
||||
onNodeDragStart={(_, node, dragged) => beginDrag(node, dragged)}
|
||||
onNodeDragStop={(_, node, dragged) => finishDrag(node, dragged)}
|
||||
onNodeDragStop={(_, node, dragged) => { void finishDrag(node, dragged); }}
|
||||
onSelectionDragStart={(_, dragged) => { if (dragged[0]) beginDrag(dragged[0], dragged); }}
|
||||
onSelectionDragStop={(_, dragged) => { if (dragged[0]) finishDrag(dragged[0], dragged); }} onConnect={connect} connectionMode={ConnectionMode.Loose}
|
||||
onSelectionDragStop={(_, dragged) => { if (dragged[0]) void finishDrag(dragged[0], dragged); }} onConnect={(connection) => { void connect(connection); }} connectionMode={ConnectionMode.Loose}
|
||||
connectOnClick={false} connectionDragThreshold={5}
|
||||
onConnectStart={(event, params) => { closeBranch(); const point = "touches" in event ? event.touches[0] : event; if (params.nodeId && params.handleId && point) connectionStart.current = { id: params.nodeId, direction: params.handleId as NodeDirection, point: { x: point.clientX, y: point.clientY } }; }}
|
||||
onConnectEnd={connectionEnd}
|
||||
onPaneClick={() => { closeBranch(); setRotating(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); }}
|
||||
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 })}
|
||||
defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
|
||||
panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
|
||||
nodesDraggable={tool === "select"} nodesConnectable={tool === "select"}
|
||||
nodesDraggable={tool === "select" && !tree.busy} nodesConnectable={tool === "select" && !tree.busy}
|
||||
elevateNodesOnSelect={false}
|
||||
elementsSelectable={tool === "select"} selectionMode={SelectionMode.Partial}
|
||||
panActivationKeyCode="Space" selectionKeyCode="Shift"
|
||||
deleteKeyCode={["Backspace", "Delete"]} preventScrolling
|
||||
defaultEdgeOptions={{ style: { stroke: "#aa968b", strokeWidth: 1.5 } }}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#c9bfb5" />
|
||||
{ai.enabled && nodes.filter((node) => node.selected && ["text", "script", "plain-text", "image", "plain-image"].includes(node.data.kind)).map((node) => {
|
||||
const ref = tree.views.nodeRefs.get(node.id)!;
|
||||
const owned = tree.documents.get(ref.boardId)?.nodes.find((item) => item.id === ref.id);
|
||||
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
|
||||
})}
|
||||
</ReactFlow>
|
||||
</EdgeEditing.Provider>
|
||||
</FrameFeedback.Provider>
|
||||
</NodeEditing.Provider>
|
||||
<div className={styles.topRight}>
|
||||
<div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!state.past.length} onClick={() => dispatch({ type: "undo" })} />
|
||||
<IconButton icon={Redo2} label="重做" disabled={!state.future.length} onClick={() => dispatch({ type: "redo" })} />
|
||||
<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>
|
||||
@@ -300,6 +439,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
|
||||
<IconButton icon={Frame} label="添加框选区" onClick={() => addNode("frame")} />
|
||||
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
|
||||
<IconButton icon={Bot} label="AI 模式" aria-pressed={ai.enabled} onClick={() => ai.setEnabled(!ai.enabled)} />
|
||||
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
|
||||
@@ -321,18 +461,20 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
</div>
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
{iconPicker && <IconPicker close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
if (tree.rootDocument.nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
const node = makeCanvasNode("icon", center());
|
||||
node.data = { ...node.data, icon: name, title: label };
|
||||
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
dispatch({ type: "replace", document: { ...tree.rootDocument, nodes: [...tree.rootDocument.nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
setTool("select");
|
||||
}} />}
|
||||
<div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>
|
||||
{recovery && <DraftRecovery restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
|
||||
{snapshot && sync.status === "error" && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
|
||||
{recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
|
||||
{!recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
|
||||
{snapshot && sync.error && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
|
||||
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
|
||||
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
|
||||
</div>}
|
||||
{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 />
|
||||
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
|
||||
import { useContext, useRef } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
@@ -23,7 +24,7 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
<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={data.src} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
{data.kind === "image" ? <img className={styles.image} src={managedImageSource(data.src)} 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} />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { NodeToolbar, Position, type NodeProps } from "@xyflow/react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, RotateCw, Underline } from "lucide-react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import type { TextStyle } from "@/lib/canvas-elements";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import { NodeEditing, ResizeToggle } from "./transformable-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
@@ -29,6 +29,7 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
|
||||
</>}
|
||||
{!(["image", "plain-image"].includes(data.kind)) && <label className={styles.colorPicker} title="背景色"><span style={{ background: style.background && style.background !== "transparent" ? style.background : "#ffffff" }} /><input aria-label="背景色" type="color" value={style.background && style.background !== "transparent" ? style.background : "#ffffff"} onChange={(event) => patch({ background: event.target.value })} /></label>}
|
||||
{!(["image", "plain-image"].includes(data.kind)) && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
|
||||
{data.kind !== "icon" && <ResizeToggle id={id} />}
|
||||
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
|
||||
</motion.div>}</AnimatePresence>
|
||||
</NodeToolbar>;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
import 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={props.data.src} alt={props.data.title} draggable={false} referrerPolicy="no-referrer" /></TransformableNode>;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useReactFlow, useUpdateNodeInternals, type NodeProps, type XYPosition } from "@xyflow/react";
|
||||
import { Grip, RotateCw } from "lucide-react";
|
||||
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
|
||||
import { Grip, Maximize2, RotateCw } from "lucide-react";
|
||||
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform } from "@/lib/canvas-geometry";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||||
import { MembershipBadge } from "./frame-feedback";
|
||||
import { ContentHandles } from "./content-handles";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const NodeEditing = createContext({
|
||||
begin: () => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
|
||||
workspace: "", schema: 1 as 1 | 2,
|
||||
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
|
||||
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
|
||||
transform: (_id: string, _transform: NodeTransform) => {},
|
||||
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
|
||||
rename: (_id: string, _title: string) => {},
|
||||
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode }) => {},
|
||||
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
|
||||
rotating: null as string | null, rotate: (_id: string | null) => {},
|
||||
resizing: null as string | null, resize: (_id: string | null) => {},
|
||||
});
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: "bottom-right" | "rotate" };
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
|
||||
|
||||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
|
||||
const { id, data, selected, width, height, children, className = "" } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const update = useUpdateNodeInternals();
|
||||
const { zoom } = useViewport();
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const rotation = data.rotation || 0;
|
||||
const plain = data.kind === "plain-text" || data.kind === "icon" || data.kind === "plain-image";
|
||||
const capabilities = elementCapabilities(data.kind);
|
||||
const resizing = capabilities.resizable && editing.resizing === id && selected;
|
||||
const rotating = capabilities.rotatable && editing.rotating === id && selected;
|
||||
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
|
||||
useEffect(() => {
|
||||
if (!rotating) return;
|
||||
if (!rotating && !resizing) return;
|
||||
const outside = (event: globalThis.PointerEvent) => {
|
||||
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"]')) editing.rotate(null);
|
||||
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") editing.rotate(null); };
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { cancelGesture(); editing.rotate(null); editing.resize(null); } };
|
||||
window.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
|
||||
}, [rotating, id, editing]);
|
||||
}, [rotating, resizing, id, editing]);
|
||||
|
||||
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const node = flow.getNode(id);
|
||||
if (!node) return;
|
||||
editing.begin();
|
||||
editing.begin(id);
|
||||
if (!node.selected) editing.select(id);
|
||||
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
gesture.current = { node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<HTMLElement>) {
|
||||
@@ -61,7 +66,13 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
const { node, start, direction } = gesture.current;
|
||||
editing.transform(id, direction === "rotate"
|
||||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }));
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined));
|
||||
}
|
||||
function cancelGesture() {
|
||||
const current = gesture.current; if (!current) return;
|
||||
gesture.current = null;
|
||||
editing.transform(id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||||
if (current.target.hasPointerCapture(current.pointerId)) current.target.releasePointerCapture(current.pointerId);
|
||||
}
|
||||
function finish(event: PointerEvent<HTMLElement>) {
|
||||
event.stopPropagation(); gesture.current = null;
|
||||
@@ -71,17 +82,36 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
|
||||
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
|
||||
onPointerDownCapture={rotating ? (event) => start(event, "rotate") : undefined}
|
||||
onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}
|
||||
onLostPointerCapture={() => { gesture.current = null; }}
|
||||
style={{ width: width || (data.kind === "frame" ? 640 : data.kind === "plain-image" ? 320 : data.kind === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "frame" ? 400 : data.kind === "plain-image" ? 240 : data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
|
||||
onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
|
||||
onLostPointerCapture={cancelGesture}
|
||||
style={{ zoom: data.viewScale || 1, width: (width || (data.kind === "frame" ? 640 : data.kind === "plain-image" ? 320 : data.kind === "icon" ? 80 : plain ? 240 : 260)) / (data.viewScale || 1), height: ( height || (data.kind === "frame" ? 400 : data.kind === "plain-image" ? 240 : data.kind === "icon" ? 80 : plain ? 72 : 200)) / (data.viewScale || 1), transform: `rotate(${rotation}deg)` }}>
|
||||
{children}
|
||||
{editing.schema === 2 && ["plain-text", "plain-image"].includes(data.kind) && <ContentHandles selected={selected} />}
|
||||
{data.kind !== "frame" && <MembershipBadge id={id} frameId={data.frameId} selected={selected} />}
|
||||
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={rotating ? -1 : 0}
|
||||
{capabilities.resizable && !resizing && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={rotating ? -1 : 0}
|
||||
aria-hidden={rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
|
||||
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}>
|
||||
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}>
|
||||
<Grip size={14} />
|
||||
</button>}
|
||||
{resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => {
|
||||
const left = direction.includes("left") ? "0%" : direction.includes("right") ? "100%" : "50%";
|
||||
const top = direction.includes("top") ? "0%" : direction.includes("bottom") ? "100%" : "50%";
|
||||
return <button type="button" key={direction} className={`${styles.resizeHandle} nodrag nopan`} data-resize-handle={direction} aria-label={`拉伸 ${direction}`} title="拖动拉伸 · Shift 固定比例" style={{ left, top, width: 10 / (zoom * (data.viewScale || 1)), height: 10 / (zoom * (data.viewScale || 1)), borderWidth: 1 / (zoom * (data.viewScale || 1)), cursor: direction === "top" || direction === "bottom" ? "ns-resize" : direction === "left" || direction === "right" ? "ew-resize" : direction === "top-left" || direction === "bottom-right" ? "nwse-resize" : "nesw-resize" }}
|
||||
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
|
||||
onKeyDown={(event) => {
|
||||
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
|
||||
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
|
||||
editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
||||
editing.transform(id, resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey));
|
||||
}} />;
|
||||
})}
|
||||
{resizing && <span className={styles.rotationHint}>拖动八向手柄 · Shift 固定比例 · Esc 退出</span>}
|
||||
{rotating && <span className={styles.rotationHint}><RotateCw size={12} /> {Math.round(rotation)}° · 拖动旋转,点击外部退出</span>}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ResizeToggle({ id }: { id: string }) {
|
||||
const editing = useContext(NodeEditing);
|
||||
return <button type="button" data-resize-toggle={id} aria-label="八向拉伸模式" title="八向拉伸 · Shift 固定比例" aria-pressed={editing.resizing === id} onClick={() => editing.resize(editing.resizing === id ? null : id)}><Maximize2 size={16} /></button>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { CANVAS_ACCEPT } from "@/lib/canvas-v2";
|
||||
import { canvasPath } from "@/lib/canvas-sync";
|
||||
import type { useCanvasTree } from "./use-canvas-tree";
|
||||
export type CanvasModel = { id: string; label?: string; display_name?: string; output_modalities: string[]; input_modalities: string[]; available: boolean; unavailable_reason?: string; parameters_schema?: unknown };
|
||||
export type AIRun = {
|
||||
run_id: string; board_id: string; status: string; next_seq: number; has_more?: boolean; error?: string;
|
||||
steps?: { step_id: string; board_id: string; node_id: string; status: string; error?: string }[];
|
||||
outputs?: { step_id: string; output: { output_id: string; kind: string; src?: string } }[];
|
||||
events?: { seq: number; type: string; board_id?: string; node_id?: string; logical_edge_id?: string; canvas_version?: number; output?: { output_id: string; text?: string; src?: string } }[];
|
||||
applied_versions?: Record<string, number>;
|
||||
};
|
||||
const active = (status: string) => status === "queued" || status === "running";
|
||||
export function useCanvasAI(workspace: string, root: string, live: boolean, tree: ReturnType<typeof useCanvasTree>) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [runs, setRuns] = useState(new Map<string, AIRun>());
|
||||
const [error, setError] = useState("");
|
||||
const [conflicts, setConflicts] = useState(new Set<string>());
|
||||
const [activeEdges, setActiveEdges] = useState(new Set<string>());
|
||||
const reference = useRef(tree); reference.current = tree;
|
||||
const cache = useRef(new Map<string, AIRun>());
|
||||
const edgeStates = useRef(new Map<string, Set<string>>());
|
||||
const pendingRefresh = useRef(new Map<string, number>());
|
||||
const changed = () => { setRuns(new Map(cache.current)); setActiveEdges(new Set([...edgeStates.current.values()].flatMap((values) => [...values]))); };
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const controller = new AbortController();
|
||||
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
|
||||
async function discover() {
|
||||
let cursor = ""; const seen = new Set<string>();
|
||||
do {
|
||||
const page = await request<{ runs: { run_id: string; board_id: string; status: string }[]; next_cursor?: string }>(canvasPath(workspace, root) + "/ai/runs?status=active&include_descendants=true&limit=50" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""));
|
||||
for (const run of page.runs || []) if (!cache.current.has(run.run_id)) cache.current.set(run.run_id, { ...run, next_seq: 0 });
|
||||
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
|
||||
} while (cursor && alive);
|
||||
}
|
||||
let polls = 0;
|
||||
async function poll() {
|
||||
try {
|
||||
if (++polls % 4 === 0) await discover();
|
||||
for (const [id, previous] of cache.current) {
|
||||
if (!active(previous.status) && !previous.has_more) continue;
|
||||
let current = previous;
|
||||
do {
|
||||
const page = await request<Omit<AIRun, "board_id">>(canvasPath(workspace, previous.board_id) + `/ai/runs/${id}?after_seq=${current.next_seq}&limit=100`);
|
||||
if (!Number.isSafeInteger(page.next_seq) || page.next_seq < current.next_seq || page.has_more && page.next_seq === current.next_seq) throw new Error("invalid_run_cursor");
|
||||
const edges = edgeStates.current.get(id) || new Set<string>();
|
||||
for (const event of page.events || []) {
|
||||
if (event.logical_edge_id) { if (event.type === "edge.active") edges.add(event.logical_edge_id); else if (["edge.completed", "edge.failed", "edge.skipped"].includes(event.type)) edges.delete(event.logical_edge_id); }
|
||||
if (event.type === "step.applied" && event.board_id && event.canvas_version) pendingRefresh.current.set(event.board_id, Math.max(pendingRefresh.current.get(event.board_id) || 0, event.canvas_version));
|
||||
}
|
||||
if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges);
|
||||
current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) };
|
||||
cache.current.set(id, current);
|
||||
} while (current.has_more && alive);
|
||||
}
|
||||
for (const [board, version] of pendingRefresh.current) {
|
||||
if (reference.current.busy) break;
|
||||
const writer = reference.current.writers.current.get(board);
|
||||
if (writer && writer.version < version) {
|
||||
try { await reference.current.refresh(board); } catch (error) {
|
||||
if (error instanceof Error && error.message === "version_conflict") { pendingRefresh.current.delete(board); setConflicts((current) => new Set(current).add(board)); }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
pendingRefresh.current.delete(board);
|
||||
}
|
||||
if (alive) { changed(); setError(""); }
|
||||
} catch (error) { if (alive && !controller.signal.aborted) { changed(); setError(error instanceof Error ? error.message : "request_failed"); } }
|
||||
if (alive) timer = setTimeout(poll, 1200);
|
||||
}
|
||||
void discover().catch((error) => { if (alive) setError(error.message); }).finally(() => { if (alive) void poll(); });
|
||||
return () => { alive = false; controller.abort(); if (timer) clearTimeout(timer); };
|
||||
}, [workspace, root, live]);
|
||||
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
|
||||
if (!live) throw new Error("demo_ai_unavailable");
|
||||
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
|
||||
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed();
|
||||
}
|
||||
async function cancel(run: AIRun) {
|
||||
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
|
||||
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } // Poll canonical terminal state, including already applied steps.
|
||||
}
|
||||
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
|
||||
await reference.current.prepareApply(targetBoard, node);
|
||||
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
|
||||
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
|
||||
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); }
|
||||
}
|
||||
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { EdgeChange, NodeChange } from "@xyflow/react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
|
||||
import { CanvasSync, canvasFingerprint, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
|
||||
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
|
||||
|
||||
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
|
||||
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
|
||||
type LocalHistory = { boardId: string; before: CanvasDocument };
|
||||
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
|
||||
type Entry = LocalHistory | TransactionHistory;
|
||||
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
|
||||
|
||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
|
||||
const [documents, setDocuments] = useState(new Map([[root, initial]]));
|
||||
const current = useRef(documents); current.current = documents;
|
||||
const [status, setStatus] = useState("saved");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
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;
|
||||
const writers = useRef(new Map<string, CanvasSync>());
|
||||
const loading = useRef(new Set<string>());
|
||||
const [retryLoad, setRetryLoad] = useState(0);
|
||||
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
|
||||
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
|
||||
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
|
||||
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
|
||||
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
|
||||
}
|
||||
function resolveRecovery(boardId: string, restore: boolean) {
|
||||
const draft = pendingRecovery.current.get(boardId);
|
||||
if (restore && draft) update(boardId, { type: "replace", document: draft });
|
||||
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
||||
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
|
||||
}
|
||||
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
|
||||
const boardAliases = useRef(new Map<string, string>());
|
||||
const [, tick] = useState(0);
|
||||
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
|
||||
const view = useRef(views); view.current = views;
|
||||
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
|
||||
function install(boardId: string, value: CanvasSnapshot) {
|
||||
const writer = writers.current.get(boardId);
|
||||
if (writer) writer.adopt(value);
|
||||
else if (snapshot) writers.current.set(boardId, new CanvasSync(api, canvasPath(workspace, boardId), value, (state) => {
|
||||
if (!mounted.current) return;
|
||||
setStatus(state.status); if (state.error) setError(state.error);
|
||||
}));
|
||||
}
|
||||
// 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; }; }, []);
|
||||
const update = (boardId: string, action: CanvasAction) => {
|
||||
if (locked.current || retryOperation.current) return;
|
||||
const before = current.current.get(boardId); if (!before) return;
|
||||
const result = canvasReducer(history(before), action);
|
||||
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));
|
||||
};
|
||||
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
|
||||
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
|
||||
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
|
||||
if (locked.current || retryOperation.current) return;
|
||||
const groups = new Map<string, NodeChange<CanvasNode>[]>();
|
||||
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
|
||||
for (const change of changes) {
|
||||
if (change.type === "add" || change.type === "replace") continue;
|
||||
const ref = view.current.nodeRefs.get(change.id); if (!ref) continue;
|
||||
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 };
|
||||
groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), next]);
|
||||
}
|
||||
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
|
||||
};
|
||||
const edgesChanged = (changes: EdgeChange[]) => {
|
||||
const groups = new Map<string, EdgeChange[]>();
|
||||
for (const change of changes) {
|
||||
if (change.type === "add" || change.type === "replace") continue;
|
||||
const ref = view.current.edgeRefs.get(change.id); if (ref) groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), { ...change, id: ref.id }]);
|
||||
}
|
||||
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
|
||||
};
|
||||
const patchEdge = (id: string, data: EdgeData, transient = false) => {
|
||||
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
|
||||
};
|
||||
const select = (id: string) => {
|
||||
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 }));
|
||||
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);
|
||||
if (!document) {
|
||||
const value = snapshot ? await read(boardId) : (() => { const raw = localStorage.getItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); return { version: 1, document: raw ? decodeCanvas(JSON.stringify(JSON.parse(raw).document)) : { schema_version: 2 as const, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; })();
|
||||
install(boardId, value); if (snapshot) inspectRecovery(boardId, value); document = value.document; publish(new Map(current.current).set(boardId, document));
|
||||
}
|
||||
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
|
||||
}
|
||||
async function exportDocuments() {
|
||||
return exclusive(async () => {
|
||||
await ensure(root, true);
|
||||
const reachable = new Map<string, CanvasDocument>();
|
||||
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
|
||||
collect(root); return reachable;
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!snapshot || !parentBoardId) return;
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
let id: string | undefined = parentBoardId; const seen = new Set<string>([root]);
|
||||
while (id && alive) {
|
||||
if (seen.has(id) || seen.size > 8) throw new Error("subgraph_cycle"); seen.add(id);
|
||||
const [metadata, value]: [{ parent_board_id?: string }, CanvasSnapshot] = await Promise.all([api<{ parent_board_id?: string }>(`workspaces/${workspace}/materials/${id}`), read(id)]);
|
||||
if (!alive) return;
|
||||
if (!current.current.has(id)) { install(id, value); publish(new Map(current.current).set(id, value.document)); }
|
||||
id = metadata.parent_board_id;
|
||||
}
|
||||
})().catch((error) => { if (alive) setError(error.message); });
|
||||
return () => { alive = false; };
|
||||
// Root identity is stable until this editor unmounts.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [parentBoardId, root, workspace]);
|
||||
useEffect(() => {
|
||||
for (const [boardId, document] of documents) for (const node of document.nodes) {
|
||||
if (!view.current.boards.has(boardId)) continue;
|
||||
const child = node.data.subgraph?.boardId;
|
||||
if (!child || node.data.presentation?.collapsed || documents.has(child) || loading.current.has(child)) continue;
|
||||
loading.current.add(child);
|
||||
read(child).then((value) => {
|
||||
if (!mounted.current || current.current.has(child) || ![...current.current.values()].some((doc) => doc.nodes.some((node) => node.data.subgraph?.boardId === child))) return;
|
||||
install(child, value); inspectRecovery(child, value); publish(new Map(current.current).set(child, value.document));
|
||||
}).catch((error) => { if (mounted.current) setError(error.message); }).finally(() => loading.current.delete(child));
|
||||
}
|
||||
// Values and writers are scoped to this mounted root/workspace.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documents, retryLoad]);
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const backup = setTimeout(() => {
|
||||
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 }));
|
||||
} catch { setError("local_backup_failed"); }
|
||||
}, 250);
|
||||
const save = setTimeout(() => {
|
||||
if (locked.current || retryOperation.current || pause.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]);
|
||||
useEffect(() => {
|
||||
const backup = () => {
|
||||
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. */ }
|
||||
}
|
||||
};
|
||||
window.addEventListener("pagehide", backup);
|
||||
return () => { window.removeEventListener("pagehide", backup); backup(); };
|
||||
}, [workspace]);
|
||||
async function flush() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function exclusive<T>(operation: () => Promise<T>) {
|
||||
if (locked.current || pause.current) 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); }
|
||||
}
|
||||
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
|
||||
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
|
||||
}
|
||||
function accept(result: CanvasCommandResult) {
|
||||
const next = new Map(current.current);
|
||||
const values = [...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])];
|
||||
for (const value of values) { const decoded = decodeSnapshot(value); install(value.board_id, decoded); next.set(value.board_id, keepSelection(decoded.document, current.current.get(value.board_id))); }
|
||||
for (const id of result.archived_board_ids || []) { next.delete(id); writers.current.delete(id); }
|
||||
publish(next);
|
||||
return [...new Set(values.map((value) => value.board_id))];
|
||||
}
|
||||
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
|
||||
if (retryOperation.current) throw new Error("canvas_busy");
|
||||
return exclusive(async () => {
|
||||
for (const id of participants || []) if (!writers.current.has(id)) {
|
||||
const value = await read(id); install(id, value); publish(new Map(current.current).set(id, value.document));
|
||||
}
|
||||
await flush();
|
||||
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
|
||||
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
|
||||
const execute = async (): Promise<CanvasCommandResult> => {
|
||||
try {
|
||||
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
|
||||
const changed = accept(result);
|
||||
if (!changed.length && result.canvas_version) { const target = typeof result.board_id === "string" ? result.board_id : boardId; const remote = await read(target); install(target, remote); publish(new Map(current.current).set(target, keepSelection(remote.document, current.current.get(target)))); }
|
||||
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
|
||||
retryOperation.current = null; setPendingCommand(false); setError("");
|
||||
return result;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "request_failed";
|
||||
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
|
||||
else { retryOperation.current = null; setPendingCommand(false); }
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return execute();
|
||||
});
|
||||
}
|
||||
async function refresh(boardId: string) {
|
||||
return exclusive(async () => {
|
||||
const writer = writers.current.get(boardId); if (!writer) return;
|
||||
await writer.settled(); const base = writer.document;
|
||||
const remote = await read(boardId), local = current.current.get(boardId)!;
|
||||
const merged = mergeCanvasDocuments(base, local, remote.document);
|
||||
if (canvasFingerprint(merged) !== canvasFingerprint(local)) { undo.current.push({ boardId, before: local }); redo.current = []; }
|
||||
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
||||
});
|
||||
}
|
||||
async function prepareApply(boardId: string, nodeId: string) {
|
||||
await exclusive(async () => {
|
||||
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
|
||||
await writer.settled(); const remote = await read(boardId), base = structuredClone(writer.document), local = current.current.get(boardId)!;
|
||||
const before = base.nodes.find((node) => node.id === nodeId), after = remote.document.nodes.find((node) => node.id === nodeId);
|
||||
if (!before || !after) throw new Error("invalid_reference");
|
||||
for (const key of ["text", "src", "assetId", "ai"] as const) { if (key in after.data) (before.data as Record<string, unknown>)[key] = after.data[key]; else delete before.data[key]; }
|
||||
const merged = mergeCanvasDocuments(base, local, remote.document);
|
||||
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
|
||||
await writer.enqueue(merged); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
setError("");
|
||||
});
|
||||
}
|
||||
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 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;
|
||||
if ("id" in entry) {
|
||||
if (isRedo) {
|
||||
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
|
||||
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || [])];
|
||||
if (entry.path === "subgraphs" && result.child) {
|
||||
const priorChild = entry.participants.find((id) => id !== entry.boardId); if (priorChild) boardAliases.current.set(priorChild, result.child.board_id);
|
||||
}
|
||||
stack.pop(); destination.push({ ...entry, id: result.transaction_id as string, participants: [...new Set(snapshots.map((value) => value.board_id))] });
|
||||
} else {
|
||||
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
|
||||
stack.pop(); destination.push(entry);
|
||||
}
|
||||
} else {
|
||||
const before = current.current.get(entry.boardId); if (!before) return;
|
||||
publish(new Map(current.current).set(entry.boardId, entry.before)); stack.pop(); destination.push({ ...entry, before });
|
||||
}
|
||||
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 };
|
||||
}
|
||||
+20
-4
@@ -5,17 +5,22 @@ export type Profile = {
|
||||
email: string | null;
|
||||
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"; }
|
||||
}
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/foundry/" + path, {
|
||||
...options,
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok)
|
||||
throw new Error(body?.error?.code || body?.error || "request_failed");
|
||||
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"));
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
@@ -46,11 +51,22 @@ export const errorText = (error: unknown) => {
|
||||
workspace_required: "请先创建或选择一个工作空间。",
|
||||
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
|
||||
invalid_title: "名称不能为空,且不能超过 300 字节(约 100 个汉字)。",
|
||||
version_conflict: "图谱已在别处修改,请再次保存以读取最新版本。",
|
||||
version_conflict: "画布已更新,本地草稿已保留,请导出备份或重新读取后处理冲突。",
|
||||
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
|
||||
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
|
||||
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
|
||||
invalid_canvas_snapshot: "服务端画布数据格式无效,请重新读取或联系维护者。",
|
||||
canvas_schema_upgrade_required: "这张画布已升级,请刷新以加载新版客户端。",
|
||||
undo_receipt_expired: "撤销记录已过期,当前内容已保留。",
|
||||
draft_recovery_required: "请先处理未同步的本地草稿。",
|
||||
canvas_busy: "画布正在同步,请稍后重试。",
|
||||
node_busy: "这个元素正在生成,请等待完成或先取消。",
|
||||
model_unavailable: "模型暂时不可用,请重新选择。",
|
||||
model_incompatible: "这个模型不支持当前元素的输出类型。",
|
||||
ownership_conflict: "子画布关系已变化,请重新读取画布。",
|
||||
execution_cycle: "执行路径中有循环,请先断开循环连接。",
|
||||
queue_full: "生成队列已满,请稍后重试。",
|
||||
context_too_large: "输入内容超出模型容量,请减少输入。",
|
||||
canvas_too_large: "画布超过 5 MiB,请减少图片或拆分画布后重试。",
|
||||
body_too_large: "画布内容过大,请减少图片或拆分画布。",
|
||||
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { XYPosition } from "@xyflow/react";
|
||||
import type { Connector } from "./canvas-geometry";
|
||||
import type { EdgeData } from "./canvas-v2";
|
||||
export type Cubic = [XYPosition, XYPosition, XYPosition, XYPosition];
|
||||
export const plus = (a: XYPosition, b: XYPosition) => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
export const minus = (a: XYPosition, b: XYPosition) => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const lerp = (a: XYPosition, b: XYPosition, t: number) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
export function curveSegments(from: Connector, to: Connector, path?: EdgeData["path"]): Cubic[] {
|
||||
const reach = Math.min(240, Math.max(40, Math.hypot(to.point.x - from.point.x, to.point.y - from.point.y) * .4));
|
||||
const knots = path?.knots || [];
|
||||
const anchors = [from.point, ...knots.map((knot) => knot.position), to.point];
|
||||
return anchors.slice(0, -1).map((point, index) => [point,
|
||||
plus(point, index ? knots[index - 1].out : path?.startControl || { x: from.normal.x * reach, y: from.normal.y * reach }),
|
||||
plus(anchors[index + 1], index < knots.length ? knots[index].in : path?.endControl || { x: to.normal.x * reach, y: to.normal.y * reach }), anchors[index + 1]]);
|
||||
}
|
||||
export function cubicPoint([a, b, c, d]: Cubic, t: number) { const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t); return lerp(lerp(ab, bc, t), lerp(bc, cd, t), t); }
|
||||
export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${segments[0][0].y} ` + segments.map(([, b, c, d]) => `C ${b.x},${b.y} ${c.x},${c.y} ${d.x},${d.y}`).join(" "); }
|
||||
/** Split the closest segment without altering its shape. */
|
||||
export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["path"], point?: XYPosition) {
|
||||
const segments = curveSegments(from, to, path);
|
||||
let selected = Math.floor(segments.length / 2), t = .5, distance = Infinity;
|
||||
if (point) segments.forEach((segment, index) => { for (let step = 1; step < 40; step++) {
|
||||
const p = cubicPoint(segment, step / 40), d = Math.hypot(p.x - point.x, p.y - point.y);
|
||||
if (d < distance) { distance = d; selected = index; t = step / 40; }
|
||||
} });
|
||||
const [a, b, c, d] = segments[selected];
|
||||
const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t), abc = lerp(ab, bc, t), bcd = lerp(bc, cd, t), anchor = lerp(abc, bcd, t);
|
||||
const next = structuredClone(path || { type: "bezier-chain" as const, knots: [] });
|
||||
const knot = { id: crypto.randomUUID(), position: anchor, in: minus(abc, anchor), out: minus(bcd, anchor), mode: "smooth" as const };
|
||||
if (selected) next.knots[selected - 1].out = minus(ab, a); else next.startControl = minus(ab, a);
|
||||
if (selected < next.knots.length) next.knots[selected].in = minus(cd, d); else next.endControl = minus(cd, d);
|
||||
// Splitting one side of an existing symmetric knot can make its handle lengths differ.
|
||||
for (const index of [selected - 1, selected]) if (next.knots[index]?.mode === "symmetric") next.knots[index].mode = "smooth";
|
||||
next.knots.splice(selected, 0, knot); return { path: next, knot };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Node, XYPosition } from "@xyflow/react";
|
||||
import type { AIConfig, FramePort, FramePresentation } from "./canvas-v2";
|
||||
|
||||
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
|
||||
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
|
||||
@@ -14,6 +15,12 @@ export type CanvasNodeData = {
|
||||
icon?: string;
|
||||
frameId?: string;
|
||||
labelMode?: FrameLabelMode;
|
||||
ai?: AIConfig;
|
||||
assetId?: string;
|
||||
subgraph?: { boardId: string };
|
||||
ports?: FramePort[];
|
||||
presentation?: FramePresentation;
|
||||
viewScale?: number;
|
||||
};
|
||||
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon" | "frame" | "plainImage">;
|
||||
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
|
||||
|
||||
@@ -26,7 +26,7 @@ export function rotationFromPointer(node: CanvasNode, start: XYPosition, current
|
||||
if (snap) rotation = Math.round(rotation / 15) * 15;
|
||||
return normalizeRotation(rotation);
|
||||
}
|
||||
export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition): NodeTransform {
|
||||
export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition, lockAspect?: boolean): NodeTransform {
|
||||
const { width, height } = nodeSize(node);
|
||||
const rotation = node.data.rotation || 0;
|
||||
const local = rotateVector(delta, -rotation);
|
||||
@@ -34,13 +34,17 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
|
||||
const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
|
||||
const plain = node.data.kind === "plain-text";
|
||||
const icon = node.data.kind === "icon" || node.data.kind === "plain-image";
|
||||
let nextWidth = Math.max(plain ? 60 : 140, Math.min(3000, width + horizontal * local.x));
|
||||
let nextHeight = Math.max(plain ? 24 : 100, Math.min(3000, height + vertical * local.y));
|
||||
if (icon) {
|
||||
const viewScale = node.data.viewScale || 1;
|
||||
const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale;
|
||||
const minWidth = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 60 : 140) * viewScale;
|
||||
const minHeight = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 24 : 100) * viewScale;
|
||||
let nextWidth = Math.max(minWidth, Math.min(maximum, width + horizontal * local.x));
|
||||
let nextHeight = Math.max(minHeight, Math.min(maximum, height + vertical * local.y));
|
||||
if (lockAspect ?? icon) {
|
||||
const sx = 1 + horizontal * local.x / width;
|
||||
const sy = 1 + vertical * local.y / height;
|
||||
let scale = !horizontal ? sy : !vertical ? sx : Math.abs(sx - 1) > Math.abs(sy - 1) ? sx : sy;
|
||||
scale = Math.max(24 / width, 24 / height, Math.min(scale, 3000 / width, 3000 / height));
|
||||
scale = Math.max(minWidth / width, minHeight / height, Math.min(scale, maximum / width, maximum / height));
|
||||
nextWidth = width * scale;
|
||||
nextHeight = height * scale;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
|
||||
import { decodeSnapshot, type CanvasSnapshot, type CanvasRequest } from "./canvas-sync.ts";
|
||||
import { matchesRef, type Board } from "./workspace.ts";
|
||||
|
||||
export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) {
|
||||
const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`;
|
||||
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal })]);
|
||||
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal, headers: { Accept: CANVAS_ACCEPT } })]);
|
||||
if (board.kind !== "board" || !matchesRef(board, boardRef) || typeof board.workspace_id !== "string" || !board.workspace_id) throw new Error("invalid_reference");
|
||||
return { board, snapshot: decodeSnapshot(snapshot) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { CanvasDocument } from "./canvas.ts";
|
||||
export function subgraphParents(documents: Map<string, CanvasDocument>) {
|
||||
const parents = new Map<string, { boardId: string; frameId: string }>();
|
||||
for (const [boardId, document] of documents) for (const node of document.nodes) if (node.data.subgraph) parents.set(node.data.subgraph.boardId, { boardId, frameId: node.id });
|
||||
return parents;
|
||||
}
|
||||
export function subgraphMoveParticipants(documents: Map<string, CanvasDocument>, parent: string, source: string, target: string, ids: string[]) {
|
||||
const participants = new Set([parent, source, target]);
|
||||
const parents = subgraphParents(documents);
|
||||
const addDescendants = (board: string, seen = new Set<string>()) => {
|
||||
if (seen.has(board)) throw new Error("subgraph_cycle"); seen.add(board); participants.add(board);
|
||||
for (const node of documents.get(board)?.nodes || []) if (node.data.subgraph) addDescendants(node.data.subgraph.boardId, seen);
|
||||
};
|
||||
for (const node of documents.get(source)?.nodes || []) if (ids.includes(node.id) && node.data.subgraph) addDescendants(node.data.subgraph.boardId);
|
||||
const ancestor = parents.get(parent);
|
||||
if (ancestor) {
|
||||
const frame = documents.get(ancestor.boardId)?.nodes.find((node) => node.id === ancestor.frameId);
|
||||
const affected = frame?.data.ports?.some((port) => {
|
||||
if (source === parent) return ids.includes(port.nodeId);
|
||||
const inner = documents.get(parent)?.nodes.find((node) => node.id === port.nodeId);
|
||||
return inner?.data.subgraph?.boardId === source && inner.data.ports?.some((p) => `port:${p.id}` === port.handle && p.direction === port.direction && ids.includes(p.nodeId));
|
||||
});
|
||||
if (affected) participants.add(ancestor.boardId);
|
||||
}
|
||||
return [...participants];
|
||||
}
|
||||
export function subgraphMovePath(documents: Map<string, CanvasDocument>, source: string, target: string) {
|
||||
const parents = subgraphParents(documents);
|
||||
const chain = (board: string) => { const result = [board]; while (parents.has(board)) { board = parents.get(board)!.boardId; if (result.includes(board)) throw new Error("subgraph_cycle"); result.push(board); } return result; };
|
||||
const from = chain(source), to = chain(target), common = from.find((id) => to.includes(id));
|
||||
if (!common) throw new Error("ownership_conflict");
|
||||
const path: { parent: string; frame: string; source: string; target: string }[] = [];
|
||||
for (let i = 0; from[i] !== common; i++) { const relation = parents.get(from[i])!; path.push({ parent: relation.boardId, frame: relation.frameId, source: from[i], target: relation.boardId }); }
|
||||
for (let i = to.indexOf(common) - 1; i >= 0; i--) { const relation = parents.get(to[i])!; path.push({ parent: relation.boardId, frame: relation.frameId, source: relation.boardId, target: to[i] }); }
|
||||
return path;
|
||||
}
|
||||
export function exposedConnection(documents: Map<string, CanvasDocument>, source: { boardId: string; id: string; handle?: string | null }, target: { boardId: string; id: string; handle?: string | null }) {
|
||||
const parents = subgraphParents(documents);
|
||||
const ancestors = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; };
|
||||
const owner = ancestors(source.boardId).find((id) => ancestors(target.boardId).includes(id));
|
||||
if (!owner) throw new Error("ownership_conflict");
|
||||
const expose = (endpoint: typeof source, direction: "input" | "output") => {
|
||||
let { boardId, id, handle } = endpoint;
|
||||
while (boardId !== owner) {
|
||||
const parent = parents.get(boardId)!;
|
||||
const frame = documents.get(parent.boardId)?.nodes.find((node) => node.id === parent.frameId);
|
||||
const port = frame?.data.ports?.find((port) => port.nodeId === id && port.handle === handle && port.direction === direction);
|
||||
if (!port) throw new Error("boundary_port_required");
|
||||
id = parent.frameId; handle = `port:${port.id}`; boardId = parent.boardId;
|
||||
}
|
||||
return { id, handle };
|
||||
};
|
||||
return { boardId: owner, source: expose(source, "output"), target: expose(target, "input") };
|
||||
}
|
||||
export function connectionTransactionPlan(documents: Map<string, CanvasDocument>, source: { boardId: string; id: string; handle: string }, target: { boardId: string; id: string; handle: string }) {
|
||||
const parents = subgraphParents(documents);
|
||||
const path = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; };
|
||||
const from = path(source.boardId), to = path(target.boardId), owner = from.find((id) => to.includes(id));
|
||||
if (!owner) throw new Error("ownership_conflict");
|
||||
return { boardId: owner, participants: [...new Set([...from.slice(0, from.indexOf(owner) + 1), ...to.slice(0, to.indexOf(owner) + 1)])], source: { board_id: source.boardId, node_id: source.id, handle: source.handle }, target: { board_id: target.boardId, node_id: target.id, handle: target.handle } };
|
||||
}
|
||||
+16
-1
@@ -1,3 +1,4 @@
|
||||
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
|
||||
import { decodeCanvas, type CanvasDocument } from "./canvas.ts";
|
||||
|
||||
export type CanvasSnapshot = { version: number; document: CanvasDocument };
|
||||
@@ -23,6 +24,7 @@ export class CanvasSync {
|
||||
version: number;
|
||||
saved: string;
|
||||
error = "";
|
||||
document: CanvasDocument;
|
||||
private pending: CanvasDocument | null = null;
|
||||
private running: Promise<void> | null = null;
|
||||
private request: CanvasRequest;
|
||||
@@ -31,6 +33,7 @@ export class CanvasSync {
|
||||
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.document = initial.document;
|
||||
}
|
||||
enqueue(document: CanvasDocument): Promise<void> {
|
||||
this.pending = document;
|
||||
@@ -39,6 +42,12 @@ export class CanvasSync {
|
||||
return this.running;
|
||||
}
|
||||
retry() { this.error = ""; return this.pending ? this.enqueue(this.pending) : Promise.resolve(); }
|
||||
async settled() { await this.running; }
|
||||
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 });
|
||||
}
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
const document = this.pending; this.pending = null;
|
||||
@@ -48,9 +57,10 @@ export class CanvasSync {
|
||||
if (fingerprint === this.saved) { this.notify({ status: "saved", version: this.version }); continue; }
|
||||
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: { "If-Match": `"${this.version}"` }, body });
|
||||
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.document = document;
|
||||
this.notify({ status: "saved", version: this.version });
|
||||
} catch (error) {
|
||||
this.pending ||= document;
|
||||
@@ -75,3 +85,8 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
|
||||
const doc = decodeCanvas(legacy);
|
||||
return !snapshot || canvasFingerprint(doc) !== canvasFingerprint(snapshot.document) ? doc : null;
|
||||
}
|
||||
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
|
||||
if (draft.schema_version < remote.schema_version) return false;
|
||||
const relations = (document: CanvasDocument) => document.nodes.filter((node) => node.data.subgraph).map((node) => ({ id: node.id, subgraph: node.data.subgraph, ports: node.data.ports || [] })).sort((a, b) => a.id.localeCompare(b.id));
|
||||
return JSON.stringify(relations(draft)) === JSON.stringify(relations(remote));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { nodeSize } from "./canvas-geometry.ts";
|
||||
import type { CanvasDocument, CanvasNode } from "./canvas.ts";
|
||||
import type { CanvasEdgeModel, EdgeData } from "./canvas-v2.ts";
|
||||
export type CanvasReference = { boardId: string; id: string; origin: { x: number; y: number }; scale: number; parentBoard?: string; parentFrame?: string };
|
||||
export type CanvasDocuments = Map<string, CanvasDocument>;
|
||||
export const displayID = (root: string, board: string, id: string) => root === board ? id : `${board}::${id}`;
|
||||
export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined {
|
||||
if (!data?.path) return data ? structuredClone(data) : undefined;
|
||||
const { origin, scale } = reference;
|
||||
const point = (point: { x: number; y: number }, vector = false) => inverse
|
||||
? { x: (point.x - (vector ? 0 : origin.x)) / scale, y: (point.y - (vector ? 0 : origin.y)) / scale }
|
||||
: { x: point.x * scale + (vector ? 0 : origin.x), y: point.y * scale + (vector ? 0 : origin.y) };
|
||||
return { ...data, path: { ...data.path, ...(data.path.startControl ? { startControl: point(data.path.startControl, true) } : {}), ...(data.path.endControl ? { endControl: point(data.path.endControl, true) } : {}), knots: data.path.knots.map((knot) => ({ ...knot, position: point(knot.position), in: point(knot.in, true), out: point(knot.out, true) })) } };
|
||||
}
|
||||
/** Projection is disposable view data. Only owner documents are ever serialized. */
|
||||
export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
const nodeRefs = new Map<string, CanvasReference>(), edgeRefs = new Map<string, CanvasReference>();
|
||||
const nodes: CanvasNode[] = [], edges: CanvasEdgeModel[] = [];
|
||||
const boards = new Map<string, CanvasReference>();
|
||||
const pending: { edge: CanvasEdgeModel; ref: CanvasReference }[] = [];
|
||||
function visit(boardId: string, origin: { x: number; y: number }, scale: number, ancestors: Set<string>, parentBoard?: string, parentFrame?: string) {
|
||||
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
||||
const document = documents.get(boardId);
|
||||
if (!document) return;
|
||||
const next = new Set(ancestors); next.add(boardId);
|
||||
const reference = { boardId, id: boardId, origin, scale, parentBoard, parentFrame }; boards.set(boardId, reference);
|
||||
for (const node of document.nodes) {
|
||||
const id = displayID(root, boardId, node.id);
|
||||
nodeRefs.set(id, { ...reference, id: node.id });
|
||||
const width = nodeSize(node).width * scale, height = nodeSize(node).height * scale;
|
||||
const position = { x: origin.x + node.position.x * scale, y: origin.y + node.position.y * scale };
|
||||
nodes.push({ ...node, id, position, width, height, style: { ...node.style, width, height }, data: { ...node.data, ...(parentBoard && parentFrame ? { frameId: displayID(root, parentBoard, parentFrame) } : {}), viewScale: scale } });
|
||||
if (node.data.subgraph && !node.data.presentation?.collapsed) visit(node.data.subgraph.boardId,
|
||||
{ x: position.x + 24 * scale, y: position.y + 36 * scale }, scale * (node.data.presentation?.contentScale || 1), next, boardId, node.id);
|
||||
}
|
||||
for (const edge of document.edges) pending.push({ edge, ref: reference });
|
||||
}
|
||||
visit(root, { x: 0, y: 0 }, 1, new Set());
|
||||
const visible = new Set(nodes.map((node) => node.id));
|
||||
function endpoint(board: string, nodeId: string, handle: string | null | undefined, depth = 0): { id: string; handle?: string | null } {
|
||||
const id = displayID(root, board, nodeId);
|
||||
if (depth > 8) return { id, handle };
|
||||
const node = documents.get(board)?.nodes.find((node) => node.id === nodeId);
|
||||
const port = node?.data.ports?.find((port) => `port:${port.id}` === handle);
|
||||
if (port && node?.data.subgraph && !node.data.presentation?.collapsed) {
|
||||
const inner = endpoint(node.data.subgraph.boardId, port.nodeId, port.handle, depth + 1);
|
||||
if (visible.has(inner.id)) return inner;
|
||||
}
|
||||
return { id, handle };
|
||||
}
|
||||
for (const { edge, ref } of pending) {
|
||||
const id = displayID(root, ref.boardId, edge.id); edgeRefs.set(id, { ...ref, id: edge.id });
|
||||
const source = endpoint(ref.boardId, edge.source, edge.sourceHandle), target = endpoint(ref.boardId, edge.target, edge.targetHandle);
|
||||
edges.push({ ...edge, id, source: source.id, target: target.id, sourceHandle: source.handle, targetHandle: target.handle, data: transformEdgeData(edge.data, ref) });
|
||||
}
|
||||
return { nodes, edges, nodeRefs, edgeRefs, boards };
|
||||
}
|
||||
/** A selected child and its selected parent frame must not receive the same drag twice. */
|
||||
export function referenceAfterMoves(root: string, documents: CanvasDocuments, boards: Map<string, CanvasReference>, boardId: string, moves: Map<string, { x: number; y: number }>): CanvasReference {
|
||||
const reference = boards.get(boardId);
|
||||
if (!reference) throw new Error("invalid_reference");
|
||||
if (boardId === root || !reference.parentBoard || !reference.parentFrame) return reference;
|
||||
const parent = referenceAfterMoves(root, documents, boards, reference.parentBoard, moves);
|
||||
const frame = documents.get(reference.parentBoard)?.nodes.find((node) => node.id === reference.parentFrame);
|
||||
if (!frame) throw new Error("invalid_reference");
|
||||
const position = moves.get(displayID(root, reference.parentBoard, reference.parentFrame)) || { x: parent.origin.x + frame.position.x * parent.scale, y: parent.origin.y + frame.position.y * parent.scale };
|
||||
return { ...reference, origin: { x: position.x + 24 * parent.scale, y: position.y + 36 * parent.scale } };
|
||||
}
|
||||
|
||||
const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value)
|
||||
=== JSON.stringify(b, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
||||
/** Three-way merge preserves local geometry while applying server AI output. */
|
||||
export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument, remote: CanvasDocument): CanvasDocument {
|
||||
const merge = (base: unknown, local: unknown, remote: unknown): unknown => {
|
||||
if (equal(local, base)) return remote;
|
||||
if (equal(remote, base) || equal(local, remote)) return local;
|
||||
if ([base, local, remote].every((v) => !!v && typeof v === "object" && !Array.isArray(v))) {
|
||||
const b = base as Record<string, unknown>, l = local as Record<string, unknown>, r = remote as Record<string, unknown>;
|
||||
return Object.fromEntries([...new Set([...Object.keys(b), ...Object.keys(l), ...Object.keys(r)])].map((key) => [key, merge(b[key], l[key], r[key])]).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
throw new Error("version_conflict");
|
||||
};
|
||||
function collection<T extends { id: string }>(base: T[], local: T[], remote: T[]): T[] {
|
||||
const b = new Map(base.map((v) => [v.id, v])), l = new Map(local.map((v) => [v.id, v])), r = new Map(remote.map((v) => [v.id, v]));
|
||||
return [...new Set([...r.keys(), ...l.keys()])].map((id) => merge(b.get(id), l.get(id), r.get(id))).filter((v) => v !== undefined) as T[];
|
||||
}
|
||||
const strip = <T extends { selected?: boolean; dragging?: boolean; measured?: unknown; style?: unknown }>(value: T) => { const { selected, dragging, measured, style, ...clean } = value; return clean; };
|
||||
const result = {
|
||||
schema_version: Math.max(local.schema_version, remote.schema_version) as 1 | 2,
|
||||
nodes: collection(base.nodes.map(strip), local.nodes.map(strip), remote.nodes.map(strip)) as CanvasNode[],
|
||||
edges: collection(base.edges.map(strip), local.edges.map(strip), remote.edges.map(strip)) as CanvasEdgeModel[], viewport: local.viewport,
|
||||
};
|
||||
return { ...result, nodes: result.nodes.map((node) => ({ ...node, selected: local.nodes.find((item) => item.id === node.id)?.selected, style: { width: node.width, height: node.height, ...(node.data.kind === "frame" ? { pointerEvents: "none" } : {}) }, ...(node.data.kind === "frame" ? { zIndex: -1 } : {}) })) };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Edge, XYPosition } from "@xyflow/react";
|
||||
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
|
||||
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
|
||||
export type BezierKnot = { id: string; position: XYPosition; in: XYPosition; out: XYPosition; mode: "corner" | "smooth" | "symmetric" };
|
||||
export type EdgeData = {
|
||||
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
|
||||
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
|
||||
path?: { type: "bezier-chain"; startControl?: XYPosition; endControl?: XYPosition; knots: BezierKnot[] };
|
||||
execution?: { enabled?: boolean };
|
||||
};
|
||||
export type CanvasEdgeModel = Edge<EdgeData>;
|
||||
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
|
||||
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
|
||||
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
|
||||
export const CANVAS_ACCEPT = "application/vnd.foundry.canvas+json;version=2";
|
||||
export const wireID = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
export const numberIn = (value: unknown, min: number, max: number): value is number => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
||||
export const pointValid = (value: unknown): value is XYPosition => !!value && typeof value === "object" && numberIn((value as XYPosition).x, -1e6, 1e6) && numberIn((value as XYPosition).y, -1e6, 1e6);
|
||||
export function fields(value: unknown, keys: string[]): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); }
|
||||
function optional(object: Record<string, unknown>, key: string, valid: (value: unknown) => boolean) { check(!(key in object) || valid(object[key])); }
|
||||
const choice = (...values: unknown[]) => (value: unknown) => values.includes(value);
|
||||
const color = (value: unknown) => typeof value === "string" && /^#[a-f\d]{6}$/i.test(value);
|
||||
const bool = (value: unknown) => typeof value === "boolean";
|
||||
export function decodeEdgeData(value: unknown): EdgeData | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
check(fields(value, ["appearance", "animation", "path", "execution"]));
|
||||
const data = value as Record<string, unknown>;
|
||||
if (data.appearance !== undefined) {
|
||||
check(fields(data.appearance, ["color", "width", "opacity", "line", "head", "tail"])); const a = data.appearance as Record<string, unknown>;
|
||||
optional(a, "color", color); optional(a, "width", (v) => numberIn(v, .5, 20)); optional(a, "opacity", (v) => numberIn(v, .05, 1)); optional(a, "line", choice("solid", "dashed", "dotted"));
|
||||
for (const key of ["head", "tail"]) if (a[key] !== undefined) {
|
||||
check(fields(a[key], ["type", "size", "filled"])); const marker = a[key] as Record<string, unknown>;
|
||||
check(choice("none", "open-arrow", "triangle", "circle", "diamond", "square")(marker.type)); optional(marker, "size", (v) => numberIn(v, 4, 32)); optional(marker, "filled", bool);
|
||||
}
|
||||
}
|
||||
if (data.animation !== undefined) {
|
||||
check(fields(data.animation, ["mode", "effect", "direction", "durationMs", "color"])); const a = data.animation as Record<string, unknown>;
|
||||
optional(a, "mode", choice("off", "always", "executing")); optional(a, "effect", choice("flow", "dash", "pulse")); optional(a, "direction", choice("forward", "reverse")); optional(a, "durationMs", (v) => numberIn(v, 250, 30000)); optional(a, "color", color);
|
||||
}
|
||||
if (data.execution !== undefined) { check(fields(data.execution, ["enabled"])); optional(data.execution as Record<string, unknown>, "enabled", bool); }
|
||||
if (data.path !== undefined) {
|
||||
check(fields(data.path, ["type", "startControl", "endControl", "knots"])); const path = data.path as Record<string, unknown>;
|
||||
check(path.type === "bezier-chain" && Array.isArray(path.knots) && path.knots.length <= 64);
|
||||
optional(path, "startControl", pointValid); optional(path, "endControl", pointValid);
|
||||
const ids = new Set<string>();
|
||||
for (const knot of path.knots as BezierKnot[]) {
|
||||
check(fields(knot, ["id", "position", "in", "out", "mode"]) && wireID(knot.id) && !ids.has(knot.id) && pointValid(knot.position) && pointValid(knot.in) && pointValid(knot.out) && choice("corner", "smooth", "symmetric")(knot.mode)); ids.add(knot.id);
|
||||
if (knot.mode !== "corner") {
|
||||
const a = Math.hypot(knot.in.x, knot.in.y), b = Math.hypot(knot.out.x, knot.out.y);
|
||||
if (a && b) check(Math.abs(knot.in.x / a * knot.out.y / b - knot.in.y / a * knot.out.x / b) <= 1e-6 && knot.in.x / a * knot.out.x / b + knot.in.y / a * knot.out.y / b <= 0);
|
||||
if (knot.mode === "symmetric") check(Math.abs(a - b) <= 1e-6 * Math.max(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
return structuredClone(value) as EdgeData;
|
||||
}
|
||||
export function decodeNodeExtensions(data: Record<string, unknown>, schema: number) {
|
||||
const extensions: { ai?: AIConfig; assetId?: string; subgraph?: { boardId: string }; ports?: FramePort[]; presentation?: FramePresentation } = {};
|
||||
for (const key of ["ai", "assetId", "subgraph", "ports", "presentation"]) if (key in data) check(schema === 2);
|
||||
if (data.assetId !== undefined) { check(["image", "plain-image"].includes(String(data.kind)) && typeof data.assetId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.assetId)); extensions.assetId = data.assetId as string; }
|
||||
if (data.ai !== undefined) {
|
||||
check(["text", "script", "plain-text", "image", "plain-image"].includes(String(data.kind)) && fields(data.ai, ["enabled", "modelId", "prompt", "propagate", "parameters"])); const ai = data.ai as Record<string, unknown>;
|
||||
optional(ai, "enabled", bool); optional(ai, "propagate", bool); optional(ai, "modelId", (v) => typeof v === "string" && v.length > 0 && v.length <= 256 && v.trim() === v);
|
||||
optional(ai, "prompt", (v) => typeof v === "string" && [...v].length <= 16000); optional(ai, "parameters", (v) => !!v && typeof v === "object" && !Array.isArray(v) && new TextEncoder().encode(JSON.stringify(v)).length <= 16384);
|
||||
extensions.ai = structuredClone(ai) as AIConfig;
|
||||
}
|
||||
if (["subgraph", "ports", "presentation"].some((key) => key in data)) check(data.kind === "frame");
|
||||
if (data.subgraph !== undefined) { check(fields(data.subgraph, ["boardId"]) && typeof data.subgraph.boardId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.subgraph.boardId)); extensions.subgraph = { boardId: (data.subgraph as { boardId: string }).boardId }; }
|
||||
if (data.ports !== undefined) {
|
||||
check(Array.isArray(data.ports) && data.ports.length <= 4000); const ids = new Set<string>();
|
||||
for (const port of data.ports as FramePort[]) { check(fields(port, ["id", "direction", "nodeId", "handle"]) && wireID(port.id) && !ids.has(port.id) && wireID(port.nodeId) && choice("input", "output")(port.direction) && typeof port.handle === "string" && (/^(top|right|bottom|left)$/.test(port.handle) || /^port:[A-Za-z0-9_-]{1,128}$/.test(port.handle))); ids.add(port.id); }
|
||||
extensions.ports = structuredClone(data.ports) as FramePort[];
|
||||
}
|
||||
if (data.presentation !== undefined) {
|
||||
check(fields(data.presentation, ["collapsed", "contentScale", "expandedSize", "collapsedSize"])); const p = data.presentation as Record<string, unknown>;
|
||||
optional(p, "collapsed", bool); optional(p, "contentScale", (v) => numberIn(v, .05, 4));
|
||||
for (const key of ["expandedSize", "collapsedSize"]) optional(p, key, (v) => fields(v, ["width", "height"]) && numberIn(v.width, 32, 100000) && numberIn(v.height, 32, 100000));
|
||||
extensions.presentation = structuredClone(p) as FramePresentation;
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
export function managedImageSource(src?: string) {
|
||||
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];
|
||||
} catch { /* inline and demo images are already browser-readable */ }
|
||||
return src;
|
||||
}
|
||||
+34
-15
@@ -1,3 +1,4 @@
|
||||
import { decodeEdgeData, decodeNodeExtensions, type CanvasEdgeModel } from "./canvas-v2.ts";
|
||||
import { applyEdgeChanges } from "@xyflow/react";
|
||||
import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from "@xyflow/react";
|
||||
|
||||
@@ -7,10 +8,12 @@ import { nodeSize, rotateVector, nodeCenter, type NodeTransform } from "./canvas
|
||||
export type { CanvasNode } from "./canvas-elements.ts";
|
||||
export type NodeDirection = "top" | "right" | "bottom" | "left";
|
||||
export const nodeDirections: NodeDirection[] = ["top", "right", "bottom", "left"];
|
||||
export type CanvasDocument = { schema_version: 1; nodes: CanvasNode[]; edges: Edge[]; viewport: Viewport };
|
||||
export type CanvasDocument = { schema_version: 1 | 2; nodes: CanvasNode[]; edges: CanvasEdgeModel[]; viewport: Viewport };
|
||||
export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] };
|
||||
export type CanvasAction =
|
||||
| { type: "checkpoint" }
|
||||
| { type: "node-data"; id: string; patch: Partial<CanvasNode["data"]> }
|
||||
| { type: "edge-data"; id: string; data: import("./canvas-v2").EdgeData; transient?: boolean }
|
||||
| { type: "drop"; ids: string[] }
|
||||
| { type: "rename-node"; id: string; title: string }
|
||||
| { type: "frame"; id: string; title?: string; labelMode?: import("./canvas-elements.ts").FrameLabelMode }
|
||||
@@ -34,6 +37,11 @@ function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory
|
||||
}
|
||||
export function canvasReducer(state: CanvasHistory, action: CanvasAction): CanvasHistory {
|
||||
switch (action.type) {
|
||||
case "node-data": return recorded(state, { ...state.present, schema_version: 2, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, ...action.patch } } : node) });
|
||||
case "edge-data": {
|
||||
const present: CanvasDocument = { ...state.present, schema_version: 2, edges: state.present.edges.map((edge) => edge.id === action.id ? { ...edge, data: action.data } : edge) };
|
||||
return action.transient ? { ...state, present, future: [] } : recorded(state, present);
|
||||
}
|
||||
case "drop": return { ...state, present: { ...state.present, nodes: assignFrameMembership(state.present.nodes, action.ids) } };
|
||||
case "rename-node": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, title: action.title } } : node) });
|
||||
case "frame": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id && node.data.kind === "frame" ? { ...node, data: { ...node.data, ...(action.title !== undefined ? { title: action.title } : {}), ...(action.labelMode ? { labelMode: action.labelMode } : {}) } } : node) });
|
||||
@@ -66,7 +74,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
width: patch.width, height: patch.height, measured: { width: patch.width, height: patch.height },
|
||||
style: { ...node.style, width: patch.width, height: patch.height },
|
||||
} : {}),
|
||||
data: { ...node.data, ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
|
||||
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 } };
|
||||
@@ -136,21 +144,21 @@ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").
|
||||
return { ...style };
|
||||
}
|
||||
export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (raw.length > 5_000_000) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
if (new TextEncoder().encode(raw).length > 5 * 1024 * 1024) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
const value = JSON.parse(raw);
|
||||
if (!value || value.schema_version !== 1 || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
|
||||
if (!value || ![1, 2].includes(value.schema_version) || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
|
||||
throw new Error("不是有效的啡跃图谱文件。");
|
||||
const ids = new Set<string>();
|
||||
const nodes: CanvasNode[] = value.nodes.map((node: CanvasNode) => {
|
||||
if (!node || typeof node.id !== "string" || !/^[\w-]{1,128}$/.test(node.id) || ids.has(node.id) || !node.position ||
|
||||
![node.position.x, node.position.y].every((n) => Number.isFinite(n) && Math.abs(n) <= 1_000_000) ||
|
||||
!node.data || !["text", "image", "script", "plain-text", "icon", "frame", "plain-image"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
|
||||
typeof node.data.text !== "string" || node.data.text.length > 100_000 || (["image", "plain-image"].includes(node.data.kind) && !validImage(node.data.src)))
|
||||
!node.data || !["text", "image", "script", "plain-text", "icon", "frame", "plain-image"].includes(node.data.kind) || typeof node.data.title !== "string" || [...node.data.title].length > 300 ||
|
||||
typeof node.data.text !== "string" || [...node.data.text].length > 100_000 || (["image", "plain-image"].includes(node.data.kind) && !validImage(node.data.src)))
|
||||
throw new Error("图谱节点格式无效。");
|
||||
const width = node.width ?? node.style?.width;
|
||||
const height = node.height ?? node.style?.height;
|
||||
if ((width === undefined) !== (height === undefined) || (width !== undefined && (typeof width !== "number" || !Number.isFinite(width) || width < 20 || width > 3000)) ||
|
||||
(height !== undefined && (typeof height !== "number" || !Number.isFinite(height) || height < 20 || height > 3000)) ||
|
||||
if ((width === undefined) !== (height === undefined) || (width !== undefined && (typeof width !== "number" || !Number.isFinite(width) || width < (value.schema_version === 2 && node.data.kind === "frame" ? 32 : 20) || width > (value.schema_version === 2 && node.data.kind === "frame" ? 100000 : 3000))) ||
|
||||
(height !== undefined && (typeof height !== "number" || !Number.isFinite(height) || height < (value.schema_version === 2 && node.data.kind === "frame" ? 32 : 20) || height > (value.schema_version === 2 && node.data.kind === "frame" ? 100000 : 3000))) ||
|
||||
(node.data.rotation !== undefined && (!Number.isFinite(node.data.rotation) || node.data.rotation < 0 || node.data.rotation >= 360)) ||
|
||||
(node.data.fontSize !== undefined && (!Number.isFinite(node.data.fontSize) || node.data.fontSize < 8 || node.data.fontSize > 160)))
|
||||
throw new Error("节点大小或旋转角度无效。");
|
||||
@@ -158,10 +166,12 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (node.data.frameId !== undefined && (node.data.kind === "frame" || typeof node.data.frameId !== "string" || !/^[\w-]{1,128}$/.test(node.data.frameId))) throw new Error("分区归属无效。");
|
||||
if (node.data.kind === "frame" && ((node.data.labelMode !== undefined && !["hidden", "fixed", "scaled"].includes(node.data.labelMode)) || (node.data.rotation || 0) !== 0)) throw new Error("分区设置无效。");
|
||||
const textStyle = decodeTextStyle(node.data.textStyle);
|
||||
const extensions = decodeNodeExtensions(node.data, value.schema_version);
|
||||
ids.add(node.id);
|
||||
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : node.data.kind === "icon" ? "icon" : node.data.kind === "frame" ? "frame" : node.data.kind === "plain-image" ? "plainImage" : "material",
|
||||
...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), ...(node.data.kind === "frame" ? { zIndex: -1, style: { ...(width !== undefined ? { width, height } : {}), pointerEvents: "none" as const } } : {}),
|
||||
position: { x: node.position.x, y: node.position.y }, data: {
|
||||
...extensions,
|
||||
...(node.data.frameId ? { frameId: node.data.frameId } : {}), ...(node.data.kind === "frame" ? { labelMode: node.data.labelMode || "fixed" } : {}),
|
||||
...(textStyle ? { textStyle } : {}), ...(node.data.kind === "icon" ? { icon: node.data.icon } : {}),
|
||||
kind: node.data.kind, title: node.data.title, text: node.data.text,
|
||||
@@ -172,15 +182,24 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (nodes.some((node) => node.data.frameId && !frameIds.has(node.data.frameId))) throw new Error("分区归属不存在。");
|
||||
const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind]));
|
||||
const edgeIds = new Set<string>();
|
||||
const edges: Edge[] = value.edges.map((edge: Edge) => {
|
||||
const edges: CanvasEdgeModel[] = value.edges.map((edge: CanvasEdgeModel) => {
|
||||
if (!edge || typeof edge.id !== "string" || !/^[\w-]{1,128}$/.test(edge.id) || edgeIds.has(edge.id) || !ids.has(edge.source) || !ids.has(edge.target))
|
||||
throw new Error("图谱连线格式无效。");
|
||||
if (!elementCapabilities(nodeKinds.get(edge.source)!).connectable || !elementCapabilities(nodeKinds.get(edge.target)!).connectable)
|
||||
throw new Error("纯文本和图标不支持节点连线。");
|
||||
if ([edge.sourceHandle, edge.targetHandle].some((handle) => handle != null && !nodeDirections.includes(handle as NodeDirection)))
|
||||
throw new Error("图谱连接方向无效。");
|
||||
for (const [nodeId, handle, direction] of [[edge.source, edge.sourceHandle, "output"], [edge.target, edge.targetHandle, "input"]] as const) {
|
||||
const kind = nodeKinds.get(nodeId)!;
|
||||
if (kind === "frame" && value.schema_version === 2) {
|
||||
const node = nodes.find((item) => item.id === nodeId)!;
|
||||
if (!node.data.subgraph || !node.data.ports?.some((port) => `port:${port.id}` === handle && port.direction === direction)) throw new Error("分区端口无效。");
|
||||
} else {
|
||||
const allowed = value.schema_version === 2 ? ["text", "script", "image", "plain-text", "plain-image"].includes(kind) : elementCapabilities(kind).connectable;
|
||||
if (!allowed) throw new Error("该元素不支持节点连线。");
|
||||
if (handle != null && !nodeDirections.includes(handle as NodeDirection)) throw new Error("图谱连接方向无效。");
|
||||
}
|
||||
}
|
||||
const edgeData = decodeEdgeData(edge.data);
|
||||
if (value.schema_version === 1 && edgeData) throw new Error("invalid_canvas");
|
||||
edgeIds.add(edge.id);
|
||||
return { id: edge.id, source: edge.source, target: edge.target,
|
||||
return { id: edge.id, source: edge.source, target: edge.target, ...(edgeData ? { data: edgeData } : {}),
|
||||
...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}),
|
||||
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
|
||||
};
|
||||
@@ -188,5 +207,5 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
const viewport = value.viewport;
|
||||
if (!viewport || ![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom < 0.1 || viewport.zoom > 4)
|
||||
throw new Error("图谱视图格式无效。");
|
||||
return { schema_version: 1, nodes, edges, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } };
|
||||
return { schema_version: value.schema_version, nodes, edges, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export type Board = {
|
||||
version: number;
|
||||
content?: { schema_version: number };
|
||||
description?: string;
|
||||
parent_board_id?: string;
|
||||
frame_id?: string;
|
||||
};
|
||||
export function matchesRef(item: { id: string; slug?: string } | null | undefined, ref: string | undefined) { return Boolean(item && ref && (item.id === ref || item.slug === ref)); }
|
||||
export const DEFAULT_BOARD_TITLE = "未命名图谱";
|
||||
|
||||
Reference in New Issue
Block a user