215 lines
12 KiB
TypeScript
215 lines
12 KiB
TypeScript
"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>;
|
||
}
|