Connect canonical projects to persistent workspace runtimes and canvas views
This commit is contained in:
+2
-1
@@ -9,7 +9,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:headless": "node --test tests/headless/*.test.mjs",
|
||||
"test:headless:browser": "node tests/headless/browser-storage.mjs",
|
||||
"test:headless:proxy": "node tests/headless/proxy.integration.mjs"
|
||||
"test:headless:proxy": "node tests/headless/proxy.integration.mjs",
|
||||
"test:headless:renderer": "node tests/headless/renderer.integration.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "1.3.1",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useActiveBoard } from "@/components/workspace/active-board";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { EditableTitle } from "@/components/workspace/editable-title";
|
||||
import { UserAvatar } from "@/components/workspace/user-avatar";
|
||||
import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
|
||||
import { AccountPanel } from "@/components/workspace/account-panel";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
import { loadBoardDirectory } from "@/lib/board-directory";
|
||||
@@ -20,6 +21,7 @@ import styles from "@/components/workspace/workspace.module.css";
|
||||
|
||||
export default function DashboardSidebar() {
|
||||
const router = useRouter();
|
||||
const runtimeHost = useCanvasRuntimeHost();
|
||||
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const params = useParams<{ workspace?: string; board?: string }>();
|
||||
@@ -140,7 +142,7 @@ export default function DashboardSidebar() {
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
if (!demo) await api("auth/logout", { method: "POST" });
|
||||
if (!demo) { await runtimeHost.retireIdentity(); await api("auth/logout", { method: "POST" }); }
|
||||
router.push("/login");
|
||||
} catch (e) { setError(errorText(e)); }
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { CanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
|
||||
import { AppFeedback } from "@/components/ui/app-feedback";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -19,7 +20,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN" data-scroll-behavior="smooth">
|
||||
<body><AppFeedback>{children}</AppFeedback></body>
|
||||
<body><AppFeedback><CanvasRuntimeHost>{children}</CanvasRuntimeHost></AppFeedback></body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useRef, useState, type PointerEvent } from "react";
|
||||
import { useContext, useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import { BaseEdge, useInternalNode, useReactFlow, useViewport, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { connectorBezier, nodeConnector, type ConnectorSide, type Connector } from "@/lib/canvas-geometry";
|
||||
import { curvePath, curveSegments, cubicPoint, insertCurveKnot, minus, plus } from "@/lib/canvas-curves";
|
||||
import type { CanvasEdgeModel, EdgeMarker } from "@/lib/canvas-v2";
|
||||
import type { EditingLease } from "./editing-lease";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { EdgeToolbarControls } from "./edge-toolbar";
|
||||
import styles from "./canvas.module.css";
|
||||
@@ -35,7 +36,13 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
const { zoom } = useViewport();
|
||||
const reduce = useReducedMotion();
|
||||
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
|
||||
const gesture = useRef<{ id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
|
||||
const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease<NonNullable<CanvasEdgeModel['data']>['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
|
||||
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): void } | null>(null);
|
||||
useEffect(() => () => {
|
||||
const current = gesture.current; gesture.current = null;
|
||||
current?.controller.abort(); current?.lease?.cancel();
|
||||
if (current?.capture.hasPointerCapture(current.pointerId)) current.capture.releasePointerCapture(current.pointerId);
|
||||
}, []);
|
||||
if (!source || !target) return null;
|
||||
const from = connector(source, props.sourceHandleId, "right"), to = connector(target, props.targetHandleId, "left");
|
||||
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
|
||||
@@ -51,8 +58,17 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
|
||||
}
|
||||
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["part"]) {
|
||||
event.preventDefault(); event.stopPropagation(); editing.begin(props.id); gesture.current = { id, part }; setSelectedKnot(id);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
// The SVG control can be replaced by a projection update. The view surface survives it.
|
||||
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
|
||||
const lease = editing.begin(props.id); if (lease === false) return;
|
||||
const controller = new AbortController();
|
||||
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
|
||||
const owned = (input: Event) => (input as globalThis.PointerEvent).pointerId === event.pointerId;
|
||||
capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
capture.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<SVGCircleElement>) {
|
||||
const current = gesture.current;
|
||||
@@ -76,14 +92,24 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
editing.patch(props.id, { ...data, path: next }, true);
|
||||
if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true);
|
||||
}
|
||||
function stop(event: PointerEvent<SVGCircleElement>) {
|
||||
event.stopPropagation(); gesture.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
if (!gesture.current) return;
|
||||
event.stopPropagation(); const current = gesture.current; move(event); gesture.current = null; current.controller.abort();
|
||||
if (current.lease) current.lease.commit(); else editing.finish?.();
|
||||
if (current.capture.hasPointerCapture(current.pointerId)) current.capture.releasePointerCapture(current.pointerId);
|
||||
}
|
||||
function cancel(_event: PointerEvent<SVGCircleElement>) {
|
||||
const current = gesture.current; gesture.current = null;
|
||||
if (!current) return;
|
||||
current.controller.abort();
|
||||
if (current.lease) current.lease.cancel(); else editing.cancel?.();
|
||||
if (current.capture.hasPointerCapture(current.pointerId)) current.capture.releasePointerCapture(current.pointerId);
|
||||
}
|
||||
pointerHandlers.current = { move, stop, cancel };
|
||||
const control = (point: { x: number; y: number }, id: string, part: NonNullable<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
|
||||
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={() => { gesture.current = null; }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
|
||||
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} onLostPointerCapture={(event) => { if (gesture.current) cancel(event); }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation(); const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
||||
if (delta) { event.preventDefault(); const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); const vector = { x: delta[0] * (event.shiftKey ? 10 : 1), y: delta[1] * (event.shiftKey ? 10 : 1) };
|
||||
|
||||
@@ -27,7 +27,7 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st
|
||||
aria-label={label} readOnly={!active} tabIndex={active ? 0 : -1}
|
||||
placeholder={plain ? "写下文字…" : data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
|
||||
value={data.text} maxLength={100000} onChange={(event) => actions.edit(id, event.target.value)}
|
||||
onBlur={() => setEditing(false)} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
|
||||
onBlur={() => { setEditing(false); actions.endEditing?.(id); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
|
||||
{!active && <div className={styles.textSelectLayer} role="button" tabIndex={selected ? 0 : -1} aria-label={`单击选中,双击编辑${label}`}
|
||||
onPointerDown={(event) => { press.current = { x: event.clientX, y: event.clientY, moved: false }; }}
|
||||
onPointerMove={(event) => { if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 4) press.current.moved = true; }}
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
.edgeToolbar button:hover { background: #f1e6df; }
|
||||
.edgeToolbar button:disabled { opacity: .4; }
|
||||
.edgeToolbar small { width: 100%; color: #92877c; font-size: 10px; }
|
||||
.curveControl { cursor: move; touch-action: none; }
|
||||
.curveControl { cursor: move; touch-action: none; pointer-events: all; }
|
||||
.edgeDashAnimation { animation: edgeDash 1.6s linear infinite; }
|
||||
.edgePulseAnimation { animation: edgePulse 1.6s ease-in-out infinite; }
|
||||
@keyframes edgeDash { to { stroke-dashoffset: -40; } }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { createContext } from "react";
|
||||
import type { EditingLease } from "./editing-lease";
|
||||
import type { EdgeData } from "@/lib/canvas-v2";
|
||||
export const EdgeEditing = createContext({ begin: (_id?: string) => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() });
|
||||
const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease<NonNullable<EdgeData['path']>> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set<string>() };
|
||||
export const EdgeEditing = createContext<Omit<typeof defaultEditing, 'finish' | 'cancel'> & Partial<Pick<typeof defaultEditing, 'finish' | 'cancel'>>>(defaultEditing);
|
||||
|
||||
@@ -9,7 +9,7 @@ export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count
|
||||
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
||||
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
||||
const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]];
|
||||
return <EdgeToolbar edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
||||
return <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<label>颜色<input aria-label="连线颜色" type="color" value={a.color || "#aa968b"} onChange={(event) => appearance({ color: event.target.value })} /></label>
|
||||
<label>粗细<input aria-label="连线粗细" type="number" min={.5} max={20} step={.5} value={a.width ?? 1.5} onChange={(event) => { const value = +event.target.value; if (value >= .5 && value <= 20) appearance({ width: value }); }} /></label>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** A captured pointer may only finish the exact interaction that acquired it. */
|
||||
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
|
||||
|
||||
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
|
||||
return Object.freeze({
|
||||
preview(value: T) { if (isCurrent()) actions.preview(value); },
|
||||
commit() { if (isCurrent()) actions.commit(); },
|
||||
cancel() { if (isCurrent()) actions.cancel(); },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection, type NodeProps } from "@xyflow/react";
|
||||
import { Download, Frame, Hand, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, StickyNote, Type, Undo2 } from "lucide-react";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
|
||||
import { equal } from "@/lib/canvas-core/values";
|
||||
import { ancestors, commonCanvas } from "@/lib/canvas-core/document";
|
||||
import { projectCanvas, toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
import { errorText } from "@/lib/api";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { guardEditingLease } from "./editing-lease";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { CanvasEdge } from "./canvas-edge";
|
||||
import { MaterialNode } from "./material-node";
|
||||
import { PlainTextNode } from "./plain-text-node";
|
||||
import { PlainImageNode } from "./plain-image-node";
|
||||
import { FrameNode } from "./frame-node";
|
||||
import { IconNode } from "./icon-node";
|
||||
import { ImagePreviews } from "./canvas-image";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, plainImage: PlainImageNode, frame: FrameNode, icon: IconNode,
|
||||
unsupported: ({ data }: NodeProps<ProjectionNode>) => <div className={styles.node} style={{ width: "100%", height: "100%", padding: 14 }} role="note">{data.title}<small style={{ display: "block" }}>此元素需要更新版本才能编辑</small></div>,
|
||||
};
|
||||
const edgeTypes = { default: CanvasEdge };
|
||||
const json = (value: unknown): Data => JSON.parse(JSON.stringify(value));
|
||||
function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: Board; viewId: Id }) {
|
||||
const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]);
|
||||
const root = useSyncExternalStore(subscribe, engine.getSnapshot, engine.getSnapshot);
|
||||
const sessions = useSyncExternalStore(engine.sessions.subscribe, engine.sessions.getSnapshot, engine.sessions.getSnapshot);
|
||||
const liveView = sessions.views.get(viewId), lastView = useRef(liveView);
|
||||
if (liveView) lastView.current = liveView;
|
||||
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
|
||||
const [error, setError] = useState(""), [sources, setSources] = useState(new Map<string, string>());
|
||||
const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
|
||||
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
||||
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id, Id>());
|
||||
const projection = useMemo(() => {
|
||||
const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture);
|
||||
// Preserve renderer measurements across metadata/preview updates. Dropping them
|
||||
// makes React Flow discard handle bounds and unmount active edge controls.
|
||||
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height) node.measured = cached.measured; }
|
||||
return result;
|
||||
}, [engine, root.document, sessions, view]);
|
||||
const current = useRef({ root, projection, view }); current.current = { root, projection, view };
|
||||
const report = useCallback((result: Result | { enabled: boolean; code?: string }) => {
|
||||
if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code)));
|
||||
}, []);
|
||||
const target = (id: Id) => { const element = engine.getSnapshot().document.elements.get(id); if (!element) throw new Error('element_missing'); return { id, incarnation: element.incarnation }; };
|
||||
const data = (id: Id, patch: Data, groupId?: Id) => report(engine.editor.dispatch({ type: 'element.data', target: target(id), patch }, { groupId }));
|
||||
function selectChanges(changes: readonly { id: Id; selected: boolean }[]) {
|
||||
const current = engine.sessions.getSnapshot().views.get(viewId)!;
|
||||
const ids = new Set(current.selection);
|
||||
for (const change of changes) if (change.selected) ids.add(change.id); else ids.delete(change.id);
|
||||
if (ids.size !== current.selection.size || [...ids].some(id => !current.selection.has(id))) engine.sessions.select(viewId, [...ids]);
|
||||
}
|
||||
function beginTransform(id: Id) {
|
||||
const result = engine.sessions.begin(viewId, [target(id)]); report(result); if (!result.enabled) return false;
|
||||
const token = engine.sessions.getSnapshot().gesture!.token;
|
||||
const current = () => engine.sessions.getSnapshot().gesture?.token === token;
|
||||
return guardEditingLease(current, { preview: (change: Parameters<typeof transform>[1]) => transform(id, change), commit: () => report(engine.sessions.commit(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||
}
|
||||
function transform(id: Id, change: { position?: { x: number; y: number }; width?: number; height?: number; rotation?: number; fontSize?: number }) {
|
||||
const { root, projection } = current.current, element = root.document.elements.get(id);
|
||||
if (element?.structure !== 'node') return;
|
||||
const t = projection.transforms.get(element.ownerCanvasId)!;
|
||||
const displayed = projection.nodes.find(node => node.id === id)!;
|
||||
const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale;
|
||||
const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 });
|
||||
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
|
||||
report(engine.sessions.update(viewId, target(id), patch));
|
||||
}
|
||||
function add(kind: 'text' | 'plain-text' | 'image' | 'frame', ref?: { id: Id; src: string }, tx?: Parameters<Parameters<typeof engine.editor.transaction>[0]>[0], at?: { x: number; y: number }) {
|
||||
const id = crypto.randomUUID(), canvasId = current.current.view.canvasId;
|
||||
const rect = window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();
|
||||
const position = at ?? flow.screenToFlowPosition({ x: (rect?.left ?? 0) + (rect?.width ?? window.innerWidth) / 2, y: (rect?.top ?? 0) + (rect?.height ?? window.innerHeight) / 2 });
|
||||
const size = kind === 'frame' ? { width: 640, height: 400 } : kind === 'plain-text' ? { width: 240, height: 72 } : { width: 260, height: 200 };
|
||||
const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } };
|
||||
const create = (transaction: NonNullable<typeof tx>) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); };
|
||||
if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' }));
|
||||
return id;
|
||||
}
|
||||
function connect(connection: Connection) {
|
||||
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
|
||||
if (!source || !destination) return;
|
||||
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
||||
}
|
||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||
useEffect(() => {
|
||||
if (!liveView) return;
|
||||
let active = true;
|
||||
const handles: { release(): void }[] = [], entries: [Id, string][] = [];
|
||||
void Promise.all((JSON.parse(resourceKey) as [Id, Id][]).map(async ([id, resource]) => {
|
||||
const handle = await engine.displayResource(resource);
|
||||
if (!active) { handle.release(); return; }
|
||||
handles.push(handle); entries.push([id, handle.url]);
|
||||
})).then(() => { if (active) setSources(new Map(entries)); }).catch(e => { if (active) setError(errorText(e)); });
|
||||
return () => { active = false; for (const handle of handles) handle.release(); };
|
||||
}, [engine, resourceKey, Boolean(liveView)]);
|
||||
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
||||
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
||||
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
||||
useEffect(() => {
|
||||
const cancel = () => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('view_interrupted'); drag.current = null; };
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !event.isComposing) cancel(); };
|
||||
window.addEventListener('blur', cancel); window.addEventListener('keydown', escape, true);
|
||||
return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape, true); };
|
||||
}, [engine, viewId]);
|
||||
const modes = view.modes;
|
||||
if (!liveView) return <p role="status">画布已关闭</p>;
|
||||
return <div className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas tabIndex={-1}
|
||||
onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }}
|
||||
onTouchStartCapture={event => { if (event.touches.length > 1) { engine.sessions.cancel('multi_touch'); drag.current = null; return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }); }}
|
||||
onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }}
|
||||
onPointerCancelCapture={() => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); drag.current = null; }}
|
||||
onKeyDown={event => {
|
||||
const input = (event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="dialog"],[role="menu"]');
|
||||
if (input || event.nativeEvent.isComposing) return;
|
||||
if (event.key === 'Escape') { engine.sessions.cancel('escape'); return; }
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'z') { event.preventDefault(); report(event.shiftKey ? engine.editor.history.redo() : engine.editor.history.undo()); }
|
||||
else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); removeSelected(); }
|
||||
else if (event.key.toLowerCase() === 'h') engine.sessions.tool(viewId, 'pan');
|
||||
else if (event.key.toLowerCase() === 'v') engine.sessions.tool(viewId, 'select');
|
||||
}}>
|
||||
<ImagePreviews.Provider value={{ sources, loaded: () => {} }}>
|
||||
<NodeEditing.Provider value={{ workspace: board.workspace_id, schema: 2,
|
||||
begin: id => { if (id) { groups.current.set(id, crypto.randomUUID()); engine.sessions.mode(viewId, id, 'editing'); } },
|
||||
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
|
||||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id, { text }, groups.current.get(id)), rename: (id, title) => data(id, { title }),
|
||||
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
||||
rotating: [...modes].find(([, mode]) => mode === 'rotate')?.[0] ?? null, rotate: id => { for (const [key, mode] of modes) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
||||
resizing: [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null, resize: id => { for (const [key, mode] of modes) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
|
||||
branch: (id, direction) => {
|
||||
const node = projection.nodes.find(node => node.id === id); if (!node) return;
|
||||
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
|
||||
report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' }));
|
||||
},
|
||||
}}>
|
||||
<EdgeEditing.Provider value={{ active: new Set(), begin: id => {
|
||||
if (!id) return; const result = engine.sessions.beginConnection(viewId, target(id)); report(result); if (!result.enabled) return false;
|
||||
const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!;
|
||||
const transform = projection.transforms.get(element.ownerCanvasId)!;
|
||||
const current = () => engine.sessions.getSnapshot().connectionGesture?.token === token;
|
||||
return guardEditingLease(current, { preview: (path: NonNullable<import('@/lib/canvas-v2').EdgeData['path']>) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') });
|
||||
},
|
||||
finish: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) report(engine.sessions.commitConnection(viewId)); }, cancel: () => { if (engine.sessions.getSnapshot().connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||
patch: (id, value, transient) => {
|
||||
const element = engine.getSnapshot().document.elements.get(id); if (element?.structure !== 'connection') return;
|
||||
const t = projection.transforms.get(element.ownerCanvasId)!;
|
||||
const path = transformPath((value.path ?? null) as Json, t, true);
|
||||
const displayedPath = projection.edges.find(edge => edge.id === id)?.data?.path;
|
||||
const changedPath = Object.hasOwn(value, 'path') && !equal(value.path, displayedPath);
|
||||
if (transient) { report(engine.sessions.updateConnection(viewId, path)); return; }
|
||||
const { path: ignored, ...rest } = value; void ignored;
|
||||
report(engine.editor.transaction(tx => { tx.dispatch({ type: 'element.data', target: target(id), patch: json(rest) }); if (changedPath) tx.dispatch({ type: 'connection.path', target: target(id), path }); }, { label: 'Edit connection' }));
|
||||
},
|
||||
}}>
|
||||
<ReactFlow<ProjectionNode> nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
|
||||
nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport}
|
||||
|
||||
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
||||
onNodeDragStart={(event, node, nodes) => {
|
||||
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
|
||||
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
|
||||
const point = 'clientX' in event ? event : event.changedTouches[0];
|
||||
drag.current = { token: 0, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, current.current.projection.nodes.find(node => node.id === id)!.position])) };
|
||||
const result = engine.sessions.begin(viewId, top.map(target)); report(result);
|
||||
if (result.enabled) drag.current.token = engine.sessions.getSnapshot().gesture!.token; else drag.current = null;
|
||||
}}
|
||||
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
|
||||
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = current.current.projection.nodes.find(node => node.id === change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
|
||||
onNodeDragStop={event => {
|
||||
const saved = drag.current; drag.current = null; pointerStart.current = null;
|
||||
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
|
||||
const point = 'clientX' in event ? event : event.changedTouches[0], end = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
||||
for (const [id, start] of saved.positions) transform(id, { position: { x: start.x + end.x - saved.start.x, y: start.y + end.y - saved.start.y } });
|
||||
report(engine.sessions.commit(viewId));
|
||||
}}
|
||||
onConnect={connect} onPaneClick={() => engine.sessions.select(viewId, [])}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color="#ded7ce" />
|
||||
</ReactFlow>
|
||||
</EdgeEditing.Provider></NodeEditing.Provider></ImagePreviews.Provider>
|
||||
<div className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||||
<IconButton icon={Minus} label="缩小" onClick={() => flow.zoomOut()} /><IconButton icon={Plus} label="放大" onClick={() => flow.zoomIn()} /><IconButton icon={Maximize} label="适应画布" onClick={() => flow.fitView({ padding: .2 })} />
|
||||
<IconButton icon={Download} label="导出图谱" onClick={() => { const snapshot = engine.getSnapshot(), url = URL.createObjectURL(new Blob([JSON.stringify({ documentFormatVersion: 1, projectId: snapshot.document.projectId, canvases: [...snapshot.document.canvases.values()], elements: [...snapshot.document.elements.values()], tombstones: Object.fromEntries(snapshot.document.tombstones) })], { type: 'application/json' })); const a = window.document.createElement('a'); a.href = url; a.download = board.title + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }} />
|
||||
</div><p role="status" style={{ fontSize: 11 }}>{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <button onClick={() => void engine.retry().catch(e => setError(errorText(e)))}>重试</button>}</p></div>
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
|
||||
<IconButton icon={Type} label="添加纯文本" onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" onClick={() => add('frame')} />
|
||||
</div>
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
|
||||
{error && <div className={styles.error} role="alert">{error} <button onClick={() => setError('')}>关闭</button></div>}
|
||||
</div>;
|
||||
}
|
||||
export default function HeadlessGraphCanvas({ engine, board }: { engine: ProjectEngine; board: Board }) {
|
||||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id]);
|
||||
return attached ? <ReactFlowProvider><CanvasView engine={engine} board={board} viewId={viewId} /></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import { Grip, Maximize2, RotateCw } from "lucide-react";
|
||||
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||||
import type { EditingLease } from "./editing-lease";
|
||||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||||
import { MembershipBadge } from "./frame-feedback";
|
||||
import { ContentHandles } from "./content-handles";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const NodeEditing = createContext({
|
||||
const defaultEditing = {
|
||||
beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease<NodeTransform>), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void),
|
||||
endEditing: undefined as undefined | ((id: string) => void),
|
||||
workspace: "", schema: 1 as 1 | 2,
|
||||
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
|
||||
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
|
||||
@@ -21,8 +24,10 @@ export const NodeEditing = createContext({
|
||||
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
|
||||
rotating: null as string | null, rotate: (_id: string | null) => {},
|
||||
resizing: null as string | null, resize: (_id: string | null) => {},
|
||||
});
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
|
||||
};
|
||||
type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing';
|
||||
export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(defaultEditing);
|
||||
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
|
||||
|
||||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
|
||||
const { id, data, selected, width, height, children, className = "" } = props;
|
||||
@@ -54,9 +59,10 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const node = flow.getNode(id);
|
||||
if (!node) return;
|
||||
editing.begin(id);
|
||||
const lease = editing.beginTransform?.(id); if (lease === false) return;
|
||||
if (!editing.beginTransform) editing.begin(id);
|
||||
if (!node.selected) editing.select(id);
|
||||
gesture.current = { node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<HTMLElement>) {
|
||||
@@ -64,18 +70,21 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
event.stopPropagation();
|
||||
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
const { node, start, direction } = gesture.current;
|
||||
editing.transform(id, direction === "rotate"
|
||||
const change = direction === "rotate"
|
||||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined));
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
|
||||
if (gesture.current.lease) gesture.current.lease.preview(change); else editing.transform(id, change);
|
||||
}
|
||||
function cancelGesture() {
|
||||
const current = gesture.current; if (!current) return;
|
||||
gesture.current = null;
|
||||
editing.transform(id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||||
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||||
if (current.target.hasPointerCapture(current.pointerId)) current.target.releasePointerCapture(current.pointerId);
|
||||
}
|
||||
function finish(event: PointerEvent<HTMLElement>) {
|
||||
event.stopPropagation(); gesture.current = null;
|
||||
if (!gesture.current) return;
|
||||
event.stopPropagation(); const current = gesture.current; move(event); gesture.current = null;
|
||||
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
return <>
|
||||
@@ -101,8 +110,9 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
onKeyDown={(event) => {
|
||||
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
|
||||
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
|
||||
editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
||||
editing.transform(id, resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey));
|
||||
const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
||||
const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey);
|
||||
if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); }
|
||||
}} />;
|
||||
})}
|
||||
{resizing && <span className={styles.rotationHint}>拖动八向手柄 · Shift 固定比例 · Esc 退出</span>}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { notify } from "@/lib/notifications";
|
||||
import { errorText } from "@/lib/api";
|
||||
import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
|
||||
import { createRuntimeHost, type RuntimeHost } from "@/lib/canvas-runtime/host";
|
||||
import { createWorkspaceRuntime } from "@/lib/canvas-runtime/workspace";
|
||||
import { createBrowserStorage } from "@/lib/canvas-runtime/storage";
|
||||
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport } from "@/lib/canvas-service/index";
|
||||
|
||||
const Context = createContext<RuntimeHost | null>(null);
|
||||
export function CanvasRuntimeHost({ children }: { children: ReactNode }) {
|
||||
const [host] = useState(() => {
|
||||
const registry = createRegistry(builtinComponents());
|
||||
return createRuntimeHost({ registry,
|
||||
workspace(accountId, workspaceId, authEpoch, isCurrentIdentity) {
|
||||
return createWorkspaceRuntime({ accountId, workspaceId, authEpoch, isCurrentIdentity, registry, storage: createBrowserStorage(),
|
||||
adapters(rootId, current) {
|
||||
const transport = createFoundryTransport({ accountId, isCurrentIdentity: current });
|
||||
return { persistence: createFoundryPersistence({ workspaceId, rootId, registry, transport }), assets: createFoundryAssets({ workspaceId, transport, project: process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap", origin: process.env.NEXT_PUBLIC_FOUNDRY_API_ORIGIN }) };
|
||||
},
|
||||
onError: error => notify({ id: `canvas-runtime:${accountId}:${workspaceId}`, title: "画布同步需要处理", description: errorText(error), level: "error" }),
|
||||
});
|
||||
},
|
||||
onError: error => notify({ id: "canvas-host-close", title: "本地修改尚未保存成功", description: errorText(error), level: "error" }),
|
||||
});
|
||||
});
|
||||
const lifetime = useRef(0);
|
||||
useEffect(() => {
|
||||
const generation = ++lifetime.current;
|
||||
// Strict Mode replays effects synchronously; only a real detach retires the host.
|
||||
return () => { queueMicrotask(() => { if (lifetime.current === generation) void host.close().catch(error => console.error("[canvas-host]", error.message)); }); };
|
||||
}, [host]);
|
||||
return <Context.Provider value={host}>{children}</Context.Provider>;
|
||||
}
|
||||
export function useCanvasRuntimeHost() {
|
||||
const host = useContext(Context);
|
||||
if (!host) throw new Error("CanvasRuntimeHost is missing");
|
||||
return host;
|
||||
}
|
||||
@@ -5,17 +5,22 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api, errorText } from "@/lib/api";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
import { matchesRef, type Board } from "@/lib/workspace";
|
||||
import { loadBoardCanvas } from "@/lib/canvas-loader";
|
||||
import { loadCanvasEntry } from "@/lib/canvas-entry";
|
||||
import { createFoundryTransport } from "@/lib/canvas-service/foundry";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
import { useCanvasRuntimeHost } from "./canvas-runtime-host";
|
||||
import type { CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import styles from "./workspace.module.css";
|
||||
import { useActiveBoard } from "./active-board";
|
||||
import dynamic from "next/dynamic";
|
||||
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
||||
|
||||
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
|
||||
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
||||
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine };
|
||||
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
|
||||
const demo = useSearchParams().get("demo") === "1";
|
||||
const router = useRouter();
|
||||
const runtimeHost = useCanvasRuntimeHost();
|
||||
const route = workspaceId + ":" + boardId;
|
||||
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
|
||||
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||
@@ -23,8 +28,6 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
const [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
setLoaded(null); setError("");
|
||||
// Fetch data while the much larger editor chunk loads.
|
||||
void import("@/components/canvas/graph-canvas").catch(() => {});
|
||||
if (demo) {
|
||||
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
|
||||
if (item) setLoaded({ board: item, route });
|
||||
@@ -33,11 +36,17 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
}
|
||||
let active = true;
|
||||
const controller = new AbortController();
|
||||
loadBoardCanvas(api, workspaceId, boardId, controller.signal)
|
||||
.then((result) => { if (active) setLoaded({ ...result, route }); })
|
||||
loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,
|
||||
transport(accountId) { return createFoundryTransport({ accountId, isCurrentIdentity: runtimeHost.bindAccount(accountId) }); },
|
||||
}).then(async result => {
|
||||
if (!active) return;
|
||||
if (result.mode === 'legacy') { setLoaded({ board: result.board, snapshot: result.snapshot, route }); return; }
|
||||
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions);
|
||||
if (active) setLoaded({ board: result.board, engine, route });
|
||||
})
|
||||
.catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } });
|
||||
return () => { active = false; controller.abort(); };
|
||||
}, [workspaceId, boardId, route, demo, retry, router]);
|
||||
}, [workspaceId, boardId, route, demo, retry, router, runtimeHost]);
|
||||
const current = loaded?.route === route ? loaded : null;
|
||||
const board = current?.board;
|
||||
useEffect(() => {
|
||||
@@ -46,7 +55,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
}, [board, workspaceId, setActiveBoard]);
|
||||
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
|
||||
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
|
||||
{displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} /> : displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,16 @@ export async function startOAuth(provider: "google" | "slack") {
|
||||
export const errorText = (error: unknown) => {
|
||||
const code = error instanceof Error ? error.message : "";
|
||||
const messages: Record<string, string> = {
|
||||
identity_changed: "账户已变化,当前画布已停止同步,请重新打开。",
|
||||
engine_closing: "画布正在关闭,本地修改会保留用于恢复。",
|
||||
storage_scope_in_use: "这张图谱已在另一个标签页中打开,请先关闭那里的编辑器。",
|
||||
persistent_storage_unavailable: "浏览器无法保存本地恢复记录,请检查存储权限后重试。",
|
||||
workspace_close_failed: "本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
|
||||
host_close_failed: "部分本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
|
||||
resource_bytes_unavailable: "此图片的本地文件暂不可用,相关修改已保留,请重新选择图片。",
|
||||
recovery_checksum_mismatch: "本地恢复记录校验失败,原记录已保留,请先备份再处理。",
|
||||
recovery_journal_gap: "本地恢复记录不完整,原记录已保留,请先备份再处理。",
|
||||
unsupported_component: "这个元素需要更新版本或安装对应功能后才能编辑。",
|
||||
invalid_credentials: "邮箱或密码不正确,或邮箱尚未验证。",
|
||||
rate_limited: "请求太频繁,请稍后再试。",
|
||||
invalid_session: "登录已过期,请重新登录。",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts';
|
||||
import { connectionPathValid } from '../canvas-core/features.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import type { CanvasNode } from '../canvas-elements.ts';
|
||||
import type { CanvasEdgeModel, EdgeData } from '../canvas-v2.ts';
|
||||
|
||||
export type Transform = { x: number; y: number; scale: number; rotation: number };
|
||||
const origin: Transform = { x: 0, y: 0, scale: 1, rotation: 0 };
|
||||
const rotate = (point: Point, angle: number): Point => { const a = angle * Math.PI / 180; return { x: point.x * Math.cos(a) - point.y * Math.sin(a), y: point.x * Math.sin(a) + point.y * Math.cos(a) }; };
|
||||
export function toWorld(transform: Transform, point: Point): Point { const value = rotate(point, transform.rotation); return { x: transform.x + value.x * transform.scale, y: transform.y + value.y * transform.scale }; }
|
||||
export function toLocal(transform: Transform, point: Point): Point { return rotate({ x: (point.x - transform.x) / transform.scale, y: (point.y - transform.y) / transform.scale }, -transform.rotation); }
|
||||
export type ProjectionNode = Omit<CanvasNode, 'type'> & { type: NonNullable<CanvasNode['type']> | 'unsupported' };
|
||||
const nodeTypes: Record<string, NonNullable<CanvasNode['type']>> = { 'plain-text': 'plainText', 'plain-image': 'plainImage', frame: 'frame', icon: 'icon' };
|
||||
const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
export function transformPath(path: Json, transform: Transform, inverse = false): Json {
|
||||
const raw = object(path);
|
||||
if (raw.type !== 'bezier-chain' || !Array.isArray(raw.knots)) return path;
|
||||
const point = (input: Json | undefined, vector = false): Json | undefined => {
|
||||
const p = object(input); if (typeof p.x !== 'number' || typeof p.y !== 'number') return input;
|
||||
const t = vector ? { ...transform, x: 0, y: 0 } : transform;
|
||||
return { ...p, ...(inverse ? toLocal(t, { x: p.x, y: p.y }) : toWorld(t, { x: p.x, y: p.y })) };
|
||||
};
|
||||
return { ...raw, ...(raw.startControl ? { startControl: point(raw.startControl, true)! } : {}), ...(raw.endControl ? { endControl: point(raw.endControl, true)! } : {}), knots: raw.knots.map(knot => {
|
||||
const k = object(knot); return { ...k, position: point(k.position)!, in: point(k.in, true)!, out: point(k.out, true)! };
|
||||
}) };
|
||||
}
|
||||
|
||||
/** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */
|
||||
export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet<Id> | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null) {
|
||||
if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]);
|
||||
const transforms = new Map<Id, Transform>([[canvasId, origin]]), hiddenBy = new Map<Id, Id>(), childByEntry = new Map<Id, Id>();
|
||||
for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id);
|
||||
const members = new Map<Id, ElementRecord[]>();
|
||||
for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
|
||||
const nodes: ProjectionNode[] = [], visible = new Set<Id>();
|
||||
const visit = (id: Id, depth: number) => {
|
||||
const transform = transforms.get(id)!;
|
||||
for (const element of members.get(id) ?? []) if (element.structure === 'node') {
|
||||
const g = geometry(element.id) ?? element.geometry, center = toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 });
|
||||
const width = g.width * transform.scale, height = g.height * transform.scale, known = registry.resolve(element) === 'known';
|
||||
const type = !known ? 'unsupported' : nodeTypes[element.componentId] ?? 'material';
|
||||
const child = childByEntry.get(element.id), data = element.data;
|
||||
const projected: ProjectionNode = { id: element.id, type, position: { x: center.x - width / 2, y: center.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
|
||||
style: { width, height, ...(element.componentId === 'frame' ? { pointerEvents: 'none' as const } : {}) },
|
||||
data: { ...data, kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: typeof data.text === 'string' ? data.text : '', rotation: (g.rotation + transform.rotation) % 360, viewScale: transform.scale, ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'],
|
||||
};
|
||||
nodes.push(projected); visible.add(element.id);
|
||||
if (child) {
|
||||
const presentation = object(data.presentation), scale = typeof presentation.contentScale === 'number' && presentation.contentScale > 0 ? presentation.contentScale : 1;
|
||||
if (presentation.collapsed === true || !known) {
|
||||
const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); }
|
||||
} else {
|
||||
const offset = rotate({ x: 24 - g.width / 2, y: 36 - g.height / 2 }, g.rotation);
|
||||
const start = toWorld(transform, { x: g.x + g.width / 2 + offset.x, y: g.y + g.height / 2 + offset.y });
|
||||
transforms.set(child, { ...start, scale: transform.scale * scale, rotation: transform.rotation + g.rotation }); visit(child, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(canvasId, 0);
|
||||
const edges: CanvasEdgeModel[] = [];
|
||||
for (const element of document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) {
|
||||
const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? '');
|
||||
const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId);
|
||||
if (!source || !target || source === target) continue;
|
||||
const path = transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transforms.get(element.ownerCanvasId)!);
|
||||
const renderPath = registry.resolve(element) === 'known' && connectionPathValid(path) ? path : undefined;
|
||||
const { path: unusedPath, ...edgeData } = element.data; void unusedPath;
|
||||
edges.push({ id: element.id, source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: { ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData });
|
||||
}
|
||||
return { nodes, edges, transforms };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts';
|
||||
import { applyChanges, commonCanvas, createDocument, invert, targetElement, validateDocument } from './document.ts';
|
||||
import { clone, equal, fault, freeze, readMap, reject } from './values.ts';
|
||||
import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts';
|
||||
|
||||
export type EditorOptions<Runtime = undefined> = {
|
||||
registry: Registry; document: Document; createId?: () => Id; historyLimit?: number;
|
||||
@@ -52,7 +52,12 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
} else {
|
||||
let element = targetElement(document, command.target);
|
||||
if (options.registry.resolve(element) !== 'known') throw fault('unsupported_component', [element.id]);
|
||||
if (command.type === 'element.remove') {
|
||||
if (command.type === 'connection.path') {
|
||||
if (element.structure !== 'connection' || !options.registry.supports(element, 'connection.edit')) throw fault('unsupported_capability', [element.id]);
|
||||
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
|
||||
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
|
||||
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
|
||||
} else if (command.type === 'element.remove') {
|
||||
changes = [{ kind: 'element', id: element.id, before: element, after: null }];
|
||||
for (const edge of document.elements.values()) if (edge.structure === 'connection' && (edge.source.elementId === element.id || edge.target.elementId === element.id)) changes = [...changes, { kind: 'element', id: edge.id, before: edge, after: null }];
|
||||
} else if (command.type === 'element.move') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CapabilityHandler, ComponentDefinition, Data, FieldChange, Geometry } from './types.ts';
|
||||
import type { CapabilityHandler, ComponentDefinition, Data, FieldChange, Geometry, Json } from './types.ts';
|
||||
import { defineComponent, defineFeature } from './registry.ts';
|
||||
import { equal, fault, validJson } from './values.ts';
|
||||
|
||||
@@ -47,5 +47,24 @@ export function builtinComponents(): readonly ComponentDefinition[] {
|
||||
id, dataVersion: 1, structure: 'node', validate: textData,
|
||||
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
|
||||
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
|
||||
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable()], resourceFields: [] })]);
|
||||
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable(), connectionEditable()], resourceFields: [] })]);
|
||||
}
|
||||
|
||||
export function connectionPathValid(path: Json): boolean {
|
||||
if (path === null) return true;
|
||||
if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false;
|
||||
const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y);
|
||||
if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false;
|
||||
const ids = new Set<string>();
|
||||
for (const knot of path.knots) {
|
||||
if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false;
|
||||
ids.add(knot.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => {
|
||||
const path = (input.patch as { path?: Json }).path;
|
||||
if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]);
|
||||
if (path === undefined || !validJson(path) || !connectionPathValid(path)) throw fault('invalid_connection_path', [element.id]);
|
||||
return equal(element.path, path) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'path', before: element.path, after: path }];
|
||||
} });
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import type { Editor } from './editor.ts';
|
||||
import type { Availability, Capability, ElementRecord, Geometry, Id, Result, Target } from './types.ts';
|
||||
import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts';
|
||||
import { geometryValid, targetElement } from './document.ts';
|
||||
import { equal, fault, freeze, readMap, readSet, reject } from './values.ts';
|
||||
import { clone, equal, fault, freeze, readMap, readSet, reject, validJson } from './values.ts';
|
||||
|
||||
export type ViewState = { readonly id: Id; readonly selection: ReturnType<typeof readSet<Id>>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap<Id, string> };
|
||||
export type GestureState = { readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap<Id, Geometry>; readonly phase: 'active' | 'committing' };
|
||||
export type SessionSnapshot = { readonly views: ReadonlyMap<Id, ViewState>; readonly gesture: GestureState | null; readonly lastCancelReason: string | null };
|
||||
export type ViewState = { readonly id: Id; readonly canvasId: Id; readonly selection: ReturnType<typeof readSet<Id>>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap<Id, string> };
|
||||
export type GestureState = { readonly token: number; readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap<Id, Geometry>; readonly phase: 'active' | 'committing' };
|
||||
export type ConnectionGesture = { readonly token: number; readonly viewId: Id; readonly target: Target; readonly path: Json; readonly phase: 'active' | 'committing' };
|
||||
export type SessionSnapshot = { readonly views: ReadonlyMap<Id, ViewState>; readonly gesture: GestureState | null; readonly connectionGesture: ConnectionGesture | null; readonly lastCancelReason: string | null };
|
||||
|
||||
/** Transient interaction state belongs to views; no pointer event enters the ledger. */
|
||||
export function createSessions(editor: Editor) {
|
||||
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, lastCancelReason: null });
|
||||
let baseline = new Map<Id, ElementRecord>(), disposed = false;
|
||||
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
|
||||
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
|
||||
function setView(view: ViewState) { const views = new Map(snapshot.views); views.set(view.id, view); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); }
|
||||
function cancel(reason = 'canceled') {
|
||||
if (!snapshot.gesture) return;
|
||||
baseline.clear(); snapshot = Object.freeze({ ...snapshot, gesture: null, lastCancelReason: reason }); notify();
|
||||
if (!snapshot.gesture && !snapshot.connectionGesture) return;
|
||||
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
|
||||
}
|
||||
const detachEditor = editor.subscribe(() => {
|
||||
const document = editor.getSnapshot().document;
|
||||
const views = new Map(snapshot.views); let changed = false;
|
||||
for (const [id, view] of views) {
|
||||
const canvasMissing = !document.canvases.has(view.canvasId);
|
||||
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => document.elements.has(target)));
|
||||
if (selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, selection: readSet(selection), modes: readMap(modes) })); changed = true; }
|
||||
if (canvasMissing || selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, canvasId: canvasMissing ? [...document.canvases.values()].find(canvas => !canvas.parent)!.id : view.canvasId, selection: readSet(canvasMissing ? [] : selection), modes: readMap(canvasMissing ? new Map() : modes) })); changed = true; }
|
||||
}
|
||||
if (changed) { snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); }
|
||||
if (snapshot.connectionGesture?.phase === 'active') {
|
||||
const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline;
|
||||
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.path, before.path)) cancel('target_changed');
|
||||
else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancel('permission_changed');
|
||||
}
|
||||
if (snapshot.gesture?.phase !== 'active') return;
|
||||
for (const target of snapshot.gesture.targets) {
|
||||
const before = baseline.get(target.id), current = document.elements.get(target.id);
|
||||
@@ -64,10 +71,17 @@ export function createSessions(editor: Editor) {
|
||||
}
|
||||
return Object.freeze({
|
||||
getSnapshot: () => snapshot,
|
||||
attach(id: Id) {
|
||||
attach(id: Id, canvasId = [...editor.getSnapshot().document.canvases.values()].find(canvas => !canvas.parent)!.id) {
|
||||
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
|
||||
setView(freeze({ id, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
|
||||
return () => { if (snapshot.gesture?.viewId === id) cancel('view_detached'); const views = new Map(snapshot.views); views.delete(id); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); };
|
||||
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
||||
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
|
||||
return () => { if ((snapshot.gesture?.viewId === id || snapshot.connectionGesture?.viewId === id)) cancel('view_detached'); const views = new Map(snapshot.views); views.delete(id); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); };
|
||||
},
|
||||
canvas(id: Id, canvasId: Id) {
|
||||
const current = view(id);
|
||||
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
||||
if ((snapshot.gesture?.viewId === id || snapshot.connectionGesture?.viewId === id)) cancel('canvas_changed');
|
||||
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }));
|
||||
},
|
||||
select(id: Id, ids: readonly Id[]) {
|
||||
const current = view(id), document = editor.getSnapshot().document;
|
||||
@@ -83,13 +97,45 @@ export function createSessions(editor: Editor) {
|
||||
begin(viewId: Id, targets: readonly Target[]): Availability {
|
||||
try {
|
||||
view(viewId);
|
||||
if (snapshot.gesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy');
|
||||
if (snapshot.gesture || snapshot.connectionGesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy');
|
||||
baseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; }));
|
||||
for (const target of targets) { const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); }
|
||||
snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, gesture: freeze({ viewId, targets: targets.map(target => ({ ...target })), preview: readMap(new Map<Id, Geometry>()), phase: 'active' as const }) }); notify();
|
||||
snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, gesture: freeze({ token: ++nextGestureToken, viewId, targets: targets.map(target => ({ ...target })), preview: readMap(new Map<Id, Geometry>()), phase: 'active' as const }) }); notify();
|
||||
return { enabled: true };
|
||||
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
},
|
||||
beginConnection(viewId: Id, target: Target): Availability {
|
||||
try {
|
||||
view(viewId);
|
||||
if (snapshot.gesture || snapshot.connectionGesture) throw fault('gesture_busy');
|
||||
const element = targetElement(editor.getSnapshot().document, target);
|
||||
if (element.structure !== 'connection') throw fault('unsupported_capability', [target.id]);
|
||||
const allowed = editor.can({ type: 'connection.path', target, path: element.path });
|
||||
if (!allowed.enabled) throw fault(allowed.code, allowed.targets);
|
||||
connectionBaseline = element;
|
||||
snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, connectionGesture: freeze({ token: ++nextGestureToken, viewId, target: { ...target }, path: element.path, phase: 'active' as const }) }); notify();
|
||||
return { enabled: true };
|
||||
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
},
|
||||
updateConnection(viewId: Id, path: Json): Availability {
|
||||
try {
|
||||
view(viewId);
|
||||
if (snapshot.connectionGesture?.viewId !== viewId) throw fault('gesture_not_owned');
|
||||
if (!validJson(path)) throw fault('invalid_connection_path');
|
||||
snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...snapshot.connectionGesture, path: clone(path) }) }); notify();
|
||||
return { enabled: true };
|
||||
} catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; }
|
||||
},
|
||||
commitConnection(viewId: Id): Result {
|
||||
try {
|
||||
view(viewId);
|
||||
const gesture = snapshot.connectionGesture;
|
||||
if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned');
|
||||
snapshot = Object.freeze({ ...snapshot, connectionGesture: freeze({ ...gesture, phase: 'committing' as const }) });
|
||||
const result = editor.dispatch({ type: 'connection.path', target: gesture.target, path: gesture.path }, { label: 'Edit connection' });
|
||||
cancel(result.status === 'rejected' ? result.code : 'committed'); return result;
|
||||
} catch (error) { cancel('commit_failed'); return reject(error); }
|
||||
},
|
||||
update,
|
||||
commit(viewId: Id): Result {
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ export type Change = FieldChange
|
||||
| { readonly kind: 'canvas'; readonly id: Id; readonly before: CanvasRecord | null; readonly after: CanvasRecord | null };
|
||||
export type Command =
|
||||
| { readonly type: 'element.create'; readonly element: ElementRecord }
|
||||
| { readonly type: 'connection.path'; readonly target: Target; readonly path: Json }
|
||||
| { readonly type: 'element.remove'; readonly target: Target }
|
||||
| { readonly type: 'element.data'; readonly target: Target; readonly patch: Data }
|
||||
| { readonly type: 'element.geometry'; readonly target: Target; readonly patch: Partial<Geometry> }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Profile } from './api.ts';
|
||||
import type { CanvasRequest } from './canvas-sync.ts';
|
||||
import { decodeSnapshot } from './canvas-sync.ts';
|
||||
import { CANVAS_ACCEPT } from './canvas-v2.ts';
|
||||
import { matchesRef, type Board } from './workspace.ts';
|
||||
import type { Registry } from './canvas-core/types.ts';
|
||||
import { loadFoundryModel, type ServiceTransport } from './canvas-service/foundry.ts';
|
||||
|
||||
/** Read canonical first: a migrated project must never be opened through snapshot writers. */
|
||||
export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport(accountId: string): ServiceTransport; signal?: AbortSignal }) {
|
||||
const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`;
|
||||
const [board, profile] = await Promise.all([options.request<Board>(path, { signal: options.signal }), options.request<Profile>('profile', { signal: options.signal })]);
|
||||
options.signal?.throwIfAborted();
|
||||
if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference');
|
||||
const transport = options.transport(profile.id);
|
||||
try {
|
||||
const model = await loadFoundryModel(transport, board.workspace_id, board.id, options.registry, options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
if (model.status === 'ready') return { mode: 'headless' as const, board, profile, model };
|
||||
// Existing projects keep their current editor until the user explicitly migrates them.
|
||||
} catch (error) {
|
||||
options.signal?.throwIfAborted();
|
||||
if ((error as { code?: string }).code !== 'not_found') throw error;
|
||||
}
|
||||
const snapshot = decodeSnapshot(await options.request(path + '/canvas', { signal: options.signal, headers: { Accept: CANVAS_ACCEPT } }));
|
||||
return { mode: 'legacy' as const, board, profile, snapshot };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Document, Id, Registry } from '../canvas-core/types.ts';
|
||||
import type { Document, Id, Registry, Result } from '../canvas-core/types.ts';
|
||||
import { createEditor, type EditorOptions, type Transaction } from '../canvas-core/editor.ts';
|
||||
import { createDocument } from '../canvas-core/document.ts';
|
||||
import { createSessions } from '../canvas-core/gestures.ts';
|
||||
import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
||||
import { equal, fault, freeze, readMap, reject } from '../canvas-core/values.ts';
|
||||
import type { EngineSnapshot, PersistencePort, RuntimeState } from './contracts.ts';
|
||||
import { createLedger, type FrozenRequest } from './ledger.ts';
|
||||
import { operationCanvases, replayPending } from './reconcile.ts';
|
||||
@@ -14,7 +14,7 @@ import type { StoragePort, StorageScope } from './storage.ts';
|
||||
export type EngineOptions = {
|
||||
scope: StorageScope; registry: Registry; document: Document; versions: ReadonlyMap<Id, number>;
|
||||
storage: StoragePort; persistence: PersistencePort; assets?: AssetPort; createId?: () => Id;
|
||||
historyLimit?: number; policy?: EditorOptions['policy']; clock?: Clock;
|
||||
historyLimit?: number; policy?: EditorOptions['policy']; traversalPolicy?: EditorOptions['traversalPolicy']; clock?: Clock;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const initial: RuntimeState = recovered?.runtime ?? Object.freeze({ acknowledged: createDocument(options.document.projectId, [...options.document.canvases.values()], [...options.document.elements.values()], options.document.tombstones), versions: readMap(options.versions), ledger: createLedger().getSnapshot(), receipts: readMap(new Map()), conflicts: readMap(new Map()), resources: readMap(new Map()), resourcePins: readMap(new Map()) });
|
||||
const editor = createEditor({ registry: options.registry, document: options.document, recovery: recovered, runtime: initial, createId: options.createId, historyLimit: options.historyLimit,
|
||||
policy(command, document) { if (closing || closed) return { enabled: false, code: 'engine_closing', targets: [] }; return options.policy?.(command, document) ?? { enabled: true }; },
|
||||
traversalPolicy() { return closing || closed ? { enabled: false, code: 'engine_closing', targets: [] } : { enabled: true }; },
|
||||
traversalPolicy(entry, direction) { return closing || closed ? { enabled: false, code: 'engine_closing', targets: [] } : options.traversalPolicy?.(entry, direction) ?? { enabled: true }; },
|
||||
onListenerError: report,
|
||||
nextOperationSequence(runtime, fallback) { let next = fallback; for (const record of runtime.ledger.operations.values()) next = Math.max(next, record.intent.sequence + 1); return next + staged.length; },
|
||||
reduceRuntime(previous, next, commit) {
|
||||
@@ -195,6 +195,25 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
scheduler.start(); prepareDigests();
|
||||
return Object.freeze({
|
||||
editor, sessions,
|
||||
refresh(document: Document, versions: ReadonlyMap<Id, number>): Result {
|
||||
const root = editor.getSnapshot(), previous = root.runtime;
|
||||
try {
|
||||
if (closing || closed) throw fault('engine_closing');
|
||||
if (document.projectId !== root.document.projectId) throw fault('project_mismatch');
|
||||
if ([...previous.ledger.requests.values()].some(request => request.phase !== 'resolved')) throw fault('refresh_waiting_for_receipts');
|
||||
for (const canvas of document.canvases.keys()) if (!Number.isSafeInteger(versions.get(canvas)) || versions.get(canvas)! < 1) throw fault('missing_server_version', [canvas]);
|
||||
for (const [id, version] of previous.versions) if ((versions.get(id) ?? -1) < version) throw fault('stale_canonical_version', [id]);
|
||||
if (equal([...previous.versions], [...versions]) && equal([...previous.acknowledged.canvases], [...document.canvases]) && equal([...previous.acknowledged.elements], [...document.elements]) && equal([...previous.acknowledged.tombstones], [...document.tombstones])) return { status: 'applied', revision: root.revision, operationIds: [] };
|
||||
const acknowledged = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
|
||||
const working = replayPending(acknowledged, previous.ledger, options.registry);
|
||||
const conflicts = new Map(previous.conflicts); conflicts.delete('refresh');
|
||||
return editor.reconcile(working, Object.freeze({ ...previous, acknowledged, versions: readMap(versions), conflicts: readMap(conflicts) }), root.rootRevision);
|
||||
} catch (error) {
|
||||
const result = reject(error);
|
||||
if (result.code !== 'refresh_waiting_for_receipts' && result.code !== 'engine_closing') editor.updateRuntime(previous => { const conflicts = new Map(previous.conflicts); conflicts.set('refresh', result.code); return Object.freeze({ ...previous, conflicts: readMap(conflicts) }); });
|
||||
return result;
|
||||
}
|
||||
},
|
||||
stageFiles(files: readonly StagedFile[], callback: (resources: readonly ResourceHandle[], transaction: Transaction) => unknown) {
|
||||
if (closing || closed) throw fault('engine_closing');
|
||||
if (staged.length) throw fault('reentrant_dispatch');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Id, Registry } from '../canvas-core/types.ts';
|
||||
import { fault, readMap } from '../canvas-core/values.ts';
|
||||
import type { WorkspaceRuntime } from './workspace.ts';
|
||||
|
||||
/** Host lifetime matches the signed-in application, independently of navigation. */
|
||||
export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; onError?: (error: unknown) => void }) {
|
||||
let accountId: Id | null = null, authEpoch = 0, disposed = false;
|
||||
const workspaces = new Map<Id, WorkspaceRuntime>(), retired = new Set<WorkspaceRuntime>();
|
||||
const closeRetired = async () => {
|
||||
const results = await Promise.allSettled([...retired].map(async runtime => { await runtime.close(); retired.delete(runtime); }));
|
||||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
|
||||
if (errors.length) throw new AggregateError(errors, 'host_close_failed');
|
||||
};
|
||||
function retire() { authEpoch++; accountId = null; for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); }
|
||||
function activate(account: Id) {
|
||||
if (disposed) throw fault('host_disposed');
|
||||
if (accountId !== account) {
|
||||
retire(); accountId = account;
|
||||
void closeRetired().catch(error => { try { options.onError?.(error); } catch { /* Reporting cannot change scope. */ } });
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
registry: options.registry,
|
||||
bindAccount(account: Id) { activate(account); const epoch = authEpoch; return () => !disposed && accountId === account && authEpoch === epoch; },
|
||||
identity: () => Object.freeze({ accountId, authEpoch }),
|
||||
getWorkspaces: () => readMap(workspaces),
|
||||
workspace(account: Id, workspace: Id) {
|
||||
activate(account);
|
||||
let runtime = workspaces.get(workspace);
|
||||
if (!runtime) {
|
||||
const epoch = authEpoch;
|
||||
runtime = options.workspace(account, workspace, epoch, () => !disposed && accountId === account && authEpoch === epoch);
|
||||
workspaces.set(workspace, runtime);
|
||||
}
|
||||
return runtime;
|
||||
},
|
||||
retireIdentity() { retire(); return closeRetired(); },
|
||||
close() { disposed = true; retire(); return closeRetired(); },
|
||||
});
|
||||
}
|
||||
export type RuntimeHost = ReturnType<typeof createRuntimeHost>;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Document, Id, Registry } from '../canvas-core/types.ts';
|
||||
import { fault, readMap } from '../canvas-core/values.ts';
|
||||
import { openProjectEngine, type EngineOptions } from './engine.ts';
|
||||
import type { StoragePort } from './storage.ts';
|
||||
|
||||
export type ProjectEngine = Awaited<ReturnType<typeof openProjectEngine>>;
|
||||
export type WorkspaceOptions = {
|
||||
accountId: Id; workspaceId: Id; authEpoch: number; registry: Registry; storage: StoragePort;
|
||||
isCurrentIdentity(): boolean;
|
||||
adapters(projectId: Id, current: () => boolean): Pick<EngineOptions, 'persistence' | 'assets'>;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/** Workspace owns persistent projects; a view only borrows one and never closes it. */
|
||||
export function createWorkspaceRuntime(options: WorkspaceOptions) {
|
||||
const projects = new Map<Id, Promise<ProjectEngine>>();
|
||||
let accepting = true, closed = false, closing: Promise<void> | undefined;
|
||||
const current = () => accepting && !closed && options.isCurrentIdentity();
|
||||
return Object.freeze({
|
||||
accountId: options.accountId, workspaceId: options.workspaceId, authEpoch: options.authEpoch, registry: options.registry,
|
||||
isCurrentIdentity: current,
|
||||
getProjects: () => readMap(projects),
|
||||
open(document: Document, versions: ReadonlyMap<Id, number>) {
|
||||
if (!current()) return Promise.reject(fault('identity_changed'));
|
||||
const existing = projects.get(document.projectId);
|
||||
if (existing) return existing.then(engine => { if (!current()) throw fault('identity_changed'); engine.refresh(document, versions); return engine; });
|
||||
const opening = openProjectEngine({ scope: { accountId: options.accountId, workspaceId: options.workspaceId, projectId: document.projectId }, registry: options.registry, document, versions, storage: options.storage, ...options.adapters(document.projectId, current), onError: options.onError,
|
||||
traversalPolicy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
|
||||
policy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
|
||||
});
|
||||
projects.set(document.projectId, opening);
|
||||
void opening.catch(() => { if (projects.get(document.projectId) === opening) projects.delete(document.projectId); });
|
||||
return opening.then(engine => { if (!current()) throw fault('identity_changed'); engine.refresh(document, versions); return engine; });
|
||||
},
|
||||
close() {
|
||||
accepting = false; // Fence commands/transports before awaiting pending opens or storage.
|
||||
if (closed) return Promise.resolve();
|
||||
if (closing) return closing;
|
||||
const task = (async () => {
|
||||
const results = await Promise.allSettled([...projects.values()].map(async pending => {
|
||||
let engine: ProjectEngine;
|
||||
try { engine = await pending; } catch { return; }
|
||||
await engine.close();
|
||||
}));
|
||||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
|
||||
if (errors.length) throw new AggregateError(errors, 'workspace_close_failed');
|
||||
options.storage.close(); projects.clear(); closed = true;
|
||||
})();
|
||||
closing = task.finally(() => { closing = undefined; });
|
||||
return closing;
|
||||
},
|
||||
});
|
||||
}
|
||||
export type WorkspaceRuntime = ReturnType<typeof createWorkspaceRuntime>;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDocument, createEditor, createRegistry, builtinComponents, createSessions } from '../../src/lib/canvas-core/index.ts';
|
||||
import { projectCanvas, toLocal, toWorld, transformPath } from '../../src/lib/canvas-bindings/projection.ts';
|
||||
import { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
|
||||
const registry = createRegistry(builtinComponents());
|
||||
const node = (id, ownerCanvasId, componentId = 'text', geometry = {}) => ({ id, incarnation: 1, structure: 'node', ownerCanvasId, componentId, dataVersion: 1, data: { title: id, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0, ...geometry } });
|
||||
const edge = { id: 'edge', incarnation: 1, structure: 'connection', ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, source: { elementId: 'outside' }, target: { elementId: 'inside' }, path: null };
|
||||
const document = createDocument('project', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [node('frame', 'root', 'frame', { x: 100, y: 200, width: 640, height: 400 }), node('inside', 'child', 'text', { x: 10, y: 20 }), node('outside', 'root', 'text', { x: 900 }), edge]);
|
||||
|
||||
test('canvas projection uses owner-local geometry and stable IDs; collapse changes only display endpoints', () => {
|
||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor); const detach = sessions.attach('view', 'root');
|
||||
let projection = projectCanvas(document, 'root', registry, new Set(['inside']), sessions.effectiveGeometry);
|
||||
const inside = projection.nodes.find(node => node.id === 'inside'); assert.deepEqual(inside.position, { x: 134, y: 256 });
|
||||
assert.equal(projection.edges[0].target, 'inside'); assert.equal(inside.selected, true);
|
||||
editor.dispatch({ type: 'element.data', target: { id: 'frame', incarnation: 1 }, patch: { presentation: { collapsed: true } } });
|
||||
projection = projectCanvas(editor.getSnapshot().document, 'root', registry, new Set(), sessions.effectiveGeometry);
|
||||
assert.equal(projection.nodes.some(node => node.id === 'inside'), false); assert.equal(projection.edges[0].target, 'frame');
|
||||
assert.equal(editor.getSnapshot().document.elements.get('edge').target.elementId, 'inside');
|
||||
assert.equal(editor.getSnapshot().document.elements.get('inside').ownerCanvasId, 'child');
|
||||
sessions.canvas('view', 'child'); assert.equal(sessions.getSnapshot().views.get('view').canvasId, 'child');
|
||||
assert.deepEqual(projectCanvas(editor.getSnapshot().document, 'child', registry, new Set(), sessions.effectiveGeometry).nodes[0].position, { x: 10, y: 20 });
|
||||
detach(); sessions.dispose();
|
||||
});
|
||||
|
||||
test('rotated/scaled canvas transforms and curve controls round-trip without changing opaque path fields', () => {
|
||||
const transform = { x: 100, y: 200, scale: .5, rotation: 90 }, point = { x: 40, y: 80 };
|
||||
const round = toLocal(transform, toWorld(transform, point)); assert.ok(Math.abs(round.x - point.x) < 1e-9 && Math.abs(round.y - point.y) < 1e-9);
|
||||
const path = { type: 'bezier-chain', extension: { untouched: true }, startControl: { x: 40, y: 0 }, knots: [{ id: 'k', mode: 'corner', position: point, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] };
|
||||
const transformed = transformPath(path, transform); assert.deepEqual(transformed.extension, path.extension); assert.ok(Math.abs(transformed.startControl.y - 20) < 1e-9);
|
||||
const local = transformPath(transformed, transform, true); assert.ok(Math.abs(local.knots[0].position.x - 40) < 1e-9);
|
||||
});
|
||||
|
||||
test('connection drag owns the project gesture, previews without history, commits once and cancels on detach', () => {
|
||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor), target = { id: 'edge', incarnation: 1 };
|
||||
const detach = sessions.attach('one'), detach2 = sessions.attach('two');
|
||||
assert.equal(sessions.beginConnection('one', target).enabled, true);
|
||||
assert.equal(sessions.begin('two', [{ id: 'outside', incarnation: 1 }]).code, 'gesture_busy');
|
||||
assert.equal(sessions.beginConnection('two', target).code, 'gesture_busy');
|
||||
const path = { type: 'bezier-chain', knots: [] };
|
||||
for (let i = 0; i < 20; i++) sessions.updateConnection('one', { ...path, startControl: { x: i, y: 0 } });
|
||||
assert.equal(editor.getSnapshot().history.undo.length, 0); assert.equal(editor.getSnapshot().operations.size, 0); assert.equal(editor.getSnapshot().document.elements.get('edge').path, null);
|
||||
assert.equal(sessions.commitConnection('one').status, 'applied'); assert.equal(editor.getSnapshot().history.undo.length, 1); assert.equal(editor.getSnapshot().operations.size, 1);
|
||||
assert.equal(editor.getSnapshot().document.elements.get('edge').path.startControl.x, 19);
|
||||
sessions.beginConnection('one', target); sessions.updateConnection('one', { ...path, startControl: { x: 99, y: 0 } }); detach();
|
||||
assert.equal(sessions.getSnapshot().connectionGesture, null); assert.equal(editor.getSnapshot().document.elements.get('edge').path.startControl.x, 19);
|
||||
detach2(); sessions.dispose();
|
||||
});
|
||||
|
||||
test('host scopes account epochs, preserves workspace owners across navigation, and fences before asynchronous close', async () => {
|
||||
const created = [], closes = [];
|
||||
const host = createRuntimeHost({ registry, workspace(accountId, workspaceId, authEpoch, current) { const value = { accountId, workspaceId, authEpoch, current, async close() { closes.push(current()); } }; created.push(value); return value; } });
|
||||
const first = host.workspace('account-a', 'workspace'), current = host.bindAccount('account-a');
|
||||
assert.equal(first, host.workspace('account-a', 'workspace')); assert.equal(current(), true); assert.equal(created.length, 1);
|
||||
const second = host.workspace('account-b', 'workspace'); assert.notEqual(second, first); assert.equal(current(), false); assert.ok(second.authEpoch > first.authEpoch);
|
||||
await host.retireIdentity(); assert.equal(second.current(), false); assert.ok(closes.every(value => value === false));
|
||||
await host.close(); assert.throws(() => host.workspace('account-b', 'workspace'), /host_disposed/);
|
||||
});
|
||||
|
||||
test('late connection changes cancel preview without restoring stale path or unrelated appearance', () => {
|
||||
const editor = createEditor({ document, registry, allowRawChanges: true }), sessions = createSessions(editor); sessions.attach('view');
|
||||
sessions.beginConnection('view', { id: 'edge', incarnation: 1 }); sessions.updateConnection('view', { type: 'bezier-chain', knots: [] });
|
||||
const remote = { type: 'bezier-chain', startControl: { x: 100, y: 0 }, knots: [] };
|
||||
editor.dispatch({ type: 'changes.apply', changes: [{ kind: 'field', target: { id: 'edge', incarnation: 1 }, field: 'path', before: null, after: remote }] }, { origin: 'remote', history: false });
|
||||
assert.equal(sessions.getSnapshot().connectionGesture, null); assert.deepEqual(editor.getSnapshot().document.elements.get('edge').path, remote);
|
||||
assert.equal(editor.getSnapshot().history.undo.length, 0); sessions.dispose();
|
||||
});
|
||||
|
||||
test('workspace retains a failed close for retry and stops edits before releasing its persistent owner', async () => {
|
||||
const { createWorkspaceRuntime } = await import('../../src/lib/canvas-runtime/workspace.ts');
|
||||
let image = null, leader, broken = false, closed = false, server = document, version = 1;
|
||||
const receipts = new Map();
|
||||
const storage = { async claim(scope) { leader = { scope, fence: 1, active: true, async release() { leader.active = false; } }; return leader; }, async recover() { return image; }, async commit(_leader, batch) { if (broken) throw new Error('quota'); image = structuredClone(batch.image); return { durableRootRevision: image.rootRevision }; }, async readFile() {}, close() { closed = true; } };
|
||||
const persistence = { compile: (intent, requestId, snapshot) => ({ canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])), payload: { intent, requestId } }),
|
||||
async submit(request) { const { applyChanges } = await import('../../src/lib/canvas-core/document.ts'); server = applyChanges(server, JSON.parse(request.body).intent.changes); version++; receipts.set(request.id, { document: server, versions: new Map([['root', version], ['child', 1]]) }); return { outcome: 'applied' }; }, async query() { return { outcome: 'unresolved' }; }, canonical: request => receipts.get(request.id) };
|
||||
const workspace = createWorkspaceRuntime({ accountId: 'account', workspaceId: 'workspace', authEpoch: 1, registry, storage, isCurrentIdentity: () => true, adapters: () => ({ persistence }) });
|
||||
const [a, b] = await Promise.all([workspace.open(document, new Map([['root', 1], ['child', 1]])), workspace.open(document, new Map([['root', 1], ['child', 1]]))]);
|
||||
assert.equal(a, b); const detach = a.sessions.attach('first'); a.sessions.attach('second'); detach();
|
||||
a.editor.dispatch({ type: 'element.data', target: { id: 'outside', incarnation: 1 }, patch: { title: 'survives view detach' } }); await a.settle();
|
||||
broken = true; a.editor.dispatch({ type: 'element.data', target: { id: 'outside', incarnation: 1 }, patch: { title: 'not durable yet' } });
|
||||
await assert.rejects(() => workspace.close(), /workspace_close_failed/); assert.equal(leader.active, true); assert.equal(closed, false);
|
||||
assert.equal(a.editor.dispatch({ type: 'element.data', target: { id: 'outside', incarnation: 1 }, patch: { title: 'late' } }).status, 'rejected');
|
||||
assert.equal(workspace.getProjects().size, 1);
|
||||
broken = false; await workspace.close(); assert.equal(leader.active, false); assert.equal(closed, true);
|
||||
});
|
||||
|
||||
test('future curve formats remain readable and opaque while appearance edits preserve their path', () => {
|
||||
const future = { ...edge, path: { type: 'future-curve', opaque: { retained: true } } };
|
||||
const source = createDocument('project', [...document.canvases.values()], [...document.elements.values()].filter(element => element.id !== 'edge').concat(future));
|
||||
const editor = createEditor({ document: source, registry });
|
||||
assert.equal(editor.dispatch({ type: 'element.data', target: { id: 'edge', incarnation: 1 }, patch: { appearance: { color: '#123456' } } }).status, 'applied');
|
||||
assert.equal(editor.dispatch({ type: 'connection.path', target: { id: 'edge', incarnation: 1 }, path: null }).code, 'unsupported_connection_path');
|
||||
assert.deepEqual(editor.getSnapshot().document.elements.get('edge').path, future.path);
|
||||
assert.equal(projectCanvas(editor.getSnapshot().document, 'root', registry, new Set(), id => editor.getSnapshot().document.elements.get(id)?.geometry).edges[0].data.path, undefined);
|
||||
});
|
||||
|
||||
test('a canceled pointer lease cannot update, finish or cancel a newer gesture in the same view', async () => {
|
||||
const { guardEditingLease } = await import('../../src/components/canvas/editing-lease.ts');
|
||||
const editor = createEditor({ document, registry }), sessions = createSessions(editor); sessions.attach('view');
|
||||
const target = { id: 'outside', incarnation: 1 };
|
||||
const lease = () => { const token = sessions.getSnapshot().gesture.token; return guardEditingLease(() => sessions.getSnapshot().gesture?.token === token, { preview: patch => sessions.update('view', target, patch), commit: () => sessions.commit('view'), cancel: () => sessions.cancel('pointer_cancel') }); };
|
||||
sessions.begin('view', [target]); const first = lease(); first.preview({ x: 100 }); first.cancel();
|
||||
sessions.begin('view', [target]); const second = lease(); second.preview({ x: 200 });
|
||||
first.preview({ x: 500 }); first.commit(); first.cancel();
|
||||
assert.equal(sessions.getSnapshot().gesture.preview.get('outside').x, 200); assert.equal(editor.getSnapshot().history.undo.length, 0);
|
||||
second.commit(); assert.equal(editor.getSnapshot().document.elements.get('outside').geometry.x, 200); assert.equal(editor.getSnapshot().history.undo.length, 1);
|
||||
sessions.dispose();
|
||||
});
|
||||
@@ -127,3 +127,16 @@ test('recovery codec restores immutable maps and rejects broken history/dependen
|
||||
assert.equal(restored.document.elements.get('a').data.title, 'encoded');
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
test('a fresh canonical read replaces a clean recovered base, keeps history and refuses older versions', async () => {
|
||||
const storage = storageFixture(), port = persistenceFixture(), first = await open(storage, port);
|
||||
first.engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'local' } }); await first.engine.settle(); await first.engine.close();
|
||||
const reopened = await open(storage, port), current = reopened.engine.getSnapshot();
|
||||
const remote = applyChanges(current.runtime.acknowledged, [{ kind: 'field', target, field: 'geometry', before: current.document.elements.get('a').geometry, after: { ...current.document.elements.get('a').geometry, x: 400 } }]);
|
||||
assert.equal(reopened.engine.refresh(remote, new Map([['canvas', 3]])).status, 'applied');
|
||||
assert.equal(reopened.engine.getSnapshot().document.elements.get('a').geometry.x, 400);
|
||||
assert.equal(reopened.engine.getSnapshot().history.undo.length, 1);
|
||||
assert.equal(reopened.engine.refresh(original, new Map([['canvas', 1]])).code, 'stale_canonical_version');
|
||||
assert.equal(reopened.engine.getSnapshot().document.elements.get('a').geometry.x, 400);
|
||||
await reopened.engine.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
|
||||
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
|
||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
const board = { id: id(1), workspace_id: id(2), kind: 'board', title: 'Board', version: 1 };
|
||||
const profile = { id: id(3) }, registry = createRegistry(builtinComponents());
|
||||
function fixture(response) {
|
||||
const calls = [], request = async path => { calls.push(path); if (path === 'profile') return profile; if (path.endsWith('/canvas')) return { version: 1, document: { schema_version: 2, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; return board; };
|
||||
return { calls, options: { request, workspaceRef: board.workspace_id, boardRef: board.id, registry, transport: accountId => { assert.equal(accountId, profile.id); return async input => { calls.push(input.path); return response; }; } } };
|
||||
}
|
||||
|
||||
test('canonical-first entry never reads a legacy snapshot for an adopted project', async () => {
|
||||
const f = fixture({ status: 200, body: { model: { documentFormatVersion: 1, projectId: board.id, complete: true, canvases: [{ id: board.id, parent: null }], elements: [], tombstones: {}, versions: { [board.id]: 1 }, removedCanvasIds: [] } } });
|
||||
const result = await loadCanvasEntry(f.options); assert.equal(result.mode, 'headless'); assert.equal(result.model.document.projectId, board.id);
|
||||
assert.equal(f.calls.some(path => path.endsWith('/canvas')), false);
|
||||
});
|
||||
|
||||
test('explicit migration requirement or missing deployment keeps legacy reads; permission failure does not fall back', async () => {
|
||||
for (const response of [{ status: 404, body: { error: 'not_found' } }, { status: 409, body: { error: 'canonical_migration_required', root_id: board.id, document_format_version: 1, source_versions: { [board.id]: 1 } } }]) {
|
||||
const f = fixture(response); assert.equal((await loadCanvasEntry(f.options)).mode, 'legacy'); assert.equal(f.calls.filter(path => path.endsWith('/canvas')).length, 1);
|
||||
}
|
||||
const f = fixture({ status: 403, body: { error: 'forbidden' } });
|
||||
await assert.rejects(() => loadCanvasEntry(f.options), /forbidden/); assert.equal(f.calls.some(path => path.endsWith('/canvas')), false);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/** Actual dashboard, GraphPage, headless renderer, browser storage and Next proxy. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtemp, cp, copyFile, symlink, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as pause } from 'node:timers/promises';
|
||||
import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
const source = resolve(import.meta.dirname, '../..'), directory = await mkdtemp(join(tmpdir(), 'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-'));
|
||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
const account = id(1), workspace = id(2), root = id(3), other = id(4), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map();
|
||||
const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } });
|
||||
documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }]));
|
||||
documents.set(other, createDocument(other, [{ id: other, parent: null }], []));
|
||||
const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' }));
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
|
||||
let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
|
||||
const model = (document, complete = true) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()], elements: [...document.elements.values()], tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries([...document.canvases.keys()].map(id => [id, versions.get(id)])), removedCanvasIds: [] });
|
||||
const upstream = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const bytes = Buffer.concat(chunks); calls.push({ method: request.method, path, bytes, headers: request.headers });
|
||||
const reply = (status, body) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); };
|
||||
if (path.endsWith('/auth/logout')) return reply(204, null);
|
||||
if (path.endsWith('/profile')) return reply(200, { id: account, username: 'fixture', display_name: 'Creator', email: null });
|
||||
if (path.endsWith('/workspaces')) return reply(200, [{ id: workspace, name: 'Fixture workspace', role: 'admin' }]);
|
||||
if (path.endsWith('/materials')) return reply(200, { items: boards, next_offset: null });
|
||||
const board = boards.find(board => path.endsWith('/materials/' + board.id)); if (board) return reply(200, board);
|
||||
const read = path.match(/\/materials\/([^/]+)\/canvas\/model$/);
|
||||
if (read) return reply(200, { model: model(documents.get(read[1])) });
|
||||
const write = path.match(/\/materials\/([^/]+)\/canvas\/model\/operations$/);
|
||||
if (write) {
|
||||
const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]);
|
||||
assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id);
|
||||
assert.equal(batch.expected_versions[write[1]], versions.get(write[1]));
|
||||
let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes);
|
||||
documents.set(write[1], next); versions.set(write[1], versions.get(write[1]) + 1);
|
||||
return reply(200, { protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false) });
|
||||
}
|
||||
if (path.endsWith('/assets') && request.method === 'POST') {
|
||||
const upload = new Request('http://fixture', { method: 'POST', body: bytes, headers: { 'Content-Type': request.headers['content-type'] } }), form = await upload.formData(), file = form.get('image');
|
||||
const asset = { id: request.headers['x-resource-id'], bytes: file.size, mime_type: 'image/png', width: 1, height: 1, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${workspace}/assets/${request.headers['x-resource-id']}` };
|
||||
uploads.push(asset); assets.set(asset.id, asset); await uploadGate;
|
||||
return reply(201, { protocol_version: 2, request_id: request.headers['idempotency-key'], status: 'applied', asset });
|
||||
}
|
||||
if (/\/assets\/[^/]+$/.test(path)) { response.writeHead(200, { 'Content-Type': 'image/png' }); return response.end(png); }
|
||||
reply(404, { error: 'not_found' });
|
||||
} catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: error.message })); }
|
||||
});
|
||||
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const reserve = createServer(); await new Promise(resolve => reserve.listen(0, '127.0.0.1', resolve)); const port = reserve.address().port; await new Promise(resolve => reserve.close(resolve));
|
||||
let next, chrome, socket, logs = '';
|
||||
try {
|
||||
await cp(join(source, 'src'), join(directory, 'src'), { recursive: true });
|
||||
await cp(join(source, 'public'), join(directory, 'public'), { recursive: true });
|
||||
for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs']) await copyFile(join(source, file), join(directory, file));
|
||||
await symlink(join(source, 'node_modules'), join(directory, 'node_modules'));
|
||||
next = spawn(process.execPath, [join(source, 'node_modules/next/dist/bin/next'), 'dev', '--webpack', '--hostname', '127.0.0.1', '--port', String(port)], { cwd: directory, detached: true, env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1', FOUNDRY_API_ORIGIN: `http://127.0.0.1:${upstream.address().port}`, NEXT_PUBLIC_FOUNDRY_PROJECT: 'endoleap' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
next.stdout.on('data', chunk => { logs = (logs + chunk).slice(-16000); }); next.stderr.on('data', chunk => { logs = (logs + chunk).slice(-16000); });
|
||||
for (let i = 0; i < 120; i++) { if (next.exitCode !== null) throw new Error(logs); try { const r = await fetch(`http://127.0.0.1:${port}/api/foundry/profile`, { signal: AbortSignal.timeout(1000) }); if (r.ok) break; } catch {} await pause(250); }
|
||||
chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: 'ignore' });
|
||||
let debugPort;
|
||||
for (let i = 0; i < 100; i++) { try { debugPort = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); } }
|
||||
assert.ok(debugPort, 'Chrome did not start');
|
||||
const tabs = await fetch(`http://127.0.0.1:${debugPort}/json/list`).then(response => response.json());
|
||||
socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
|
||||
let serial = 0; const pending = new Map(), exceptions = [];
|
||||
socket.onmessage = event => { const message = JSON.parse(event.data); if(message.method==='Runtime.consoleAPICalled' && message.params.type==='error') console.log('BROWSER ERROR',message.params.args.map(arg=>arg.description??arg.value).join(' ')); if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.exception?.description ?? message.params.exceptionDetails.text); if (!message.id) return; const p = pending.get(message.id); pending.delete(message.id); message.error ? p.reject(new Error(JSON.stringify(message.error))) : p.resolve(message.result); };
|
||||
const call = (method, params = {}) => new Promise((resolve, reject) => { const id = ++serial; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); });
|
||||
const evaluate = async expression => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); return r.result?.value; };
|
||||
async function until(expression, label) { for (let i = 0; i < 200; i++) { if (exceptions.length) throw new Error(exceptions.join('\n')); if (await evaluate(expression)) return; await pause(100); } throw new Error(label + '\n' + await evaluate('document.body.innerText') + '\n' + logs); }
|
||||
await call('Runtime.enable'); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||
const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
|
||||
await call('Page.navigate', { url: route });
|
||||
await until('!!document.querySelector("[data-headless-canvas]") && document.querySelectorAll(".react-flow__node").length === 2', 'actual headless canvas did not open');
|
||||
assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot');
|
||||
assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true);
|
||||
const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`);
|
||||
await click('添加文本卡片'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'create did not render');
|
||||
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
|
||||
assert.equal(documents.get(root).elements.size, 4);
|
||||
await click('撤销'); await until('document.querySelectorAll(".react-flow__node").length === 2', 'undo did not render');
|
||||
for (let i = 0; i < 100 && documents.get(root).elements.size !== 3; i++) await pause(20);
|
||||
assert.equal(documents.get(root).elements.size, 3);
|
||||
await click('重做'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'redo did not render');
|
||||
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
|
||||
const beforeDrag = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
|
||||
await evaluate(`window.inputEvents=[];for(const type of ['pointerdown','mousedown','pointerup','mouseup','pointercancel','lostpointercapture'])window.addEventListener(type,event=>window.inputEvents.push({type,x:event.clientX,y:event.clientY,target:event.target.outerHTML?.slice(0,120)}),true);`);
|
||||
const rect = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()');
|
||||
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
for (let step = 1; step <= 10; step++) await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + step * 5, y: rect.y + step * 3, button: 'left', buttons: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + 50, y: rect.y + 30, button: 'left', buttons: 0 });
|
||||
for (let i = 0; i < 100 && documents.get(root).elements.get('b').geometry.x === 450; i++) await pause(20);
|
||||
assert.ok(Math.abs(documents.get(root).elements.get('b').geometry.x - 500) < 1, JSON.stringify({ geometry: documents.get(root).elements.get('b').geometry, rect, writes: calls.filter(call => call.path.endsWith('/model/operations')).map(call => JSON.parse(call.bytes.toString()).operations), inputs: await evaluate('window.inputEvents'), text: await evaluate('document.body.innerText') }));
|
||||
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeDrag + 1, 'pointer frames became document requests');
|
||||
const beforeCancel = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
|
||||
const moved = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()');
|
||||
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: moved.x, y: moved.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 1 });
|
||||
await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 0 });
|
||||
await pause(100); assert.equal(documents.get(root).elements.get('b').geometry.x, 500); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel);
|
||||
const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: corner.x + 40, y: corner.y + 20, button: 'left', buttons: 0 });
|
||||
for (let i=0;i<100&&documents.get(root).elements.get('b').geometry.width!==300;i++)await pause(20);
|
||||
assert.equal(documents.get(root).elements.get('b').geometry.width,300,JSON.stringify({geometry:documents.get(root).elements.get('b').geometry,inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText')}));assert.equal(documents.get(root).elements.get('b').geometry.height,220);
|
||||
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel+1);
|
||||
const textPoint = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+60,y:r.y+30}})()`);
|
||||
await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...textPoint, button: 'left', buttons: 1, clickCount: 2 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', ...textPoint, button: 'left', buttons: 0, clickCount: 2 });
|
||||
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`, 'text did not enter edit mode');
|
||||
await call('Input.imeSetComposition', { text: '中', selectionStart: 1, selectionEnd: 1 });
|
||||
await call('Input.insertText', { text: '中文' });
|
||||
for(let i=0;i<100&&!documents.get(root).elements.get('b').data.text.endsWith('中文');i++)await pause(20);
|
||||
assert.ok(documents.get(root).elements.get('b').data.text.endsWith('中文'));
|
||||
await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
await click('撤销');
|
||||
for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Editable content';i++)await pause(20);
|
||||
assert.equal(documents.get(root).elements.get('b').data.text,'Editable content');assert.equal(documents.get(root).elements.get('b').geometry.width,300);
|
||||
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 400, y: 250, button: 'left', buttons: 1, clickCount: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 400, y: 250, button: 'left', buttons: 0, clickCount: 1 });
|
||||
await until(`!!document.querySelector('[aria-label="曲线点 1"]')`, 'curve did not become selected');
|
||||
assert.equal(await evaluate(`document.elementFromPoint(400,250)?.getAttribute('aria-label')`),'曲线点 1','curve drag handle is not hit-testable');
|
||||
const beforeCurve = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
|
||||
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 400, y: 250, button: 'left', buttons: 1, clickCount: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 420, y: 260, button: 'left', buttons: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 440, y: 265, button: 'left', buttons: 0 });
|
||||
for(let i=0;i<100&&documents.get(root).elements.get('edge').path.knots[0].position.x!==440;i++)await pause(20);
|
||||
assert.deepEqual(documents.get(root).elements.get('edge').path.knots[0].position,{x:440,y:265},JSON.stringify({inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText'),writes:calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).map(call=>JSON.parse(call.bytes.toString()).operations)}));
|
||||
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length,beforeCurve+1);
|
||||
console.log('PASS actual GraphPage chooses canonical editor; create/undo/redo, drag/resize/curve final coordinates, Escape and IME text history publish correctly');
|
||||
await evaluate(`(()=>{const bytes=Uint8Array.from(atob('${png.toString('base64')}'),c=>c.charCodeAt(0));const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await until(`document.querySelectorAll('.react-flow__node').length === 4 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
|
||||
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64'));
|
||||
for (let i = 0; i < 100 && !uploads.length; i++) await pause(20);
|
||||
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 4);
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);
|
||||
await until(`location.pathname.endsWith('${other}') && !!document.querySelector('[data-headless-canvas]') && document.querySelectorAll('.react-flow__node').length === 0`, 'other board did not open');
|
||||
releaseUpload();
|
||||
for (let i = 0; i < 200 && documents.get(root).elements.size !== 5; i++) await pause(20);
|
||||
assert.equal(documents.get(root).elements.size, 5, 'view detach aborted the upload or document operation');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'), 0, 'late result appeared on the wrong board');
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);
|
||||
await until('document.querySelectorAll(".react-flow__node").length === 4', 'returning project lost its engine');
|
||||
assert.equal(uploads.length, 1);
|
||||
await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 4', 'page reload did not recover the document');
|
||||
assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false);
|
||||
console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity');
|
||||
console.log('PASS canvas floating controls remain visible while the sidebar is expanded');
|
||||
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
|
||||
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
|
||||
console.log('PASS logout retires the runtime before dropping its view without a render error');
|
||||
} finally {
|
||||
releaseUpload(); socket?.close();
|
||||
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await exit; }
|
||||
if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} }
|
||||
upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 5 }); await rm(profile, { recursive: true, force: true, maxRetries: 5 });
|
||||
}
|
||||
Reference in New Issue
Block a user