feat(canvas): refine connections, text styles and icon editing

This commit is contained in:
2026-11-06 02:38:39 +08:00
parent 54ff5a0fbd
commit 86b5a4e9e4
19 changed files with 580 additions and 149 deletions
+13 -3
View File
@@ -1,6 +1,7 @@
import type { Node, XYPosition } from "@xyflow/react";
export type ElementKind = "text" | "image" | "script" | "plain-text";
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon";
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
export type CanvasNodeData = {
kind: ElementKind;
title: string;
@@ -8,8 +9,10 @@ export type CanvasNodeData = {
src?: string;
rotation?: number;
fontSize?: number;
textStyle?: TextStyle;
icon?: string;
};
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText">;
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon">;
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
/** Business inheritance only: React views reuse these capabilities by composition. */
@@ -47,7 +50,14 @@ export class ImageCardElement extends CardElement { readonly kind = "image"; rea
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 class IconElement extends CanvasElement {
readonly kind = "icon"; readonly title = "图标";
protected override readonly nodeType = "icon" as const;
protected override readonly size = { width: 80, height: 80 };
override toNode(): CanvasNode { const node = super.toNode(); return { ...node, data: { ...node.data, icon: "Star" } }; }
}
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement };
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();
+6 -8
View File
@@ -5,8 +5,8 @@ export type ResizeDirection = "top" | "right" | "bottom" | "left" | "top-left" |
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),
width: node.width || Number(node.style?.width) || node.measured?.width || (node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 240 : 260),
height: node.height || Number(node.style?.height) || node.measured?.height || (node.data.kind === "icon" ? 80 : node.data.kind === "plain-text" ? 72 : 200),
};
}
export function normalizeRotation(angle: number) { return ((angle % 360) + 360) % 360; }
@@ -33,23 +33,21 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
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";
const icon = node.data.kind === "icon";
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) {
if (icon) {
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));
scale = Math.max(24 / width, 24 / height, Math.min(scale, 3000 / width, 3000 / height));
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 } : {}),
width: nextWidth, height: nextHeight,
};
}
+27 -7
View File
@@ -1,5 +1,5 @@
import { applyEdgeChanges, applyNodeChanges } from "@xyflow/react";
import type { Edge, EdgeChange, Node, NodeChange, Viewport, XYPosition } from "@xyflow/react";
import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from "@xyflow/react";
import { createCanvasElement, elementCapabilities, type CanvasNode } from "./canvas-elements.ts";
import { nodeSize, rotateVector, nodeCenter, type NodeTransform } from "./canvas-geometry.ts";
@@ -15,6 +15,7 @@ export type CanvasAction =
| { type: "edges"; changes: EdgeChange[] }
| { type: "edit"; id: string; text: string }
| { type: "transform"; id: string; transform: NodeTransform }
| { type: "style"; id: string; style: import("./canvas-elements.ts").TextStyle; fontSize?: number }
| { type: "viewport"; viewport: Viewport }
| { type: "undo" | "redo" };
@@ -48,6 +49,7 @@ 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 "style": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, textStyle: { ...node.data.textStyle, ...action.style }, ...(action.fontSize !== undefined ? { fontSize: action.fontSize } : {}) } } : node) });
case "transform": {
const patch = action.transform;
return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id !== action.id ? node : {
@@ -76,11 +78,11 @@ export function makeCanvasNode(kind: CanvasNode["data"]["kind"], position: XYPos
return createCanvasElement(kind, position, src);
}
export function branchCanvasNode(document: CanvasDocument, id: string, direction: NodeDirection): CanvasDocument {
export function branchCanvasNode(document: CanvasDocument, id: string, direction: NodeDirection, kind: "text" | "script" | "image" = "text", src?: string, at?: XYPosition): CanvasDocument {
const source = document.nodes.find((node) => node.id === id);
if (!source) throw new Error("节点不存在。");
if (document.nodes.length >= 500) throw new Error("当前图谱已达到 500 个节点。");
if (!elementCapabilities(source.data.kind).branchable) throw new Error("纯文本不支持分支节点。");
if (!elementCapabilities(source.data.kind).branchable) throw new Error("纯文本和图标不支持分支节点。");
const { width, height } = nodeSize(source);
const gap = 80;
const vectors = { top: { x: 0, y: -1 }, right: { x: 1, y: 0 }, bottom: { x: 0, y: 1 }, left: { x: -1, y: 0 } };
@@ -96,7 +98,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
if (direction === "left" || direction === "right") position.y += 240;
else position.x += 300;
}
const child = { ...makeCanvasNode("text", position), selected: true };
const child = { ...makeCanvasNode(kind, at || position, src), selected: true };
const opposite: Record<NodeDirection, NodeDirection> = { top: "bottom", bottom: "top", left: "right", right: "left" };
return {
...document,
@@ -111,6 +113,21 @@ function validImage(src: unknown): src is string {
try { const url = new URL(src); return url.protocol === "https:" && !url.username && !url.password && src.length < 2048; }
catch { return false; }
}
export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").TextStyle | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("文本样式无效。");
const style = value as Record<string, unknown>;
const valid = Object.entries(style).every(([key, entry]) => {
if (["color", "background"].includes(key)) return typeof entry === "string" && (/^#[0-9a-f]{6}$/i.test(entry) || (key === "background" && entry === "transparent"));
if (["bold", "italic", "underline"].includes(key)) return typeof entry === "boolean";
if (key === "align") return typeof entry === "string" && ["left", "center", "right"].includes(entry);
if (key === "fontFamily") return typeof entry === "string" && ["sans", "serif", "mono"].includes(entry);
if (key === "lineHeight") return typeof entry === "number" && Number.isFinite(entry) && entry >= 1 && entry <= 3;
return false;
});
if (!valid) throw new Error("文本样式无效。");
return { ...style };
}
export function decodeCanvas(raw: string): CanvasDocument {
if (raw.length > 5_000_000) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
const value = JSON.parse(raw);
@@ -120,7 +137,7 @@ 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", "plain-text"].includes(node.data.kind) || typeof node.data.title !== "string" || node.data.title.length > 300 ||
!node.data || !["text", "image", "script", "plain-text", "icon"].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;
@@ -130,9 +147,12 @@ export function decodeCanvas(raw: string): CanvasDocument {
(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("节点大小或旋转角度无效。");
if (node.data.kind === "icon" && (typeof node.data.icon !== "string" || !/^[A-Z][A-Za-z0-9]{0,63}$/.test(node.data.icon))) throw new Error("图标标识无效。");
const textStyle = decodeTextStyle(node.data.textStyle);
ids.add(node.id);
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : "material",
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : node.data.kind === "icon" ? "icon" : "material",
...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), position: { x: node.position.x, y: node.position.y }, data: {
...(textStyle ? { textStyle } : {}), ...(node.data.kind === "icon" ? { icon: node.data.icon } : {}),
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 } : {}),
} };
@@ -143,7 +163,7 @@ export function decodeCanvas(raw: string): CanvasDocument {
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("纯文本不支持节点连线。");
throw new Error("纯文本和图标不支持节点连线。");
if ([edge.sourceHandle, edge.targetHandle].some((handle) => handle != null && !nodeDirections.includes(handle as NodeDirection)))
throw new Error("图谱连接方向无效。");
edgeIds.add(edge.id);