Integrate Canvas V2 subgraphs, editable edges and AI runs
This commit is contained in:
+20
-4
@@ -5,17 +5,22 @@ export type Profile = {
|
||||
email: string | null;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
export class ApiError extends Error {
|
||||
constructor(code: string, readonly status: number, readonly details?: unknown, readonly retryAfter?: string | null) { super(code); this.name = "ApiError"; }
|
||||
}
|
||||
export async function api<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch("/api/foundry/" + path, {
|
||||
...options,
|
||||
headers: { "Content-Type": "application/json", ...options.headers },
|
||||
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok)
|
||||
throw new Error(body?.error?.code || body?.error || "request_failed");
|
||||
if (!res.ok) {
|
||||
const code = body?.error?.code || body?.error;
|
||||
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, body?.details, res.headers.get("retry-after"));
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
|
||||
@@ -46,11 +51,22 @@ export const errorText = (error: unknown) => {
|
||||
workspace_required: "请先创建或选择一个工作空间。",
|
||||
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
|
||||
invalid_title: "名称不能为空,且不能超过 300 字节(约 100 个汉字)。",
|
||||
version_conflict: "图谱已在别处修改,请再次保存以读取最新版本。",
|
||||
version_conflict: "画布已更新,本地草稿已保留,请导出备份或重新读取后处理冲突。",
|
||||
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
|
||||
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
|
||||
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
|
||||
invalid_canvas_snapshot: "服务端画布数据格式无效,请重新读取或联系维护者。",
|
||||
canvas_schema_upgrade_required: "这张画布已升级,请刷新以加载新版客户端。",
|
||||
undo_receipt_expired: "撤销记录已过期,当前内容已保留。",
|
||||
draft_recovery_required: "请先处理未同步的本地草稿。",
|
||||
canvas_busy: "画布正在同步,请稍后重试。",
|
||||
node_busy: "这个元素正在生成,请等待完成或先取消。",
|
||||
model_unavailable: "模型暂时不可用,请重新选择。",
|
||||
model_incompatible: "这个模型不支持当前元素的输出类型。",
|
||||
ownership_conflict: "子画布关系已变化,请重新读取画布。",
|
||||
execution_cycle: "执行路径中有循环,请先断开循环连接。",
|
||||
queue_full: "生成队列已满,请稍后重试。",
|
||||
context_too_large: "输入内容超出模型容量,请减少输入。",
|
||||
canvas_too_large: "画布超过 5 MiB,请减少图片或拆分画布后重试。",
|
||||
body_too_large: "画布内容过大,请减少图片或拆分画布。",
|
||||
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { XYPosition } from "@xyflow/react";
|
||||
import type { Connector } from "./canvas-geometry";
|
||||
import type { EdgeData } from "./canvas-v2";
|
||||
export type Cubic = [XYPosition, XYPosition, XYPosition, XYPosition];
|
||||
export const plus = (a: XYPosition, b: XYPosition) => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
export const minus = (a: XYPosition, b: XYPosition) => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const lerp = (a: XYPosition, b: XYPosition, t: number) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
export function curveSegments(from: Connector, to: Connector, path?: EdgeData["path"]): Cubic[] {
|
||||
const reach = Math.min(240, Math.max(40, Math.hypot(to.point.x - from.point.x, to.point.y - from.point.y) * .4));
|
||||
const knots = path?.knots || [];
|
||||
const anchors = [from.point, ...knots.map((knot) => knot.position), to.point];
|
||||
return anchors.slice(0, -1).map((point, index) => [point,
|
||||
plus(point, index ? knots[index - 1].out : path?.startControl || { x: from.normal.x * reach, y: from.normal.y * reach }),
|
||||
plus(anchors[index + 1], index < knots.length ? knots[index].in : path?.endControl || { x: to.normal.x * reach, y: to.normal.y * reach }), anchors[index + 1]]);
|
||||
}
|
||||
export function cubicPoint([a, b, c, d]: Cubic, t: number) { const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t); return lerp(lerp(ab, bc, t), lerp(bc, cd, t), t); }
|
||||
export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${segments[0][0].y} ` + segments.map(([, b, c, d]) => `C ${b.x},${b.y} ${c.x},${c.y} ${d.x},${d.y}`).join(" "); }
|
||||
/** Split the closest segment without altering its shape. */
|
||||
export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["path"], point?: XYPosition) {
|
||||
const segments = curveSegments(from, to, path);
|
||||
let selected = Math.floor(segments.length / 2), t = .5, distance = Infinity;
|
||||
if (point) segments.forEach((segment, index) => { for (let step = 1; step < 40; step++) {
|
||||
const p = cubicPoint(segment, step / 40), d = Math.hypot(p.x - point.x, p.y - point.y);
|
||||
if (d < distance) { distance = d; selected = index; t = step / 40; }
|
||||
} });
|
||||
const [a, b, c, d] = segments[selected];
|
||||
const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t), abc = lerp(ab, bc, t), bcd = lerp(bc, cd, t), anchor = lerp(abc, bcd, t);
|
||||
const next = structuredClone(path || { type: "bezier-chain" as const, knots: [] });
|
||||
const knot = { id: crypto.randomUUID(), position: anchor, in: minus(abc, anchor), out: minus(bcd, anchor), mode: "smooth" as const };
|
||||
if (selected) next.knots[selected - 1].out = minus(ab, a); else next.startControl = minus(ab, a);
|
||||
if (selected < next.knots.length) next.knots[selected].in = minus(cd, d); else next.endControl = minus(cd, d);
|
||||
// Splitting one side of an existing symmetric knot can make its handle lengths differ.
|
||||
for (const index of [selected - 1, selected]) if (next.knots[index]?.mode === "symmetric") next.knots[index].mode = "smooth";
|
||||
next.knots.splice(selected, 0, knot); return { path: next, knot };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Node, XYPosition } from "@xyflow/react";
|
||||
import type { AIConfig, FramePort, FramePresentation } from "./canvas-v2";
|
||||
|
||||
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
|
||||
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
|
||||
@@ -14,6 +15,12 @@ export type CanvasNodeData = {
|
||||
icon?: string;
|
||||
frameId?: string;
|
||||
labelMode?: FrameLabelMode;
|
||||
ai?: AIConfig;
|
||||
assetId?: string;
|
||||
subgraph?: { boardId: string };
|
||||
ports?: FramePort[];
|
||||
presentation?: FramePresentation;
|
||||
viewScale?: number;
|
||||
};
|
||||
export type CanvasNode = Node<CanvasNodeData, "material" | "plainText" | "icon" | "frame" | "plainImage">;
|
||||
export type ElementCapabilities = { resizable: boolean; rotatable: boolean; connectable: boolean; branchable: boolean };
|
||||
|
||||
@@ -26,7 +26,7 @@ export function rotationFromPointer(node: CanvasNode, start: XYPosition, current
|
||||
if (snap) rotation = Math.round(rotation / 15) * 15;
|
||||
return normalizeRotation(rotation);
|
||||
}
|
||||
export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition): NodeTransform {
|
||||
export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection, delta: XYPosition, lockAspect?: boolean): NodeTransform {
|
||||
const { width, height } = nodeSize(node);
|
||||
const rotation = node.data.rotation || 0;
|
||||
const local = rotateVector(delta, -rotation);
|
||||
@@ -34,13 +34,17 @@ export function resizeFromPointer(node: CanvasNode, direction: ResizeDirection,
|
||||
const vertical = direction.includes("top") ? -1 : direction.includes("bottom") ? 1 : 0;
|
||||
const plain = node.data.kind === "plain-text";
|
||||
const icon = node.data.kind === "icon" || node.data.kind === "plain-image";
|
||||
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));
|
||||
if (icon) {
|
||||
const viewScale = node.data.viewScale || 1;
|
||||
const maximum = node.data.kind === "frame" ? 100000 * viewScale : 3000 * viewScale;
|
||||
const minWidth = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 60 : 140) * viewScale;
|
||||
const minHeight = (node.data.kind === "frame" ? 32 : icon ? 24 : plain ? 24 : 100) * viewScale;
|
||||
let nextWidth = Math.max(minWidth, Math.min(maximum, width + horizontal * local.x));
|
||||
let nextHeight = Math.max(minHeight, Math.min(maximum, height + vertical * local.y));
|
||||
if (lockAspect ?? 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;
|
||||
scale = Math.max(24 / width, 24 / height, Math.min(scale, 3000 / width, 3000 / height));
|
||||
scale = Math.max(minWidth / width, minHeight / height, Math.min(scale, maximum / width, maximum / height));
|
||||
nextWidth = width * scale;
|
||||
nextHeight = height * scale;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
|
||||
import { decodeSnapshot, type CanvasSnapshot, type CanvasRequest } from "./canvas-sync.ts";
|
||||
import { matchesRef, type Board } from "./workspace.ts";
|
||||
|
||||
export async function loadBoardCanvas(request: CanvasRequest, workspaceRef: string, boardRef: string, signal?: AbortSignal) {
|
||||
const path = `workspaces/${encodeURIComponent(workspaceRef)}/materials/${encodeURIComponent(boardRef)}`;
|
||||
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal })]);
|
||||
const [board, snapshot] = await Promise.all([request<Board>(path, { signal }), request<CanvasSnapshot>(path + "/canvas", { signal, headers: { Accept: CANVAS_ACCEPT } })]);
|
||||
if (board.kind !== "board" || !matchesRef(board, boardRef) || typeof board.workspace_id !== "string" || !board.workspace_id) throw new Error("invalid_reference");
|
||||
return { board, snapshot: decodeSnapshot(snapshot) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { CanvasDocument } from "./canvas.ts";
|
||||
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;
|
||||
}
|
||||
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);
|
||||
};
|
||||
for (const node of documents.get(source)?.nodes || []) if (ids.includes(node.id) && node.data.subgraph) addDescendants(node.data.subgraph.boardId);
|
||||
const ancestor = parents.get(parent);
|
||||
if (ancestor) {
|
||||
const frame = documents.get(ancestor.boardId)?.nodes.find((node) => node.id === ancestor.frameId);
|
||||
const affected = frame?.data.ports?.some((port) => {
|
||||
if (source === parent) return ids.includes(port.nodeId);
|
||||
const inner = documents.get(parent)?.nodes.find((node) => node.id === port.nodeId);
|
||||
return inner?.data.subgraph?.boardId === source && inner.data.ports?.some((p) => `port:${p.id}` === port.handle && p.direction === port.direction && ids.includes(p.nodeId));
|
||||
});
|
||||
if (affected) participants.add(ancestor.boardId);
|
||||
}
|
||||
return [...participants];
|
||||
}
|
||||
export function subgraphMovePath(documents: Map<string, CanvasDocument>, source: string, target: string) {
|
||||
const parents = subgraphParents(documents);
|
||||
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");
|
||||
const path: { parent: string; frame: string; source: string; target: string }[] = [];
|
||||
for (let i = 0; from[i] !== common; i++) { const relation = parents.get(from[i])!; path.push({ parent: relation.boardId, frame: relation.frameId, source: from[i], target: relation.boardId }); }
|
||||
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;
|
||||
}
|
||||
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);
|
||||
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");
|
||||
const expose = (endpoint: typeof source, direction: "input" | "output") => {
|
||||
let { boardId, id, handle } = endpoint;
|
||||
while (boardId !== owner) {
|
||||
const parent = parents.get(boardId)!;
|
||||
const frame = documents.get(parent.boardId)?.nodes.find((node) => node.id === parent.frameId);
|
||||
const port = frame?.data.ports?.find((port) => port.nodeId === id && port.handle === handle && port.direction === direction);
|
||||
if (!port) throw new Error("boundary_port_required");
|
||||
id = parent.frameId; handle = `port:${port.id}`; boardId = parent.boardId;
|
||||
}
|
||||
return { id, handle };
|
||||
};
|
||||
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);
|
||||
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");
|
||||
return { boardId: owner, participants: [...new Set([...from.slice(0, from.indexOf(owner) + 1), ...to.slice(0, to.indexOf(owner) + 1)])], source: { board_id: source.boardId, node_id: source.id, handle: source.handle }, target: { board_id: target.boardId, node_id: target.id, handle: target.handle } };
|
||||
}
|
||||
+16
-1
@@ -1,3 +1,4 @@
|
||||
import { CANVAS_ACCEPT } from "./canvas-v2.ts";
|
||||
import { decodeCanvas, type CanvasDocument } from "./canvas.ts";
|
||||
|
||||
export type CanvasSnapshot = { version: number; document: CanvasDocument };
|
||||
@@ -23,6 +24,7 @@ export class CanvasSync {
|
||||
version: number;
|
||||
saved: string;
|
||||
error = "";
|
||||
document: CanvasDocument;
|
||||
private pending: CanvasDocument | null = null;
|
||||
private running: Promise<void> | null = null;
|
||||
private request: CanvasRequest;
|
||||
@@ -31,6 +33,7 @@ export class CanvasSync {
|
||||
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.notify = notify;
|
||||
this.document = initial.document;
|
||||
}
|
||||
enqueue(document: CanvasDocument): Promise<void> {
|
||||
this.pending = document;
|
||||
@@ -39,6 +42,12 @@ export class CanvasSync {
|
||||
return this.running;
|
||||
}
|
||||
retry() { this.error = ""; return this.pending ? this.enqueue(this.pending) : Promise.resolve(); }
|
||||
async settled() { await this.running; }
|
||||
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.notify({ status: "saved", version: this.version });
|
||||
}
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
const document = this.pending; this.pending = null;
|
||||
@@ -48,9 +57,10 @@ export class CanvasSync {
|
||||
if (fingerprint === this.saved) { this.notify({ status: "saved", version: this.version }); continue; }
|
||||
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: { "If-Match": `"${this.version}"` }, body });
|
||||
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");
|
||||
this.version = result.version; this.saved = fingerprint;
|
||||
this.document = document;
|
||||
this.notify({ status: "saved", version: this.version });
|
||||
} catch (error) {
|
||||
this.pending ||= document;
|
||||
@@ -75,3 +85,8 @@ export function readLocalRecovery(storage: Pick<Storage, "getItem">, key: string
|
||||
const doc = decodeCanvas(legacy);
|
||||
return !snapshot || canvasFingerprint(doc) !== canvasFingerprint(snapshot.document) ? doc : null;
|
||||
}
|
||||
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));
|
||||
return JSON.stringify(relations(draft)) === JSON.stringify(relations(remote));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { nodeSize } from "./canvas-geometry.ts";
|
||||
import type { CanvasDocument, CanvasNode } from "./canvas.ts";
|
||||
import type { CanvasEdgeModel, EdgeData } from "./canvas-v2.ts";
|
||||
export type CanvasReference = { boardId: string; id: string; origin: { x: number; y: number }; scale: number; parentBoard?: string; parentFrame?: string };
|
||||
export type CanvasDocuments = Map<string, CanvasDocument>;
|
||||
export const displayID = (root: string, board: string, id: string) => root === board ? id : `${board}::${id}`;
|
||||
export function transformEdgeData(data: EdgeData | undefined, reference: CanvasReference, inverse = false): EdgeData | undefined {
|
||||
if (!data?.path) return data ? structuredClone(data) : undefined;
|
||||
const { origin, scale } = reference;
|
||||
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. */
|
||||
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>();
|
||||
const pending: { edge: CanvasEdgeModel; ref: CanvasReference }[] = [];
|
||||
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);
|
||||
if (!document) return;
|
||||
const next = new Set(ancestors); next.add(boardId);
|
||||
const reference = { boardId, id: boardId, origin, scale, parentBoard, parentFrame }; boards.set(boardId, reference);
|
||||
for (const node of document.nodes) {
|
||||
const id = displayID(root, boardId, node.id);
|
||||
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 };
|
||||
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);
|
||||
}
|
||||
for (const edge of document.edges) pending.push({ edge, ref: reference });
|
||||
}
|
||||
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 };
|
||||
const node = documents.get(board)?.nodes.find((node) => node.id === nodeId);
|
||||
const port = node?.data.ports?.find((port) => `port:${port.id}` === handle);
|
||||
if (port && node?.data.subgraph && !node.data.presentation?.collapsed) {
|
||||
const inner = endpoint(node.data.subgraph.boardId, port.nodeId, port.handle, depth + 1);
|
||||
if (visible.has(inner.id)) return inner;
|
||||
}
|
||||
return { id, handle };
|
||||
}
|
||||
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);
|
||||
edges.push({ ...edge, id, source: source.id, target: target.id, sourceHandle: source.handle, targetHandle: target.handle, data: transformEdgeData(edge.data, ref) });
|
||||
}
|
||||
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");
|
||||
if (boardId === root || !reference.parentBoard || !reference.parentFrame) return reference;
|
||||
const parent = referenceAfterMoves(root, documents, boards, reference.parentBoard, moves);
|
||||
const frame = documents.get(reference.parentBoard)?.nodes.find((node) => node.id === reference.parentFrame);
|
||||
if (!frame) throw new Error("invalid_reference");
|
||||
const position = moves.get(displayID(root, reference.parentBoard, reference.parentFrame)) || { x: parent.origin.x + frame.position.x * parent.scale, y: parent.origin.y + frame.position.y * parent.scale };
|
||||
return { ...reference, origin: { x: position.x + 24 * parent.scale, y: position.y + 36 * parent.scale } };
|
||||
}
|
||||
|
||||
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. */
|
||||
export function mergeCanvasDocuments(base: CanvasDocument, local: CanvasDocument, remote: CanvasDocument): CanvasDocument {
|
||||
const merge = (base: unknown, local: unknown, remote: unknown): unknown => {
|
||||
if (equal(local, base)) return remote;
|
||||
if (equal(remote, base) || equal(local, remote)) return local;
|
||||
if ([base, local, remote].every((v) => !!v && typeof v === "object" && !Array.isArray(v))) {
|
||||
const b = base as Record<string, unknown>, l = local as Record<string, unknown>, r = remote as Record<string, unknown>;
|
||||
return Object.fromEntries([...new Set([...Object.keys(b), ...Object.keys(l), ...Object.keys(r)])].map((key) => [key, merge(b[key], l[key], r[key])]).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
throw new Error("version_conflict");
|
||||
};
|
||||
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[];
|
||||
}
|
||||
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,
|
||||
};
|
||||
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 } : {}) })) };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Edge, XYPosition } from "@xyflow/react";
|
||||
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
|
||||
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
|
||||
export type BezierKnot = { id: string; position: XYPosition; in: XYPosition; out: XYPosition; mode: "corner" | "smooth" | "symmetric" };
|
||||
export type EdgeData = {
|
||||
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
|
||||
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
|
||||
path?: { type: "bezier-chain"; startControl?: XYPosition; endControl?: XYPosition; knots: BezierKnot[] };
|
||||
execution?: { enabled?: boolean };
|
||||
};
|
||||
export type CanvasEdgeModel = Edge<EdgeData>;
|
||||
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
|
||||
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
|
||||
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
|
||||
export const CANVAS_ACCEPT = "application/vnd.foundry.canvas+json;version=2";
|
||||
export const wireID = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
export const numberIn = (value: unknown, min: number, max: number): value is number => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
||||
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));
|
||||
}
|
||||
function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); }
|
||||
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);
|
||||
const color = (value: unknown) => typeof value === "string" && /^#[a-f\d]{6}$/i.test(value);
|
||||
const bool = (value: unknown) => typeof value === "boolean";
|
||||
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>;
|
||||
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"));
|
||||
for (const key of ["head", "tail"]) if (a[key] !== undefined) {
|
||||
check(fields(a[key], ["type", "size", "filled"])); const marker = a[key] as Record<string, unknown>;
|
||||
check(choice("none", "open-arrow", "triangle", "circle", "diamond", "square")(marker.type)); optional(marker, "size", (v) => numberIn(v, 4, 32)); optional(marker, "filled", bool);
|
||||
}
|
||||
}
|
||||
if (data.animation !== undefined) {
|
||||
check(fields(data.animation, ["mode", "effect", "direction", "durationMs", "color"])); const a = data.animation as Record<string, unknown>;
|
||||
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); }
|
||||
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);
|
||||
optional(path, "startControl", pointValid); optional(path, "endControl", pointValid);
|
||||
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);
|
||||
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);
|
||||
if (knot.mode === "symmetric") check(Math.abs(a - b) <= 1e-6 * Math.max(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
return structuredClone(value) as EdgeData;
|
||||
}
|
||||
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; }
|
||||
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);
|
||||
optional(ai, "prompt", (v) => typeof v === "string" && [...v].length <= 16000); optional(ai, "parameters", (v) => !!v && typeof v === "object" && !Array.isArray(v) && new TextEncoder().encode(JSON.stringify(v)).length <= 16384);
|
||||
extensions.ai = structuredClone(ai) as AIConfig;
|
||||
}
|
||||
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 }; }
|
||||
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));
|
||||
for (const key of ["expandedSize", "collapsedSize"]) optional(p, key, (v) => fields(v, ["width", "height"]) && numberIn(v.width, 32, 100000) && numberIn(v.height, 32, 100000));
|
||||
extensions.presentation = structuredClone(p) as FramePresentation;
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
export function managedImageSource(src?: string) {
|
||||
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_-]+)$/);
|
||||
if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1];
|
||||
} catch { /* inline and demo images are already browser-readable */ }
|
||||
return src;
|
||||
}
|
||||
+34
-15
@@ -1,3 +1,4 @@
|
||||
import { decodeEdgeData, decodeNodeExtensions, type CanvasEdgeModel } from "./canvas-v2.ts";
|
||||
import { applyEdgeChanges } from "@xyflow/react";
|
||||
import type { Edge, EdgeChange, NodeChange, Viewport, XYPosition } from "@xyflow/react";
|
||||
|
||||
@@ -7,10 +8,12 @@ import { nodeSize, rotateVector, nodeCenter, type NodeTransform } from "./canvas
|
||||
export type { CanvasNode } from "./canvas-elements.ts";
|
||||
export type NodeDirection = "top" | "right" | "bottom" | "left";
|
||||
export const nodeDirections: NodeDirection[] = ["top", "right", "bottom", "left"];
|
||||
export type CanvasDocument = { schema_version: 1; nodes: CanvasNode[]; edges: Edge[]; viewport: Viewport };
|
||||
export type CanvasDocument = { schema_version: 1 | 2; nodes: CanvasNode[]; edges: CanvasEdgeModel[]; viewport: Viewport };
|
||||
export type CanvasHistory = { past: CanvasDocument[]; present: CanvasDocument; future: CanvasDocument[] };
|
||||
export type CanvasAction =
|
||||
| { type: "checkpoint" }
|
||||
| { type: "node-data"; id: string; patch: Partial<CanvasNode["data"]> }
|
||||
| { type: "edge-data"; id: string; data: import("./canvas-v2").EdgeData; transient?: boolean }
|
||||
| { type: "drop"; ids: string[] }
|
||||
| { type: "rename-node"; id: string; title: string }
|
||||
| { type: "frame"; id: string; title?: string; labelMode?: import("./canvas-elements.ts").FrameLabelMode }
|
||||
@@ -34,6 +37,11 @@ function recorded(state: CanvasHistory, present: CanvasDocument): CanvasHistory
|
||||
}
|
||||
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) });
|
||||
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);
|
||||
}
|
||||
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) });
|
||||
@@ -66,7 +74,7 @@ export function canvasReducer(state: CanvasHistory, action: CanvasAction): Canva
|
||||
width: patch.width, height: patch.height, measured: { width: patch.width, height: patch.height },
|
||||
style: { ...node.style, width: patch.width, height: patch.height },
|
||||
} : {}),
|
||||
data: { ...node.data, ...(patch.rotation !== undefined ? { rotation: patch.rotation } : {}), ...(patch.fontSize !== undefined ? { fontSize: patch.fontSize } : {}) },
|
||||
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": return { ...state, present: { ...state.present, viewport: action.viewport } };
|
||||
@@ -136,21 +144,21 @@ export function decodeTextStyle(value: unknown): import("./canvas-elements.ts").
|
||||
return { ...style };
|
||||
}
|
||||
export function decodeCanvas(raw: string): CanvasDocument {
|
||||
if (raw.length > 5_000_000) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
if (new TextEncoder().encode(raw).length > 5 * 1024 * 1024) throw new Error("图谱文件太大,请使用小于 5 MB 的文件。");
|
||||
const value = JSON.parse(raw);
|
||||
if (!value || value.schema_version !== 1 || !Array.isArray(value.nodes) || !Array.isArray(value.edges) || value.nodes.length > 500 || value.edges.length > 2000)
|
||||
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>();
|
||||
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)))
|
||||
!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("图谱节点格式无效。");
|
||||
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 < 20 || width > 3000)) ||
|
||||
(height !== undefined && (typeof height !== "number" || !Number.isFinite(height) || height < 20 || height > 3000)) ||
|
||||
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))) ||
|
||||
(height !== undefined && (typeof height !== "number" || !Number.isFinite(height) || height < (value.schema_version === 2 && node.data.kind === "frame" ? 32 : 20) || height > (value.schema_version === 2 && node.data.kind === "frame" ? 100000 : 3000))) ||
|
||||
(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("节点大小或旋转角度无效。");
|
||||
@@ -158,10 +166,12 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
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("分区设置无效。");
|
||||
const textStyle = decodeTextStyle(node.data.textStyle);
|
||||
const extensions = decodeNodeExtensions(node.data, value.schema_version);
|
||||
ids.add(node.id);
|
||||
return { id: node.id, type: node.data.kind === "plain-text" ? "plainText" : node.data.kind === "icon" ? "icon" : node.data.kind === "frame" ? "frame" : node.data.kind === "plain-image" ? "plainImage" : "material",
|
||||
...(width !== undefined && height !== undefined ? { width, height, style: { width, height } } : {}), ...(node.data.kind === "frame" ? { zIndex: -1, style: { ...(width !== undefined ? { width, height } : {}), pointerEvents: "none" as const } } : {}),
|
||||
position: { x: node.position.x, y: node.position.y }, data: {
|
||||
...extensions,
|
||||
...(node.data.frameId ? { frameId: node.data.frameId } : {}), ...(node.data.kind === "frame" ? { labelMode: node.data.labelMode || "fixed" } : {}),
|
||||
...(textStyle ? { textStyle } : {}), ...(node.data.kind === "icon" ? { icon: node.data.icon } : {}),
|
||||
kind: node.data.kind, title: node.data.title, text: node.data.text,
|
||||
@@ -172,15 +182,24 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
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>();
|
||||
const edges: Edge[] = value.edges.map((edge: Edge) => {
|
||||
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("图谱连线格式无效。");
|
||||
if (!elementCapabilities(nodeKinds.get(edge.source)!).connectable || !elementCapabilities(nodeKinds.get(edge.target)!).connectable)
|
||||
throw new Error("纯文本和图标不支持节点连线。");
|
||||
if ([edge.sourceHandle, edge.targetHandle].some((handle) => handle != null && !nodeDirections.includes(handle as NodeDirection)))
|
||||
throw new Error("图谱连接方向无效。");
|
||||
for (const [nodeId, handle, direction] of [[edge.source, edge.sourceHandle, "output"], [edge.target, edge.targetHandle, "input"]] as const) {
|
||||
const kind = nodeKinds.get(nodeId)!;
|
||||
if (kind === "frame" && value.schema_version === 2) {
|
||||
const node = nodes.find((item) => item.id === nodeId)!;
|
||||
if (!node.data.subgraph || !node.data.ports?.some((port) => `port:${port.id}` === handle && port.direction === direction)) throw new Error("分区端口无效。");
|
||||
} else {
|
||||
const allowed = value.schema_version === 2 ? ["text", "script", "image", "plain-text", "plain-image"].includes(kind) : elementCapabilities(kind).connectable;
|
||||
if (!allowed) throw new Error("该元素不支持节点连线。");
|
||||
if (handle != null && !nodeDirections.includes(handle as NodeDirection)) throw new Error("图谱连接方向无效。");
|
||||
}
|
||||
}
|
||||
const edgeData = decodeEdgeData(edge.data);
|
||||
if (value.schema_version === 1 && edgeData) throw new Error("invalid_canvas");
|
||||
edgeIds.add(edge.id);
|
||||
return { id: edge.id, source: edge.source, target: edge.target,
|
||||
return { id: edge.id, source: edge.source, target: edge.target, ...(edgeData ? { data: edgeData } : {}),
|
||||
...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}),
|
||||
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
|
||||
};
|
||||
@@ -188,5 +207,5 @@ export function decodeCanvas(raw: string): CanvasDocument {
|
||||
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("图谱视图格式无效。");
|
||||
return { schema_version: 1, nodes, edges, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } };
|
||||
return { schema_version: value.schema_version, nodes, edges, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export type Board = {
|
||||
version: number;
|
||||
content?: { schema_version: number };
|
||||
description?: string;
|
||||
parent_board_id?: string;
|
||||
frame_id?: string;
|
||||
};
|
||||
export function matchesRef(item: { id: string; slug?: string } | null | undefined, ref: string | undefined) { return Boolean(item && ref && (item.id === ref || item.slug === ref)); }
|
||||
export const DEFAULT_BOARD_TITLE = "未命名图谱";
|
||||
|
||||
Reference in New Issue
Block a user