docs(canvas): explain legacy state history synchronization and subgraphs
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* 旧画布的元素创建类体系。类只提供内置类型、默认样式/尺寸和能力,不持有editor或DOM,
|
||||
* 实际视图通过组合使用能力;新版可扩展registry与这些旧内置class并非同一个类型系统。
|
||||
*/
|
||||
import type { Node, XYPosition } from "@xyflow/react";
|
||||
import type { ElementKind, CanvasNodeData } from "./canvas-bindings/compat-types";
|
||||
export type {ElementKind,FrameLabelMode,TextStyle,CanvasNodeData} from "./canvas-bindings/compat-types";
|
||||
|
||||
/** 旧React Flow节点加本地pendingUpload标记;该标记在snapshot保存前会被过滤。 */
|
||||
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon" | "frame" | "plainImage"> & { pendingUpload?: string };
|
||||
/** 旧UI四项能力声明:缩放、旋转、连线、分支;实际组件借用它,不继承React组件类。 */
|
||||
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
|
||||
|
||||
/** Business inheritance only: React views reuse these capabilities by composition. */
|
||||
/** 所有旧元素的业务基类,默认240×72和plainText视图;子类声明kind/title并覆盖特定默认值。 */
|
||||
export abstract class CanvasElement {
|
||||
// 基类可缩放/旋转但不连线/分支;静态能力由类表读取,不为每个节点重复创建行为对象。
|
||||
static readonly capabilities: ElementCapabilities = { resizable: true, rotatable: true, connectable: false, branchable: false };
|
||||
abstract readonly kind: ElementKind;
|
||||
abstract readonly title: string;
|
||||
@@ -15,11 +23,13 @@ export abstract class CanvasElement {
|
||||
readonly position: XYPosition;
|
||||
readonly text: string;
|
||||
readonly src?: string;
|
||||
/** 构造业务创建参数;position在toNode输出时复制,尚未创建节点身份或渲染对象。 */
|
||||
constructor(position: XYPosition, text = "", src?: string) {
|
||||
this.position = position;
|
||||
this.text = text;
|
||||
this.src = src;
|
||||
}
|
||||
/** 每次转换都生成新UUID与独立position/data/style,字体默认随plain-text与卡片类型选择。 */
|
||||
toNode(): CanvasNode {
|
||||
return {
|
||||
id: crypto.randomUUID(), type: this.nodeType, position: { ...this.position },
|
||||
@@ -30,42 +40,55 @@ export abstract class CanvasElement {
|
||||
};
|
||||
}
|
||||
}
|
||||
/** 文本/图片/脚本共享卡片视图、260×200尺寸和连线/分支能力。 */
|
||||
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 };
|
||||
}
|
||||
/** 文本卡片,只覆盖内容类型和默认标题,其余沿用CardElement。 */
|
||||
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 = "纯文本"; }
|
||||
|
||||
/** 图标用80×80图形视图,首次创建默认Star;具体SVG由图标加载器另读。 */
|
||||
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" } }; }
|
||||
}
|
||||
|
||||
/** 普通旧分区框640×400,不可旋转;子图入口/端口由其它流程按schema v2赋予。 */
|
||||
export class FrameElement extends CanvasElement {
|
||||
static override readonly capabilities: ElementCapabilities = { ...CanvasElement.capabilities, rotatable: false };
|
||||
readonly kind = "frame"; readonly title = "未命名分区";
|
||||
protected override readonly nodeType = "frame" as const;
|
||||
protected override readonly size = { width: 640, height: 400 };
|
||||
/** 分区置底且内容区不截获子元素指针,标题默认固定显示;这不是新建子board操作。 */
|
||||
override toNode(): CanvasNode {
|
||||
const node = super.toNode();
|
||||
return { ...node, zIndex: -1, style: { ...node.style, pointerEvents: "none" }, data: { ...node.data, labelMode: "fixed" } };
|
||||
}
|
||||
}
|
||||
|
||||
/** 无卡片外壳的320×240图片,是否按原图比例调整由解码/创建尺寸流程负责。 */
|
||||
export class PlainImageElement extends CanvasElement {
|
||||
readonly kind = "plain-image"; readonly title = "图片";
|
||||
protected override readonly nodeType = "plainImage" as const;
|
||||
protected override readonly size = { width: 320, height: 240 };
|
||||
}
|
||||
|
||||
/** 封闭内置kind→构造器表,是旧元素工厂入口,不接受任意未知kind动态执行。 */
|
||||
export const elementClasses = { text: TextCardElement, image: ImageCardElement, script: ScriptCardElement, "plain-text": PlainTextElement, icon: IconElement, frame: FrameElement, "plain-image": PlainImageElement };
|
||||
/** 按kind读取静态能力,调用方需先保证kind属于内置枚举。 */
|
||||
export function elementCapabilities(kind: ElementKind) { return elementClasses[kind].capabilities; }
|
||||
/** 创建指定内置类型的空内容节点,src仅作为创建参数传入;不自动上传文件或登记history。 */
|
||||
export function createCanvasElement(kind: ElementKind, position: XYPosition, src?: string): CanvasNode {
|
||||
return new elementClasses[kind](position, "", src).toNode();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* 旧平面frameId成员关系的几何规则。内容放下才按中心点重新归组,分区移动带动成员,缩放不自动重分组。
|
||||
* 这是单board的旧分区外壳逻辑,与schema v2子board树和新版ownerCanvasId层级分别处理。
|
||||
*/
|
||||
import type { NodeChange } from "@xyflow/react";
|
||||
import { applyNodeChanges } from "@xyflow/react";
|
||||
import type { CanvasNode } from "./canvas-elements.ts";
|
||||
import { nodeCenter, nodeSize } from "./canvas-geometry.ts";
|
||||
|
||||
/** Membership changes only on content drops, never on frame movement or resize. */
|
||||
/** 非frame节点按中心点找包含它的分区,多个重叠候选选面积最小者;frame本身不成为另一frame成员。 */
|
||||
export function containingFrame(node: CanvasNode, nodes: CanvasNode[]) {
|
||||
if (node.data.kind === "frame") return undefined;
|
||||
const center = nodeCenter(node);
|
||||
@@ -14,6 +19,7 @@ export function containingFrame(node: CanvasNode, nodes: CanvasNode[]) {
|
||||
}).sort((a, b) => nodeSize(a).width * nodeSize(a).height - nodeSize(b).width * nodeSize(b).height)[0];
|
||||
}
|
||||
|
||||
/** 纯计算选中内容的from/to反馈,尚未改frameId;用于拖动结束时判断归属变动。 */
|
||||
export function frameDropFeedback(nodes: CanvasNode[], droppedIds: string[]) {
|
||||
const ids = new Set(droppedIds);
|
||||
return nodes.filter((node) => ids.has(node.id) && node.data.kind !== "frame").map((node) => ({
|
||||
@@ -21,6 +27,7 @@ export function frameDropFeedback(nodes: CanvasNode[], droppedIds: string[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 仅改droppedIds中非frame节点,归属没变复用原引用,移出所有frame时删除frameId字段。 */
|
||||
export function assignFrameMembership(nodes: CanvasNode[], droppedIds: string[]): CanvasNode[] {
|
||||
const dropped = new Set(droppedIds);
|
||||
return nodes.map((node) => {
|
||||
@@ -32,6 +39,8 @@ export function assignFrameMembership(nodes: CanvasNode[], droppedIds: string[])
|
||||
});
|
||||
}
|
||||
|
||||
/** 先用React Flow归约节点变更,再按旧成员关系传播分区平移。
|
||||
* 同时明确移动的子节点不再叠加父位移,防选中父子一起拖时移动两次。 */
|
||||
export function applyFrameNodeChanges(changes: NodeChange<CanvasNode>[], previous: CanvasNode[]) {
|
||||
const nodes = applyNodeChanges(changes, previous);
|
||||
const before = new Map(previous.map((node) => [node.id, node]));
|
||||
@@ -41,10 +50,12 @@ export function applyFrameNodeChanges(changes: NodeChange<CanvasNode>[], previou
|
||||
const frameId = node.data.frameId;
|
||||
if (!frameId) return node;
|
||||
const frame = frames.get(frameId);
|
||||
// frame被删除只撤销成员标记,不顺带删除内容节点。
|
||||
if (!frame) {
|
||||
const { frameId: _old, ...data } = node.data;
|
||||
return { ...node, data };
|
||||
}
|
||||
// 只有分区收到明确position变化才传播;resize或新建分区不移动成员。
|
||||
const oldFrame = before.get(frameId);
|
||||
if (!oldFrame || !positioned.has(frameId) || positioned.has(node.id)) return node;
|
||||
return { ...node, position: { x: node.position.x + frame.position.x - oldFrame.position.x, y: node.position.y + frame.position.y - oldFrame.position.y } };
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* 旧跨board子图的父链与事务范围规划。共同祖先拥有跨分区连线,端点逐层通过分区端口向上暴露。
|
||||
* 这里只计算读写参与者、路径与请求描述,不创建元素/端口或发网络;调用方负责加载足够的文档。
|
||||
*/
|
||||
import type { CanvasDocument } from "./canvas.ts";
|
||||
/** 从入口节点建立子board→父board/frame索引;重复子ID后项覆盖,不替代单父归属的独立校验。 */
|
||||
export function subgraphParents(documents: Map<string, CanvasDocument>) {
|
||||
const parents = new Map<string, { boardId: string; frameId: string }>();
|
||||
for (const [boardId, document] of documents) for (const node of document.nodes) if (node.data.subgraph) parents.set(node.data.subgraph.boardId, { boardId, frameId: node.id });
|
||||
return parents;
|
||||
}
|
||||
/** 移动至少涉及parent/source/target,选中入口还纳入其后代;祖先端口引用受影响时再纳入上一层owner。 */
|
||||
export function subgraphMoveParticipants(documents: Map<string, CanvasDocument>, parent: string, source: string, target: string, ids: string[]) {
|
||||
const participants = new Set([parent, source, target]);
|
||||
const parents = subgraphParents(documents);
|
||||
/** 递归收集选中入口后代,重复访问报错,防循环或重复树引用被误当作独立分支。 */
|
||||
const addDescendants = (board: string, seen = new Set<string>()) => {
|
||||
if (seen.has(board)) throw new Error("subgraph_cycle"); seen.add(board); participants.add(board);
|
||||
for (const node of documents.get(board)?.nodes || []) if (node.data.subgraph) addDescendants(node.data.subgraph.boardId, seen);
|
||||
@@ -24,8 +31,10 @@ export function subgraphMoveParticipants(documents: Map<string, CanvasDocument>,
|
||||
}
|
||||
return [...participants];
|
||||
}
|
||||
/** 沿source上行到最近共同祖先,再下行到target,返回逐层父子移动描述;无共同根拒绝。 */
|
||||
export function subgraphMovePath(documents: Map<string, CanvasDocument>, source: string, target: string) {
|
||||
const parents = subgraphParents(documents);
|
||||
/** 包含自身的父链,重复board立即拒绝,避免循环遍历。 */
|
||||
const chain = (board: string) => { const result = [board]; while (parents.has(board)) { board = parents.get(board)!.boardId; if (result.includes(board)) throw new Error("subgraph_cycle"); result.push(board); } return result; };
|
||||
const from = chain(source), to = chain(target), common = from.find((id) => to.includes(id));
|
||||
if (!common) throw new Error("ownership_conflict");
|
||||
@@ -34,11 +43,14 @@ export function subgraphMovePath(documents: Map<string, CanvasDocument>, source:
|
||||
for (let i = to.indexOf(common) - 1; i >= 0; i--) { const relation = parents.get(to[i])!; path.push({ parent: relation.boardId, frame: relation.frameId, source: relation.boardId, target: to[i] }); }
|
||||
return path;
|
||||
}
|
||||
/** 计算连线共同owner及其可引用的两端;每跨一层必须已有同方向匹配端口,不在此暗自补端口。 */
|
||||
export function exposedConnection(documents: Map<string, CanvasDocument>, source: { boardId: string; id: string; handle?: string | null }, target: { boardId: string; id: string; handle?: string | null }) {
|
||||
const parents = subgraphParents(documents);
|
||||
/** 生成自身到根的链,最先共同项就是当前source/target的最近共同祖先。 */
|
||||
const ancestors = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; };
|
||||
const owner = ancestors(source.boardId).find((id) => ancestors(target.boardId).includes(id));
|
||||
if (!owner) throw new Error("ownership_conflict");
|
||||
/** 从内部node/handle逐层换成入口frame/port:id,保持源output、目标input方向。 */
|
||||
const expose = (endpoint: typeof source, direction: "input" | "output") => {
|
||||
let { boardId, id, handle } = endpoint;
|
||||
while (boardId !== owner) {
|
||||
@@ -52,8 +64,10 @@ export function exposedConnection(documents: Map<string, CanvasDocument>, source
|
||||
};
|
||||
return { boardId: owner, source: expose(source, "output"), target: expose(target, "input") };
|
||||
}
|
||||
/** 给服务端连接事务的原始端点描述与完整父链参与集;真正创建端口/边由事务执行层处理。 */
|
||||
export function connectionTransactionPlan(documents: Map<string, CanvasDocument>, source: { boardId: string; id: string; handle: string }, target: { boardId: string; id: string; handle: string }) {
|
||||
const parents = subgraphParents(documents);
|
||||
/** 与其它父链查询一致地防循环,不因两点在不同子board就当成无共同owner。 */
|
||||
const path = (id: string) => { const result = [id]; while (parents.has(id)) { id = parents.get(id)!.boardId; if (result.includes(id)) throw new Error("subgraph_cycle"); result.push(id); } return result; };
|
||||
const from = path(source.boardId), to = path(target.boardId), owner = from.find((id) => to.includes(id));
|
||||
if (!owner) throw new Error("ownership_conflict");
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
/**
|
||||
* 旧画布的完整快照保存与本地草稿恢复。一个实例串行PATCH一张board,pending只保留最近一次待保存快照,
|
||||
* If-Match限制覆盖基线;失败停住,等待显式retry/adopt。它没有新版ledger的冻结请求身份/回执恢复保证。
|
||||
* 序列化会去除上传未完成节点及其边,并重建允许字段,避免把React Flow测量/选择状态发给旧接口。
|
||||
*/
|
||||
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
|
||||
import { decodeCanvas, type CanvasDocument } from "./canvas.ts";
|
||||
|
||||
/** 服务端快照版本与旧格式文档;version是写入并发基线,不是schema_version。 */
|
||||
export type CanvasSnapshot = { version: number; document: CanvasDocument };
|
||||
/** 宿主注入的通用HTTP请求端口,具体凭证、异常和取消由API封装负责。 */
|
||||
export type CanvasRequest = <T>(path: string, options?: RequestInit) => Promise<T>;
|
||||
/** 保存状态通知供界面显示;saved只代表本writer内容保存判断,不代表所有任务或资源均已完成。 */
|
||||
export type SyncState = { status: "saved" | "saving" | "error"; version: number; error?: string };
|
||||
/** 编码工作区/board引用,生成旧snapshot读写端点。 */
|
||||
export function canvasPath(workspace: string, board: string) {
|
||||
return `workspaces/${encodeURIComponent(workspace)}/materials/${encodeURIComponent(board)}/canvas`;
|
||||
}
|
||||
/** 去除pendingUpload节点及连到它们的边,再经过decodeCanvas规范化;显式只输出节点持久字段。
|
||||
* pending图片仍可在本地显示,但不会以未完成资源引用覆盖服务端文档。 */
|
||||
export function canvasPayload(document: CanvasDocument) {
|
||||
const pending = new Set(document.nodes.filter((node) => node.pendingUpload).map((node) => node.id));
|
||||
const clean = decodeCanvas(JSON.stringify({ ...document, nodes: document.nodes.filter((node) => !pending.has(node.id)), edges: document.edges.filter((edge) => !pending.has(edge.source) && !pending.has(edge.target)) }));
|
||||
return { ...clean, nodes: clean.nodes.map(({ id, type, position, width, height, data }) => ({ id, type, position, ...(width !== undefined ? { width, height } : {}), data })) };
|
||||
}
|
||||
/** 对规范payload对象键排序后序列化,用于内容相等判断;includeViewport=false忽略纯视口差异。
|
||||
* 它不是密码学摘要,数组顺序仍有意义。 */
|
||||
export function canvasFingerprint(document: CanvasDocument, includeViewport = true) {
|
||||
const { viewport, ...content } = canvasPayload(document);
|
||||
return JSON.stringify(includeViewport ? { ...content, viewport } : content, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
||||
}
|
||||
/** 开发诊断用差异摘要,按节点字段报告增改删,边整体比较;不作为发送到服务端的增量协议。 */
|
||||
export function canvasChanges(before: CanvasDocument, after: CanvasDocument) {
|
||||
const a = canvasPayload(before), b = canvasPayload(after);
|
||||
/** 此诊断按JSON表示比较,正式脏判断另用稳定对象键的fingerprint。 */
|
||||
const changed = (left: unknown, right: unknown) => JSON.stringify(left) !== JSON.stringify(right);
|
||||
return {
|
||||
viewport: changed(a.viewport, b.viewport),
|
||||
@@ -32,42 +47,55 @@ export function canvasChanges(before: CanvasDocument, after: CanvasDocument) {
|
||||
edges: changed(a.edges, b.edges),
|
||||
};
|
||||
}
|
||||
/** 校验服务器正整数版本并重解码文档,不能仅凭类型断言信任网络快照。 */
|
||||
export function decodeSnapshot(value: CanvasSnapshot): CanvasSnapshot {
|
||||
if (!Number.isSafeInteger(value?.version) || value.version < 1) throw new Error("invalid_canvas_snapshot");
|
||||
return { version: value.version, document: decodeCanvas(JSON.stringify(value.document)) };
|
||||
}
|
||||
|
||||
/** One writer per board. Coalesce pending snapshots; stop on any rejected write. */
|
||||
/** 单board旧写者。document/saved是最近确认基线,pending是可被后续enqueue替换的等待槽,
|
||||
* running最多一个drain。不要将这里的快照合并理解成保留每项操作因果证据的可靠队列。 */
|
||||
export class CanvasSync {
|
||||
// version用于下一次If-Match;saved含viewport,savedContent专供忽略viewport的needsSave。
|
||||
version: number;
|
||||
saved: string;
|
||||
private savedContent: string;
|
||||
error = "";
|
||||
document: CanvasDocument;
|
||||
// 在途请求不被新enqueue替换;只合并尚未发送的后续快照,失败时保留更新的pending优先。
|
||||
private pending: CanvasDocument | null = null;
|
||||
private running: Promise<void> | null = null;
|
||||
private request: CanvasRequest;
|
||||
private path: string;
|
||||
private notify: (state: SyncState) => void;
|
||||
/** 接受已解码初始快照,建立两种指纹与状态回调;构造时不启动保存。 */
|
||||
constructor(request: CanvasRequest, path: string, initial: CanvasSnapshot, notify: (state: SyncState) => void) {
|
||||
this.request = request; this.path = path; this.version = initial.version;
|
||||
this.saved = canvasFingerprint(initial.document); this.savedContent = canvasFingerprint(initial.document, false); this.notify = notify;
|
||||
this.document = initial.document;
|
||||
}
|
||||
/** 只有持久内容变化才需PATCH,单纯平移/缩放视口不主动写;内容保存时payload仍携当前viewport。 */
|
||||
needsSave(document: CanvasDocument) { return canvasFingerprint(document, false) !== this.savedContent; }
|
||||
/** 替换等待槽,有错误时只保留内容而不自动发送;无错误且未运行时启动一个微任务drain。 */
|
||||
enqueue(document: CanvasDocument): Promise<void> {
|
||||
this.pending = document;
|
||||
if (this.error) return Promise.resolve();
|
||||
if (!this.running) this.running = Promise.resolve().then(() => this.drain()).finally(() => { this.running = null; if (this.pending && !this.error) void this.enqueue(this.pending); });
|
||||
return this.running;
|
||||
}
|
||||
/** 清除错误并重试当前pending快照;不查询旧回执,也不更换版本,基线冲突仍由服务端拒绝。 */
|
||||
retry() { this.error = ""; return this.pending ? this.enqueue(this.pending) : Promise.resolve(); }
|
||||
/** 等调用时的running结束;不是持续追踪未来enqueue或全项目同步完成的屏障。 */
|
||||
async settled() { await this.running; }
|
||||
/** 仅在没有在途写时显式接纳另一个快照,清空pending/error并重置版本/指纹。 */
|
||||
adopt(snapshot: CanvasSnapshot) {
|
||||
if (this.running) throw new Error("canvas_busy");
|
||||
this.pending = null; this.error = ""; this.version = snapshot.version; this.document = snapshot.document;
|
||||
this.saved = canvasFingerprint(snapshot.document); this.savedContent = canvasFingerprint(snapshot.document, false); this.notify({ status: "saved", version: this.version });
|
||||
}
|
||||
/** 串行消费等待快照,跳过无持久内容变化者;按当前version条件PATCH,回执必须恰好+1。
|
||||
* 任意失败都停止,保留后来更近的pending;此处不自动合并远端冲突或推断请求是否已经生效。 */
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
const document = this.pending; this.pending = null;
|
||||
@@ -76,14 +104,17 @@ export class CanvasSync {
|
||||
const fingerprint = canvasFingerprint(document);
|
||||
if (!this.needsSave(document)) { this.notify({ status: "saved", version: this.version }); continue; }
|
||||
if (process.env.NODE_ENV === "development") console.info("[canvas-dirty]", JSON.stringify({ resource: this.path, ...canvasChanges(this.document, document) }));
|
||||
// 仍发送整文档,限制5MiB;上方canvasChanges只是日志,不会把请求变成字段补丁。
|
||||
const body = JSON.stringify({ document: canvasPayload(document) });
|
||||
if (new TextEncoder().encode(body).length > 5 * 1024 * 1024) throw new Error("canvas_too_large");
|
||||
const result = await this.request<{ version: number }>(this.path, { method: "PATCH", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${this.version}"` }, body });
|
||||
if (!Number.isSafeInteger(result?.version) || result.version !== this.version + 1) throw new Error("invalid_canvas_snapshot");
|
||||
// 只有通过版本回执后推进保存基线;在途期间新enqueue留在pending供下一轮处理。
|
||||
this.version = result.version; this.saved = fingerprint; this.savedContent = canvasFingerprint(document, false);
|
||||
this.document = canvasPayload(document);
|
||||
this.notify({ status: "saved", version: this.version });
|
||||
} catch (error) {
|
||||
// 已有更新快照时保留它,否则把失败快照放回;错误不伪造成功版本。
|
||||
this.pending ||= document;
|
||||
this.error = error instanceof Error ? error.message : "request_failed";
|
||||
this.notify({ status: "error", version: this.version, error: this.error });
|
||||
@@ -93,6 +124,8 @@ export class CanvasSync {
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先读取带dirty标记的新草稿记录,无记录才兼容旧键;忽略只改viewport或与远端相同的内容。
|
||||
* 格式错误继续抛出,不将损坏草稿当作空白文档自动覆盖。 */
|
||||
export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string, snapshot?: CanvasSnapshot): CanvasDocument | null {
|
||||
const record = storage.getItem(key + ":recovery");
|
||||
if (record) {
|
||||
@@ -106,6 +139,8 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
|
||||
const doc = decodeCanvas(legacy);
|
||||
return !snapshot || canvasFingerprint(doc, false) !== canvasFingerprint(snapshot.document, false) ? doc : null;
|
||||
}
|
||||
/** 恢复旧草稿前检查schema不倒退,且分区入口/端口关系与远端一致。
|
||||
* 只是必要的局部条件,不证明整棵跨board树可原子恢复。 */
|
||||
export function canRestoreCanvasDraft(draft: CanvasDocument, remote: CanvasDocument) {
|
||||
if (draft.schema_version < remote.schema_version) return false;
|
||||
const relations = (document: CanvasDocument) => document.nodes.filter((node) => node.data.subgraph).map((node) => ({ id: node.id, subgraph: node.data.subgraph, ports: node.data.ports || [] })).sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
/**
|
||||
* 旧多board画布树的显示投影、移动参考系和三路合并。每个owner保留独立CanvasDocument,
|
||||
* 显示ID加board命名空间,投影节点/边不能直接回写为owner快照。分区展开/折叠通过旧端口映射呈现。
|
||||
*/
|
||||
import { nodeSize } from "./canvas-geometry.ts";
|
||||
import type { CanvasDocument, CanvasNode } from "./canvas.ts";
|
||||
import type { CanvasEdgeModel, EdgeData } from "./canvas-v2.ts";
|
||||
/** 显示ID到真实board/元素ID的映射与平移/缩放参考系;旧协议不在此表达父级旋转矩阵。 */
|
||||
export type CanvasReference = { boardId: string; id: string; origin: { x: number; y: number }; scale: number; parentBoard?: string; parentFrame?: string };
|
||||
/** 已读取的各owner文档表,缺失子board不会凭空创建空文档。 */
|
||||
export type CanvasDocuments = Map<string, CanvasDocument>;
|
||||
/** 根board保留原ID,其余加board::前缀,避免旧协议不同board里的同名元素显示碰撞。 */
|
||||
export const displayID = (root: string, board: string, id: string) => root === board ? id : `${board}::${id}`;
|
||||
/** 路径位置点应用平移+缩放,切线/首尾控制量是向量只缩放;inverse将显示路径转换回owner空间。
|
||||
* 没有path时复制data,避免调用方修改投影时改变原始对象。 */
|
||||
export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined {
|
||||
if (!data?.path) return data ? structuredClone(data) : undefined;
|
||||
const { origin, scale } = reference;
|
||||
/** vector=true排除origin偏移,不能把切线当绝对点从而重复平移。 */
|
||||
const point = (point: { x: number; y: number }, vector = false) => inverse
|
||||
? { x: (point.x - (vector ? 0 : origin.x)) / scale, y: (point.y - (vector ? 0 : origin.y)) / scale }
|
||||
: { x: point.x * scale + (vector ? 0 : origin.x), y: point.y * scale + (vector ? 0 : origin.y) };
|
||||
return { ...data, path: { ...data.path, ...(data.path.startControl ? { startControl: point(data.path.startControl, true) } : {}), ...(data.path.endControl ? { endControl: point(data.path.endControl, true) } : {}), knots: data.path.knots.map((knot) => ({ ...knot, position: point(knot.position), in: point(knot.in, true), out: point(knot.out, true) })) } };
|
||||
}
|
||||
/** Projection is disposable view data. Only owner documents are ever serialized. */
|
||||
/** 从root递归展开已加载且未折叠的子图,输出显示对象、反向引用和board参考系。
|
||||
* 先完成可见节点,再处理暂存边,端口递归可映射到真正显示的内层节点。 */
|
||||
export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
const nodeRefs = new Map<string, CanvasReference>(), edgeRefs = new Map<string, CanvasReference>();
|
||||
const nodes: CanvasNode[] = [], edges: CanvasEdgeModel[] = [];
|
||||
const boards = new Map<string, CanvasReference>();
|
||||
// 这里pending只是本次投影中延后处理的边,不是网络同步队列。
|
||||
const pending: { edge: CanvasEdgeModel; ref: CanvasReference }[] = [];
|
||||
/** 用祖先集合防循环并限制深度;子内容原点固定偏移24/36,contentScale乘父显示比例。 */
|
||||
function visit(boardId: string, origin: { x: number; y: number }, scale: number, ancestors: Set<string>, parentBoard?: string, parentFrame?: string) {
|
||||
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
|
||||
const document = documents.get(boardId);
|
||||
@@ -29,6 +43,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
nodeRefs.set(id, { ...reference, id: node.id });
|
||||
const width = nodeSize(node).width * scale, height = nodeSize(node).height * scale;
|
||||
const position = { x: origin.x + node.position.x * scale, y: origin.y + node.position.y * scale };
|
||||
// 只改投影id/尺寸/frameId/viewScale,nodeRefs保留真实owner身份供后续命令路由。
|
||||
nodes.push({ ...node, id, position, width, height, style: { ...node.style, width, height }, data: { ...node.data, ...(parentBoard && parentFrame ? { frameId: displayID(root, parentBoard, parentFrame) } : {}), viewScale: scale } });
|
||||
if (node.data.subgraph && !node.data.presentation?.collapsed) visit(node.data.subgraph.boardId,
|
||||
{ x: position.x + 24 * scale, y: position.y + 36 * scale }, scale * (node.data.presentation?.contentScale || 1), next, boardId, node.id);
|
||||
@@ -37,6 +52,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
}
|
||||
visit(root, { x: 0, y: 0 }, 1, new Set());
|
||||
const visible = new Set(nodes.map((node) => node.id));
|
||||
/** 展开入口且端口有效时递归寻内层显示端点;找不到已显示内层就保留外侧入口端口。 */
|
||||
function endpoint(board: string, nodeId: string, handle: string | null | undefined, depth = 0): { id: string; handle?: string | null } {
|
||||
const id = displayID(root, board, nodeId);
|
||||
if (depth > 8) return { id, handle };
|
||||
@@ -48,6 +64,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
}
|
||||
return { id, handle };
|
||||
}
|
||||
// 边引用仍指回原owner文档,显示端点可以在另一展开分区内,数据路径按本边owner变换。
|
||||
for (const { edge, ref } of pending) {
|
||||
const id = displayID(root, ref.boardId, edge.id); edgeRefs.set(id, { ...ref, id: edge.id });
|
||||
const source = endpoint(ref.boardId, edge.source, edge.sourceHandle), target = endpoint(ref.boardId, edge.target, edge.targetHandle);
|
||||
@@ -56,6 +73,7 @@ export function projectCanvasTree(root: string, documents: CanvasDocuments) {
|
||||
return { nodes, edges, nodeRefs, edgeRefs, boards };
|
||||
}
|
||||
/** A selected child and its selected parent frame must not receive the same drag twice. */
|
||||
/** 父框与子节点同时选中拖动时,先递归取得父框移动后的显示原点,避免同一拖动位移叠加两次。 */
|
||||
export function referenceAfterMoves(root: string, documents: CanvasDocuments, boards: Map<string, CanvasReference>, boardId: string, moves: Map<string, { x: number; y: number }>): CanvasReference {
|
||||
const reference = boards.get(boardId);
|
||||
if (!reference) throw new Error("invalid_reference");
|
||||
@@ -67,10 +85,14 @@ export function referenceAfterMoves(root: string, documents: CanvasDocuments, bo
|
||||
return { ...reference, origin: { x: position.x + 24 * parent.scale, y: position.y + 36 * parent.scale } };
|
||||
}
|
||||
|
||||
/** 三路合并的稳定键JSON比较,数组顺序仍参与比较;只供旧格式JSON风格数据使用。 */
|
||||
const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value)
|
||||
=== JSON.stringify(b, (_key, value) => value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value);
|
||||
/** Three-way merge preserves local geometry while applying server AI output. */
|
||||
/** base/local/remote三路字段合并:一侧未改采用另一侧,两侧同值也接受,不兼容双改明确冲突。
|
||||
* nodes/edges按ID合并,框架瞬态字段先剥离;viewport与选择最终取本地,绝非远端整快照覆盖。 */
|
||||
export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument, remote: CanvasDocument): CanvasDocument {
|
||||
/** 仅普通对象可逐字段递归,数组/标量两侧不同改动不会“最后写入获胜”,而是抛version_conflict。 */
|
||||
const merge = (base: unknown, local: unknown, remote: unknown): unknown => {
|
||||
if (equal(local, base)) return remote;
|
||||
if (equal(remote, base) || equal(local, remote)) return local;
|
||||
@@ -80,15 +102,18 @@ export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument
|
||||
}
|
||||
throw new Error("version_conflict");
|
||||
};
|
||||
/** ID集合以remote序后补local新项,缺项参与三路删除判断,删除与另一侧修改可能冲突。 */
|
||||
function collection<T extends { id: string }>(base: T[], local: T[], remote: T[]): T[] {
|
||||
const b = new Map(base.map((v) => [v.id, v])), l = new Map(local.map((v) => [v.id, v])), r = new Map(remote.map((v) => [v.id, v]));
|
||||
return [...new Set([...r.keys(), ...l.keys()])].map((id) => merge(b.get(id), l.get(id), r.get(id))).filter((v) => v !== undefined) as T[];
|
||||
}
|
||||
/** 排除选择/拖动/测量/style这些框架字段,它们不该引发业务字段合并冲突。 */
|
||||
const strip = <T extends { selected?: boolean; dragging?: boolean; measured?: unknown; style?: unknown }>(value: T) => { const { selected, dragging, measured, style, ...clean } = value; return clean; };
|
||||
const result = {
|
||||
schema_version: Math.max(local.schema_version, remote.schema_version) as 1 | 2,
|
||||
nodes: collection(base.nodes.map(strip), local.nodes.map(strip), remote.nodes.map(strip)) as CanvasNode[],
|
||||
edges: collection(base.edges.map(strip), local.edges.map(strip), remote.edges.map(strip)) as CanvasEdgeModel[], viewport: local.viewport,
|
||||
};
|
||||
// 合并后重新生成样式与frame层序,恢复本地选择;不继承远端React Flow临时测量。
|
||||
return { ...result, nodes: result.nodes.map((node) => ({ ...node, selected: local.nodes.find((item) => item.id === node.id)?.selected, style: { width: node.width, height: node.height, ...(node.data.kind === "frame" ? { pointerEvents: "none" } : {}) }, ...(node.data.kind === "frame" ? { zIndex: -1 } : {}) })) };
|
||||
}
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
/**
|
||||
* 旧CanvasDocument schema v2的扩展字段校验与兼容类型入口。
|
||||
* 处理边样式/贝塞尔链、AI配置、素材ID、子图端口和分区展示;版本2并非新版canonical documentFormatVersion。
|
||||
*/
|
||||
import type { Edge, XYPosition } from "@xyflow/react";
|
||||
import type {EdgeData,BezierKnot,AIConfig,FramePort,FramePresentation} from "./canvas-bindings/compat-types";
|
||||
export type {MarkerShape,EdgeMarker,BezierKnot,EdgeData,AIConfig,FramePort,FramePresentation} from "./canvas-bindings/compat-types";
|
||||
/** 旧连线使用React Flow Edge外壳,持久data限制为兼容EdgeData。 */
|
||||
export type CanvasEdgeModel = Edge<EdgeData>;
|
||||
/** 请求旧快照接口使用的媒体类型,避免服务端用默认旧表示丢掉v2扩展字段。 */
|
||||
export const CANVAS_ACCEPT = "application/vnd.foundry.canvas+json;version=2";
|
||||
/** 旧节点/端口ID的短字符串语法,独立于服务端board/resource使用的UUID。 */
|
||||
export const wireID = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
/** 包含端点的有限数值范围检查,拒绝NaN与Infinity。 */
|
||||
export const numberIn = (value: unknown, min: number, max: number): value is number => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
||||
/** 位置/控制向量各轴限定在±100万,调用路径决定它表示绝对点还是向量。 */
|
||||
export const pointValid = (value: unknown): value is XYPosition => !!value && typeof value === "object" && numberIn((value as XYPosition).x, -1e6, 1e6) && numberIn((value as XYPosition).y, -1e6, 1e6);
|
||||
/** 只允许已知自有键的对象外壳;必填字段与每字段值需由调用处另外校验。 */
|
||||
export function fields(value: unknown, keys: string[]): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
/** 所有v2校验失败统一抛invalid_canvas,不返回半份有效扩展。 */
|
||||
function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); }
|
||||
/** 字段缺失可通过,字段存在时必须满足校验;存在但undefined不等同于缺失。 */
|
||||
function optional(object: Record<string, unknown>, key: string, valid: (value: unknown) => boolean) { check(!(key in object) || valid(object[key])); }
|
||||
/** 构造封闭枚举校验器,不接受任意扩展字符串。 */
|
||||
const choice = (...values: unknown[]) => (value: unknown) => values.includes(value);
|
||||
/** 颜色仅接六位十六进制,不能把任意CSS表达式带入旧边样式。 */
|
||||
const color = (value: unknown) => typeof value === "string" && /^#[a-f\d]{6}$/i.test(value);
|
||||
/** 可选开关仍需真实boolean,不把字符串false当作关闭。 */
|
||||
const bool = (value: unknown) => typeof value === "boolean";
|
||||
/** 验证已知边扩展并structuredClone,与输入断开引用;undefined保留为无扩展。 */
|
||||
export function decodeEdgeData(value: unknown): EdgeData | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
check(fields(value, ["appearance", "animation", "path", "execution"]));
|
||||
const data = value as Record<string, unknown>;
|
||||
// 限制线宽/透明度/箭头形状尺寸;样式不是直接透传任意SVG属性。
|
||||
if (data.appearance !== undefined) {
|
||||
check(fields(data.appearance, ["color", "width", "opacity", "line", "head", "tail"])); const a = data.appearance as Record<string, unknown>;
|
||||
optional(a, "color", color); optional(a, "width", (v) => numberIn(v, .5, 20)); optional(a, "opacity", (v) => numberIn(v, .05, 1)); optional(a, "line", choice("solid", "dashed", "dotted"));
|
||||
@@ -31,6 +48,7 @@ export function decodeEdgeData(value: unknown): EdgeData | undefined {
|
||||
optional(a, "mode", choice("off", "always", "executing")); optional(a, "effect", choice("flow", "dash", "pulse")); optional(a, "direction", choice("forward", "reverse")); optional(a, "durationMs", (v) => numberIn(v, 250, 30000)); optional(a, "color", color);
|
||||
}
|
||||
if (data.execution !== undefined) { check(fields(data.execution, ["enabled"])); optional(data.execution as Record<string, unknown>, "enabled", bool); }
|
||||
// 贝塞尔链最多64个结点,身份唯一、各点有界;不只检查path.type就信任控制数据。
|
||||
if (data.path !== undefined) {
|
||||
check(fields(data.path, ["type", "startControl", "endControl", "knots"])); const path = data.path as Record<string, unknown>;
|
||||
check(path.type === "bezier-chain" && Array.isArray(path.knots) && path.knots.length <= 64);
|
||||
@@ -38,6 +56,7 @@ export function decodeEdgeData(value: unknown): EdgeData | undefined {
|
||||
const ids = new Set<string>();
|
||||
for (const knot of path.knots as BezierKnot[]) {
|
||||
check(fields(knot, ["id", "position", "in", "out", "mode"]) && wireID(knot.id) && !ids.has(knot.id) && pointValid(knot.position) && pointValid(knot.in) && pointValid(knot.out) && choice("corner", "smooth", "symmetric")(knot.mode)); ids.add(knot.id);
|
||||
// smooth要求切线反向共线,symmetric还要求两侧等长,允许零长度向量的退化情形。
|
||||
if (knot.mode !== "corner") {
|
||||
const a = Math.hypot(knot.in.x, knot.in.y), b = Math.hypot(knot.out.x, knot.out.y);
|
||||
if (a && b) check(Math.abs(knot.in.x / a * knot.out.y / b - knot.in.y / a * knot.out.x / b) <= 1e-6 && knot.in.x / a * knot.out.x / b + knot.in.y / a * knot.out.y / b <= 0);
|
||||
@@ -47,10 +66,12 @@ export function decodeEdgeData(value: unknown): EdgeData | undefined {
|
||||
}
|
||||
return structuredClone(value) as EdgeData;
|
||||
}
|
||||
/** 仅schema2允许这些扩展,且能力受kind限制:图片资源ID、可生成节点AI、frame子图/端口/展示。 */
|
||||
export function decodeNodeExtensions(data: Record<string, unknown>, schema: number) {
|
||||
const extensions: { ai?: AIConfig; assetId?: string; subgraph?: { boardId: string }; ports?: FramePort[]; presentation?: FramePresentation } = {};
|
||||
for (const key of ["ai", "assetId", "subgraph", "ports", "presentation"]) if (key in data) check(schema === 2);
|
||||
if (data.assetId !== undefined) { check(["image", "plain-image"].includes(String(data.kind)) && typeof data.assetId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.assetId)); extensions.assetId = data.assetId as string; }
|
||||
// AI提示按Unicode码点计长,parameters限制JSON序列化字节;这是配置,不是生成任务受理记录。
|
||||
if (data.ai !== undefined) {
|
||||
check(["text", "script", "plain-text", "image", "plain-image"].includes(String(data.kind)) && fields(data.ai, ["enabled", "modelId", "prompt", "propagate", "parameters"])); const ai = data.ai as Record<string, unknown>;
|
||||
optional(ai, "enabled", bool); optional(ai, "propagate", bool); optional(ai, "modelId", (v) => typeof v === "string" && v.length > 0 && v.length <= 256 && v.trim() === v);
|
||||
@@ -59,11 +80,13 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
|
||||
}
|
||||
if (["subgraph", "ports", "presentation"].some((key) => key in data)) check(data.kind === "frame");
|
||||
if (data.subgraph !== undefined) { check(fields(data.subgraph, ["boardId"]) && typeof data.subgraph.boardId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.subgraph.boardId)); extensions.subgraph = { boardId: (data.subgraph as { boardId: string }).boardId }; }
|
||||
// 分区端口限定方向、唯一短ID和内部node/handle引用形状;跨board引用是否存在由树/事务流程校验。
|
||||
if (data.ports !== undefined) {
|
||||
check(Array.isArray(data.ports) && data.ports.length <= 4000); const ids = new Set<string>();
|
||||
for (const port of data.ports as FramePort[]) { check(fields(port, ["id", "direction", "nodeId", "handle"]) && wireID(port.id) && !ids.has(port.id) && wireID(port.nodeId) && choice("input", "output")(port.direction) && typeof port.handle === "string" && (/^(top|right|bottom|left)$/.test(port.handle) || /^port:[A-Za-z0-9_-]{1,128}$/.test(port.handle))); ids.add(port.id); }
|
||||
extensions.ports = structuredClone(data.ports) as FramePort[];
|
||||
}
|
||||
// 折叠状态、内容比例和展开/折叠尺寸分别验证;不据此自动创建或迁移子图。
|
||||
if (data.presentation !== undefined) {
|
||||
check(fields(data.presentation, ["collapsed", "contentScale", "expandedSize", "collapsedSize"])); const p = data.presentation as Record<string, unknown>;
|
||||
optional(p, "collapsed", bool); optional(p, "contentScale", (v) => numberIn(v, .05, 4));
|
||||
@@ -72,6 +95,8 @@ export function decodeNodeExtensions(data: Record<string, unknown>, schema: numb
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
/** 只把固定Foundry项目的规范素材路径映射为同源代理,可选preview;
|
||||
* 内联、demo或其它地址原样返回,不把任意HTTPS资源都伪装成受管资源。 */
|
||||
export function managedImageSource(src?: string, preview = false) {
|
||||
if (!src) return src;
|
||||
try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/);
|
||||
|
||||
@@ -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