From e2987de21aad743739b9b87ce502096512183ac1 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 00:07:27 +0800 Subject: [PATCH] Connect canonical projects to persistent workspace runtimes and canvas views --- package.json | 3 +- src/app/dashboard/dashboard.tsx | 4 +- src/app/layout.tsx | 3 +- src/components/canvas/canvas-edge.tsx | 42 +++- src/components/canvas/canvas-text-editor.tsx | 2 +- src/components/canvas/canvas.module.css | 2 +- src/components/canvas/edge-editing.tsx | 4 +- src/components/canvas/edge-toolbar.tsx | 2 +- src/components/canvas/editing-lease.ts | 10 + .../canvas/headless-graph-canvas.tsx | 206 ++++++++++++++++++ src/components/canvas/transformable-node.tsx | 32 ++- .../workspace/canvas-runtime-host.tsx | 41 ++++ src/components/workspace/graph-page.tsx | 25 ++- src/lib/api.ts | 10 + src/lib/canvas-bindings/projection.ts | 72 ++++++ src/lib/canvas-core/editor.ts | 9 +- src/lib/canvas-core/features.ts | 23 +- src/lib/canvas-core/gestures.ts | 76 +++++-- src/lib/canvas-core/types.ts | 1 + src/lib/canvas-entry.ts | 27 +++ src/lib/canvas-runtime/engine.ts | 27 ++- src/lib/canvas-runtime/host.ts | 41 ++++ src/lib/canvas-runtime/workspace.ts | 54 +++++ tests/headless/bindings.test.mjs | 108 +++++++++ tests/headless/engine.test.mjs | 13 ++ tests/headless/entry.test.mjs | 25 +++ tests/headless/renderer.integration.mjs | 165 ++++++++++++++ 27 files changed, 970 insertions(+), 57 deletions(-) create mode 100644 src/components/canvas/editing-lease.ts create mode 100644 src/components/canvas/headless-graph-canvas.tsx create mode 100644 src/components/workspace/canvas-runtime-host.tsx create mode 100644 src/lib/canvas-bindings/projection.ts create mode 100644 src/lib/canvas-entry.ts create mode 100644 src/lib/canvas-runtime/host.ts create mode 100644 src/lib/canvas-runtime/workspace.ts create mode 100644 tests/headless/bindings.test.mjs create mode 100644 tests/headless/entry.test.mjs create mode 100644 tests/headless/renderer.integration.mjs diff --git a/package.json b/package.json index d61efcc..9f7f7ef 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/dashboard/dashboard.tsx b/src/app/dashboard/dashboard.tsx index c053436..f3654e1 100644 --- a/src/app/dashboard/dashboard.tsx +++ b/src/app/dashboard/dashboard.tsx @@ -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)); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 15c13f5..1a6a8f3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + {children} ); } diff --git a/src/components/canvas/canvas-edge.tsx b/src/components/canvas/canvas-edge.tsx index 20d6d98..5f9357f 100644 --- a/src/components/canvas/canvas-edge.tsx +++ b/src/components/canvas/canvas-edge.tsx @@ -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) { const { zoom } = useViewport(); const reduce = useReducedMotion(); const [selectedKnot, setSelectedKnot] = useState(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['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null); + const pointerHandlers = useRef<{ move(event: PointerEvent): void; stop(event: PointerEvent): void; cancel(event: PointerEvent): 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) { editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id); } function start(event: PointerEvent, id: string, part: NonNullable["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); }, { signal: controller.signal }); + capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent); }, { 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); }, { signal: controller.signal }); + capture.setPointerCapture(event.pointerId); } function move(event: PointerEvent) { const current = gesture.current; @@ -76,14 +92,24 @@ export function CanvasEdge(props: EdgeProps) { } } } - 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) { - 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) { + 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["part"], label: string) => 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) }; diff --git a/src/components/canvas/canvas-text-editor.tsx b/src/components/canvas/canvas-text-editor.tsx index dc7fa49..1b9247e 100644 --- a/src/components/canvas/canvas-text-editor.tsx +++ b/src/components/canvas/canvas-text-editor.tsx @@ -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 &&
{ 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; }} diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index 04a7e01..823de3b 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -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; } } diff --git a/src/components/canvas/edge-editing.tsx b/src/components/canvas/edge-editing.tsx index 075c480..fdcee67 100644 --- a/src/components/canvas/edge-editing.tsx +++ b/src/components/canvas/edge-editing.tsx @@ -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() }); +const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set() }; +export const EdgeEditing = createContext & Partial>>(defaultEditing); diff --git a/src/components/canvas/edge-toolbar.tsx b/src/components/canvas/edge-toolbar.tsx index 67a68b1..ff3ff33 100644 --- a/src/components/canvas/edge-toolbar.tsx +++ b/src/components/canvas/edge-toolbar.tsx @@ -9,7 +9,7 @@ export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count const appearance = (patch: NonNullable) => change({ ...data, appearance: { ...a, ...patch } }); const animate = (patch: NonNullable) => change({ ...data, animation: { ...animation, ...patch } }); const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]]; - return
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> diff --git a/src/components/canvas/editing-lease.ts b/src/components/canvas/editing-lease.ts new file mode 100644 index 0000000..8ebf5a2 --- /dev/null +++ b/src/components/canvas/editing-lease.ts @@ -0,0 +1,10 @@ +/** A captured pointer may only finish the exact interaction that acquired it. */ +export type EditingLease = { preview(value: T): void; commit(): void; cancel(): void }; + +export function guardEditingLease(isCurrent: () => boolean, actions: EditingLease): EditingLease { + return Object.freeze({ + preview(value: T) { if (isCurrent()) actions.preview(value); }, + commit() { if (isCurrent()) actions.commit(); }, + cancel() { if (isCurrent()) actions.cancel(); }, + }); +} diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx new file mode 100644 index 0000000..9a62d7b --- /dev/null +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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) =>
{data.title}此元素需要更新版本才能编辑
, +}; +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(); + const [error, setError] = useState(""), [sources, setSources] = useState(new Map()); + const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map } | null>(null); + const measurements = useRef(new Map()); + const imageInput = useRef(null), groups = useRef(new Map()); + 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[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 = { 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[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) => { 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

画布已关闭

; + return
{ 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'); + }}> + {} }}> + { 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' })); + }, + }}> + { + 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) => 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' })); + }, + }}> + 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, [])}> + + + +
+ report(engine.editor.history.undo())} /> report(engine.editor.history.redo())} /> + flow.zoomOut()} /> flow.zoomIn()} /> flow.fitView({ padding: .2 })} /> + { 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); }} /> +

