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
+10 -1
View File
@@ -105,4 +105,13 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、
左上和底部工具栏按右上视图控件的分组节奏增加横向留白(间隙 6px、横向内边距 6px),按钮仍为 32×32px,竖向内边距仍为 4px。三处分隔线统一;左侧标题不再额外叠加外边距,右上现有样式保持不变。
选中节点后,上、下、左、右各显示一个加号。点击会在对应方向新增文字节点并连线,视图跟随新节点;重复添加会避开已有节点,整个操作可一步撤销。方向连线也会保存在本地草稿和导出的 JSON 中,旧版左右连接继续兼容。
选中卡片后,上、下、左、右各显示一个加号。点击会在对应方向新增文字卡片并连线,视图跟随新节点;重复添加会避开已有节点,整个操作可一步撤销。方向连线也会保存在本地草稿和导出的 JSON 中,旧版左右连接继续兼容。
## 元素变换与继承
- 卡片选中后可拖动四边或四角调整大小;拖动上方旋转柄旋转,Shift 吸附到 15°,双击旋转柄复位。旋转后仍按元素自身方向缩放。
- 底部 T 添加纯文本,便签按钮添加文本卡片。纯文本没有卡片外框、连接点或四向加号;缩放时同步改变字号及文本框大小。
- 大小、角度与字号保存在本地草稿和 JSON 中;一次拖动对应一步撤销,旧文本卡片导入后仍是卡片。
- 业务类继承为 `CanvasElement → CardElement → TextCardElement / ImageCardElement / ScriptCardElement`;`PlainTextElement` 直接继承 `CanvasElement`。基础元素拥有缩放和旋转能力,卡片层追加连线和分支能力。React 组件通过组合共享 `TransformableNode`,不使用组件类继承。
- `/dashboard/{workspace}/{board}` 的两个 ID 分别标识工作空间和图谱。前者确定访问范围,后者定位该空间内的图谱;读取和改名接口也使用相同的空间层级。
- 回归:`node --experimental-strip-types --test tests/canvas-geometry.test.mjs tests/canvas.test.mjs tests/workspace.test.mjs`。
+8
View File
@@ -1,5 +1,13 @@
# 本地验收记录
## 元素继承、缩放、旋转与纯文本(2026-11-06)
- 新增业务元素类继承,卡片与纯文本共享基础变换;纯文本不提供连接点或四向分支。
- 选中元素提供八向缩放与旋转柄;几何计算使用旋转后的局部坐标,保持对侧锚点。纯文本按比例缩放字号,支持大小、角度、字号的草稿/JSON 持久化和单次手势撤销。
- 类型检查通过;24 项自动测试通过,包含八个方向在多个角度下的锚点不变、纯文本缩放限制、旋转吸附、继承能力、导入兼容与撤销。
- 独立临时目录的 Webpack 生产构建通过;完整 API/路由/88 个品牌资源冒烟回归通过,未改写正在使用的开发服务缓存。
- 遵循用户安排未访问项目浏览器,实际拖动手感和旋转后的连线视觉仍由用户试用反馈。
- 生产构建:Next.js 16.4.5,TypeScript 检查及 6 个页面静态生成成功。
- API 会话测试:HttpOnly / SameSite Cookie、令牌不返回前端、带会话代理、401、跨站拒绝、非法 Origin、路径白名单、OAuth challenge 校验通过。
- 线上轻量检查:profile 401;空登录参数 400;Slack 与 Google 启动登录均返回 302,目标分别为 slack.com、accounts.google.com。未执行真人账户登录。
+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);
+91
View File
@@ -0,0 +1,91 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { CanvasElement, CardElement, TextCardElement, ImageCardElement, ScriptCardElement, PlainTextElement, elementCapabilities } from "../src/lib/canvas-elements.ts";
import { nodeCenter, nodeSize, resizeFromPointer, rotateVector, rotationFromPointer } from "../src/lib/canvas-geometry.ts";
import { branchCanvasNode, canvasReducer, decodeCanvas, emptyCanvas } from "../src/lib/canvas.ts";
function close(actual, expected) { assert(Math.abs(actual - expected) < 0.00001, `${actual} != ${expected}`); }
function transformed(node, patch) { return { ...node, ...patch, data: { ...node.data, rotation: patch.rotation ?? node.data.rotation, fontSize: patch.fontSize ?? node.data.fontSize } }; }
test("card types inherit common element transforms and add branching; plain text inherits only common transforms", () => {
for (const Type of [TextCardElement, ImageCardElement, ScriptCardElement]) {
const element = new Type({ x: 0, y: 0 });
assert(element instanceof CanvasElement);
assert(element instanceof CardElement);
assert.equal(Type.capabilities.resizable, true);
assert.equal(Type.capabilities.rotatable, true);
assert.equal(Type.capabilities.branchable, true);
}
const plain = new PlainTextElement({ x: 0, y: 0 });
assert(plain instanceof CanvasElement);
assert.equal(plain instanceof CardElement, false);
assert.equal(elementCapabilities("plain-text").branchable, false);
assert.equal(elementCapabilities("plain-text").connectable, false);
assert.equal(plain.toNode().type, "plainText");
assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [plain.toNode()] }, "missing", "top"));
const node = plain.toNode();
assert.throws(() => branchCanvasNode({ ...emptyCanvas(), nodes: [node] }, node.id, "right"), /纯文本/);
});
test("all eight handles resize cards while retaining the opposite local anchor, including rotated cards", () => {
const directions = ["top", "right", "bottom", "left", "top-left", "top-right", "bottom-left", "bottom-right"];
for (const rotation of [0, 37, 90, 180, 270]) {
for (const direction of directions) {
const node = new TextCardElement({ x: 100, y: 200 }).toNode();
node.data.rotation = rotation;
const hx = direction.includes("left") ? -1 : direction.includes("right") ? 1 : 0;
const hy = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
const delta = rotateVector({ x: hx * 60, y: hy * 40 }, rotation);
const patch = resizeFromPointer(node, direction, delta);
const updated = transformed(node, patch);
close(patch.width, 260 + Math.abs(hx) * 60);
close(patch.height, 200 + Math.abs(hy) * 40);
const before = nodeCenter(node), after = nodeCenter(updated);
const oldOffset = rotateVector({ x: -hx * 260 / 2, y: -hy * 200 / 2 }, rotation);
const newOffset = rotateVector({ x: -hx * patch.width / 2, y: -hy * patch.height / 2 }, rotation);
close(before.x + oldOffset.x, after.x + newOffset.x);
close(before.y + oldOffset.y, after.y + newOffset.y);
}
}
});
test("plain text resizing keeps aspect ratio and scales the font", () => {
const node = new PlainTextElement({ x: 0, y: 0 }).toNode();
const patch = resizeFromPointer(node, "bottom-right", { x: 240, y: 72 });
close(patch.width, 480); close(patch.height, 144); close(patch.fontSize, 48);
const min = resizeFromPointer(node, "bottom-right", { x: -10000, y: -10000 });
assert(min.width >= 60); assert(min.height >= 24); assert(min.fontSize >= 8);
});
test("rotation follows the pointer, supports 15-degree snapping and normalizes the angle", () => {
const node = new TextCardElement({ x: 0, y: 0 }).toNode();
const center = nodeCenter(node);
const start = { x: center.x + 100, y: center.y };
close(rotationFromPointer(node, start, { x: center.x, y: center.y + 100 }), 90);
const angle = 22 * Math.PI / 180;
close(rotationFromPointer(node, start, { x: center.x + 100 * Math.cos(angle), y: center.y + 100 * Math.sin(angle) }, true), 15);
close(rotationFromPointer(node, start, { x: center.x, y: center.y - 100 }), 270);
});
test("geometry and plain-text type survive save/restore and transforms undo as one gesture", () => {
const node = new PlainTextElement({ x: 20, y: 30 }).toNode();
let state = { past: [], present: { ...emptyCanvas(), nodes: [node] }, future: [] };
state = canvasReducer(state, { type: "checkpoint" });
state = canvasReducer(state, { type: "transform", id: node.id, transform: { ...resizeFromPointer(node, "right", { x: 120, y: 0 }), rotation: 45 } });
const restored = decodeCanvas(JSON.stringify(state.present)).nodes[0];
assert.equal(restored.type, "plainText"); close(restored.width, 360); close(restored.height, 108);
close(restored.data.rotation, 45); close(restored.data.fontSize, 36);
const undo = canvasReducer(state, { type: "undo" });
assert.deepEqual(nodeSize(undo.present.nodes[0]), { width: 240, height: 72 });
close(undo.present.nodes[0].data.rotation, 0);
});
test("old text-card drafts remain cards and invalid transform values are rejected", () => {
const old = { id: "old", type: "material", position: { x: 0, y: 0 }, data: { kind: "text", title: "Old", text: "Keep" } };
const document = { ...emptyCanvas(), nodes: [old] };
const restored = decodeCanvas(JSON.stringify(document)).nodes[0];
assert.equal(restored.type, "material"); assert.equal(restored.data.rotation, 0);
for (const patch of [{ width: -1, height: 100 }, { data: { ...old.data, rotation: 999 } }, { data: { ...old.data, fontSize: 1000 } }]) {
assert.throws(() => decodeCanvas(JSON.stringify({ ...document, nodes: [{ ...old, ...patch }] })));
}
});
+1
View File
@@ -10,6 +10,7 @@
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",