feat(canvas): add inherited elements, resizing and rotation

This commit is contained in:
2026-11-06 02:03:23 +08:00
parent e28ddb673f
commit 54ff5a0fbd
12 changed files with 411 additions and 38 deletions
+27 -2
View File
@@ -6,9 +6,9 @@
.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 { display: block; width: 100%; flex: 1; min-height: 0; height: 100%; padding: 0; resize: none; 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; }
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; max-height: none; margin: auto; border-radius: 5px; object-fit: contain; }
.node :global(.react-flow__handle) { width: 8px; height: 8px; background: #a47764; border: 2px solid #fff; }
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
.toolbar button:disabled { cursor: not-allowed; }
@@ -38,3 +38,28 @@
.branch_right { right: -48px; top: 50%; transform: translateY(-50%); }
.branch_bottom { bottom: -48px; left: 50%; transform: translateX(-50%); }
.branch_left { left: -48px; top: 50%; transform: translateY(-50%); }
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; }
.nodeHeading { flex: 0 0 auto; }
.plainFrame { padding: 4px; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
.plainFrame.selectedFrame { border-color: #a47764; }
.plainText { display: block; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; padding: 0; border: 0; outline: none; background: transparent; color: #37352f; font-family: inherit; line-height: 1.25; resize: none; }
.plainText::placeholder { color: #b3a79c; }
.resizeHandle { position: absolute; box-sizing: border-box; width: 8px; height: 8px; padding: 0; border: 1px solid #a47764; border-radius: 2px; background: white; z-index: 8; touch-action: none; }
.resize_top { top: -4px; left: calc(50% - 4px); cursor: ns-resize; }
.resize_bottom { bottom: -4px; left: calc(50% - 4px); cursor: ns-resize; }
.resize_left { left: -4px; top: calc(50% - 4px); cursor: ew-resize; }
.resize_right { right: -4px; top: calc(50% - 4px); cursor: ew-resize; }
.resize_top-left { left: -4px; top: -4px; cursor: nwse-resize; }
.resize_bottom-right { right: -4px; bottom: -4px; cursor: nwse-resize; }
.resize_top-right { right: -4px; top: -4px; cursor: nesw-resize; }
.resize_bottom-left { left: -4px; bottom: -4px; cursor: nesw-resize; }
.rotationStem { position: absolute; left: 50%; top: -62px; width: 1px; height: 62px; background: #d5c7bb; pointer-events: none; }
.rotationHandle { position: absolute; top: -88px; left: calc(50% - 12px); display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 1px solid #cbb8a8; border-radius: 50%; background: white; color: #78523f; z-index: 8; cursor: grab; touch-action: none; }
.rotationHandle:active { cursor: grabbing; }
.rotationValue { position: absolute; top: -85px; left: calc(50% + 18px); color: #92877c; background: #fffefbe6; padding: 0 4px; border-radius: 3px; font-size: 10px; pointer-events: none; }
.plainFrame .rotationHandle { top: -38px; }
.plainFrame .rotationStem { top: -14px; height: 14px; }
.plainFrame .rotationValue { top: -35px; }
.handMode .plainText { pointer-events: none; }
.handMode .resizeHandle, .handMode .rotationHandle, .handMode .rotationStem, .handMode .rotationValue, .handMode .branchButton { display: none; }
+11 -5
View File
@@ -5,16 +5,18 @@ import {
Background, BackgroundVariant, ConnectionMode, 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 { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, 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 { 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 };
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode };
function CanvasEditor({ board }: { board: Board }) {
const flow = useReactFlow<CanvasNode>();
@@ -71,7 +73,8 @@ function CanvasEditor({ board }: { board: Board }) {
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 editing = useMemo(() => ({ begin: beginEdit, edit, branch }), [beginEdit, edit, branch]);
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 center = () => {
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
@@ -194,7 +197,8 @@ function CanvasEditor({ board }: { board: Board }) {
<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={toolbar.divider} aria-hidden="true" />
<IconButton icon={Type} label="添加文字" onClick={() => addNode("text")} />
<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={FileText} label="添加脚本" onClick={() => addNode("script")} />
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
@@ -209,7 +213,9 @@ function CanvasEditor({ board }: { board: Board }) {
<p>拖动空白处平移,拖动节点移动位置。</p>
<p>触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。</p>
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
<p>拖动节点两侧圆点创建连线;Delete 删除所选内容。</p>
<p>选中后拖动边角调整大小,拖动顶部旋转柄旋转;Shift 吸附 15°,双击复位。</p>
<p>纯文本缩放会同步改变字号,不显示卡片边框、连接点和四向加号。</p>
<p>拖动卡片连接点创建连线;Delete 删除所选内容。</p>
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
<p>当前编辑保存在此浏览器,可导出 JSON 备份。</p>
</div>
+11 -8
View File
@@ -1,21 +1,23 @@
"use client";
import { createContext, useContext } from "react";
import { useContext } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { FileText, Image as ImageIcon, Plus, Type } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import { nodeDirections, type CanvasNode, type NodeDirection } from "@/lib/canvas";
import styles from "./canvas.module.css";
export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {}, branch: (_id: string, _direction: NodeDirection) => {} });
import { TransformableNode, NodeEditing } from "./transformable-node";
export { NodeEditing } from "./transformable-node";
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧" };
export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
export function MaterialNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
const editing = useContext(NodeEditing);
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
return (
<article className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
<header className={styles.nodeHeading}><Icon size={13} /><span>{data.title}</span></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" /> : (
@@ -23,8 +25,9 @@ export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
value={data.text} maxLength={100000} onFocus={editing.begin} onChange={(event) => editing.edit(id, event.target.value)} />
)}
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
{nodeDirections.map((direction) => <Handle key={direction} id={direction} type="source" position={positions[direction]} />)}
{selected && nodeDirections.map((direction) => (
{nodeDirections.map((direction) => <Handle key={direction} id={direction} type="source" position={positions[direction]}
style={direction === "left" || direction === "right" ? { top: "calc(50% + 20px)" } : { left: "calc(50% + 20px)" }} />)}
{selected && elementCapabilities(data.kind).branchable && nodeDirections.map((direction) => (
<button key={direction} type="button" className={`${styles.branchButton} ${styles["branch_" + direction]} nodrag nopan`}
aria-label={`在${labels[direction]}添加节点`} title={`在${labels[direction]}添加节点并连线`}
onPointerDown={(event) => event.stopPropagation()}
@@ -32,6 +35,6 @@ export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
</button>
))}
</article>
</TransformableNode>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useContext } from "react";
import type { NodeProps } from "@xyflow/react";
import type { CanvasNode } from "@/lib/canvas";
import { NodeEditing, TransformableNode } from "./transformable-node";
import styles from "./canvas.module.css";
export function PlainTextNode(props: NodeProps<CanvasNode>) {
const editing = useContext(NodeEditing);
return (
<TransformableNode {...props}>
<textarea className={`${styles.plainText} nodrag nowheel`} aria-label="纯文本" placeholder="写下文字…"
style={{ fontSize: props.data.fontSize || 24 }} value={props.data.text} maxLength={100000}
onFocus={editing.begin} onChange={(event) => editing.edit(props.id, event.target.value)} />
</TransformableNode>
);
}
@@ -0,0 +1,82 @@
"use client";
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { useReactFlow, useUpdateNodeInternals, type NodeProps, type XYPosition } from "@xyflow/react";
import { RotateCw } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
import { normalizeRotation, resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
import styles from "./canvas.module.css";
export const NodeEditing = createContext({
begin: () => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection) => {},
transform: (_id: string, _transform: NodeTransform) => {},
});
const resizeDirections: ResizeDirection[] = ["top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left", "top-left"];
const resizeLabels = { top: "上边", right: "右边", bottom: "下边", left: "左边", "top-right": "右上角", "bottom-right": "右下角", "bottom-left": "左下角", "top-left": "左上角" };
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate" };
export function TransformableNode({ id, data, selected, width, height, children, className = "" }: NodeProps<CanvasNode> & { children: ReactNode; className?: string }) {
const editing = useContext(NodeEditing);
const flow = useReactFlow<CanvasNode>();
const update = useUpdateNodeInternals();
const gesture = useRef<Gesture | null>(null);
const rotation = data.rotation || 0;
const plain = data.kind === "plain-text";
const capabilities = elementCapabilities(data.kind);
useEffect(() => { update(id); }, [id, rotation, width, height, update]);
function start(event: PointerEvent<HTMLButtonElement>, direction: Gesture["direction"]) {
if (event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
const node = flow.getNode(id);
if (!node) return;
editing.begin();
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
event.currentTarget.setPointerCapture(event.pointerId);
}
function move(event: PointerEvent<HTMLButtonElement>) {
if (!gesture.current) return;
event.stopPropagation();
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const { node, start, direction } = gesture.current;
editing.transform(id, direction === "rotate"
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }));
}
function finish(event: PointerEvent<HTMLButtonElement>) {
event.stopPropagation();
gesture.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
function rotateBy(step: number) {
editing.begin();
editing.transform(id, { rotation: normalizeRotation(rotation + step) });
}
return (
<div className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${className}`}
style={{ width: width || (plain ? 240 : 260), height: height || (plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
{children}
{selected && <>
{capabilities.resizable && resizeDirections.map((direction) => <button key={direction} type="button"
className={`${styles.resizeHandle} ${styles["resize_" + direction]} nodrag nopan`}
aria-label={`缩放${resizeLabels[direction]}`} title={`拖动${resizeLabels[direction]}调整大小`}
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={() => { gesture.current = null; }} />)}
{capabilities.rotatable && <>
<span className={styles.rotationStem} />
<button type="button" className={`${styles.rotationHandle} nodrag nopan`} aria-label="旋转节点" title="拖动旋转;Shift 吸附 15°;双击复位"
onPointerDown={(event) => start(event, "rotate")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={() => { gesture.current = null; }}
onDoubleClick={(event) => { event.stopPropagation(); editing.begin(); editing.transform(id, { rotation: 0 }); }}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); event.stopPropagation(); rotateBy((event.key === "ArrowLeft" ? -1 : 1) * (event.shiftKey ? 15 : 1)); }
if (event.key === "Home") { event.preventDefault(); event.stopPropagation(); editing.begin(); editing.transform(id, { rotation: 0 }); }
}}>
<RotateCw size={14} strokeWidth={1.6} />
</button>
<span className={styles.rotationValue}>{Math.round(rotation)}°</span>
</>}
</>}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Node, XYPosition } from "@xyflow/react";
export type ElementKind = "text" | "image" | "script" | "plain-text";
export type CanvasNodeData = {
kind: ElementKind;
title: string;
text: string;
src?: string;
rotation?: number;
fontSize?: number;
};
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText">;
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
/** Business inheritance only: React views reuse these capabilities by composition. */
export abstract class CanvasElement {
static readonly capabilities: ElementCapabilities = { resizable: true, rotatable: true, connectable: false, branchable: false };
abstract readonly kind: ElementKind;
abstract readonly title: string;
protected readonly nodeType: CanvasNode["type"] = "plainText";
protected readonly size = { width: 240, height: 72 };
readonly position: XYPosition;
readonly text: string;
readonly src?: string;
constructor(position: XYPosition, text = "", src?: string) {
this.position = position;
this.text = text;
this.src = src;
}
toNode(): CanvasNode {
return {
id: crypto.randomUUID(), type: this.nodeType, position: { ...this.position },
width: this.size.width, height: this.size.height,
style: { width: this.size.width, height: this.size.height },
data: { kind: this.kind, title: this.title, text: this.text, rotation: 0,
fontSize: this.kind === "plain-text" ? 24 : 13, ...(this.src ? { src: this.src } : {}) },
};
}
}
export abstract class CardElement extends CanvasElement {
static override readonly capabilities: ElementCapabilities = { ...CanvasElement.capabilities, connectable: true, branchable: true };
protected override readonly nodeType = "material" as const;
protected override readonly size = { width: 260, height: 200 };
}
export class TextCardElement extends CardElement { readonly kind = "text"; readonly title = "灵感笔记"; }
export class ImageCardElement extends CardElement { readonly kind = "image"; readonly title = "视觉参考"; }
export class ScriptCardElement extends CardElement { readonly kind = "script"; readonly title = "脚本"; }
export class PlainTextElement extends CanvasElement { readonly kind = "plain-text"; readonly title = "纯文本"; }
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement };
export function elementCapabilities(kind: ElementKind) { return elementClasses[kind].capabilities; }
export function createCanvasElement(kind: ElementKind, position: XYPosition, src?: string): CanvasNode {
return new elementClasses[kind](position, "", src).toNode();
}
+55
View File
@@ -0,0 +1,55 @@
import type { XYPosition } from "@xyflow/react";
import type { CanvasNode } from "./canvas-elements";
export type ResizeDirection = "top" | "right" | "bottom" | "left" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
export type NodeTransform = { position?: XYPosition; width?: number; height?: number; rotation?: number; fontSize?: number };
export function nodeSize(node: CanvasNode) {
return {
width: node.width || Number(node.style?.width) || node.measured?.width || (node.data.kind === "plain-text" ? 240 : 260),
height: node.height || Number(node.style?.height) || node.measured?.height || (node.data.kind === "plain-text" ? 72 : 200),
};
}
export function normalizeRotation(angle: number) { return ((angle % 360) + 360) % 360; }
export function rotateVector(point: XYPosition, angle: number): XYPosition {
const radians = angle * Math.PI / 180;
return { x: point.x * Math.cos(radians) - point.y * Math.sin(radians), y: point.x * Math.sin(radians) + point.y * Math.cos(radians) };
}
export function nodeCenter(node: CanvasNode): XYPosition {
const { width, height } = nodeSize(node);
return { x: node.position.x + width / 2, y: node.position.y + height / 2 };
}
export function rotationFromPointer(node: CanvasNode, start: XYPosition, current: XYPosition, snap = false) {
const center = nodeCenter(node);
const startAngle = Math.atan2(start.y - center.y, start.x - center.x);
const angle = Math.atan2(current.y - center.y, current.x - center.x);
let rotation = (node.data.rotation || 0) + (angle - startAngle) * 180 / Math.PI;
if (snap) rotation = Math.round(rotation / 15) * 15;
return normalizeRotation(rotation);
}
export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition): NodeTransform {
const { width, height } = nodeSize(node);
const rotation = node.data.rotation || 0;
const local = rotateVector(delta, -rotation);
const horizontal = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
const plain = node.data.kind === "plain-text";
let nextWidth = Math.max(plain ? 60 : 140, Math.min(3000, width + horizontal * local.x));
let nextHeight = Math.max(plain ? 24 : 100, Math.min(3000, height + vertical * local.y));
let fontSize: number | undefined;
if (plain) {
const sx = 1 + horizontal * local.x / width;
const sy = 1 + vertical * local.y / height;
let scale = !horizontal ? sy : !vertical ? sx : Math.abs(sx - 1) > Math.abs(sy - 1) ? sx : sy;
const font = node.data.fontSize || 24;
scale = Math.max(60 / width, 24 / height, 8 / font, Math.min(scale, 3000 / width, 3000 / height, 160 / font));
nextWidth = width * scale;
nextHeight = height * scale;
fontSize = font * scale;
}
const center = nodeCenter(node);
const shift = rotateVector({ x: horizontal * (nextWidth - width) / 2, y: vertical * (nextHeight - height) / 2 }, rotation);
return {
position: { x: center.x + shift.x - nextWidth / 2, y: center.y + shift.y - nextHeight / 2 },
width: nextWidth, height: nextHeight, ...(fontSize !== undefined ? { fontSize } : {}),
};
}
+43 -22
View File
@@ -1,12 +1,9 @@
import { applyEdgeChanges, applyNodeChanges } from "@xyflow/react";
import type { Edge, EdgeChange, Node, NodeChange, Viewport, XYPosition } from "@xyflow/react";
export type CanvasNode = Node<{
kind: "text" | "image" | "script";
title: string;
text: string;
src?: string;
}, "material">;
import { createCanvasElement, elementCapabilities, type CanvasNode } from "./canvas-elements.ts";
import { nodeSize, rotateVector, nodeCenter, type NodeTransform } from "./canvas-geometry.ts";
export type { CanvasNode } from "./canvas-elements.ts";
export type NodeDirection = "top" | "right" | "bottom" | "left";
export const nodeDirections: NodeDirection[] = ["top", "right", "bottom", "left"];
export type CanvasDocument = { schema_version: 1; nodes: CanvasNode[]; edges: Edge[]; viewport: Viewport };
@@ -17,6 +14,7 @@ export type CanvasAction =
| { type: "nodes"; changes: NodeChange<CanvasNode>[] }
| { type: "edges"; changes: EdgeChange[] }
| { type: "edit"; id: string; text: string }
| { type: "transform"; id: string; transform: NodeTransform }
| { type: "viewport"; viewport: Viewport }
| { type: "undo" | "redo" };
@@ -50,6 +48,18 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
? recorded(state, present) : { ...state, present };
}
case "edit": return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, text: action.text } } : node) } };
case "transform": {
const patch = action.transform;
return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id !== action.id ? node : {
...node,
...(patch.position ? { position: patch.position } : {}),
...(patch.width !== undefined && patch.height !== undefined ? {
width: patch.width, height: patch.height, measured: { width: patch.width, height: patch.height },
style: { ...node.style, width: patch.width, height: patch.height },
} : {}),
data: { ...node.data, ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
}) } };
}
case "viewport": return { ...state, present: { ...state.present, viewport: action.viewport } };
case "undo": {
const last = state.past.at(-1);
@@ -63,26 +73,25 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
}
export function makeCanvasNode(kind: CanvasNode["data"]["kind"], position: XYPosition, src?: string): CanvasNode {
return {
id: crypto.randomUUID(), type: "material", position,
data: { kind, title: kind === "image" ? "视觉参考" : kind === "script" ? "脚本" : "灵感笔记", text: "", ...(src ? { src } : {}) },
};
return createCanvasElement(kind, position, src);
}
export function branchCanvasNode(document: CanvasDocument, id: string, direction: NodeDirection): CanvasDocument {
const source = document.nodes.find((node) => node.id === id);
if (!source) throw new Error("节点不存在。");
if (document.nodes.length >= 500) throw new Error("当前图谱已达到 500 个节点。");
const width = source.measured?.width || 260;
const height = source.measured?.height || 200;
if (!elementCapabilities(source.data.kind).branchable) throw new Error("纯文本不支持分支节点。");
const { width, height } = nodeSize(source);
const gap = 80;
const position = {
x: direction === "right" ? source.position.x + width + gap : direction === "left" ? source.position.x - 260 - gap : source.position.x + (width - 260) / 2,
y: direction === "bottom" ? source.position.y + height + gap : direction === "top" ? source.position.y - 200 - gap : source.position.y + (height - 200) / 2,
};
const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } };
const vector = rotateVector(vectors[direction], source.data.rotation || 0);
const center = nodeCenter(source);
const distance = (direction === "left" || direction === "right" ? width / 2 : height / 2) + gap + Math.abs(vector.x) * 130 + Math.abs(vector.y) * 100;
const position = { x: center.x + vector.x * distance - 130, y: center.y + vector.y * distance - 100 };
// Keep repeated additions on the same side from covering one another.
for (let attempts = 0; attempts < 500; attempts++) {
const overlap = document.nodes.some((node) => position.x < node.position.x + (node.measured?.width || 260) + 12 &&
position.x + 260 + 12 > node.position.x && position.y < node.position.y + (node.measured?.height || 200) + 12 && position.y + 200 + 12 > node.position.y);
const overlap = document.nodes.some((node) => position.x < node.position.x + nodeSize(node).width + 12 &&
position.x + 260 + 12 > node.position.x && position.y < node.position.y + nodeSize(node).height + 12 && position.y + 200 + 12 > node.position.y);
if (!overlap) break;
if (direction === "left" || direction === "right") position.y += 240;
else position.x += 300;
@@ -92,7 +101,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
return {
...document,
nodes: [...document.nodes.map((node) => ({ ...node, selected: false })), child],
edges: [...document.edges, { id: crypto.randomUUID(), source: id, target: child.id, sourceHandle: direction, targetHandle: opposite[direction] }],
edges: [...document.edges, { id: crypto.randomUUID(), source: id, target: child.id, sourceHandle: direction, targetHandle: opposite[Math.abs(vector.x) > Math.abs(vector.y) ? vector.x > 0 ? "right" : "left" : vector.y > 0 ? "bottom" : "top"] }],
};
}
@@ -111,18 +120,30 @@ export function decodeCanvas(raw: string): CanvasDocument {
const nodes: CanvasNode[] = value.nodes.map((node: CanvasNode) => {
if (!node || typeof node.id !== "string" || !/^[\w-]{1,128}$/.test(node.id) || ids.has(node.id) || !node.position ||
![node.position.x, node.position.y].every((n) => Number.isFinite(n) && Math.abs(n) <= 1_000_000) ||
!node.data || !["text", "image", "script"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
!node.data || !["text", "image", "script", "plain-text"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
typeof node.data.text !== "string" || node.data.text.length > 100_000 || (node.data.kind === "image" && !validImage(node.data.src)))
throw new Error("图谱节点格式无效。");
const width = node.width ?? node.style?.width;
const height = node.height ?? node.style?.height;
if ((width === undefined) !== (height === undefined) || (width !== undefined && (typeof width !== "number" || !Number.isFinite(width) || width < 20 || width > 3000)) ||
(height !== undefined && (typeof height !== "number" || !Number.isFinite(height) || height < 20 || height > 3000)) ||
(node.data.rotation !== undefined && (!Number.isFinite(node.data.rotation) || node.data.rotation < 0 || node.data.rotation >= 360)) ||
(node.data.fontSize !== undefined && (!Number.isFinite(node.data.fontSize) || node.data.fontSize < 8 || node.data.fontSize > 160)))
throw new Error("节点大小或旋转角度无效。");
ids.add(node.id);
return { id: node.id, type: "material", position: { x: node.position.x, y: node.position.y }, data: {
kind: node.data.kind, title: node.data.title, text: node.data.text, ...(node.data.kind === "image" ? { src: node.data.src } : {}),
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : "material",
...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), position: { x: node.position.x, y: node.position.y }, data: {
kind: node.data.kind, title: node.data.title, text: node.data.text,
rotation: node.data.rotation || 0, fontSize: node.data.fontSize || (node.data.kind === "plain-text" ? 24 : 13), ...(node.data.kind === "image" ? { src: node.data.src } : {}),
} };
});
const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind]));
const edgeIds = new Set<string>();
const edges: Edge[] = value.edges.map((edge: Edge) => {
if (!edge || typeof edge.id !== "string" || !/^[\w-]{1,128}$/.test(edge.id) || edgeIds.has(edge.id) || !ids.has(edge.source) || !ids.has(edge.target))
throw new Error("图谱连线格式无效。");
if (!elementCapabilities(nodeKinds.get(edge.source)!).connectable || !elementCapabilities(nodeKinds.get(edge.target)!).connectable)
throw new Error("纯文本不支持节点连线。");
if ([edge.sourceHandle, edge.targetHandle].some((handle) => handle != null && !nodeDirections.includes(handle as NodeDirection)))
throw new Error("图谱连接方向无效。");
edgeIds.add(edge.id);