feat: add interactive React Flow canvas and floating toolbars

This commit is contained in:
2026-11-06 00:52:07 +08:00
parent fec69c6e59
commit d4b45c5051
13 changed files with 778 additions and 36 deletions
+37
View File
@@ -0,0 +1,37 @@
.editor { position: absolute; inset: 0; width: 100%; height: 100%; background: #fffefb; }
.editor :global(.react-flow) { --xy-background-color-default: #fffefb; --xy-node-border-selected-default: 1px solid #a47764; }
.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
.node { position: relative; width: 260px; padding: 14px; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
.nodeHeading { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; font-size: 11px; color: #92877c; }
.node textarea { display: block; width: 100%; min-height: 140px; padding: 0; resize: vertical; border: 0; background: none; color: #37352f; font-family: inherit; font-size: 13px; line-height: 1.8; outline: none; }
.node textarea::placeholder { color: #b2a99e; }
.image { display: block; max-width: 100%; max-height: 280px; margin: auto; border-radius: 5px; object-fit: contain; }
.node :global(.react-flow__handle) { width: 8px; height: 8px; background: #a47764; border: 2px solid #fff; }
.toolbar { display: flex; align-items: center; gap: 5px; padding: 9px; border: 1px solid #e2dcd3; border-radius: 14px; background: #fffffff5; color: #4b443d; box-shadow: 0 7px 24px #32251a0a, 0 1px 3px #32251a05; backdrop-filter: blur(12px); }
.toolbar button { box-sizing: content-box; padding: 8px; border-radius: 7px; }
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.toolbar button:disabled { cursor: not-allowed; }
.divider { width: 1px; height: 22px; margin: 0 4px; background: #ece6df; }
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.toolbar .zoomValue { box-sizing: border-box; width: 48px; height: 32px; padding: 0; font-size: 12px; font-variant-numeric: tabular-nums; }
.saveStatus { padding-right: 5px; font-size: 10px; color: #a59b90; }
.bottomToolbar { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 10; }
.helpButton { position: absolute; right: 16px; bottom: 22px; display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid #e2dcd3; border-radius: 50%; background: white; color: #74675c; box-shadow: 0 3px 12px #32251a0a; z-index: 10; }
.helpPopup { position: fixed; inset: auto 16px 68px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
.helpPopup strong { color: #49372e; font-size: 13px; }
.helpPopup p { margin-top: 9px; }
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
@media (max-width: 700px) {
.topRight { top: 58px; right: 10px; }
.toolbar { padding: 5px; gap: 2px; border-radius: 11px; }
.toolbar button { padding: 6px; }
.divider { margin: 0 2px; }
.bottomToolbar { bottom: 16px; max-width: calc(100vw - 48px); }
.helpButton { right: 7px; bottom: 20px; width: 28px; height: 28px; }
.saveStatus { font-size: 9px; }
}
.handMode .node textarea, .handMode .node img { pointer-events: none; }
+214
View File
@@ -0,0 +1,214 @@
"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>;
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { createContext, useContext } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { FileText, Image as ImageIcon, Type } from "lucide-react";
import type { CanvasNode } from "@/lib/canvas";
import styles from "./canvas.module.css";
export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {} });
export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
const editing = useContext(NodeEditing);
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
return (
<article className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><span>{data.title}</span></header>
<Handle type="target" position={Position.Left} />
{data.kind === "image" ? <img className={styles.image} src={data.src} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
<textarea className="nodrag nowheel" aria-label={data.title} placeholder={data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
value={data.text} maxLength={100000} onFocus={editing.begin} onChange={(event) => editing.edit(id, event.target.value)} />
)}
<Handle type="source" position={Position.Right} />
</article>
);
}
+2 -1
View File
@@ -6,6 +6,7 @@ import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import type { Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
import GraphCanvas from "@/components/canvas/graph-canvas";
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const demo = useSearchParams().get("demo") === "1";
@@ -46,7 +47,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
return (
<main className={styles.graph} aria-label={board ? board.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
<div className={styles.graphSurface} aria-label={board ? board.title + "的画布" : "画布"} />
{board ? <GraphCanvas key={workspaceId + ":" + boardId} board={board} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <p className={styles.graphStatus} role="alert">{error}</p>
: !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>
@@ -116,3 +116,7 @@
.logoLink img { display: block; width: 16px; height: 16px; }
.logoLink:hover { background: #e9e7e3; }
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
.floatingAvatarButton { display: grid; place-items: center; width: 28px; height: 24px; border-radius: 5px; }
.floatingAvatarButton:hover { background: #e9e7e3; }
.accountPopup[data-floating="true"] { inset: 56px auto auto 12px; max-height: calc(100dvh - 70px); transform-origin: top left; }