Files
Endoleap/src/components/canvas/graph-canvas.tsx
T

215 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react";
import {
Background, BackgroundVariant, ReactFlow, ReactFlowProvider, SelectionMode,
addEdge, useReactFlow, useViewport, type Connection,
} from "@xyflow/react";
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, Type, Undo2, Upload } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { IconButton } from "@/components/ui/icon-button";
import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
import type { Board } from "@/lib/workspace";
import { MaterialNode, NodeEditing } from "./material-node";
import styles from "./canvas.module.css";
const nodeTypes = { material: MaterialNode };
function CanvasEditor({ board }: { board: Board }) {
const flow = useReactFlow<CanvasNode>();
const { zoom } = useViewport();
const reduceMotion = useReducedMotion();
const storageKey = canvasStorageKey(board.workspace_id, board.id);
const [initial] = useState(() => {
try {
const stored = localStorage.getItem(storageKey);
return { document: stored ? decodeCanvas(stored) : emptyCanvas(), error: "" };
} catch { return { document: emptyCanvas(), 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 [saved, setSaved] = useState(true);
const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(null);
const firstSave = useRef(true);
const { nodes, edges } = state.present;
const duration = reduceMotion ? 0 : 180;
useEffect(() => {
if (firstSave.current) { firstSave.current = false; return; }
setSaved(false);
const timeout = setTimeout(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(decodeCanvas(JSON.stringify(state.present))));
setSaved(true);
} catch { setError("浏览器存储空间不足或暂不可用,请导出图谱备份。当前内容仍保留在画布中。"); }
}, 400);
return () => clearTimeout(timeout);
}, [state.present, storageKey]);
// Flush the latest view and nodes when navigating away before the debounce runs.
const latest = useRef(state.present);
latest.current = state.present;
useEffect(() => {
const flush = () => {
try { localStorage.setItem(storageKey, JSON.stringify(decodeCanvas(JSON.stringify(latest.current)))); }
catch { /* The editor already reports storage failures while it is open. */ }
};
window.addEventListener("pagehide", flush);
return () => { window.removeEventListener("pagehide", flush); flush(); };
}, [storageKey]);
const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []);
const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []);
const editing = useMemo(() => ({ begin: beginEdit, edit }), [beginEdit, edit]);
const center = () => {
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
const offset = (nodes.length % 6) * 18;
return { x: point.x - 130 + offset, y: point.y - 80 + offset };
};
function addNode(kind: CanvasNode["data"]["kind"], src?: string) {
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
dispatch({ type: "nodes", changes: [{ type: "add", item: makeCanvasNode(kind, center(), src) }] });
setTool("select");
}
function addTemplate() {
if (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记录主题、受众和一个关键想法。" };
const idea = makeCanvasNode("text", { x: position.x + 20, y: position.y - 100 });
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,
{ id: crypto.randomUUID(), source: brief.id, target: idea.id },
{ id: crypto.randomUUID(), source: idea.id, target: script.id },
] } });
setTool("select");
}
function connect(connection: Connection) {
dispatch({ type: "replace", document: { ...state.present, edges: addEdge({ ...connection, id: crypto.randomUUID() }, edges) } });
}
async function readImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > 2_000_000) {
setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
}
try {
const src = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("无法读取图片。"));
reader.readAsDataURL(file);
});
addNode("image", src);
} catch { setError("无法读取图片,请重新选择。"); }
}
async function importProject(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
const document = decodeCanvas(await file.text());
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);
const url = URL.createObjectURL(new Blob([content], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `${board.title.replace(/[\\/:*?"<>|]/g, "_") || "未命名图谱"}.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
useEffect(() => {
const keydown = (event: KeyboardEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.closest("input, textarea, [contenteditable=true], [role=dialog], [popover]:popover-open")) return;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
event.preventDefault(); dispatch({ type: event.shiftKey ? "redo" : "undo" });
} else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "y") {
event.preventDefault(); dispatch({ type: "redo" });
} else if (!event.metaKey && !event.ctrlKey && !event.altKey) {
if (event.key.toLowerCase() === "v") setTool("select");
if (event.key.toLowerCase() === "h") setTool("hand");
}
};
window.addEventListener("keydown", keydown);
return () => window.removeEventListener("keydown", keydown);
}, []);
return (
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
<NodeEditing.Provider value={editing}>
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes}
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
onNodeDragStart={beginEdit} onConnect={connect}
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"}
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" />
</ReactFlow>
</NodeEditing.Provider>
<div className={styles.topRight}>
<div className={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" })} />
<span className={styles.divider} />
<IconButton icon={Minus} label="缩小" onClick={() => void flow.zoomOut({ duration })} />
<button className={styles.zoomValue} onClick={() => void flow.zoomTo(1, { duration })} title="恢复 100% 缩放">{Math.round(zoom * 100)}%</button>
<IconButton icon={Plus} label="放大" onClick={() => void flow.zoomIn({ duration })} />
<IconButton icon={Maximize} label="适应画布" onClick={() => nodes.length ? void flow.fitView({ padding: 0.2, duration }) : void flow.setViewport({ x: 0, y: 0, zoom: 1 }, { duration })} />
<span className={styles.divider} />
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
</div>
<span className={styles.saveStatus} role="status">{saved ? "保存在此浏览器" : "正在保存到此浏览器…"}</span>
</div>
<div className={`${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
<span className={styles.divider} />
<IconButton icon={Type} label="添加文字" onClick={() => addNode("text")} />
<IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} />
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
<span className={styles.divider} />
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
</div>
<button className={styles.helpButton} popoverTarget="canvas-help" aria-label="画布操作帮助"><HelpCircle size={18} /></button>
<div id="canvas-help" popover="auto" className={styles.helpPopup}>
<strong>自由创作,从移动画布开始</strong>
<p>拖动空白处平移,拖动节点移动位置。</p>
<p>触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。</p>
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
<p>拖动节点两侧圆点创建连线;Delete 删除所选内容。</p>
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
<p>当前编辑保存在此浏览器,可导出 JSON 备份。</p>
</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 />
</div>
);
}
export default function GraphCanvas({ board }: { board: Board }) {
return <ReactFlowProvider><CanvasEditor board={board} /></ReactFlowProvider>;
}