{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && }

+
+ engine.sessions.tool(viewId, 'select')} /> engine.sessions.tool(viewId, 'pan')} /> + add('plain-text')} /> add('text')} /> imageInput.current?.click()} /> add('frame')} /> +
+ { 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 &&
{error}
} +
; +} +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 ? :

正在准备画布…

; +} diff --git a/src/components/canvas/transformable-node.tsx b/src/components/canvas/transformable-node.tsx index c4b4913..d3e6c8d 100644 --- a/src/components/canvas/transformable-node.tsx +++ b/src/components/canvas/transformable-node.tsx @@ -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), 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 & Partial>>(defaultEditing); +type Gesture = { lease?: EditingLease; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number }; export function TransformableNode(props: NodeProps & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) { const { id, data, selected, width, height, children, className = "" } = props; @@ -54,9 +59,10 @@ export function TransformableNode(props: NodeProps & { 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) { @@ -64,18 +70,21 @@ export function TransformableNode(props: NodeProps & { 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) { - 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 & { 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 && 拖动八向手柄 · Shift 固定比例 · Esc 退出} diff --git a/src/components/workspace/canvas-runtime-host.tsx b/src/components/workspace/canvas-runtime-host.tsx new file mode 100644 index 0000000..36bb75b --- /dev/null +++ b/src/components/workspace/canvas-runtime-host.tsx @@ -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(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 {children}; +} +export function useCanvasRuntimeHost() { + const host = useContext(Context); + if (!host) throw new Error("CanvasRuntimeHost is missing"); + return host; +} diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx index 16d552e..c8a760d 100644 --- a/src/components/workspace/graph-page.tsx +++ b/src/components/workspace/graph-page.tsx @@ -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: () =>

正在准备画布…

}); -type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string }; +const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () =>

正在准备画布…

}); +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(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
- {displayBoard ? :
} + {displayBoard && current?.engine ? : displayBoard ? :
} {error ?
{error}
: !board &&

正在打开图谱…

}
; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 3eed285..77f17a6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 = { + 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: "登录已过期,请重新登录。", diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts new file mode 100644 index 0000000..5124ed3 --- /dev/null +++ b/src/lib/canvas-bindings/projection.ts @@ -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 & { type: NonNullable | 'unsupported' }; +const nodeTypes: Record> = { '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 | { 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([[canvasId, origin]]), hiddenBy = new Map(), childByEntry = new Map(); + for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id); + const members = new Map(); + 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(); + 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 }; +} diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index e96e330..e3c53c8 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -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 = { registry: Registry; document: Document; createId?: () => Id; historyLimit?: number; @@ -52,7 +52,12 @@ export function createEditor(options: EditorOptions 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(); + 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 }]; +} }); diff --git a/src/lib/canvas-core/gestures.ts b/src/lib/canvas-core/gestures.ts index 632e98c..aa7bdeb 100644 --- a/src/lib/canvas-core/gestures.ts +++ b/src/lib/canvas-core/gestures.ts @@ -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>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap }; -export type GestureState = { readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap; readonly phase: 'active' | 'committing' }; -export type SessionSnapshot = { readonly views: ReadonlyMap; readonly gesture: GestureState | null; readonly lastCancelReason: string | null }; +export type ViewState = { readonly id: Id; readonly canvasId: Id; readonly selection: ReturnType>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap }; +export type GestureState = { readonly token: number; readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap; 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; 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()), gesture: null, lastCancelReason: null }); - let baseline = new Map(), disposed = false; + let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map()), gesture: null, connectionGesture: null, lastCancelReason: null }); + let baseline = new Map(), 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([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map()) })); - 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([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map()) })); + 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([]), modes: readMap(new Map()) })); }, 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()), 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()), 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 { diff --git a/src/lib/canvas-core/types.ts b/src/lib/canvas-core/types.ts index c9d0204..69cf41e 100644 --- a/src/lib/canvas-core/types.ts +++ b/src/lib/canvas-core/types.ts @@ -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 } diff --git a/src/lib/canvas-entry.ts b/src/lib/canvas-entry.ts new file mode 100644 index 0000000..27e094d --- /dev/null +++ b/src/lib/canvas-entry.ts @@ -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(path, { signal: options.signal }), options.request('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 }; +} diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 913b57a..33dc894 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -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; 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): 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'); diff --git a/src/lib/canvas-runtime/host.ts b/src/lib/canvas-runtime/host.ts new file mode 100644 index 0000000..2a76998 --- /dev/null +++ b/src/lib/canvas-runtime/host.ts @@ -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(), retired = new Set(); + 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; diff --git a/src/lib/canvas-runtime/workspace.ts b/src/lib/canvas-runtime/workspace.ts new file mode 100644 index 0000000..725623b --- /dev/null +++ b/src/lib/canvas-runtime/workspace.ts @@ -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>; +export type WorkspaceOptions = { + accountId: Id; workspaceId: Id; authEpoch: number; registry: Registry; storage: StoragePort; + isCurrentIdentity(): boolean; + adapters(projectId: Id, current: () => boolean): Pick; + 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>(); + let accepting = true, closed = false, closing: Promise | 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) { + 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; diff --git a/tests/headless/bindings.test.mjs b/tests/headless/bindings.test.mjs new file mode 100644 index 0000000..2f3dc40 --- /dev/null +++ b/tests/headless/bindings.test.mjs @@ -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(); +}); diff --git a/tests/headless/engine.test.mjs b/tests/headless/engine.test.mjs index e7182f9..880af38 100644 --- a/tests/headless/engine.test.mjs +++ b/tests/headless/engine.test.mjs @@ -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(); +}); diff --git a/tests/headless/entry.test.mjs b/tests/headless/entry.test.mjs new file mode 100644 index 0000000..f37e5c4 --- /dev/null +++ b/tests/headless/entry.test.mjs @@ -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); +}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs new file mode 100644 index 0000000..973a078 --- /dev/null +++ b/tests/headless/renderer.integration.mjs @@ -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 }); +}