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