Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/plugins-v1.3-impl-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg>` 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.)
1 change: 1 addition & 0 deletions src/components/editor/PluginCodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
24 changes: 23 additions & 1 deletion src/components/plugins/PluginFullscreenView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -61,12 +62,32 @@ const getFocusable = (root: HTMLElement): HTMLElement[] =>
export const PluginFullscreenView = () => {
const active = usePluginStore((s) => s.activeFullscreen)
const dialogRef = useRef<HTMLDivElement | null>(null)
const bodyRef = useRef<HTMLDivElement | null>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(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(() => {
Expand Down Expand Up @@ -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 } : {}) },
)
}

Expand Down Expand Up @@ -219,6 +240,7 @@ export const PluginFullscreenView = () => {
</div>

<div
ref={bodyRef}
className="flex-1 min-h-0 overflow-auto p-4 text-sm text-obsidianText"
data-testid="plugin-fullscreen-body"
>
Expand Down
34 changes: 29 additions & 5 deletions src/components/sidebar/PluginsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -32,14 +33,31 @@ export const PluginsPanel = () => {
// emits panelContent for a panel that lives in this tab.
const [contents, setContents] = useState<Record<string, unknown>>({})

// 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<Map<string, HTMLElement | null>>(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)

Expand Down Expand Up @@ -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 } : {}),
})
},
[],
Expand Down Expand Up @@ -101,7 +120,12 @@ export const PluginsPanel = () => {
{p.pluginName}
</span>
</header>
<div className="px-3 py-2 text-sm text-obsidianText whitespace-pre-wrap break-words">
<div
ref={(el) => {
sectionRefs.current.set(key, el)
}}
className="px-3 py-2 text-sm text-obsidianText whitespace-pre-wrap break-words"
>
{node === undefined ? (
<span className="text-obsidianSecondaryText">(awaiting first render…)</span>
) : (
Expand Down
Binary file modified src/plugins/PluginHost.ts
Binary file not shown.
Loading
Loading