diff --git a/docs/plugins-v1.3-impl-notes.md b/docs/plugins-v1.3-impl-notes.md index 2cf72c7..686b80a 100644 --- a/docs/plugins-v1.3-impl-notes.md +++ b/docs/plugins-v1.3-impl-notes.md @@ -105,3 +105,99 @@ for required reasons (the `noteser-graph` plugin track was NOT touched): - L2: `onWheel`, host-owned pan/zoom, `surface.transform`. - L3: `onPointerEnter` / `onPointerLeave` hover events. - L4: `worker:patchSvgPositions` position-patch fast path. + +## L2 + L3 + L4 — wheel, host pan/zoom, hover, position-patch channel + +PR branch: `feat/plugins-v1.3-wheel-hover-patch`. Shipped together +because all three layers edit the same platform files (`PluginVNode.tsx`, +`PluginHost.ts`, `protocol.ts`, the surface adapters) and splitting them +would have meant three conflicting PRs over the same code. The +`noteser-graph` plugin (G2/G3/G4) is a separate PR and was NOT touched. + +### What shipped + +- **L2 wheel** (`PluginVNode.tsx`). `WheelHandlers { onWheel }` on + `VNodeSvg` + `VNodeBox`. `WheelEventPayload { deltaX, deltaY, x, y, + ctrlKey }`; x/y use the same surface mapping as pointer (svg user-space + via inverse CTM, box element-local). Wheel is high-frequency → rides + L1's coalescing, gated on `interaction.wheel`. +- **L2 host-owned pan/zoom** (`PluginVNode.tsx`). `VNodeSvg.panZoom: + 'host'`. The host owns the viewBox: drag-pan (surface-level pointer) + and wheel-zoom mutate the rendered `` viewBox attribute DIRECTLY + via a ref (no worker round-trip, no React re-render), and on settle + (pointerup, or a 150ms wheel-idle debounce) emit exactly ONE + `surface.transform` event `{ x, y, scale }`. +- **L3 hover** (`PluginVNode.tsx`). `onPointerEnter` / `onPointerLeave` + added to `PointerHandlers` (so they land on circle, rect, svg, box). + `HoverEventPayload { target, x, y }`. High-frequency → coalesced, + gated on `interaction.hover`. +- **L4 position-patch** (`protocol.ts`, `PluginHost.ts`, + `svgPositionPatch.ts`, surface adapters, `workerEntry.ts`, `sdk.ts`). + New worker→host envelope `worker:patchSvgPositions { viewId?, panelId?, + patches: {id,x,y}[] }` in the `WorkerToHost` union + `isWorkerToHost`. + Host sanitises + emits a `svgPositionsPatch` PluginHostEvent; the + surface adapters apply patches to the mounted svg by mutating + `cx`/`cy` on circles + the matching endpoint of connected edge lines, + with no React re-render. New SDK method `ctx.patchSvgPositions(...)`. + +### Deviations / decisions + +1. **Per-kind HF gating, not a single pointer flag.** L1's + `surfaceHasPointerInteraction` only checked `interaction.pointer`, and + the wire event name is plugin-opaque so the host cannot tell pointer + from wheel from hover by name. Resolved by adding `interaction?: + 'pointer'|'wheel'|'hover'` (`InteractionKind`, defined in + `protocol.ts`) to `PluginVNodeEvent` and to the `sendVNodeEvent` + options. The renderer tags each HF dispatch with its kind; the host's + renamed `surfaceHasInteraction(manifest, source, kind)` checks the + matching sub-flag. An HF event with no kind defaults to `'pointer'`, + so L1's existing dispatches + tests are unchanged. The three surface + adapters forward the new `interaction` field alongside + `highFrequency`. +2. **`surface.transform` is a reserved event NAME on the existing + `host:vnodeEvent` envelope, not a new envelope** (per plan 2.8). + Constant `SURFACE_TRANSFORM_EVENT` lives in `protocol.ts`. The + RENDERER emits it (host-owned pan/zoom is host logic), not the plugin; + it is discrete (one per settle), so it bypasses coalescing and never + carries `highFrequency`. +3. **Wheel listeners are bound manually as non-passive** rather than via + React's `onWheel`. React registers wheel passive and cannot + `preventDefault`, so any surface that interprets the wheel (a plugin + `onWheel` or host pan/zoom) needs a manual `addEventListener('wheel', + …, { passive: false })`. This forced `renderSvg` and the wheel-bearing + `renderBox` to render as real components (`PluginSvg` / `PluginBox`) + so they can hold a ref + effect. A plain svg/box (no wheel, no + panZoom) keeps the exact v1.2/L1 path and attaches no wheel listener + — non-interactive surfaces still scroll the page normally. +4. **Host pan only starts on the svg background** (`e.target === + e.currentTarget`), so grabbing a draggable child circle pans nothing + — node-drag and pan coexist. While `panZoom: 'host'` is active the + svg's own surface-level pointer handlers are NOT forwarded to the + plugin (the host consumes them for panning); child-shape handlers + still fire. `touchAction: none` is set so touch-drag pans instead of + scrolling. +5. **Edge lines opt into the patch path via `sourceId` / `targetId`** on + the `line` SvgChild — the renderer stamps `data-edge-source` / + `data-edge-target` (and `data-node-id` on circles). The patch helper + (`svgPositionPatch.ts`) builds the id→element map by WALKING those + `data-*` attributes, never by feeding a plugin-controlled id into a + selector, so a hostile node id cannot inject a querySelector. The map + is rebuilt per patch batch, which is inherently "refreshed when the + tree re-renders." +6. **Oversized patch rejection reuses the existing envelope-size guard.** + `worker:patchSvgPositions` is subject to `MAX_ENVELOPE_BYTES` like + every other message (the size check in `handleWorkerMessage` runs + before the type switch), so an oversized batch is rejected with the + generic "Envelope too large" workerError. The host additionally + sanitises the patch shape (drops non-string ids / non-finite coords) + before emitting the event. + +### Files touched outside `src/plugins/` + `src/components/plugins/` + +- `src/components/sidebar/PluginsPanel.tsx` and + `src/components/editor/PluginCodeBlock.tsx` — forward the new + `interaction` field into `sendVNodeEvent` (mirror of L1's + `highFrequency` forward). `PluginsPanel` also applies L4 position + patches to the matching panel section via `applySvgPositionPatches`. + (`PluginFullscreenView`, the primary interactive surface, IS in + `src/components/plugins/` and gained the same patch wiring.) diff --git a/src/components/editor/PluginCodeBlock.tsx b/src/components/editor/PluginCodeBlock.tsx index dcd644f..fba3860 100644 --- a/src/components/editor/PluginCodeBlock.tsx +++ b/src/components/editor/PluginCodeBlock.tsx @@ -58,6 +58,7 @@ export const PluginCodeBlock = ({ pluginId, language, source }: Props) => { if (!host) return host.sendVNodeEvent(pluginId, { kind: 'codeBlock', blockId }, e.event, e.payload, { highFrequency: e.highFrequency === true, + ...(e.interaction ? { interaction: e.interaction } : {}), }) }, [pluginId, blockId], diff --git a/src/components/plugins/PluginFullscreenView.tsx b/src/components/plugins/PluginFullscreenView.tsx index 5bafa4e..5536787 100644 --- a/src/components/plugins/PluginFullscreenView.tsx +++ b/src/components/plugins/PluginFullscreenView.tsx @@ -36,6 +36,7 @@ import { getPluginHost, } from '@/plugins/pluginHostSingleton' import { PluginNode, type PluginVNodeEvent } from '@/plugins/PluginVNode' +import { applySvgPositionPatches } from '@/plugins/svgPositionPatch' // Same selector approach as Modal.tsx — keep the trap inline rather // than pulling focus-trap-react for one more mount point. @@ -61,12 +62,32 @@ const getFocusable = (root: HTMLElement): HTMLElement[] => export const PluginFullscreenView = () => { const active = usePluginStore((s) => s.activeFullscreen) const dialogRef = useRef(null) + const bodyRef = useRef(null) const previouslyFocusedRef = useRef(null) // Track the active descriptor's identity (pluginId + viewId) so we // only re-run focus snapshot logic when the mounted view actually // changes, not on every content update. const activeKey = active ? `${active.pluginId}:${active.viewId}` : null + // v1.3 (L4) — position-patch fast path. Subscribe to the host's + // `svgPositionsPatch` events for the open view and apply them straight + // to the mounted svg via direct DOM mutation, never re-rendering the + // VNode tree. Scoped by pluginId + (optional) viewId so a patch for a + // different surface is ignored. + const activePluginId = active?.pluginId ?? null + const activeViewId = active?.viewId ?? null + useEffect(() => { + if (!activePluginId) return + const host = getPluginHost() + if (!host) return + return host.on((event) => { + if (event.type !== 'svgPositionsPatch') return + if (event.pluginId !== activePluginId) return + if (event.viewId !== undefined && event.viewId !== activeViewId) return + applySvgPositionPatches(bodyRef.current, event.patches) + }) + }, [activePluginId, activeViewId]) + // Esc handler. Capture phase per plan section 3.1 so a plugin's // own listeners on a rendered control cannot swallow Esc. useEffect(() => { @@ -177,7 +198,7 @@ export const PluginFullscreenView = () => { { kind: 'fullscreen', viewId: active.viewId }, e.event, e.payload, - { highFrequency: e.highFrequency === true }, + { highFrequency: e.highFrequency === true, ...(e.interaction ? { interaction: e.interaction } : {}) }, ) } @@ -219,6 +240,7 @@ export const PluginFullscreenView = () => {
diff --git a/src/components/sidebar/PluginsPanel.tsx b/src/components/sidebar/PluginsPanel.tsx index 05b866f..e490fe1 100644 --- a/src/components/sidebar/PluginsPanel.tsx +++ b/src/components/sidebar/PluginsPanel.tsx @@ -18,10 +18,11 @@ // in the real VNode → React mapper (see docs/plugins-plan.md // "VNode" section). -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { usePluginStore, selectAllPluginPanels, type PluginPanelEntry } from '@/stores/pluginStore' import { getPluginHost } from '@/plugins/pluginHostSingleton' import { PluginNode, type PluginVNodeEvent } from '@/plugins/PluginVNode' +import { applySvgPositionPatches } from '@/plugins/svgPositionPatch' import type { PluginHostEvent } from '@/plugins/PluginHost' export const PluginsPanel = () => { @@ -32,14 +33,31 @@ export const PluginsPanel = () => { // emits panelContent for a panel that lives in this tab. const [contents, setContents] = useState>({}) + // v1.3 (L4) — per-panel container element, used by the position-patch + // fast path to mutate the mounted svg directly (no React re-render). + const sectionRefs = useRef>(new Map()) + useEffect(() => { const host = getPluginHost() if (!host) return const handler = (event: PluginHostEvent) => { - if (event.type !== 'panelContent') return - const key = `${event.pluginId}:${event.panelId}` - setContents((prev) => ({ ...prev, [key]: event.node })) + if (event.type === 'panelContent') { + const key = `${event.pluginId}:${event.panelId}` + setContents((prev) => ({ ...prev, [key]: event.node })) + return + } + if (event.type === 'svgPositionsPatch') { + // Apply to the matching panel section(s). A patch with an + // explicit panelId targets that one; without it, every panel of + // the emitting plugin. + for (const p of panels) { + if (p.pluginId !== event.pluginId) continue + if (event.panelId !== undefined && p.panelId !== event.panelId) continue + applySvgPositionPatches(sectionRefs.current.get(`${p.pluginId}:${p.panelId}`), event.patches) + } + return + } } const unsubscribe = host.on(handler) @@ -71,6 +89,7 @@ export const PluginsPanel = () => { if (!host) return host.sendVNodeEvent(pluginId, { kind: 'panel', panelId }, e.event, e.payload, { highFrequency: e.highFrequency === true, + ...(e.interaction ? { interaction: e.interaction } : {}), }) }, [], @@ -101,7 +120,12 @@ export const PluginsPanel = () => { {p.pluginName} -
+
{ + sectionRefs.current.set(key, el) + }} + className="px-3 py-2 text-sm text-obsidianText whitespace-pre-wrap break-words" + > {node === undefined ? ( (awaiting first render…) ) : ( diff --git a/src/plugins/PluginHost.ts b/src/plugins/PluginHost.ts index 94bce56..1ba80b1 100644 Binary files a/src/plugins/PluginHost.ts and b/src/plugins/PluginHost.ts differ diff --git a/src/plugins/PluginVNode.tsx b/src/plugins/PluginVNode.tsx index cbe490e..5404562 100644 --- a/src/plugins/PluginVNode.tsx +++ b/src/plugins/PluginVNode.tsx @@ -37,7 +37,10 @@ import type { PointerEvent as ReactPointerEvent, ReactNode, } from 'react' +import { useEffect, useRef } from 'react' import { useWorkspaceStore } from '@/stores/workspaceStore' +import { SURFACE_TRANSFORM_EVENT, type InteractionKind } from './protocol' +import { NODE_ID_ATTR, EDGE_SOURCE_ATTR, EDGE_TARGET_ATTR } from './svgPositionPatch' // ─── v1 shapes (unchanged) ──────────────────────────────────────────────── @@ -83,13 +86,27 @@ export interface VNodeEvent { // `onPointerEnter` / `onPointerLeave` (L3) are deliberately NOT part of // this PR. -/** Pointer handler subset shipped in v1.3 L1. `onPointerMove` is - * high-frequency — the host rAF-coalesces it and draws it from a - * separate budget; `onPointerDown` / `onPointerUp` are discrete. */ +/** Pointer + hover handler set. `onPointerMove` (L1) and + * `onPointerEnter` / `onPointerLeave` (L3) are high-frequency — the + * host rAF-coalesces them and draws them from a separate budget gated + * on the matching manifest `interaction` sub-flag (`pointer` for move, + * `hover` for enter/leave); `onPointerDown` / `onPointerUp` are + * discrete. A listener is attached only when its prop is present. */ export interface PointerHandlers { onPointerDown?: VNodeEvent onPointerMove?: VNodeEvent onPointerUp?: VNodeEvent + /** v1.3 (L3) — high-frequency, coalesced, gated on `interaction.hover`. */ + onPointerEnter?: VNodeEvent + /** v1.3 (L3) — high-frequency, coalesced, gated on `interaction.hover`. */ + onPointerLeave?: VNodeEvent +} + +/** v1.3 (L2) — wheel handler. `onWheel` is high-frequency: the host + * rAF-coalesces it and draws it from the separate budget, gated on + * `interaction.wheel`. Lives on `VNodeSvg` + `VNodeBox` only. */ +export interface WheelHandlers { + onWheel?: VNodeEvent } /** @@ -113,6 +130,35 @@ export interface PointerEventPayload { target: string | null } +/** + * v1.3 (L2) — payload the host augments onto a wheel event. `x` / `y` + * are the focal point in the same coordinate space as pointer events + * (SVG user-space for svg surfaces via the inverse screen CTM, + * element-local pixels for box surfaces). `ctrlKey` is true for a + * trackpad pinch (the browser reports pinch as ctrl+wheel). Host keys + * win the shallow merge, so a plugin payload cannot spoof the deltas / + * coords. */ +export interface WheelEventPayload { + deltaX: number + deltaY: number + x: number + y: number + ctrlKey: boolean +} + +/** + * v1.3 (L3) — payload the host augments onto a hover enter/leave event. + * `target` is the opt-in `id` on the owning shape/svg/box (or null); + * `x` / `y` are the pointer position in the surface coordinate space. + * No DOM ref crosses the wire. */ +export interface HoverEventPayload { + target: string | null + x: number + y: number +} + +export type { InteractionKind } + /** * Wire-level event message emitted by the renderer when a plugin's * control (button / input / radio / clickable svg shape) fires. This @@ -140,6 +186,14 @@ export interface PluginVNodeEvent { * The host treats an unset flag as a normal discrete event. */ highFrequency?: boolean + /** + * v1.3 (L2/L3) — which `interaction` sub-flag this high-frequency + * event belongs to, so the host gates it on the right manifest opt-in + * (`pointer` for move, `wheel` for wheel, `hover` for enter/leave). + * Unset on discrete events; the host defaults a high-frequency event + * with no kind to `'pointer'` (L1 behaviour). + */ + interaction?: InteractionKind } // ─── v1.2 new shapes ────────────────────────────────────────────────────── @@ -203,7 +257,22 @@ export interface VNodeRadio { * any other tag and emits nothing. */ export type SvgChild = - | { tag: 'line'; x1: number; y1: number; x2: number; y2: number; stroke?: string; strokeWidth?: number } + | { + tag: 'line' + x1: number + y1: number + x2: number + y2: number + stroke?: string + strokeWidth?: number + /** v1.3 (L4) — node id this edge's first endpoint (`x1`/`y1`) + * follows. The position-patch fast path moves the endpoint when a + * patch for this id arrives, without a full re-render. */ + sourceId?: string + /** v1.3 (L4) — node id this edge's second endpoint (`x2`/`y2`) + * follows. */ + targetId?: string + } | ({ tag: 'circle' cx: number @@ -230,7 +299,7 @@ export type SvgChild = | { tag: 'text'; x: number; y: number; value: string; fontSize?: number; fill?: string } | { tag: 'path'; d: string; stroke?: string; fill?: string; strokeWidth?: number } -export interface VNodeSvg extends PointerHandlers { +export interface VNodeSvg extends PointerHandlers, WheelHandlers { tag: 'svg' width: number height: number @@ -238,10 +307,22 @@ export interface VNodeSvg extends PointerHandlers { /** v1.3 (L1) — surface-level id, echoed as `payload.target` on * pointer events that fire on the svg element itself. */ id?: string + /** + * v1.3 (L2) — when set to `'host'`, the host OWNS the viewBox + * transform locally. It interprets surface-level drag (pan) and wheel + * (zoom) into a viewBox applied directly to the rendered `` with + * NO worker round-trip (instant 60fps), and emits exactly one + * `surface.transform` event (`{ x, y, scale }`) on gesture settle so + * the plugin can persist + sync its viewport. While host pan/zoom is + * active the svg's own surface-level pointer/wheel handlers are NOT + * forwarded to the plugin (the host consumes them); child-shape + * handlers (e.g. a draggable circle) still fire normally. + */ + panZoom?: 'host' children: ReadonlyArray } -export interface VNodeBox extends PointerHandlers { +export interface VNodeBox extends PointerHandlers, WheelHandlers { tag: 'box' children: ReadonlyArray /** Gap between children, mapped to tailwind spacing. */ @@ -516,7 +597,71 @@ function dispatchPointer( typeof evt.payload === 'object' && evt.payload !== null ? { ...(evt.payload as Record), ...hostKeys } : hostKeys - ctx.onEvent({ event: evt.event, payload, ...(opts.isMove ? { highFrequency: true } : {}) }) + ctx.onEvent({ + event: evt.event, + payload, + ...(opts.isMove ? { highFrequency: true, interaction: 'pointer' as const } : {}), + }) +} + +/** + * v1.3 (L3) — dispatch a hover enter/leave event. High-frequency: the + * host coalesces it and gates it on `interaction.hover`. Payload is the + * host-controlled `HoverEventPayload` shallow-merged over the plugin + * payload (host keys win). Coordinates use the same surface mapping as + * pointer events. + */ +function dispatchHover( + ctx: RenderContext, + el: Element, + evt: VNodeEvent | undefined, + e: ReactPointerEvent, + opts: { id: string | undefined; coordKind: PointerCoordKind }, +): void { + if (!ctx.onEvent) return + if (!evt || evt.kind !== 'emit' || typeof evt.event !== 'string' || evt.event.length === 0) return + const { x, y } = pointerSurfacePoint(el, opts.coordKind, e.clientX, e.clientY) + const hostKeys: HoverEventPayload = { + target: typeof opts.id === 'string' ? opts.id : null, + x, + y, + } + const payload = + typeof evt.payload === 'object' && evt.payload !== null + ? { ...(evt.payload as Record), ...hostKeys } + : hostKeys + ctx.onEvent({ event: evt.event, payload, highFrequency: true, interaction: 'hover' }) +} + +/** + * v1.3 (L2) — dispatch a wheel event. High-frequency: the host + * coalesces it and gates it on `interaction.wheel`. Payload is the + * host-controlled `WheelEventPayload` (deltas + focal coords + ctrlKey) + * shallow-merged over the plugin payload (host keys win). `x` / `y` use + * the same surface coordinate mapping as pointer events. + */ +function dispatchWheel( + ctx: RenderContext, + el: Element, + evt: VNodeEvent | undefined, + e: WheelEvent, + opts: { coordKind: PointerCoordKind }, +): void { + if (!ctx.onEvent) return + if (!evt || evt.kind !== 'emit' || typeof evt.event !== 'string' || evt.event.length === 0) return + const { x, y } = pointerSurfacePoint(el, opts.coordKind, e.clientX, e.clientY) + const hostKeys: WheelEventPayload = { + deltaX: e.deltaX, + deltaY: e.deltaY, + x, + y, + ctrlKey: e.ctrlKey === true, + } + const payload = + typeof evt.payload === 'object' && evt.payload !== null + ? { ...(evt.payload as Record), ...hostKeys } + : hostKeys + ctx.onEvent({ event: evt.event, payload, highFrequency: true, interaction: 'wheel' }) } /** @@ -537,6 +682,8 @@ function pointerListenerProps( onPointerDown?: (e: ReactPointerEvent) => void onPointerMove?: (e: ReactPointerEvent) => void onPointerUp?: (e: ReactPointerEvent) => void + onPointerEnter?: (e: ReactPointerEvent) => void + onPointerLeave?: (e: ReactPointerEvent) => void } { const captureOnDown = h.onPointerDown !== undefined && h.onPointerMove !== undefined return { @@ -583,9 +730,66 @@ function pointerListenerProps( }), } : {}), + ...(h.onPointerEnter !== undefined + ? { + onPointerEnter: (e: ReactPointerEvent) => + dispatchHover(ctx, e.currentTarget, h.onPointerEnter, e, { id, coordKind }), + } + : {}), + ...(h.onPointerLeave !== undefined + ? { + onPointerLeave: (e: ReactPointerEvent) => + dispatchHover(ctx, e.currentTarget, h.onPointerLeave, e, { id, coordKind }), + } + : {}), } } +// ─── v1.3 (L2) non-passive wheel binding + host-owned pan/zoom ───────────── + +/** + * Attach a non-passive `wheel` listener to `ref.current` so the handler + * can `preventDefault()` (stop page scroll). React's synthetic `onWheel` + * is registered passive and cannot cancel the default, so any surface + * that interprets the wheel (a plugin `onWheel` handler, or host-owned + * pan/zoom) must bind manually. The handler is read through a ref so the + * listener is bound once and never needs re-attaching. No listener is + * attached at all when `enabled` is false — a surface that did not opt + * into wheel keeps the browser's default passive scroll. + */ +function useNonPassiveWheel( + ref: { current: Element | null }, + handler: (e: WheelEvent) => void, + enabled: boolean, +): void { + const handlerRef = useRef(handler) + handlerRef.current = handler + useEffect(() => { + const el = ref.current + if (!el || !enabled) return + const listener = ((e: Event) => handlerRef.current(e as WheelEvent)) as EventListener + el.addEventListener('wheel', listener, { passive: false }) + return () => el.removeEventListener('wheel', listener) + // `enabled` toggling re-binds; `ref` is stable across renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled]) +} + +/** Current host-owned viewBox transform. `[minX, minY, w, h]` is the + * live viewBox; `baseW` is the initial width used to derive `scale` + * (`scale = baseW / w`) for the settle event. */ +interface PanZoomState { + minX: number + minY: number + w: number + h: number + baseW: number +} + +const WHEEL_ZOOM_STEP = 1.0015 +const MIN_ZOOM_SCALE = 0.05 +const MAX_ZOOM_SCALE = 40 + // ─── Renderer ───────────────────────────────────────────────────────────── /** @@ -825,27 +1029,216 @@ function renderRadio(v: VNodeRadio, ctx: RenderContext): ReactNode | null { ) } +/** Parse the numeric viewBox, defaulting to `[0, 0, width, height]` + * when the plugin omitted it or supplied a malformed one. */ +function resolveViewBox( + v: VNodeSvg, + width: number, + height: number, +): [number, number, number, number] { + if (Array.isArray(v.viewBox) && v.viewBox.length === 4) { + const parts = v.viewBox.map((n) => coerceFinite(n)) + if (parts.every((n): n is number => n !== null)) { + return parts as [number, number, number, number] + } + } + return [0, 0, width, height] +} + function renderSvg(v: VNodeSvg, ctx: RenderContext): ReactNode | null { const width = coerceFinite(v.width) const height = coerceFinite(v.height) if (width === null || height === null) return null if (!Array.isArray(v.children)) return null - let viewBox: string | undefined - if (Array.isArray(v.viewBox) && v.viewBox.length === 4) { - const parts = v.viewBox.map((n) => coerceFinite(n)) - if (parts.every((n): n is number => n !== null)) { - viewBox = parts.join(' ') + // The svg surface owns hooks (non-passive wheel + host pan/zoom state), + // so it must be a real component, not inline JSX. Keyed by id so a + // changed surface id remounts (and the pan/zoom state resets). + return +} + +/** + * v1.3 — svg surface component. Renders the curated `` plus its + * children, and (when opted in) wires the L2 wheel + host-owned pan/zoom + * paths. Plain (no wheel / no panZoom) svg surfaces behave exactly as + * v1.2/L1: pointer + hover listeners via React props, no wheel listener, + * no extra DOM work. + */ +function PluginSvg({ + v, + width, + height, + ctx, +}: { + v: VNodeSvg + width: number + height: number + ctx: RenderContext +}): ReactNode { + const svgRef = useRef(null) + const hostPanZoom = v.panZoom === 'host' + const hasPluginWheel = v.onWheel !== undefined + // A wheel listener is needed for either the plugin's own onWheel or + // host pan/zoom. Both need non-passive binding (preventDefault). + const wheelEnabled = hostPanZoom || hasPluginWheel + + const [vbMinX, vbMinY, vbW, vbH] = resolveViewBox(v, width, height) + const initialViewBox = `${vbMinX} ${vbMinY} ${vbW} ${vbH}` + + // Live host-owned transform. Initialised from the rendered viewBox the + // first time the svg mounts; mutated DIRECTLY on the DOM during a + // gesture so pan/zoom never round-trips through the worker or a React + // re-render. + const pzRef = useRef({ minX: vbMinX, minY: vbMinY, w: vbW, h: vbH, baseW: vbW }) + const dragRef = useRef<{ + pointerId: number + startClientX: number + startClientY: number + startMinX: number + startMinY: number + } | null>(null) + const wheelIdleRef = useRef | null>(null) + + const applyViewBox = (): void => { + const el = svgRef.current + const pz = pzRef.current + if (el) el.setAttribute('viewBox', `${pz.minX} ${pz.minY} ${pz.w} ${pz.h}`) + } + + const emitTransform = (): void => { + if (!ctx.onEvent) return + const pz = pzRef.current + const scale = pz.w !== 0 ? pz.baseW / pz.w : 1 + // ONE discrete settle event carrying the final viewport. Reserved + // host event name; rides the existing host:vnodeEvent envelope. + ctx.onEvent({ + event: SURFACE_TRANSFORM_EVENT, + payload: { x: pz.minX, y: pz.minY, scale }, + }) + } + + const onWheel = (e: WheelEvent): void => { + if (hostPanZoom) { + // Host owns the zoom — never let it scroll the page, and never + // forward it to the plugin (the settle event covers persistence). + e.preventDefault() + const pz = pzRef.current + const focal = pointerSurfacePoint( + svgRef.current as unknown as Element, + 'svg', + e.clientX, + e.clientY, + ) + const zoomFactor = Math.pow(WHEEL_ZOOM_STEP, -e.deltaY) + let newW = pz.w / zoomFactor + // Clamp on absolute scale so a fling cannot invert or explode. + const minW = pz.baseW / MAX_ZOOM_SCALE + const maxW = pz.baseW / MIN_ZOOM_SCALE + newW = Math.min(Math.max(newW, minW), maxW) + const ratio = newW / pz.w + const newH = pz.h * ratio + pz.minX = focal.x - (focal.x - pz.minX) * ratio + pz.minY = focal.y - (focal.y - pz.minY) * ratio + pz.w = newW + pz.h = newH + applyViewBox() + // Debounce the settle event — emit ONE transform after the wheel + // goes idle, not one per notch. + if (wheelIdleRef.current !== null) clearTimeout(wheelIdleRef.current) + wheelIdleRef.current = setTimeout(() => { + wheelIdleRef.current = null + emitTransform() + }, 150) + return + } + // Plugin-owned wheel handler: stop page scroll, dispatch the event + // (coalesced + gated on interaction.wheel host-side). + if (hasPluginWheel) { + e.preventDefault() + dispatchWheel(ctx, svgRef.current as unknown as Element, v.onWheel, e, { coordKind: 'svg' }) } } + + useNonPassiveWheel(svgRef as { current: Element | null }, onWheel, wheelEnabled) + + // Clean up a pending wheel-idle timer on unmount so a settle event + // does not fire into a torn-down surface. + useEffect(() => { + return () => { + if (wheelIdleRef.current !== null) clearTimeout(wheelIdleRef.current) + } + }, []) + const id = typeof v.id === 'string' ? v.id : undefined - const pointerProps = pointerListenerProps(ctx, v, id, 'svg') + + // Surface-level pointer/hover props. When the host owns pan/zoom it + // consumes the surface-level pointer for panning, so the plugin's own + // surface-level pointer handlers are NOT wired (child shapes still + // fire their own handlers normally). + const pointerProps = hostPanZoom + ? buildPanHandlers() + : pointerListenerProps(ctx, v, id, 'svg') + + function buildPanHandlers() { + return { + onPointerDown: (e: ReactPointerEvent) => { + // Only pan when the gesture starts on the svg background, not on + // a child shape that owns its own drag. + if (e.target !== e.currentTarget) return + const pz = pzRef.current + dragRef.current = { + pointerId: e.pointerId, + startClientX: e.clientX, + startClientY: e.clientY, + startMinX: pz.minX, + startMinY: pz.minY, + } + try { + ;(e.currentTarget as Element & { + setPointerCapture?: (id: number) => void + }).setPointerCapture?.(e.pointerId) + } catch { + // best-effort capture + } + }, + onPointerMove: (e: ReactPointerEvent) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== e.pointerId) return + const el = svgRef.current + const pz = pzRef.current + const clientW = el?.clientWidth || width + const clientH = el?.clientHeight || height + const userPerPxX = clientW !== 0 ? pz.w / clientW : 1 + const userPerPxY = clientH !== 0 ? pz.h / clientH : 1 + pz.minX = drag.startMinX - (e.clientX - drag.startClientX) * userPerPxX + pz.minY = drag.startMinY - (e.clientY - drag.startClientY) * userPerPxY + applyViewBox() + }, + onPointerUp: (e: ReactPointerEvent) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== e.pointerId) return + dragRef.current = null + try { + ;(e.currentTarget as Element & { + releasePointerCapture?: (id: number) => void + }).releasePointerCapture?.(e.pointerId) + } catch { + // best-effort + } + // ONE settle event at the end of the pan gesture. + emitTransform() + }, + } + } + return ( {v.children.map((child, idx) => renderSvgChild(child, idx, ctx))} @@ -866,7 +1259,23 @@ function renderSvgChild(child: SvgChild | unknown, key: number, ctx: RenderConte if (x1 === null || y1 === null || x2 === null || y2 === null) return null const stroke = safeColor(c.stroke) ?? 'currentColor' const strokeWidth = coerceFinite(c.strokeWidth) ?? 1 - return + // v1.3 (L4) — tag edge endpoints with the node ids they follow so + // the position-patch fast path can move them without a re-render. + const sourceId = typeof c.sourceId === 'string' ? c.sourceId : undefined + const targetId = typeof c.targetId === 'string' ? c.targetId : undefined + return ( + + ) } if (tag === 'circle') { @@ -892,6 +1301,8 @@ function renderSvgChild(child: SvgChild | unknown, key: number, ctx: RenderConte r={r} fill={fill} stroke={stroke} + // v1.3 (L4) — addressable by node id for the position-patch path. + {...(id !== undefined ? { [NODE_ID_ATTR]: id } : {})} onClick={c.onClick ? () => dispatchOrDrop(ctx, c.onClick) : undefined} {...pointerProps} style={interactive ? ({ cursor: 'pointer' } satisfies CSSProperties) : undefined} @@ -962,6 +1373,12 @@ function renderSvgChild(child: SvgChild | unknown, key: number, ctx: RenderConte function renderBox(v: VNodeBox, ctx: RenderContext): ReactNode | null { if (!Array.isArray(v.children)) return null if (ctx.depth + 1 > MAX_LIST_DEPTH) return null + // A box with an onWheel handler needs a non-passive wheel listener + // (preventDefault), which requires a ref + effect — so it renders as a + // component. A box with no wheel handler keeps the plain v1.2/L1 path. + if (v.onWheel !== undefined) { + return + } const childCtx: RenderContext = { onEvent: ctx.onEvent, depth: ctx.depth + 1 } const gap = v.gap !== undefined && v.gap in GAP_CLASSES ? GAP_CLASSES[v.gap] : 'gap-2' const id = typeof v.id === 'string' ? v.id : undefined @@ -977,6 +1394,33 @@ function renderBox(v: VNodeBox, ctx: RenderContext): ReactNode | null { ) } +/** v1.3 (L2) — box surface with a wheel handler. Same render as the + * plain box, plus a non-passive wheel listener that stops page scroll + * and dispatches the coalesced, `interaction.wheel`-gated event. */ +function PluginBox({ v, ctx }: { v: VNodeBox; ctx: RenderContext }): ReactNode { + const boxRef = useRef(null) + const childCtx: RenderContext = { onEvent: ctx.onEvent, depth: ctx.depth + 1 } + const gap = v.gap !== undefined && v.gap in GAP_CLASSES ? GAP_CLASSES[v.gap] : 'gap-2' + const id = typeof v.id === 'string' ? v.id : undefined + const pointerProps = pointerListenerProps(ctx, v, id, 'box') + + const onWheel = (e: WheelEvent): void => { + e.preventDefault() + dispatchWheel(ctx, boxRef.current as unknown as Element, v.onWheel, e, { coordKind: 'box' }) + } + useNonPassiveWheel(boxRef as { current: Element | null }, onWheel, true) + + return ( +
+ {v.children.map((child, idx) => { + const rendered = renderWithContext(child, childCtx) + if (rendered === null) return null + return
{rendered}
+ })} +
+ ) +} + /** * Render-or-fallback. Unrecognised shapes show a JSON dump in dev so * plugin authors can spot a typo, and the same dump in prod (we do diff --git a/src/plugins/__tests__/hostPanZoom.test.tsx b/src/plugins/__tests__/hostPanZoom.test.tsx new file mode 100644 index 0000000..bd72a68 --- /dev/null +++ b/src/plugins/__tests__/hostPanZoom.test.tsx @@ -0,0 +1,123 @@ +/** + * v1.3 (L2) — host-owned pan/zoom (`VNodeSvg.panZoom: 'host'`). + * + * The host owns the viewBox transform locally: a drag-pan or wheel-zoom + * mutates the rendered `` viewBox with NO worker round-trip, and on + * gesture settle (pointerup, or a wheel-idle debounce) it emits exactly + * ONE `surface.transform` event carrying the final `{ x, y, scale }`. + */ + +import { render, fireEvent, act } from '@testing-library/react' +import { PluginNode, type PluginVNodeEvent, type VNode } from '../PluginVNode' +import { SURFACE_TRANSFORM_EVENT } from '../protocol' + +if (typeof (globalThis as { PointerEvent?: unknown }).PointerEvent !== 'function') { + class PointerEventPolyfill extends MouseEvent { + pointerId: number + constructor(type: string, params: PointerEventInit = {}) { + super(type, params) + this.pointerId = params.pointerId ?? 0 + } + } + ;(globalThis as { PointerEvent?: unknown }).PointerEvent = + PointerEventPolyfill as unknown as typeof PointerEvent +} + +function renderPanZoom() { + const events: PluginVNodeEvent[] = [] + const { container } = render( + events.push(e)} + />, + ) + const svg = container.querySelector('svg')! + return { events, svg } +} + +const transforms = (events: PluginVNodeEvent[]) => + events.filter((e) => e.event === SURFACE_TRANSFORM_EVENT) + +describe('host-owned pan', () => { + test('drag pans the viewBox locally and emits ONE surface.transform on pointerup', () => { + const { events, svg } = renderPanZoom() + + // jsdom reports clientWidth/Height 0; the surface falls back to the + // declared width/height, so 1 user-unit per pixel here. + fireEvent.pointerDown(svg, { clientX: 10, clientY: 10, pointerId: 1, button: 0 }) + fireEvent.pointerMove(svg, { clientX: 30, clientY: 10, pointerId: 1 }) + + // No settle event mid-drag, but the viewBox already moved locally. + expect(transforms(events)).toHaveLength(0) + expect(svg.getAttribute('viewBox')).toBe('-20 0 200 100') + + fireEvent.pointerUp(svg, { clientX: 30, clientY: 10, pointerId: 1, button: 0 }) + const settled = transforms(events) + expect(settled).toHaveLength(1) + expect(settled[0].payload).toEqual({ x: -20, y: 0, scale: 1 }) + // settle events are discrete, never high-frequency. + expect(settled[0].highFrequency).toBeUndefined() + }) + + test('a pan starting on a child shape does not pan the surface', () => { + const { events, svg } = renderPanZoom() + const circle = svg.querySelector('circle')! + // target !== currentTarget (originates on the circle) → ignored. + fireEvent.pointerDown(svg, { target: circle, clientX: 10, clientY: 10, pointerId: 1, button: 0 }) + fireEvent.pointerMove(svg, { clientX: 50, clientY: 10, pointerId: 1 }) + fireEvent.pointerUp(svg, { clientX: 50, clientY: 10, pointerId: 1, button: 0 }) + expect(svg.getAttribute('viewBox')).toBe('0 0 200 100') + expect(transforms(events)).toHaveLength(0) + }) +}) + +describe('host-owned wheel zoom', () => { + beforeEach(() => jest.useFakeTimers()) + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) + + test('wheel zoom updates the viewBox locally and emits ONE transform after the idle debounce', () => { + const { events, svg } = renderPanZoom() + const before = svg.getAttribute('viewBox') + + // Several notches in one burst — page scroll prevented, viewBox + // shrinks (zoom in), but no settle event yet. + act(() => { + fireEvent.wheel(svg, { deltaY: -100, clientX: 0, clientY: 0 }) + fireEvent.wheel(svg, { deltaY: -100, clientX: 0, clientY: 0 }) + }) + expect(transforms(events)).toHaveLength(0) + const after = svg.getAttribute('viewBox') + expect(after).not.toBe(before) + + // After the wheel goes idle, exactly one settle event fires. + act(() => { + jest.advanceTimersByTime(150) + }) + const settled = transforms(events) + expect(settled).toHaveLength(1) + const payload = settled[0].payload as { x: number; y: number; scale: number } + // deltaY < 0 zooms IN → scale > 1. + expect(payload.scale).toBeGreaterThan(1) + }) + + test('wheel is non-passive — page scroll is prevented', () => { + const { svg } = renderPanZoom() + const evt = new WheelEvent('wheel', { deltaY: -50, bubbles: true, cancelable: true }) + act(() => { + svg.dispatchEvent(evt) + }) + expect(evt.defaultPrevented).toBe(true) + }) +}) diff --git a/src/plugins/__tests__/svgPositionPatch.test.tsx b/src/plugins/__tests__/svgPositionPatch.test.tsx new file mode 100644 index 0000000..044bf0a --- /dev/null +++ b/src/plugins/__tests__/svgPositionPatch.test.tsx @@ -0,0 +1,194 @@ +/** + * v1.3 (L4) — position-patch fast path. + * + * Covers: + * - sanitizeSvgPositionPatches drops malformed entries + * - applySvgPositionPatches mutates the mounted circle cx/cy and the + * edge endpoints WITHOUT remounting the React tree (same DOM element + * identity before/after) + * - the `worker:patchSvgPositions` envelope validates via + * isWorkerToHost and the host rejects an oversized batch + emits a + * clean svgPositionsPatch event for a valid one + */ + +import { render } from '@testing-library/react' +import { PluginNode, type VNode } from '../PluginVNode' +import { + sanitizeSvgPositionPatches, + applySvgPositionPatches, + NODE_ID_ATTR, +} from '../svgPositionPatch' +import { isWorkerToHost, MAX_ENVELOPE_BYTES, type WorkerToHost } from '../protocol' +import { PluginHost, type MinimalWorker, type PluginHostEvent } from '../PluginHost' +import type { PluginManifest } from '../manifest' + +describe('sanitizeSvgPositionPatches', () => { + test('keeps well-formed {id,x,y} and drops everything else', () => { + const out = sanitizeSvgPositionPatches([ + { id: 'a', x: 1, y: 2 }, + { id: '', x: 1, y: 2 }, // empty id + { id: 'b', x: NaN, y: 2 }, // non-finite + { id: 'c', x: 3, y: Infinity }, // non-finite + { id: 5, x: 1, y: 2 }, // non-string id + 'nope', + null, + { id: 'd', x: 9, y: 10 }, + ]) + expect(out).toEqual([ + { id: 'a', x: 1, y: 2 }, + { id: 'd', x: 9, y: 10 }, + ]) + }) + + test('returns [] for a non-array', () => { + expect(sanitizeSvgPositionPatches({})).toEqual([]) + expect(sanitizeSvgPositionPatches(undefined)).toEqual([]) + }) +}) + +describe('applySvgPositionPatches — DOM mutation without re-render', () => { + test('moves the circle and the connected edge endpoints, keeping element identity', () => { + const { container } = render( + {}} + />, + ) + const circleBefore = container.querySelector(`[${NODE_ID_ATTR}="n1"]`)! + const line = container.querySelector('line')! + expect(circleBefore.getAttribute('cx')).toBe('0') + + const moved = applySvgPositionPatches(container, [ + { id: 'n1', x: 33, y: 44 }, + { id: 'n2', x: 99, y: 88 }, + ]) + expect(moved).toBe(2) + + // Same element instance — the React tree did NOT remount. + const circleAfter = container.querySelector(`[${NODE_ID_ATTR}="n1"]`)! + expect(circleAfter).toBe(circleBefore) + + expect(circleAfter.getAttribute('cx')).toBe('33') + expect(circleAfter.getAttribute('cy')).toBe('44') + // edge source endpoint follows n1, target endpoint follows n2. + expect(line.getAttribute('x1')).toBe('33') + expect(line.getAttribute('y1')).toBe('44') + expect(line.getAttribute('x2')).toBe('99') + expect(line.getAttribute('y2')).toBe('88') + }) + + test('no-op on null root or empty patches', () => { + expect(applySvgPositionPatches(null, [{ id: 'a', x: 1, y: 1 }])).toBe(0) + const div = document.createElement('div') + expect(applySvgPositionPatches(div, [])).toBe(0) + }) +}) + +// ─── host envelope handling ──────────────────────────────────────────────── + +describe('worker:patchSvgPositions protocol', () => { + test('isWorkerToHost accepts the envelope', () => { + expect( + isWorkerToHost({ type: 'worker:patchSvgPositions', seq: 1, patches: [] }), + ).toBe(true) + }) +}) + +const manifest: PluginManifest = { + id: 'graph', + name: 'Graph', + version: '1.0.0', + surfaces: { fullscreenViews: [{ id: 'view', title: 'View', interaction: { pointer: true } }] }, +} + +function makeFakeWorker(): { worker: MinimalWorker; emit: (msg: unknown) => void } { + let handler: ((event: MessageEvent) => void) | null = null + const worker: MinimalWorker = { + onmessage: null, + postMessage(message: unknown) { + const msg = message as WorkerToHost + if ((msg as { type: string }).type === 'host:boot') { + queueMicrotask(() => { + handler?.({ + data: { type: 'worker:ready', seq: (msg as { seq: number }).seq, manifest }, + } as MessageEvent) + }) + } + }, + terminate() { + handler = null + }, + } as MinimalWorker + Object.defineProperty(worker, 'onmessage', { + configurable: true, + get: () => handler, + set: (v: ((event: MessageEvent) => void) | null) => { + handler = v + }, + }) + return { worker, emit: (msg) => handler?.({ data: msg } as MessageEvent) } +} + +describe('PluginHost handles worker:patchSvgPositions', () => { + test('emits a sanitized svgPositionsPatch event for a valid batch', async () => { + const fake = makeFakeWorker() + const host = new PluginHost({ createWorker: () => fake.worker }) + const seen: PluginHostEvent[] = [] + host.on((e) => seen.push(e)) + await host.load({ pluginId: 'graph', pluginSource: '' }) + + fake.emit({ + type: 'worker:patchSvgPositions', + seq: 99, + viewId: 'view', + patches: [ + { id: 'n1', x: 1, y: 2 }, + { id: 'bad', x: 'nope', y: 2 }, + { id: 'n2', x: 3, y: 4 }, + ], + }) + + const patchEvents = seen.filter((e) => e.type === 'svgPositionsPatch') + expect(patchEvents).toHaveLength(1) + const ev = patchEvents[0] as Extract + expect(ev.pluginId).toBe('graph') + expect(ev.viewId).toBe('view') + expect(ev.patches).toEqual([ + { id: 'n1', x: 1, y: 2 }, + { id: 'n2', x: 3, y: 4 }, + ]) + }) + + test('rejects an oversized batch (envelope guard) and emits no patch event', async () => { + const fake = makeFakeWorker() + const host = new PluginHost({ createWorker: () => fake.worker }) + const seen: PluginHostEvent[] = [] + host.on((e) => seen.push(e)) + await host.load({ pluginId: 'graph', pluginSource: '' }) + + // Build a batch whose JSON is well past MAX_ENVELOPE_BYTES. + const patches: Array<{ id: string; x: number; y: number }> = [] + while (JSON.stringify(patches).length < MAX_ENVELOPE_BYTES + 1000) { + patches.push({ id: `node-${patches.length}-xxxxxxxxxx`, x: 123.456, y: 789.012 }) + } + fake.emit({ type: 'worker:patchSvgPositions', seq: 100, patches }) + + expect(seen.some((e) => e.type === 'svgPositionsPatch')).toBe(false) + const errs = seen.filter((e) => e.type === 'workerError') as Array< + Extract + > + expect(errs.length).toBeGreaterThan(0) + expect(errs[errs.length - 1].message).toMatch(/Envelope too large/i) + }) +}) diff --git a/src/plugins/__tests__/wheelEvents.test.tsx b/src/plugins/__tests__/wheelEvents.test.tsx new file mode 100644 index 0000000..9f20ee9 --- /dev/null +++ b/src/plugins/__tests__/wheelEvents.test.tsx @@ -0,0 +1,184 @@ +/** + * v1.3 (L2 + L3) — wheel + hover events on the curated VNode renderer. + * + * Covers: + * - onWheel dispatches a WheelEventPayload (deltas + focal coords + + * ctrlKey) with coords mapped to the surface space, flagged + * high-frequency with interaction kind 'wheel' + * - onPointerEnter / onPointerLeave dispatch a HoverEventPayload with + * the echoed target id + mapped coords, flagged high-frequency with + * interaction kind 'hover' + * - the wheel listener is non-passive (preventDefault is honoured) so + * page scroll is stopped on an interactive surface + * - a surface with no wheel handler attaches no wheel listener + */ + +import { render, fireEvent } from '@testing-library/react' +import { PluginNode, type PluginVNodeEvent, type VNode } from '../PluginVNode' + +if (typeof (globalThis as { PointerEvent?: unknown }).PointerEvent !== 'function') { + class PointerEventPolyfill extends MouseEvent { + pointerId: number + constructor(type: string, params: PointerEventInit = {}) { + super(type, params) + this.pointerId = params.pointerId ?? 0 + } + } + ;(globalThis as { PointerEvent?: unknown }).PointerEvent = + PointerEventPolyfill as unknown as typeof PointerEvent +} + +// translate(10,20): user = client - (10,20). +function stubScreenCTM(el: Element, m = { a: 1, b: 0, c: 0, d: 1, e: 10, f: 20 }): void { + ;(el as unknown as { getScreenCTM: () => typeof m }).getScreenCTM = () => m +} + +describe('svg onWheel dispatch (L2)', () => { + test('wheel payload carries deltas, mapped focal coords, ctrlKey, and the wheel HF flag', () => { + const events: PluginVNodeEvent[] = [] + const { container } = render( + events.push(e)} + />, + ) + const svg = container.querySelector('svg')! + stubScreenCTM(svg) + fireEvent.wheel(svg, { deltaX: 3, deltaY: -12, clientX: 60, clientY: 70, ctrlKey: true }) + + expect(events).toHaveLength(1) + expect(events[0].event).toBe('zoom') + expect(events[0].highFrequency).toBe(true) + expect(events[0].interaction).toBe('wheel') + // host keys win the merge; plugin payload { src } preserved. + expect(events[0].payload).toEqual({ + src: 'plugin', + deltaX: 3, + deltaY: -12, + x: 50, + y: 50, + ctrlKey: true, + }) + }) + + test('wheel listener is non-passive — preventDefault stops page scroll', () => { + const { container } = render( + {}} + />, + ) + const svg = container.querySelector('svg')! + stubScreenCTM(svg) + const evt = new WheelEvent('wheel', { deltaY: 10, bubbles: true, cancelable: true }) + svg.dispatchEvent(evt) + expect(evt.defaultPrevented).toBe(true) + }) + + test('an svg with no wheel handler does not preventDefault (no listener attached)', () => { + const { container } = render( + {}} + />, + ) + const svg = container.querySelector('svg')! + const evt = new WheelEvent('wheel', { deltaY: 10, bubbles: true, cancelable: true }) + svg.dispatchEvent(evt) + expect(evt.defaultPrevented).toBe(false) + }) +}) + +describe('box onWheel dispatch (L2)', () => { + test('box wheel maps focal coords to element-local pixels and flags wheel HF', () => { + const events: PluginVNodeEvent[] = [] + const { container } = render( + events.push(e)} + />, + ) + const box = container.querySelector('div')! + box.getBoundingClientRect = () => + ({ left: 100, top: 200, right: 0, bottom: 0, width: 0, height: 0, x: 100, y: 200, toJSON: () => ({}) }) as DOMRect + fireEvent.wheel(box, { deltaX: 0, deltaY: 5, clientX: 130, clientY: 250, ctrlKey: false }) + + expect(events).toHaveLength(1) + expect(events[0].interaction).toBe('wheel') + expect(events[0].payload).toEqual({ deltaX: 0, deltaY: 5, x: 30, y: 50, ctrlKey: false }) + }) +}) + +describe('hover enter/leave dispatch (L3)', () => { + function renderHoverCircle() { + const events: PluginVNodeEvent[] = [] + const { container } = render( + events.push(e)} + />, + ) + const circle = container.querySelector('circle')! + stubScreenCTM(circle) + return { events, circle } + } + + test('pointerenter emits a HoverEventPayload flagged hover HF', () => { + const { events, circle } = renderHoverCircle() + fireEvent.pointerEnter(circle, { clientX: 60, clientY: 70 }) + expect(events).toHaveLength(1) + expect(events[0].event).toBe('hover-in') + expect(events[0].highFrequency).toBe(true) + expect(events[0].interaction).toBe('hover') + expect(events[0].payload).toEqual({ target: 'n1', x: 50, y: 50 }) + }) + + test('pointerleave emits a HoverEventPayload', () => { + const { events, circle } = renderHoverCircle() + fireEvent.pointerLeave(circle, { clientX: 110, clientY: 120 }) + expect(events).toHaveLength(1) + expect(events[0].event).toBe('hover-out') + expect(events[0].payload).toEqual({ target: 'n1', x: 100, y: 100 }) + }) +}) diff --git a/src/plugins/__tests__/wheelHoverCoalescing.test.ts b/src/plugins/__tests__/wheelHoverCoalescing.test.ts new file mode 100644 index 0000000..9a7322b --- /dev/null +++ b/src/plugins/__tests__/wheelHoverCoalescing.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment node + * + * v1.3 (L2 + L3) — PluginHost high-frequency coalescing + PER-KIND + * interaction gating for wheel + hover events. + * + * Wheel and hover ride the SAME rAF coalescing infrastructure L1 built + * for pointer move (one event per (event-name, target) per frame, drawn + * from the separate HF budget). The only new behaviour: each kind is + * gated on its OWN manifest `interaction` sub-flag — wheel on + * `interaction.wheel`, hover on `interaction.hover` — not on + * `interaction.pointer`. + */ + +import { PluginHost, type MinimalWorker } from '@/plugins/PluginHost' +import { + type HostToWorker, + type HostVNodeEvent, + type WorkerToHost, +} from '@/plugins/protocol' +import type { PluginManifest } from '@/plugins/manifest' + +// A view that opted into wheel + hover but NOT pointer, and a panel that +// opted into pointer only — so we can prove the gates are independent. +const manifest: PluginManifest = { + id: 'demo', + name: 'Demo', + version: '1.0.0', + surfaces: { + fullscreenViews: [ + { id: 'wh', title: 'WheelHover', interaction: { wheel: true, hover: true } }, + ], + sidebarPanels: [{ id: 'p', title: 'P', interaction: { pointer: true } }], + }, +} + +function makeFakeWorker(): { worker: MinimalWorker; sent: HostToWorker[] } { + const sent: HostToWorker[] = [] + let handler: ((event: MessageEvent) => void) | null = null + const worker: MinimalWorker = { + onmessage: null, + postMessage(message: unknown) { + sent.push(message as HostToWorker) + const msg = message as HostToWorker + if (msg.type === 'host:boot') { + queueMicrotask(() => { + handler?.({ + data: { type: 'worker:ready', seq: msg.seq, manifest } satisfies WorkerToHost, + } as MessageEvent) + }) + } + }, + terminate() { + handler = null + }, + } as MinimalWorker + Object.defineProperty(worker, 'onmessage', { + configurable: true, + get: () => handler, + set: (v: ((event: MessageEvent) => void) | null) => { + handler = v + }, + }) + return { worker, sent } +} + +function makeHost(worker: MinimalWorker) { + let pendingFrame: (() => void) | null = null + const host = new PluginHost({ + createWorker: () => worker, + requestFrame: (cb) => { + pendingFrame = cb + return 1 + }, + }) + const flush = () => { + const cb = pendingFrame + pendingFrame = null + cb?.() + } + const hasPendingFrame = () => pendingFrame !== null + return { host, flush, hasPendingFrame } +} + +const vnodeEvents = (sent: HostToWorker[]) => + sent.filter((m): m is HostVNodeEvent => m.type === 'host:vnodeEvent') + +describe('wheel coalescing + gating', () => { + test('wheel events on a wheel-opted-in surface coalesce one-per-frame', async () => { + const fake = makeFakeWorker() + const { host, flush } = makeHost(fake.worker) + await host.load({ pluginId: 'demo', pluginSource: '' }) + const source: HostVNodeEvent['source'] = { kind: 'fullscreen', viewId: 'wh' } + + for (let i = 0; i < 8; i++) { + host.sendVNodeEvent('demo', source, 'zoom', { deltaY: i, target: '' }, { + highFrequency: true, + interaction: 'wheel', + }) + } + expect(vnodeEvents(fake.sent)).toHaveLength(0) + flush() + const delivered = vnodeEvents(fake.sent) + expect(delivered).toHaveLength(1) + expect((delivered[0].payload as { deltaY: number }).deltaY).toBe(7) + }) + + test('wheel is dropped when the surface only opted into pointer', async () => { + const fake = makeFakeWorker() + const { host, flush, hasPendingFrame } = makeHost(fake.worker) + await host.load({ pluginId: 'demo', pluginSource: '' }) + // The 'p' panel declared interaction.pointer, NOT interaction.wheel. + const source: HostVNodeEvent['source'] = { kind: 'panel', panelId: 'p' } + host.sendVNodeEvent('demo', source, 'zoom', { deltaY: 1 }, { + highFrequency: true, + interaction: 'wheel', + }) + expect(hasPendingFrame()).toBe(false) + flush() + expect(vnodeEvents(fake.sent)).toHaveLength(0) + }) +}) + +describe('hover coalescing + gating', () => { + test('enter + leave are distinct keys: both survive one frame, latest wins per key', async () => { + const fake = makeFakeWorker() + const { host, flush } = makeHost(fake.worker) + await host.load({ pluginId: 'demo', pluginSource: '' }) + const source: HostVNodeEvent['source'] = { kind: 'fullscreen', viewId: 'wh' } + + host.sendVNodeEvent('demo', source, 'enter', { target: 'n1', x: 1 }, { highFrequency: true, interaction: 'hover' }) + host.sendVNodeEvent('demo', source, 'enter', { target: 'n1', x: 2 }, { highFrequency: true, interaction: 'hover' }) + host.sendVNodeEvent('demo', source, 'leave', { target: 'n1', x: 9 }, { highFrequency: true, interaction: 'hover' }) + + flush() + const delivered = vnodeEvents(fake.sent) + expect(delivered.map((d) => d.event).sort()).toEqual(['enter', 'leave']) + const enter = delivered.find((d) => d.event === 'enter')! + expect((enter.payload as { x: number }).x).toBe(2) // latest enter wins + }) + + test('hover is dropped when the surface only opted into pointer', async () => { + const fake = makeFakeWorker() + const { host, flush, hasPendingFrame } = makeHost(fake.worker) + await host.load({ pluginId: 'demo', pluginSource: '' }) + const source: HostVNodeEvent['source'] = { kind: 'panel', panelId: 'p' } + host.sendVNodeEvent('demo', source, 'enter', { target: 'n1' }, { + highFrequency: true, + interaction: 'hover', + }) + expect(hasPendingFrame()).toBe(false) + flush() + expect(vnodeEvents(fake.sent)).toHaveLength(0) + }) +}) diff --git a/src/plugins/protocol.ts b/src/plugins/protocol.ts index 56290dd..4d620b4 100644 --- a/src/plugins/protocol.ts +++ b/src/plugins/protocol.ts @@ -50,6 +50,28 @@ export const MAX_VNODE_EVENTS_PER_SECOND = 16 * loop. See docs/plugins-v1.3-plan.md section 2.7 (Cost 1). */ export const MAX_HF_EVENTS_PER_SECOND = 90 +/** v1.3 (L2) — reserved host-to-worker event name carried on the + * existing `host:vnodeEvent` envelope (NOT a new envelope type). The + * host emits ONE of these per host-owned pan/zoom gesture settle + * (pointerup, or a wheel-idle debounce) so a `VNodeSvg.panZoom: 'host'` + * plugin can persist + sync its own viewport. Payload is the final + * transform `{ x, y, scale }` in the svg's user-space. See + * docs/plugins-v1.3-plan.md sections 2.7 (Cost 2) + 2.8. */ +export const SURFACE_TRANSFORM_EVENT = 'surface.transform' + +/** v1.3 — which manifest `interaction` sub-flag a high-frequency VNode + * event is gated on. The renderer tags each HF dispatch with its kind + * (the event NAME on the wire is plugin-defined, so the host cannot + * classify from the name alone); the host charges the matching budget + * and checks the matching opt-in. */ +export type InteractionKind = 'pointer' | 'wheel' | 'hover' + +/** v1.3 (L4) — wheel-idle debounce (ms) the host-owned pan/zoom surface + * waits after the last wheel event before emitting the single coalesced + * `surface.transform` settle event. Pan gestures settle on pointerup + * instead, so this only governs the wheel-zoom path. */ +export const SURFACE_TRANSFORM_WHEEL_IDLE_MS = 150 + /** Debounce window (ms) the host applies to every `vault.events` * dispatch (vaultChanged / noteSaved / activeNoteIdChanged). Plugins * cannot lower this — the cap is host-side so a runaway plugin cannot @@ -350,6 +372,7 @@ export type WorkerToHost = | WorkerOpenFullscreen | WorkerCloseFullscreen | WorkerSetFullscreenContent + | WorkerPatchSvgPositions /** Sent in reply to host:boot once the plugin module loaded and * `definePlugin` ran. Includes the validated manifest, which the host @@ -617,6 +640,31 @@ export interface WorkerRequestVaultWrite { | { kind: 'createFolder'; path: string } } +/** v1.3 (L4) — position-patch fast path. The worker streams ONLY the + * moved node coordinates (e.g. a 500-node force-graph tick) and the + * host mutates the `cx`/`cy` of the already-mounted SVG circles plus + * the endpoints of any edge `line` keyed to that node id, WITHOUT a + * full React re-render of the VNode tree. This is the 60fps enabler + * for node drag + force simulation — see docs/plugins-v1.3-plan.md + * sections 2.7 (Cost 2) + 2.8. + * + * `viewId` / `panelId` name the interactive surface whose mounted svg + * should be patched (a fullscreen view or a sidebar panel); omit both + * to target the single active interactive surface. Subject to + * `MAX_ENVELOPE_BYTES` like every other envelope — the host rejects an + * oversized batch before it reaches the renderer. Each patch carries + * ONLY `{ id, x, y }` (numbers + an echoed id string) — no DOM ref, no + * style, no arbitrary attribute. */ +export interface WorkerPatchSvgPositions { + type: 'worker:patchSvgPositions' + seq: number + /** Target a fullscreen view's mounted svg. */ + viewId?: string + /** Target a sidebar panel's mounted svg. */ + panelId?: string + patches: ReadonlyArray<{ id: string; x: number; y: number }> +} + // ─── Helpers ────────────────────────────────────────────────────────────── export function isHostToWorker(msg: unknown): msg is HostToWorker { @@ -663,6 +711,7 @@ export function isWorkerToHost(msg: unknown): msg is WorkerToHost { 'worker:openFullscreen', 'worker:closeFullscreen', 'worker:setFullscreenContent', + 'worker:patchSvgPositions', ]) } diff --git a/src/plugins/sdk.ts b/src/plugins/sdk.ts index a4bbca8..46207f5 100644 --- a/src/plugins/sdk.ts +++ b/src/plugins/sdk.ts @@ -309,6 +309,30 @@ export interface PluginCtx { * named view is not currently open. */ setFullscreenContent(viewId: string, node: unknown): void + + /** + * v1.3 (L4) — position-patch fast path. Stream ONLY the moved node + * coordinates (e.g. each force-simulation tick of a graph) and the + * host mutates the `cx`/`cy` of the matching mounted SVG circles plus + * the endpoints of any edge `line` keyed to that node id, WITHOUT a + * full re-render of the VNode tree. This is the 60fps enabler for node + * drag + live layout. + * + * To wire it: render circles with an `id` and edge `line`s with + * `sourceId` / `targetId` naming the node ids each endpoint follows. + * Then call this with `{ id, x, y }` patches each frame. Pass `viewId` + * (fullscreen) or `panelId` (sidebar) to target a specific surface; + * omit both for the single active interactive surface. + * + * Fire-and-forget — there is no reply. Patches are subject to the same + * envelope-size cap as every other message; keep each batch to the + * nodes that actually moved. + */ + patchSvgPositions(args: { + viewId?: string + panelId?: string + patches: ReadonlyArray<{ id: string; x: number; y: number }> + }): void } /** Cleanup thunk returned by every `vault.events` subscription. The diff --git a/src/plugins/svgPositionPatch.ts b/src/plugins/svgPositionPatch.ts new file mode 100644 index 0000000..f00cfd1 --- /dev/null +++ b/src/plugins/svgPositionPatch.ts @@ -0,0 +1,121 @@ +// v1.3 (L4) — position-patch fast path: apply `{ id, x, y }` patches to +// the already-mounted SVG of an interactive plugin surface WITHOUT a +// full React re-render of the VNode tree. +// +// The curated renderer (`PluginVNode.tsx`) tags every opt-in SVG circle +// with `data-node-id` and every edge `line` with `data-edge-source` / +// `data-edge-target`. This module reads those attributes to build a +// host-side map from node id → DOM element(s) for the active surface, +// then mutates `cx`/`cy` on the circle and the matching endpoint +// (`x1`/`y1` for a source, `x2`/`y2` for a target) on each connected +// line. Direct attribute mutation bypasses React reconciliation, so a +// 500-node force-graph tick repaints at 60fps instead of re-serialising +// the whole tree each frame. +// +// Security: patches carry only `{ id, x, y }` — numbers plus an echoed +// id string. We never read or write style, class, or arbitrary +// attributes here, and we never call any selector with plugin-controlled +// strings (the map is built by walking `data-*` attributes, so a +// malicious id cannot inject a selector). + +/** Data attribute the renderer stamps on an opt-in SVG circle so the + * patch path can address it by node id. */ +export const NODE_ID_ATTR = 'data-node-id' +/** Data attribute on an edge `line` naming the node id its first + * endpoint (`x1`/`y1`) follows. */ +export const EDGE_SOURCE_ATTR = 'data-edge-source' +/** Data attribute on an edge `line` naming the node id its second + * endpoint (`x2`/`y2`) follows. */ +export const EDGE_TARGET_ATTR = 'data-edge-target' + +export interface SvgPositionPatch { + id: string + x: number + y: number +} + +/** + * Validate + normalise a raw patches payload off the wire. Returns only + * the entries with a non-empty string id and finite numeric x/y; drops + * everything else. Returns an empty array (never throws) when the input + * is not an array, so a malformed envelope degrades to a no-op patch. + */ +export function sanitizeSvgPositionPatches(input: unknown): SvgPositionPatch[] { + if (!Array.isArray(input)) return [] + const out: SvgPositionPatch[] = [] + for (const raw of input) { + if (typeof raw !== 'object' || raw === null) continue + const r = raw as { id?: unknown; x?: unknown; y?: unknown } + if (typeof r.id !== 'string' || r.id.length === 0) continue + if (typeof r.x !== 'number' || !Number.isFinite(r.x)) continue + if (typeof r.y !== 'number' || !Number.isFinite(r.y)) continue + out.push({ id: r.id, x: r.x, y: r.y }) + } + return out +} + +/** + * Apply position patches to the mounted SVG under `root`. Builds the + * id → element map fresh from the current DOM each call, so it always + * reflects the latest render (the map is "refreshed when the tree + * re-renders" simply by being rebuilt on the next patch). Returns the + * number of node circles that were actually moved. + * + * No-op when `root` is null or `patches` is empty. + */ +export function applySvgPositionPatches( + root: Element | null | undefined, + patches: ReadonlyArray, +): number { + if (!root || patches.length === 0) return 0 + + // One pass over the DOM to index every addressable shape by id. Walking + // attributes (not a selector built from the patch id) keeps a hostile + // node id from escaping into a querySelector. + const circles = new Map() + for (const el of root.querySelectorAll(`[${NODE_ID_ATTR}]`)) { + const id = el.getAttribute(NODE_ID_ATTR) + if (id !== null) circles.set(id, el) + } + const sourceLines = groupLinesBy(root, EDGE_SOURCE_ATTR) + const targetLines = groupLinesBy(root, EDGE_TARGET_ATTR) + + let moved = 0 + for (const p of patches) { + const x = String(p.x) + const y = String(p.y) + const circle = circles.get(p.id) + if (circle) { + circle.setAttribute('cx', x) + circle.setAttribute('cy', y) + moved++ + } + const sources = sourceLines.get(p.id) + if (sources) { + for (const line of sources) { + line.setAttribute('x1', x) + line.setAttribute('y1', y) + } + } + const targets = targetLines.get(p.id) + if (targets) { + for (const line of targets) { + line.setAttribute('x2', x) + line.setAttribute('y2', y) + } + } + } + return moved +} + +function groupLinesBy(root: Element, attr: string): Map { + const map = new Map() + for (const el of root.querySelectorAll(`[${attr}]`)) { + const id = el.getAttribute(attr) + if (id === null) continue + const bucket = map.get(id) + if (bucket) bucket.push(el) + else map.set(id, [el]) + } + return map +} diff --git a/src/plugins/workerEntry.ts b/src/plugins/workerEntry.ts index 8dd9ea0..751000b 100644 --- a/src/plugins/workerEntry.ts +++ b/src/plugins/workerEntry.ts @@ -825,6 +825,22 @@ function buildCtx(parentSeq: number): PluginCtx { node, }) }, + patchSvgPositions(args) { + // v1.3 (L4) — fire-and-forget position-patch fast path. No reply; + // the host mutates the mounted svg directly. Coords are coerced to + // numbers here so a stray string cannot ride the wire (the host + // re-sanitises defensively too). + const patches = Array.isArray(args?.patches) + ? args.patches.map((p) => ({ id: String(p.id), x: Number(p.x), y: Number(p.y) })) + : [] + emit({ + type: 'worker:patchSvgPositions', + seq: allocRequestSeq(), + ...(typeof args?.viewId === 'string' ? { viewId: args.viewId } : {}), + ...(typeof args?.panelId === 'string' ? { panelId: args.panelId } : {}), + patches, + }) + }, } }