docs(canvas): explain legacy state history synchronization and subgraphs
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 旧snapshot编辑器的文档、快照history reducer与解码入口。history保存整份文档,最多40个past项;
|
||||
* 它与新版按changes/目标代次记录的history分开保留。交互过程可先checkpoint,再连续更新present。
|
||||
* 解码重建允许的旧节点/边字段,去掉框架瞬态信息;不接受任意未知组件或新版canonical模型。
|
||||
*/
|
||||
import { decodeEdgeData, decodeNodeExtensions, type CanvasEdgeModel } from "./canvas-v2.ts";
|
||||
import { applyEdgeChanges } from "@xyflow/react";
|
||||
import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from "@xyflow/react";
|
||||
@@ -6,10 +11,15 @@ import { applyFrameNodeChanges, assignFrameMembership } from "./canvas-frames.ts
|
||||
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";
|
||||
/** 方向白名单,导入连线handle也通过此表校验。 */
|
||||
export const nodeDirections: NodeDirection[] = ["top", "right", "bottom", "left"];
|
||||
/** 旧持久文档由节点、边数组与viewport构成,schema_version与网络保存version不同。 */
|
||||
export type CanvasDocument = { schema_version: 1 | 2; nodes: CanvasNode[]; edges: CanvasEdgeModel[]; viewport: Viewport };
|
||||
/** past末项用于undo,future首项用于redo,present为当前文档;不是网络待发送操作队列。 */
|
||||
export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] };
|
||||
/** 旧UI动作联合:结构编辑/检查点记录历史,部分连续输入与视口动作只更新present。 */
|
||||
export type CanvasAction =
|
||||
| { type: "checkpoint" }
|
||||
| { type: "node-data"; id: string; patch: Partial<CanvasNode["data"]> }
|
||||
@@ -26,27 +36,35 @@ export type CanvasAction =
|
||||
| { type: "viewport"; viewport: Viewport }
|
||||
| { type: "undo" | "redo" };
|
||||
|
||||
/** 初始空文档仍用schema1,真正使用v2扩展的动作会升级相应文档。 */
|
||||
export function emptyCanvas(): CanvasDocument {
|
||||
return { schema_version: 1, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
|
||||
}
|
||||
/** 旧本地草稿键按workspace/board定位,不包含新版account/authEpoch作用域。 */
|
||||
export function canvasStorageKey(workspace: string, board: string) {
|
||||
return `endoleap_canvas_v1:${workspace}:${board}`;
|
||||
}
|
||||
/** 把旧present压入past,截留最近40项并清空redo;checkpoint可记录相同文档对象作为手势起点。 */
|
||||
function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory {
|
||||
return { past: [...state.past, state.present].slice(-40), present, future: [] };
|
||||
}
|
||||
/** 纯快照归约。动作是否产生撤销单元由各分支明确决定,不能假定每次pointermove或字符输入都入栈。 */
|
||||
export function canvasReducer(state: CanvasHistory, action: CanvasAction): CanvasHistory {
|
||||
switch (action.type) {
|
||||
case "node-data": return recorded(state, { ...state.present, schema_version: 2, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, ...action.patch } } : node) });
|
||||
// 连线预览可只改present并清redo,最终逻辑编辑则记录旧快照。
|
||||
case "edge-data": {
|
||||
const present: CanvasDocument = { ...state.present, schema_version: 2, edges: state.present.edges.map((edge) => edge.id === action.id ? { ...edge, data: action.data } : edge) };
|
||||
return action.transient ? { ...state, present, future: [] } : recorded(state, present);
|
||||
}
|
||||
// 放下内容才重新算旧frameId归属;拖动/缩放分区本身不在此改变成员关系。
|
||||
case "drop": return { ...state, present: { ...state.present, nodes: assignFrameMembership(state.present.nodes, action.ids) } };
|
||||
case "rename-node": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id ? { ...node, data: { ...node.data, title: action.title } } : node) });
|
||||
case "frame": return recorded(state, { ...state.present, nodes: state.present.nodes.map((node) => node.id === action.id && node.data.kind === "frame" ? { ...node, data: { ...node.data, ...(action.title !== undefined ? { title: action.title } : {}), ...(action.labelMode ? { labelMode: action.labelMode } : {}) } } : node) });
|
||||
// 交互起点可显式入栈,后续edit/transform不逐帧增加历史项。
|
||||
case "checkpoint": return recorded(state, state.present);
|
||||
case "replace": return recorded(state, action.document);
|
||||
// 节点删除同步去掉悬空边;add/remove入栈,选择/位置等框架变化仅更新present。
|
||||
case "nodes": {
|
||||
const nodes = applyFrameNodeChanges(action.changes, state.present.nodes);
|
||||
const ids = new Set(nodes.map((node) => node.id));
|
||||
@@ -54,6 +72,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
return action.changes.some((change) => change.type === "remove" || change.type === "add")
|
||||
? recorded(state, present) : { ...state, present };
|
||||
}
|
||||
// 节点删除后框架可能再次通知删同一边;过滤已不存在边,避免一次删除消耗两次undo。
|
||||
case "edges": {
|
||||
// React Flow may report the same edge removal after its node was removed.
|
||||
// Keep that notification from consuming a second undo step.
|
||||
@@ -63,8 +82,10 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
return changes.some((change) => change.type === "remove" || change.type === "add")
|
||||
? recorded(state, present) : { ...state, present };
|
||||
}
|
||||
// 正文连续编辑清redo但不压past,历史边界由调用方的checkpoint/提交动作管理。
|
||||
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) });
|
||||
// 连续几何变化同步修改width/height、measured及style;子图frame保存对应展开/折叠尺寸。
|
||||
case "transform": {
|
||||
const patch = action.transform;
|
||||
return { ...state, future: [], present: { ...state.present, nodes: state.present.nodes.map((node) => node.id !== action.id ? node : {
|
||||
@@ -77,15 +98,18 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
data: { ...node.data, ...(node.data.kind === "frame" && node.data.subgraph && patch.width !== undefined && patch.height !== undefined ? { presentation: { ...node.data.presentation, [node.data.presentation?.collapsed ? "collapsedSize" : "expandedSize"]: { width: patch.width, height: patch.height } } } : {}), ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
|
||||
}) } };
|
||||
}
|
||||
// 纯镜头变化不入历史,微小数值差忽略,避免无意义根对象更新。
|
||||
case "viewport": {
|
||||
const before = state.present.viewport;
|
||||
if (Math.abs(before.x - action.viewport.x) < 1e-6 && Math.abs(before.y - action.viewport.y) < 1e-6 && Math.abs(before.zoom - action.viewport.zoom) < 1e-9) return state;
|
||||
return { ...state, present: { ...state.present, viewport: action.viewport } };
|
||||
}
|
||||
// 撤销只还原内容,保持用户当前镜头;旧present插到future前端。
|
||||
case "undo": {
|
||||
const last = state.past.at(-1);
|
||||
return last ? { past: state.past.slice(0, -1), present: { ...last, viewport: state.present.viewport }, future: [state.present, ...state.future] } : state;
|
||||
}
|
||||
// 从future头取回内容,当前present压past并保持40项限制,镜头同样不跳回。
|
||||
case "redo": {
|
||||
const next = state.future[0];
|
||||
return next ? { past: [...state.past, state.present].slice(-40), present: { ...next, viewport: state.present.viewport }, future: state.future.slice(1) } : state;
|
||||
@@ -93,10 +117,13 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧内置元素工厂的转接入口,不直接发布历史或保存网络。 */
|
||||
export function makeCanvasNode(kind: CanvasNode["data"]["kind"], position: XYPosition, src?: string): CanvasNode {
|
||||
return createCanvasElement(kind, position, src);
|
||||
}
|
||||
|
||||
/** 给可分支节点创建一张卡片与连接边;按旋转后方向在原节点外围布局,或使用调用方指定位置。
|
||||
* 最多500个节点;布局避让最多尝试500次,并非求解保证无重叠的通用排版器。 */
|
||||
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("节点不存在。");
|
||||
@@ -105,6 +132,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
|
||||
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 } };
|
||||
// 分支方向先按源节点旋转,位置围绕其中心计算;新卡片按260×200默认尺寸留间距。
|
||||
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;
|
||||
@@ -117,6 +145,7 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
|
||||
if (direction === "left" || direction === "right") position.y += 240;
|
||||
else position.x += 300;
|
||||
}
|
||||
// 仅选中新卡片并分配独立边ID;目标handle取旋转后主方向的反侧,源handle仍保留指定方向名。
|
||||
const child = { ...makeCanvasNode(kind, at || position, src), selected: true };
|
||||
const opposite: Record<NodeDirection, NodeDirection> = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
||||
return {
|
||||
@@ -126,12 +155,14 @@ export function branchCanvasNode(document: CanvasDocument, id: string, direction
|
||||
};
|
||||
}
|
||||
|
||||
/** 接受有限长度的允许格式base64内联图或无用户名/密码的HTTPS源,拒绝其它scheme。 */
|
||||
function validImage(src: unknown): src is string {
|
||||
if (typeof src !== "string") return false;
|
||||
if (/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(src)) return src.length < 3_000_000;
|
||||
try { const url = new URL(src); return url.protocol === "https:" && !url.username && !url.password && src.length < 2048; }
|
||||
catch { return false; }
|
||||
}
|
||||
/** 旧正文样式的封闭键/值校验,返回复制对象;undefined表示未设置,非法字段不静默放行。 */
|
||||
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("文本样式无效。");
|
||||
@@ -147,18 +178,22 @@ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").
|
||||
if (!valid) throw new Error("文本样式无效。");
|
||||
return { ...style };
|
||||
}
|
||||
/** 先限制UTF-8文件5MiB,再解析旧schema1/2、最多500节点/2000边,重建规范文档。
|
||||
* 校验单board内容和其端口外壳,跨board子树/目标存在性需要上层联合加载与事务校验。 */
|
||||
export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (new TextEncoder().encode(raw).length > 5 * 1024 * 1024) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
const value = JSON.parse(raw);
|
||||
if (!value || ![1, 2].includes(value.schema_version) || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
|
||||
throw new Error("不是有效的啡跃图谱文件。");
|
||||
const ids = new Set<string>();
|
||||
// 先校验节点ID、文本/图片、坐标和基础类型;不保留输入的任意type/style/测量字段。
|
||||
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", "icon", "frame", "plain-image"].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 || (["image", "plain-image"].includes(node.data.kind) && !validImage(node.data.src)))
|
||||
throw new Error("图谱节点格式无效。");
|
||||
// 兼容旧style尺寸,但宽高必须成对;v2分区可比普通卡片更大,旋转/字体仍有独立范围。
|
||||
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 < (value.schema_version === 2 && node.data.kind === "frame" ? 32 : 20) || width > (value.schema_version === 2 && node.data.kind === "frame" ? 100000 : 3000))) ||
|
||||
@@ -169,6 +204,7 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
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("图标标识无效。");
|
||||
if (node.data.frameId !== undefined && (node.data.kind === "frame" || typeof node.data.frameId !== "string" || !/^[\w-]{1,128}$/.test(node.data.frameId))) throw new Error("分区归属无效。");
|
||||
if (node.data.kind === "frame" && ((node.data.labelMode !== undefined && !["hidden", "fixed", "scaled"].includes(node.data.labelMode)) || (node.data.rotation || 0) !== 0)) throw new Error("分区设置无效。");
|
||||
// 已知样式和v2扩展分别解释,重建时只带认可字段;schema升级不能靠类型断言蒙混。
|
||||
const textStyle = decodeTextStyle(node.data.textStyle);
|
||||
const extensions = decodeNodeExtensions(node.data, value.schema_version);
|
||||
ids.add(node.id);
|
||||
@@ -182,10 +218,12 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
rotation: node.data.rotation || 0, fontSize: node.data.fontSize || (node.data.kind === "plain-text" ? 24 : 13), ...(["image", "plain-image"].includes(node.data.kind) ? { src: node.data.src } : {}),
|
||||
} };
|
||||
});
|
||||
// frameId仅可引用本board真实frame,不能让旧平面成员关系指向一个普通节点。
|
||||
const frameIds = new Set(nodes.filter((node) => node.data.kind === "frame").map((node) => node.id));
|
||||
if (nodes.some((node) => node.data.frameId && !frameIds.has(node.data.frameId))) throw new Error("分区归属不存在。");
|
||||
const nodeKinds = new Map(nodes.map((node) => [node.id, node.data.kind]));
|
||||
const edgeIds = new Set<string>();
|
||||
// 边身份唯一且两端存在;v2分区必须通过声明的input/output port连接,其它允许节点使用四向handle。
|
||||
const edges: CanvasEdgeModel[] = value.edges.map((edge: CanvasEdgeModel) => {
|
||||
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("图谱连线格式无效。");
|
||||
@@ -200,6 +238,7 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (handle != null && !nodeDirections.includes(handle as NodeDirection)) throw new Error("图谱连接方向无效。");
|
||||
}
|
||||
}
|
||||
// schema1不能夹带v2边扩展,避免保存后服务端旧表示悄悄丢字段。
|
||||
const edgeData = decodeEdgeData(edge.data);
|
||||
if (value.schema_version === 1 && edgeData) throw new Error("invalid_canvas");
|
||||
edgeIds.add(edge.id);
|
||||
@@ -208,6 +247,7 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
|
||||
};
|
||||
});
|
||||
// 镜头坐标必须有限且缩放0.1到4;返回独立值对象,不复用外部viewport引用。
|
||||
const viewport = value.viewport;
|
||||
if (!viewport || ![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom < 0.1 || viewport.zoom > 4)
|
||||
throw new Error("图谱视图格式无效。");
|
||||
|
||||
Reference in New Issue
Block a user