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
+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);