Integrate Canvas V2 subgraphs, editable edges and AI runs

This commit is contained in:
2026-11-06 19:59:56 +08:00
parent 3478daeccb
commit 05d96abd0e
35 changed files with 1626 additions and 443 deletions
+51
View File
@@ -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>;
}
+113 -16
View File
@@ -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" }} />;
}
+1 -1
View File
@@ -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" : ""}`}>
+52
View File
@@ -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} 连接元素`} />)}</>;
}
+3 -3
View File
@@ -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);
}
+4
View File
@@ -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>() });
+32
View File
@@ -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>;
}
+15 -69
View File
@@ -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>;
}
+20 -6
View File
@@ -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>
</>;
}
+189 -47
View File
@@ -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 />
+2 -1
View File
@@ -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} />
+2 -1
View File
@@ -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>;
+2 -1
View File
@@ -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>;
}
+48 -18
View File
@@ -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>;
}
+94
View File
@@ -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 };
}
+271
View File
@@ -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 };
}