feat(canvas): refine connections, text styles and icon editing
This commit is contained in:
+27
-7
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user