feat(canvas): refine connections, text styles and icon editing
This commit is contained in:
@@ -2,21 +2,25 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
|
||||
addEdge, useReactFlow, useViewport, type Connection,
|
||||
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, Type, Undo2, Upload } from "lucide-react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, Type, Undo2, Upload } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
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 type { Board } from "@/lib/workspace";
|
||||
import { BranchMenu, type BranchRequest } from "./branch-menu";
|
||||
import { IconNode } from "./icon-node";
|
||||
import { IconPicker } from "./icon-picker";
|
||||
import type { TextStyle } from "@/lib/canvas-elements";
|
||||
import { PlainTextNode } from "./plain-text-node";
|
||||
import type { NodeTransform } from "@/lib/canvas-geometry";
|
||||
import { MaterialNode, NodeEditing } from "./material-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode };
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode };
|
||||
|
||||
function CanvasEditor({ board }: { board: Board }) {
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
@@ -32,20 +36,29 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
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 [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
const [rotating, setRotating] = useState<string | null>(null);
|
||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||
const pendingImage = useRef<BranchRequest | null>(null);
|
||||
const closeBranch = useCallback(() => setBranchRequest(null), []);
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const projectInput = useRef<HTMLInputElement>(null);
|
||||
const firstSave = useRef(true);
|
||||
useEffect(() => {
|
||||
const input = imageInput.current;
|
||||
const cancel = () => { pendingImage.current = null; };
|
||||
input?.addEventListener("cancel", cancel);
|
||||
return () => input?.removeEventListener("cancel", cancel);
|
||||
}, []);
|
||||
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);
|
||||
@@ -65,16 +78,38 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const document = branchCanvasNode(state.present, id, direction);
|
||||
dispatch({ type: "replace", document });
|
||||
const child = document.nodes[document.nodes.length - 1];
|
||||
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||
}, [state.present, flow, duration]);
|
||||
const branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => {
|
||||
setBranchRequest({ id, direction, point });
|
||||
setRotating(null);
|
||||
}, []);
|
||||
const transform = useCallback((id: string, transform: NodeTransform) => dispatch({ type: "transform", id, transform }), []);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, edit, branch, transform }), [beginEdit, edit, branch, transform]);
|
||||
const style = useCallback((id: string, style: TextStyle, fontSize?: number) => dispatch({ type: "style", id, style, fontSize }), []);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, edit, branch, transform, style, rotating, rotate: setRotating }), [beginEdit, edit, branch, transform, style, rotating]);
|
||||
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 child = document.nodes.at(-1)!;
|
||||
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
||||
setBranchRequest(null);
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||
}
|
||||
function chooseBranch(kind: "text" | "image" | "script") {
|
||||
if (!branchRequest) return;
|
||||
if (kind === "image") { pendingImage.current = branchRequest; setBranchRequest(null); imageInput.current?.click(); }
|
||||
else createBranch(branchRequest, kind);
|
||||
}
|
||||
const connectionEnd: OnConnectEnd = (event, connection) => {
|
||||
const start = connectionStart.current;
|
||||
connectionStart.current = null;
|
||||
if (!start || connection.isValid) return;
|
||||
const point = "changedTouches" in event ? event.changedTouches[0] : event;
|
||||
if (!point || Math.hypot(point.clientX - start.point.x, point.clientY - start.point.y) < 5) return;
|
||||
if (!document.elementFromPoint(point.clientX, point.clientY)?.closest(".react-flow__pane")) return;
|
||||
if (!flow.getNode(start.id)?.selected) return;
|
||||
const at = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
||||
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
|
||||
};
|
||||
|
||||
const center = () => {
|
||||
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
||||
@@ -96,7 +131,7 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
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: brief.id, target: idea.id, type: "default" },
|
||||
{ id: crypto.randomUUID(), source: idea.id, target: script.id },
|
||||
] } });
|
||||
setTool("select");
|
||||
@@ -107,9 +142,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file) { pendingImage.current = null; 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;
|
||||
pendingImage.current = null; setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
|
||||
}
|
||||
try {
|
||||
const src = await new Promise<string>((resolve, reject) => {
|
||||
@@ -118,8 +153,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
reader.onerror = () => reject(new Error("无法读取图片。"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
addNode("image", src);
|
||||
} catch { setError("无法读取图片,请重新选择。"); }
|
||||
const request = pendingImage.current; pendingImage.current = null;
|
||||
if (request) createBranch(request, "image", src); else addNode("image", src);
|
||||
} catch { pendingImage.current = null; setError("无法读取图片,请重新选择。"); }
|
||||
}
|
||||
async function importProject(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -167,7 +203,13 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes}
|
||||
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
|
||||
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
|
||||
onNodeDragStart={beginEdit} onConnect={connect} connectionMode={ConnectionMode.Loose}
|
||||
connectionLineType={ConnectionLineType.Bezier}
|
||||
onNodeDragStart={() => { beginEdit(); closeBranch(); setRotating(null); }} onConnect={connect} 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); }}
|
||||
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"]}
|
||||
@@ -191,7 +233,6 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
|
||||
</div>
|
||||
<span className={styles.saveStatus} role="status">{saved ? "保存在此浏览器" : "正在保存到此浏览器…"}</span>
|
||||
</div>
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
|
||||
@@ -199,8 +240,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Type} label="添加纯文本" onClick={() => addNode("plain-text")} />
|
||||
<IconButton icon={StickyNote} label="添加文本卡片" onClick={() => addNode("text")} />
|
||||
<IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} />
|
||||
<IconButton icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageInput.current?.click(); }} />
|
||||
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
|
||||
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
|
||||
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
|
||||
@@ -213,12 +255,20 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<p>拖动空白处平移,拖动节点移动位置。</p>
|
||||
<p>触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。</p>
|
||||
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
|
||||
<p>选中后拖动边角调整大小,拖动顶部旋转柄旋转;Shift 吸附 15°,双击复位。</p>
|
||||
<p>纯文本缩放会同步改变字号,不显示卡片边框、连接点和四向加号。</p>
|
||||
<p>拖动卡片连接点创建连线;Delete 删除所选内容。</p>
|
||||
<p>拖动右下角调整大小;在样式栏开启旋转,拖动元素旋转,点击外部退出。Shift 吸附 15°。</p>
|
||||
<p>纯文本可独立调整宽高,文字随宽度换行,字号在样式栏设置;不显示卡片连接点或四向加号。</p>
|
||||
<p>拖动四边中心点或选中后的加号创建贝塞尔连线;点击加号选择新节点类型。Delete 删除所选内容。</p>
|
||||
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
|
||||
<p>当前编辑保存在此浏览器,可导出 JSON 备份。</p>
|
||||
<p>可通过右上角导出 JSON 备份。</p>
|
||||
</div>
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
<IconPicker open={iconPicker} close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
if (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 }] } });
|
||||
setTool("select");
|
||||
}} />
|
||||
{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 />
|
||||
|
||||
Reference in New Issue
Block a user