Skip to content

feat(plugins): v1.3 L2+L3+L4 — wheel, host pan/zoom, hover, position-patch channel#170

Merged
thetechjon merged 1 commit into
devfrom
feat/plugins-v1.3-wheel-hover-patch
Jun 10, 2026
Merged

feat(plugins): v1.3 L2+L3+L4 — wheel, host pan/zoom, hover, position-patch channel#170
thetechjon merged 1 commit into
devfrom
feat/plugins-v1.3-wheel-hover-patch

Conversation

@thetechjon

Copy link
Copy Markdown
Collaborator

Round 2 of the v1.3 interaction surface, building on the merged L1 (pointer events + interaction manifest opt-in + rAF coalescing + HF budget). Ships L2, L3, and L4 together because all three edit the same platform files (PluginVNode.tsx, PluginHost.ts, protocol.ts, the surface adapters) — splitting them would mean three PRs conflicting over the same code.

L2 — wheel + host-owned pan/zoom (plan 2.2, 2.7 Cost 2)

  • onWheel on VNodeSvg + VNodeBox. WheelEventPayload { deltaX, deltaY, x, y, ctrlKey }; x/y in the surface coordinate space (svg user-space via inverse CTM, box element-local). High-frequency → rides L1's coalescing, gated on interaction.wheel.
  • Wheel listeners are non-passive only for interactive surfaces, so preventDefault can stop page scroll; a plain svg/box attaches no wheel listener and scrolls normally.
  • VNodeSvg.panZoom: 'host' — the host owns the viewBox transform: drag-pan (surface pointer) and wheel-zoom mutate the rendered <svg> directly (no worker round-trip, 60fps) and emit exactly ONE surface.transform { x, y, scale } on gesture settle (pointerup, or a 150ms wheel-idle debounce). surface.transform is a reserved event name on the existing host:vnodeEvent envelope — not a new envelope.

L3 — hover (plan 2.2, 2.3)

  • onPointerEnter / onPointerLeave on circle/rect/svg/box. HoverEventPayload { target, x, y }. High-frequency → coalesced, gated on interaction.hover.

L4 — position-patch channel (plan 2.7 Cost 2, 2.8)

  • New worker→host envelope worker:patchSvgPositions { viewId?, panelId?, patches: {id,x,y}[] } on the WorkerToHost union + isWorkerToHost. Subject to MAX_ENVELOPE_BYTES; the host sanitises the patch shape.
  • The host applies patches by mutating cx/cy on mounted SVG circles + the matching endpoint of any connected edge line (keyed via data-node-id / data-edge-source / data-edge-target), with NO React re-render. New SDK method ctx.patchSvgPositions(...).

Why ship together

All three are platform-layer changes to the same five files. The HF gate also had to grow from L1's single pointer flag into a per-kind gate (pointer/wheel/hover), which touches sendVNodeEvent + every surface adapter — a shared change all three depend on.

Notes

  • Reuses L1's rAF coalescing — no second coalescing layer. HF dispatches now carry an interaction kind; an unset kind defaults to pointer so L1 behaviour + tests are unchanged.
  • v1.2 + L1 plugins keep working: every new prop is optional; no-interaction manifests never get wheel/hover/patch paths.
  • public/plugins/noteser-graph/ (G2/G3/G4) untouched — separate PR.
  • Deviations recorded in docs/plugins-v1.3-impl-notes.md.

Tests

  • npm run lint clean, tsc --noEmit zero errors, full Jest suite green (2869 passed, no flake).
  • New unit tests: wheel payload + coords, hover enter/leave coalescing, per-kind HF gating, surface.transform fires once on pan settle + once after wheel-idle debounce, worker:patchSvgPositions validate + oversized reject, host applies a patch to the mounted svg without remounting the React tree.

🤖 Generated with Claude Code

…patch channel

L2 — wheel + host-owned pan/zoom:
- onWheel on VNodeSvg + VNodeBox (WheelEventPayload: deltaX/deltaY/x/y/ctrlKey,
  coords in the surface space); high-frequency, gated on interaction.wheel.
- Non-passive wheel listeners (preventDefault) for interactive surfaces only.
- VNodeSvg.panZoom:'host' — host owns the viewBox: drag-pan + wheel-zoom mutate
  the rendered svg locally (no worker round-trip), emitting ONE surface.transform
  {x,y,scale} on settle (pointerup / 150ms wheel-idle debounce).

L3 — hover:
- onPointerEnter / onPointerLeave on circle/rect/svg/box (HoverEventPayload:
  target/x/y); high-frequency, coalesced, gated on interaction.hover.

L4 — position-patch channel:
- worker:patchSvgPositions {viewId?,panelId?,patches:{id,x,y}[]} on WorkerToHost +
  isWorkerToHost; subject to MAX_ENVELOPE_BYTES, host sanitises the patch shape.
- Host applies patches by mutating cx/cy on mounted circles + the connected edge
  line endpoints (via data-node-id / data-edge-source/target), no React re-render.
- New SDK method ctx.patchSvgPositions(...).

Reuses L1's rAF coalescing: high-frequency dispatches now carry an interaction kind
so the host gates each on its own manifest sub-flag (pointer/wheel/hover); an unset
kind defaults to pointer (L1 behaviour). Shipped as one PR since all three layers
edit the same platform files. noteser-graph (G2/G3/G4) untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thetechjon thetechjon merged commit 8c9cb60 into dev Jun 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant