feat(workspace): integrate persisted canvas, element editing and session renewal

This commit is contained in:
2026-11-06 12:50:43 +08:00
parent 45e78f2e43
commit 373582906d
36 changed files with 1028 additions and 193 deletions
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { BaseEdge, useInternalNode, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { connectorBezier, nodeConnector, type ConnectorSide } from "@/lib/canvas-geometry";
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);
}
export function CanvasEdge(props: EdgeProps) {
const source = useInternalNode<CanvasNode>(props.source);
const target = useInternalNode<CanvasNode>(props.target);
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} />;
}
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 } };
return <path fill="none" className="react-flow__connection-path" d={connectorBezier(from, to)} style={{ stroke: "#aa968b", strokeWidth: 1.5, ...props.connectionLineStyle, strokeLinecap: "round" }} />;
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { DynamicIcon } from "lucide-react/dynamic";
import { Star, type LucideProps } from "lucide-react";
import { iconsById } from "./icon-catalog";
export function CanvasIcon({ name, ...props }: LucideProps & { name?: string }) {
const icon = iconsById.get(name || "Star");
if (!icon) return <Star {...props} />;
return <DynamicIcon name={icon.slug} {...props} fallback={() => <Star {...props} />} />;
}
@@ -0,0 +1,37 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import type { CanvasNodeData } from "@/lib/canvas-elements";
import { NodeEditing } from "./transformable-node";
import { textAppearance } from "./text-appearance";
import styles from "./canvas.module.css";
/** Single clicks select; native double-click timing controls entry into editing. */
export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) {
const actions = useContext(NodeEditing);
const [editing, setEditing] = useState(false);
const textarea = useRef<HTMLTextAreaElement>(null);
const press = useRef({ x: 0, y: 0, moved: false });
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
useEffect(() => {
if (!editing) return;
const element = textarea.current;
element?.focus({ preventScroll: true });
element?.setSelectionRange(element.value.length, element.value.length);
}, [editing]);
function begin() { actions.begin(); setEditing(true); }
const active = editing && selected;
const label = plain ? "纯文本" : data.title;
return <div className={`${styles.textEditor} ${active ? "nodrag" : ""}`}>
<textarea ref={textarea} style={textAppearance(data)} className={`${plain ? styles.plainText : styles.cardText} nodrag nowheel`}
aria-label={label} readOnly={!active} tabIndex={active ? 0 : -1}
placeholder={plain ? "写下文字…" : data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
value={data.text} maxLength={100000} onChange={(event) => actions.edit(id, event.target.value)}
onBlur={() => setEditing(false)} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
{!active && <div className={styles.textSelectLayer} role="button" tabIndex={selected ? 0 : -1} aria-label={`单击选中,双击编辑${label}`}
onPointerDown={(event) => { press.current = { x: event.clientX, y: event.clientY, moved: false }; }}
onPointerMove={(event) => { if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 4) press.current.moved = true; }}
onDoubleClick={(event) => { if (!press.current.moved) { event.preventDefault(); event.stopPropagation(); if (!selected) actions.select(id); begin(); } }}
onKeyDown={(event) => { if (selected && (event.key === "Enter" || event.key === "F2")) { event.preventDefault(); event.stopPropagation(); begin(); } }} />}
</div>;
}
+41 -13
View File
@@ -5,7 +5,7 @@
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
.node { position: relative; padding: 0; 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 { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; max-width: 100%; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
@@ -36,16 +36,15 @@
.plainFrame { padding: 0; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
.plainFrame.selectedFrame { border-color: #a47764; }
.plainText { padding: 4px; }
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 2px solid #fff; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; transition: width 160ms ease, height 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; }
.centerHandle svg { opacity: 0; scale: 0.4; pointer-events: none; transition: opacity 140ms ease, scale 160ms ease; }
.node :global(.react-flow__handle).plusHandle { width: 28px; height: 28px; background: #fffefb; border: 1px solid #cbb8a8; box-shadow: 0 2px 6px #32251a0a; }
.plusHandle svg { opacity: 1; scale: 1; }
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 0; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; opacity: 0; pointer-events: none; transition: opacity 160ms ease, background 160ms ease, border-color 160ms ease; }
.selectedNode :global(.react-flow__handle).centerHandle { opacity: 1; }
.node :global(.react-flow__handle).centerHandle:global(.connectionindicator) { pointer-events: auto; }
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 0; scale: 0.8; pointer-events: none; transition: opacity 160ms ease, scale 160ms ease; }
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 1; scale: 1; pointer-events: auto; transition: opacity 160ms ease, scale 160ms ease; }
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
.rotatingFrame { cursor: grabbing; touch-action: none; }
.rotatingFrame textarea, .rotatingFrame .centerHandle { pointer-events: none; }
.rotatingFrame textarea, .node.rotatingFrame :global(.react-flow__handle).centerHandle { pointer-events: none; }
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
@@ -71,13 +70,42 @@
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
.handMode .plainText { pointer-events: none; }
.handMode .cornerResize, .handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
.handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
.handMode .cornerResize { pointer-events: none; opacity: 0.6; }
.handMode .styleToolbar { display: none; }
@media (prefers-reduced-motion: reduce) {
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle, .centerHandle svg { transition: none; }
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle { transition: none; }
}
.textDragHandle { position: absolute; top: -23px; left: 0; display: grid; place-items: center; width: 20px; height: 20px; color: #92877c; cursor: grab; opacity: 0; pointer-events: none; transition: opacity 160ms ease; }
.selectedFrame .textDragHandle { opacity: 1; pointer-events: auto; }
.handMode .textDragHandle { visibility: hidden; }
@media (prefers-reduced-motion: reduce) { .textDragHandle { transition: none; } }
.frameNode { border: 1px solid #c9b7a5; border-radius: 8px; background: transparent; pointer-events: none; }
.frameNode.selectedFrame { border-color: #a47764; box-shadow: 0 0 0 2px #a477641a; }
.frameLabel { position: absolute; bottom: calc(100% + 8px); left: 0; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 22px; color: #92806d; transform-origin: left bottom; pointer-events: auto; cursor: grab; }
.frameBorderHit { position: absolute; pointer-events: auto; cursor: grab; }
.frameBorder_top { top: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_bottom { bottom: -5px; left: 0; right: 0; height: 10px; }
.frameBorder_left { left: -5px; top: 0; bottom: 0; width: 10px; }
.frameBorder_right { right: -5px; top: 0; bottom: 0; width: 10px; }
.styleToolbar .frameNameInput { width: 150px; height: 28px; padding: 0 6px; border: 1px solid #ded7ce; border-radius: 4px; font-size: 12px; background: transparent; }
.handMode .frameBorderHit, .handMode .frameLabel { pointer-events: none; }
.syncError { position: absolute; left: 50%; top: 70px; translate: -50% 0; z-index: 22; display: flex; flex-wrap: wrap; gap: 8px; max-width: min(580px, calc(100vw - 24px)); padding: 12px; border: 1px solid #e5c7b8; border-radius: 8px; background: #fff9f3; color: #a25439; font-size: 12px; }
.syncError span { flex-basis: 100%; }
.recoveryDialog { max-width: min(460px, calc(100vw - 32px)); padding: 24px; margin: auto; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 12px 50px #32251a20; }
.recoveryDialog::backdrop { background: #30251e33; }
.recoveryDialog h2 { font-size: 16px; margin-bottom: 12px; }
.recoveryDialog p { font-size: 13px; line-height: 1.8; color: #85766b; }
.recoveryDialog > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
.recoveryDialog button { padding: 8px 12px; border: 1px solid #cbb8a8; border-radius: 5px; }
.textEditor { position: relative; display: flex; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; border-radius: inherit; }
.textEditor textarea[readonly] { pointer-events: none; }
.textSelectLayer { position: absolute; inset: 0; border-radius: inherit; cursor: default; }
.handMode .textSelectLayer { pointer-events: none; }
.selectedFrame .textSelectLayer { cursor: grab; }
.textSelectLayer:active { cursor: grabbing; }
.plainImageFrame { padding: 0; }
.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; }
+13
View File
@@ -0,0 +1,13 @@
"use client";
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 }) {
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>
</dialog>, document.body);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { CanvasNode } from "@/lib/canvas";
import type { FrameLabelMode } from "@/lib/canvas-elements";
import { NodeEditing, TransformableNode } from "./transformable-node";
import styles from "./canvas.module.css";
export function FrameNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
const editing = useContext(NodeEditing);
const { zoom } = useViewport();
const reduce = useReducedMotion();
const [title, setTitle] = useState(data.title);
useEffect(() => setTitle(data.title), [data.title]);
const cancelBlur = useRef(false);
const mode = data.labelMode || "fixed";
function saveTitle() {
const name = title.trim() || "未命名分区";
if (name !== data.title) editing.frame(id, { title: name });
setTitle(name);
}
return <>
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<AnimatePresence>{selected && <motion.div className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }}
onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<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(); } }} />
<select aria-label="分区名称显示模式" value={mode} onChange={(event) => editing.frame(id, { labelMode: event.target.value as FrameLabelMode })}>
<option value="hidden">隐藏名称</option><option value="fixed">固定字号</option><option value="scaled">随画布缩放</option>
</select>
</motion.div>}</AnimatePresence>
</NodeToolbar>
<TransformableNode {...props} hideStyleToolbar className={styles.frameNode}>
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / zoom : 1 }} title={data.title}>{data.title}</div>}
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
</TransformableNode>
</>;
}
+75 -40
View File
@@ -5,33 +5,41 @@ 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, Type, Undo2, Upload } from "lucide-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 { 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 { errorText } from "@/lib/api";
import { readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
import { useCanvasPersistence } from "./use-canvas-persistence";
import type { Board } from "@/lib/workspace";
import { CanvasEdge, CanvasConnectionLine } from "./canvas-edge";
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 { DraftRecovery } from "./draft-recovery";
import { FrameNode } from "./frame-node";
import type { FrameLabelMode, TextStyle } from "@/lib/canvas-elements";
import { PlainImageNode } from "./plain-image-node";
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, icon: IconNode };
const edgeTypes = { default: CanvasEdge };
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
function CanvasEditor({ board }: { board: Board }) {
function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
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 local = readLocalRecovery(localStorage, storageKey, snapshot);
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");
@@ -39,12 +47,16 @@ function CanvasEditor({ board }: { board: Board }) {
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
const [iconPicker, setIconPicker] = useState(false);
const [rotating, setRotating] = useState<string | null>(null);
const dragOrigins = useRef(new Map<string, XYPosition>());
const draggingFrame = useRef(false);
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
const pendingImage = useRef<BranchRequest | null>(null);
const closeBranch = useCallback(() => setBranchRequest(null), []);
const imageKind = useRef<"image" | "plain-image">("image");
const imageInput = useRef<HTMLInputElement>(null);
const projectInput = useRef<HTMLInputElement>(null);
const firstSave = useRef(true);
const [recovery, setRecovery] = useState(initial.recovery);
const sync = useCanvasPersistence(state.present, board.workspace_id, board.id, storageKey, snapshot, Boolean(recovery));
useEffect(() => {
const input = imageInput.current;
const cancel = () => { pendingImage.current = null; };
@@ -54,28 +66,10 @@ function CanvasEditor({ board }: { board: Board }) {
const { nodes, edges } = state.present;
const duration = reduceMotion ? 0 : 180;
useEffect(() => {
if (firstSave.current) { firstSave.current = false; return; }
const timeout = setTimeout(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(decodeCanvas(JSON.stringify(state.present))));
} 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 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) => {
@@ -84,7 +78,9 @@ function CanvasEditor({ board }: { board: Board }) {
}, []);
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 editing = useMemo(() => ({ begin: beginEdit, edit, branch, transform, style, rotating, rotate: setRotating }), [beginEdit, edit, branch, transform, style, rotating]);
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]);
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
try {
const document = branchCanvasNode(state.present, request.id, request.direction, kind, src, request.at);
@@ -96,7 +92,7 @@ function CanvasEditor({ board }: { board: Board }) {
}
function chooseBranch(kind: "text" | "image" | "script") {
if (!branchRequest) return;
if (kind === "image") { pendingImage.current = branchRequest; setBranchRequest(null); imageInput.current?.click(); }
if (kind === "image") { pendingImage.current = branchRequest; imageKind.current = "image"; setBranchRequest(null); imageInput.current?.click(); }
else createBranch(branchRequest, kind);
}
const connectionEnd: OnConnectEnd = (event, connection) => {
@@ -116,9 +112,12 @@ function CanvasEditor({ board }: { board: Board }) {
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) {
function addNode(kind: CanvasNode["data"]["kind"], src?: string, size?: { width: number; height: number }, title?: string) {
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点,请新建图谱继续创作。"); return; }
dispatch({ type: "nodes", changes: [{ type: "add", item: makeCanvasNode(kind, center(), src) }] });
const node = makeCanvasNode(kind, center(), src);
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() {
@@ -136,6 +135,20 @@ function CanvasEditor({ board }: { board: Board }) {
] } });
setTool("select");
}
function beginDrag(node: CanvasNode, dragged: CanvasNode[]) {
beginEdit(); closeBranch(); setRotating(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]));
}
function finishDrag(node: CanvasNode, dragged: CanvasNode[]) {
if (!draggingFrame.current) {
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);
if (ids.length) dispatch({ type: "drop", ids });
}
dragOrigins.current.clear();
}
function connect(connection: Connection) {
dispatch({ type: "replace", document: { ...state.present, edges: addEdge({ ...connection, id: crypto.randomUUID() }, edges) } });
}
@@ -146,6 +159,8 @@ function CanvasEditor({ board }: { board: Board }) {
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > 2_000_000) {
pendingImage.current = null; setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
}
const request = pendingImage.current; pendingImage.current = null;
const kind = imageKind.current;
try {
const src = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
@@ -153,8 +168,16 @@ function CanvasEditor({ board }: { board: Board }) {
reader.onerror = () => reject(new Error("无法读取图片。"));
reader.readAsDataURL(file);
});
const request = pendingImage.current; pendingImage.current = null;
if (request) createBranch(request, "image", src); else addNode("image", src);
let size: { width: number; height: number } | undefined;
if (kind === "plain-image" && !request) {
size = await new Promise((resolve, reject) => {
const image = new window.Image();
image.onload = () => { const scale = Math.min(1, 480 / image.naturalWidth, 480 / image.naturalHeight); resolve({ width: Math.max(24, Math.round(image.naturalWidth * scale)), height: Math.max(24, Math.round(image.naturalHeight * scale)) }); };
image.onerror = () => reject(new Error("无法读取图片尺寸。"));
image.src = src;
});
}
if (request) createBranch(request, "image", src); else addNode(kind, src, size, kind === "plain-image" ? file.name : undefined);
} catch { pendingImage.current = null; setError("无法读取图片,请重新选择。"); }
}
async function importProject(event: ChangeEvent<HTMLInputElement>) {
@@ -200,11 +223,14 @@ function CanvasEditor({ board }: { board: Board }) {
return (
<div className={`${styles.editor} ${tool === "hand" ? styles.handMode : ""}`}>
<NodeEditing.Provider value={editing}>
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes}
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
connectionLineType={ConnectionLineType.Bezier}
onNodeDragStart={() => { beginEdit(); closeBranch(); setRotating(null); }} onConnect={connect} connectionMode={ConnectionMode.Loose}
connectionLineType={ConnectionLineType.Bezier} connectionLineComponent={CanvasConnectionLine}
onNodeDragStart={(_, node, dragged) => beginDrag(node, dragged)}
onNodeDragStop={(_, node, dragged) => 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}
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}
@@ -214,6 +240,7 @@ function CanvasEditor({ board }: { board: Board }) {
defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
nodesDraggable={tool === "select"} nodesConnectable={tool === "select"}
elevateNodesOnSelect={false}
elementsSelectable={tool === "select"} selectionMode={SelectionMode.Partial}
panActivationKeyCode="Space" selectionKeyCode="Shift"
deleteKeyCode={["Backspace", "Delete"]} preventScrolling
@@ -240,8 +267,10 @@ 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={() => { pendingImage.current = null; imageInput.current?.click(); }} />
<IconButton icon={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageKind.current = "image"; imageInput.current?.click(); }} />
<IconButton icon={ImageIcon} label="添加纯图片" onClick={() => { pendingImage.current = null; imageKind.current = "plain-image"; imageInput.current?.click(); }} />
<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={Sparkles} label="插入创作模板" onClick={addTemplate} />
<span className={toolbar.divider} aria-hidden="true" />
@@ -257,7 +286,8 @@ function CanvasEditor({ board }: { board: Board }) {
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
<p>拖动右下角调整大小;在样式栏开启旋转,拖动元素旋转,点击外部退出。Shift 吸附 15°。</p>
<p>纯文本可独立调整宽高,文字随宽度换行,字号在样式栏设置;不显示卡片连接点或四向加号。</p>
<p>拖动四边中心点或选中后的加号创建贝塞尔连线;点击加号选择新节点类型。Delete 删除所选内容。</p>
<p>拖动四边中心点创建贝塞尔连线;选中后点击连接点选择新节点类型。Delete 删除所选内容。</p>
<p>添加框选区后拖入内容加入;拖出退出。移动分区只带动已有成员,不收纳被覆盖的内容。选中分区可改名并选择名称显示模式。</p>
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
<p>可通过右上角导出 JSON 备份。</p>
</div>
@@ -269,6 +299,11 @@ function CanvasEditor({ board }: { board: Board }) {
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
setTool("select");
}} />
{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>
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
</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 />
@@ -276,6 +311,6 @@ function CanvasEditor({ board }: { board: Board }) {
);
}
export default function GraphCanvas({ board }: { board: Board }) {
return <ReactFlowProvider><CanvasEditor board={board} /></ReactFlowProvider>;
export default function GraphCanvas({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} /></ReactFlowProvider>;
}
+65 -19
View File
@@ -1,19 +1,65 @@
import { ArrowRight, BookOpen, Briefcase, Calendar, Camera, Check, Circle, Cloud, Code, Coffee, Compass, Diamond, FileText, Flag, Flame, Folder, Globe, Heart, Image, Layers, Leaf, Lightbulb, Link, MapPin, MessageCircle, Moon, Music, Palette, Play, Rocket, Search, Settings, Shield, Smile, Sparkles, Square, Star, Sun, Target, ThumbsUp, Triangle, Users, Video, WandSparkles, Zap } from "lucide-react";
export const iconCatalog = [
{ name: "Star", label: "星星", Icon: Star }, { name: "Heart", label: "爱心", Icon: Heart }, { name: "Lightbulb", label: "灵感", Icon: Lightbulb },
{ name: "Sparkles", label: "闪光", Icon: Sparkles }, { name: "Zap", label: "闪电", Icon: Zap }, { name: "Flame", label: "火焰", Icon: Flame },
{ name: "Rocket", label: "火箭", Icon: Rocket }, { name: "Target", label: "目标", Icon: Target }, { name: "Flag", label: "旗帜", Icon: Flag },
{ name: "Check", label: "完成", Icon: Check }, { name: "ThumbsUp", label: "点赞", Icon: ThumbsUp }, { name: "Smile", label: "笑脸", Icon: Smile },
{ name: "Coffee", label: "咖啡", Icon: Coffee }, { name: "Leaf", label: "叶子", Icon: Leaf }, { name: "Sun", label: "太阳", Icon: Sun },
{ name: "Moon", label: "月亮", Icon: Moon }, { name: "Cloud", label: "云", Icon: Cloud }, { name: "Globe", label: "地球", Icon: Globe },
{ name: "MapPin", label: "位置", Icon: MapPin }, { name: "Compass", label: "指南针", Icon: Compass }, { name: "ArrowRight", label: "箭头", Icon: ArrowRight },
{ name: "BookOpen", label: "书本", Icon: BookOpen }, { name: "FileText", label: "文件", Icon: FileText }, { name: "Folder", label: "文件夹", Icon: Folder },
{ name: "Calendar", label: "日历", Icon: Calendar }, { name: "Briefcase", label: "公文包", Icon: Briefcase }, { name: "Users", label: "用户", Icon: Users },
{ name: "MessageCircle", label: "对话", Icon: MessageCircle }, { name: "Link", label: "链接", Icon: Link }, { name: "Code", label: "代码", Icon: Code },
{ name: "Settings", label: "设置", Icon: Settings }, { name: "Shield", label: "盾牌", Icon: Shield }, { name: "Search", label: "搜索", Icon: Search },
{ name: "Image", label: "图片", Icon: Image }, { name: "Camera", label: "相机", Icon: Camera }, { name: "Video", label: "视频", Icon: Video },
{ name: "Music", label: "音乐", Icon: Music }, { name: "Play", label: "播放", Icon: Play }, { name: "Palette", label: "调色板", Icon: Palette },
{ name: "WandSparkles", label: "魔法", Icon: WandSparkles }, { name: "Layers", label: "图层", Icon: Layers }, { name: "Circle", label: "圆形", Icon: Circle },
{ name: "Square", label: "方形", Icon: Square }, { name: "Triangle", label: "三角形", Icon: Triangle }, { name: "Diamond", label: "菱形", Icon: Diamond },
];
export function canvasIcon(name?: string) { return iconCatalog.find((icon) => icon.name === name)?.Icon || Star; }
import dynamicIconImports from "lucide-react/dynamicIconImports";
import type { IconName } from "lucide-react/dynamic";
const chinese: Record<string, string> = {
"Star": "星星",
"Heart": "爱心",
"Lightbulb": "灵感",
"Sparkles": "闪光",
"Zap": "闪电",
"Flame": "火焰",
"Rocket": "火箭",
"Target": "目标",
"Flag": "旗帜",
"Check": "完成",
"ThumbsUp": "点赞",
"Smile": "笑脸",
"Coffee": "咖啡",
"Leaf": "叶子",
"Sun": "太阳",
"Moon": "月亮",
"Cloud": "云",
"Globe": "地球",
"MapPin": "位置",
"Compass": "指南针",
"ArrowRight": "箭头",
"BookOpen": "书本",
"FileText": "文件",
"Folder": "文件夹",
"Calendar": "日历",
"Briefcase": "公文包",
"Users": "用户",
"MessageCircle": "对话",
"Link": "链接",
"Code": "代码",
"Settings": "设置",
"Shield": "盾牌",
"Search": "搜索",
"Image": "图片",
"Camera": "相机",
"Video": "视频",
"Music": "音乐",
"Play": "播放",
"Palette": "调色板",
"WandSparkles": "魔法",
"Layers": "图层",
"Circle": "圆形",
"Square": "方形",
"Triangle": "三角形",
"Diamond": "菱形"
};
const keywords: Record<string, string> = { arrow: "箭头 方向", user: "用户 人物", file: "文件 文档", folder: "文件夹 目录", heart: "爱心 喜欢", calendar: "日历 日期", clock: "时间 时钟", image: "图片 图像", camera: "相机 摄影", video: "视频 电影", music: "音乐 音频", cloud: "云 天气", sun: "太阳 天气", moon: "月亮 夜晚", circle: "圆 圆形", square: "方形 正方形", triangle: "三角形", building: "建筑 公司", book: "书 阅读", code: "代码 编程", chart: "图表 数据", mail: "邮件 信封", message: "消息 对话", map: "地图 位置", search: "搜索 查找", settings: "设置", lock: "锁 安全", key: "钥匙 密钥", star: "星星 收藏", check: "确认 完成", chevron: "箭头 展开", laptop: "电脑 笔记本", monitor: "屏幕 显示器", smartphone: "手机", shopping: "购物 商店", dollar: "货币 金钱", euro: "货币 欧元", zodiac: "星座", tree: "树 植物", flower: "花 植物", leaf: "叶子 植物", dog: "狗 动物", cat: "猫 动物", fish: "鱼 动物", coffee: "咖啡 饮料", food: "食物", smile: "表情 笑脸", angry: "表情 生气", frown: "表情 难过", paint: "绘画 颜色", palette: "调色板 颜色", text: "文本 文字", type: "字体 文字", grip: "拖动", move: "移动", zoom: "缩放", eye: "眼睛 显示", accessibility: "无障碍", audio: "音频 声音", battery: "电池", network: "网络 连接", wifi: "无线 网络", globe: "地球 全球", bell: "铃铛 通知", badge: "徽章", shield: "盾牌 安全", pencil: "铅笔 编辑", pen: "笔 写作", trash: "垃圾 删除", undo: "撤销", redo: "重做", rocket: "火箭", sparkles: "闪光 灵感", lightbulb: "灯泡 灵感" };
function identifier(slug: string) {
const pascal = slug.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join("");
return /^[A-Z]/.test(pascal) ? pascal : "Icon" + pascal;
}
const entries = (Object.keys(dynamicIconImports) as IconName[]).map((slug) => {
const name = identifier(slug);
return { name, slug, label: chinese[name] || slug.replaceAll("-", " "), keywords: slug.split("-").map((word) => keywords[word] || "").join(" ") };
});
export const iconCatalog = Array.from(new Map(entries.map((icon) => [icon.name, icon])).values());
export const iconsById = new Map(iconCatalog.map((icon) => [icon.name, icon]));
export function searchIcons(query: string) {
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
return iconCatalog.filter((icon) => (icon.name + icon.label + icon.keywords).toLowerCase().replace(/[-_\s]/g, "").includes(needle));
}
+2 -3
View File
@@ -2,9 +2,8 @@
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { TransformableNode } from "./transformable-node";
import { canvasIcon } from "./icon-catalog";
import { CanvasIcon } from "./canvas-icon";
import styles from "./canvas.module.css";
export function IconNode(props: NodeProps<CanvasNode>) {
const Icon = canvasIcon(props.data.icon);
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><Icon aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><CanvasIcon name={props.data.icon} aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
}
+12 -7
View File
@@ -1,21 +1,26 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { iconCatalog } from "./icon-catalog";
import { CanvasIcon } from "./canvas-icon";
import { iconCatalog, searchIcons } from "./icon-catalog";
import styles from "./canvas.module.css";
export function IconPicker({ open, close, choose }: { open: boolean; close: () => void; choose: (name: string, label: string) => void }) {
const [query, setQuery] = useState("");
const [limit, setLimit] = useState(80);
const matches = useMemo(() => searchIcons(query), [query]);
const search = useRef<HTMLInputElement>(null);
const reduce = useReducedMotion();
useEffect(() => { if (open) search.current?.focus(); }, [open]);
useEffect(() => { if (open) { search.current?.focus(); setLimit(80); } }, [open]);
if (typeof document === "undefined") return null;
return createPortal(<AnimatePresence>{open && <motion.aside key="icons" initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: reduce ? 0 : -12 }} transition={{ duration: reduce ? 0 : 0.16 }} className={styles.iconPicker} role="dialog" aria-label="添加图标" onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") close(); }}>
<header><strong>图标</strong><button aria-label="关闭图标面板" onClick={close}><X size={16} /></button></header>
<input ref={search} type="search" aria-label="搜索图标" placeholder="搜索图标…" value={query} onChange={(event) => setQuery(event.target.value)} />
<div className={styles.iconGrid}>{iconCatalog.filter((icon) => (icon.name + icon.label).toLowerCase().includes(query.toLowerCase())).map(({ name, label, Icon }) => <button key={name} title={label} aria-label={`添加${label}图标`} onClick={() => choose(name, label)}><Icon size={24} /></button>)}</div>
{!iconCatalog.some((icon) => (icon.name + icon.label).toLowerCase().includes(query.toLowerCase())) && <p>未找到图标</p>}
<footer>Lucide · {iconCatalog.length} 个图标</footer>
<input ref={search} type="search" aria-label="搜索图标" placeholder="搜索图标…" value={query} onChange={(event) => { setQuery(event.target.value); setLimit(80); }} />
<div className={styles.iconGrid}>{matches.slice(0, limit).map(({ name, label }) => <button key={name} title={label} aria-label={`添加${label}图标`} onClick={() => choose(name, label)}><CanvasIcon name={name} size={24} /></button>)}</div>
{!matches.length && <p>未找到图标</p>}
<footer>Lucide · {iconCatalog.length} 个图标 · 已显示 {Math.min(limit, matches.length)} / {matches.length}
{limit < matches.length && <button type="button" onClick={() => setLimit(limit + 80)}>加载更多</button>}
</footer>
</motion.aside>}</AnimatePresence>, document.body);
}
+7 -8
View File
@@ -2,10 +2,11 @@
import { useContext, useRef } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { FileText, Image as ImageIcon, Plus, Type } from "lucide-react";
import { FileText, Image as ImageIcon, Type } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
import { textAppearance } from "./text-appearance";
import { NodeTitle } from "./node-title";
import { CanvasTextEditor } from "./canvas-text-editor";
import styles from "./canvas.module.css";
import { TransformableNode, NodeEditing } from "./transformable-node";
export { NodeEditing } from "./transformable-node";
@@ -20,16 +21,15 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
return (
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><span>{data.title}</span></header>
<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" /> : (
<textarea style={textAppearance(data)} className={`${styles.cardText} 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)} />
<CanvasTextEditor id={id} data={data} selected={selected} />
)}
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
{nodeDirections.map((direction) => <Handle key={direction} id={direction} type="source" position={positions[direction]}
className={`${styles.centerHandle} ${selected ? styles.plusHandle : ""}`}
aria-label={selected ? `在${labels[direction]}添加或连接节点` : `从${labels[direction]}连接节点`} role="button" tabIndex={selected ? 0 : -1}
className={styles.centerHandle} isConnectableStart={selected}
aria-hidden={!selected} aria-label={`从${labels[direction]}连接节点`} role="button" tabIndex={selected ? 0 : -1}
onPointerDown={(event) => { pointerStart.current = { x: event.clientX, y: event.clientY }; }}
onClick={(event) => {
event.stopPropagation();
@@ -37,7 +37,6 @@ export function MaterialNode(props: NodeProps<CanvasNode>) {
editing.branch(id, direction, { x: event.clientX, y: event.clientY });
}}
onKeyDown={(event) => { if (selected && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); editing.branch(id, direction, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); } }}>
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
</Handle>)}
</TransformableNode>
);
+4 -4
View File
@@ -15,10 +15,10 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
const style = data.textStyle || {};
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
return <NodeToolbar isVisible={true} position={Position.Top} offset={34}>
return <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<AnimatePresence>{selected && <motion.div initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
{data.kind !== "image" && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
{!(["image", "plain-image"].includes(data.kind)) && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
{text && <>
<select aria-label="字体" value={style.fontFamily || "sans"} onChange={(event) => patch({ fontFamily: event.target.value as TextStyle["fontFamily"] })}><option value="sans">正文</option><option value="serif">衬线</option><option value="mono">等宽</option></select>
<input aria-label="字号" title="字号" type="number" min={8} max={160} value={data.fontSize || (data.kind === "plain-text" ? 24 : 13)} onChange={(event) => { const size = Number(event.target.value); if (size >= 8 && size <= 160) patch({}, size); }} />
@@ -26,8 +26,8 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
{([["left", AlignLeft, "左对齐"], ["center", AlignCenter, "居中"], ["right", AlignRight, "右对齐"]] as const).map(([align, Icon, label]) => <button key={align} aria-label={label} aria-pressed={(style.align || "left") === align} title={label} onClick={() => patch({ align })}><Icon size={16} /></button>)}
<select aria-label="行距" title="行距" value={style.lineHeight || 1.5} onChange={(event) => patch({ lineHeight: Number(event.target.value) })}><option value={1}>1</option><option value={1.25}>1.25</option><option value={1.5}>1.5</option><option value={1.8}>1.8</option><option value={2}>2</option></select>
</>}
{data.kind !== "image" && <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>}
{data.kind !== "image" && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
{!(["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>}
<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>;
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useContext, useEffect, useRef, useState } from "react";
import { NodeEditing } from "./transformable-node";
import styles from "./canvas.module.css";
export function NodeTitle({ id, title, selected }: { id: string; title: string; selected: boolean }) {
const actions = useContext(NodeEditing);
const [editing, setEditing] = useState(false), [value, setValue] = useState(title);
const input = useRef<HTMLInputElement>(null), cancelled = useRef(false);
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]);
function save() {
if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); }
cancelled.current = false; setEditing(false);
}
if (editing) return <input ref={input} className={`${styles.nodeTitleInput} nodrag nopan nowheel`} value={value} maxLength={300} aria-label="节点名称" onChange={(event) => setValue(event.target.value)} onBlur={save}
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />;
return <span className={styles.nodeTitle} title="双击编辑名称" role="button" tabIndex={selected ? 0 : -1} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); actions.select(id); setEditing(true); }}
onKeyDown={(event) => { if (event.key === "F2" || event.key === "Enter") { event.preventDefault(); event.stopPropagation(); setEditing(true); } }}>{title}</span>;
}
@@ -0,0 +1,8 @@
"use client";
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>;
}
+3 -10
View File
@@ -1,21 +1,14 @@
"use client";
import { GripVertical } from "lucide-react";
import { useContext } from "react";
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { NodeEditing, TransformableNode } from "./transformable-node";
import { textAppearance } from "./text-appearance";
import styles from "./canvas.module.css";
import { TransformableNode } from "./transformable-node";
import { CanvasTextEditor } from "./canvas-text-editor";
export function PlainTextNode(props: NodeProps<CanvasNode>) {
const editing = useContext(NodeEditing);
return (
<TransformableNode {...props}>
<span className={styles.textDragHandle} title="拖动移动纯文本" aria-label="拖动移动纯文本"><GripVertical size={14} /></span>
<textarea className={`${styles.plainText} nodrag nowheel`} aria-label="纯文本" placeholder="写下文字…"
style={textAppearance(props.data)} value={props.data.text} maxLength={100000}
onFocus={editing.begin} onChange={(event) => editing.edit(props.id, event.target.value)} />
<CanvasTextEditor id={props.id} data={props.data} selected={props.selected} plain />
</TransformableNode>
);
}
+10 -7
View File
@@ -10,15 +10,17 @@ import { NodeStyleToolbar } from "./node-style-toolbar";
import styles from "./canvas.module.css";
export const NodeEditing = createContext({
begin: () => {}, edit: (_id: string, _text: string) => {},
begin: () => {}, 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 }) => {},
rotating: null as string | null, rotate: (_id: string | null) => {},
});
type Gesture = { node: CanvasNode; start: XYPosition; direction: "bottom-right" | "rotate" };
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string }) {
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>();
@@ -26,7 +28,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
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";
const plain = data.kind === "plain-text" || data.kind === "icon" || data.kind === "plain-image";
const capabilities = elementCapabilities(data.kind);
const rotating = capabilities.rotatable && editing.rotating === id && selected;
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
@@ -47,6 +49,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const node = flow.getNode(id);
if (!node) return;
editing.begin();
if (!node.selected) editing.select(id);
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
event.currentTarget.setPointerCapture(event.pointerId);
}
@@ -64,15 +67,15 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
return <>
<NodeStyleToolbar {...props} />
{!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 === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
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)` }}>
{children}
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={selected && !rotating ? 0 : -1}
aria-hidden={!selected || rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
{capabilities.resizable && <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}>
<Grip size={14} />
</button>}
@@ -0,0 +1,40 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { canvasFingerprint, canvasPath, CanvasSync, type CanvasSnapshot, type SyncState } from "@/lib/canvas-sync";
import { decodeCanvas, type CanvasDocument } from "@/lib/canvas";
export function useCanvasPersistence(document: CanvasDocument, workspace: string, board: string, key: string, snapshot: CanvasSnapshot | undefined, paused: boolean) {
const [sync, setSync] = useState<SyncState>({ status: "saved", version: snapshot?.version || 1 });
const alive = useRef(false);
const latest = useRef(document); latest.current = document;
const pausedRef = useRef(paused); pausedRef.current = paused;
const [writer] = useState(() => snapshot ? new CanvasSync(api, canvasPath(workspace, board), snapshot, (next) => {
if (alive.current) { setSync(next); backup(); }
}) : null);
function backup() {
if (pausedRef.current) return;
try {
const current = latest.current;
localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(current) !== writer.saved : false }));
} catch { /* Storage failure is surfaced by the effect below. */ }
}
useEffect(() => {
if (paused) return;
const local = setTimeout(() => {
try { localStorage.setItem(key + ":recovery", JSON.stringify({ document: decodeCanvas(JSON.stringify(latest.current)), baseVersion: writer?.version || 1, dirty: writer ? canvasFingerprint(latest.current) !== writer.saved : false })); }
catch { setSync({ status: "error", version: writer?.version || 1, error: "local_backup_failed" }); }
}, 250);
const remote = writer ? setTimeout(() => void writer.enqueue(latest.current), 800) : null;
return () => { clearTimeout(local); if (remote) clearTimeout(remote); };
}, [document, writer, key, paused]);
useEffect(() => {
alive.current = true;
const flush = () => backup();
window.addEventListener("pagehide", flush);
return () => { window.removeEventListener("pagehide", flush); backup(); alive.current = false; };
// Writer and key are stable for this board's mounted editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [writer, key]);
return { ...sync, retry: () => { backup(); return writer?.retry(); } };
}
+1 -1
View File
@@ -3,7 +3,7 @@
import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import type { Board } from "@/lib/workspace";
type ActiveBoard = Pick<Board, "id" | "workspace_id" | "title"> | null;
type ActiveBoard = (Pick<Board, "id" | "workspace_id" | "title" | "slug"> & { workspace_ref?: string }) | null;
const ActiveBoardContext = createContext<{
board: ActiveBoard;
setBoard: Dispatch<SetStateAction<ActiveBoard>>;
+24 -42
View File
@@ -4,64 +4,46 @@ import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { api, errorText } from "@/lib/api";
import { readDemoBoards } from "@/lib/demo-workspace";
import type { Board } from "@/lib/workspace";
import { matchesRef, type Board } from "@/lib/workspace";
import { loadBoardCanvas } from "@/lib/canvas-loader";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import GraphCanvas from "@/components/canvas/graph-canvas";
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
const demo = useSearchParams().get("demo") === "1";
const router = useRouter();
const route = workspaceId + ":" + boardId;
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const [board, setBoard] = useState<Board | null>(null);
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [error, setError] = useState("");
const [retry, setRetry] = useState(0);
useEffect(() => {
setBoard(null);
setError("");
setLoaded(null); setError("");
if (demo) {
try {
const list = readDemoBoards(workspaceId);
const item = list.find((b) => b.id === boardId);
if (item) setBoard(item);
else setError("图谱不存在。");
} catch { setError("无法读取本地演示图谱。"); }
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
else setError("图谱不存在。");
return;
}
let active = true;
const controller = new AbortController();
api<Board>(`workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`, { signal: controller.signal })
.then((item) => {
if (!active) return;
if (item.kind !== "board" || item.workspace_id !== workspaceId) {
setError("当前页面不是此工作空间的图谱。");
return;
}
setBoard(item);
})
.catch((e) => {
if (!active) return;
setError(errorText(e));
if (e.message === "invalid_session") router.replace("/login");
});
loadBoardCanvas(api, workspaceId, boardId, controller.signal)
.then((result) => { if (active) setLoaded({ ...result, route }); })
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
return () => { active = false; controller.abort(); };
}, [workspaceId, boardId, demo, router]);
}, [workspaceId, boardId, route, demo, retry, router]);
const current = loaded?.route === route ? loaded : null;
const board = current?.board;
useEffect(() => {
if (board?.id === boardId && board.workspace_id === workspaceId) {
setActiveBoard({ id: board.id, workspace_id: board.workspace_id, title: board.title });
}
if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title });
return () => setActiveBoard(null);
}, [board, boardId, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === workspaceId
? { ...board, title: activeBoard.title } : board;
return (
<main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={workspaceId + ":" + boardId} board={displayBoard} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <p className={styles.graphStatus} role="alert">{error}</p>
: !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>
);
}, [board, workspaceId, setActiveBoard]);
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
{displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
</main>;
}