view-anchor 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ import type {
2
+ AdvertisedSize,
3
+ SizeAdvertiserOptions,
4
+ SizeAdvertiserHandle,
5
+ } from './types.js'
6
+ import { createMeasureLoop } from './measure-loop.js'
7
+
8
+ /**
9
+ * Reverse of `createViewAnchor`: runs in a downstream WebContentsView's own
10
+ * renderer, measures the content's own size on ONE owned axis (from the
11
+ * `ResizeObserver` border-box — no `getBoundingClientRect`, no forced reflow),
12
+ * and advertises it via the injected `publish`. Shares the forward primitive's
13
+ * measure/coalesce/dedupe/dispose engine (`createMeasureLoop`).
14
+ *
15
+ * The extent is `Math.round`ed and clamped to `>= 0`; non-finite measurements
16
+ * drop the frame.
17
+ *
18
+ * FOOTGUN — `target` must be shrink-to-fit on the owned axis: its owned-axis
19
+ * size must NOT be driven by the host-applied view size, or the cross-process
20
+ * loop (advertise → host resizes view → remeasure) never converges (it
21
+ * oscillates or stays "stable but wrong"). Measuring `<body>`/`<html>` is the
22
+ * classic mistake — their size *is* the view size. See
23
+ * `docs/bidirectional-design.md`'s single-axis-ownership and trust-boundary
24
+ * sections.
25
+ */
26
+ export function createSizeAdvertiser(
27
+ target: HTMLElement,
28
+ opts: SizeAdvertiserOptions,
29
+ ): SizeAdvertiserHandle {
30
+ const axis = opts.axis // immutable for the advertiser's life
31
+ let publish = opts.publish
32
+ let observer: ResizeObserver | null = null
33
+ let disposed = false
34
+ // Latest border-box, stashed by the RO callback and read by `produce` in the
35
+ // RAF body (keep the entry out of the shared, DOM-agnostic loop).
36
+ let latest: ResizeObserverSize | null = null
37
+
38
+ const produce = (): AdvertisedSize | null => {
39
+ if (!latest) return null
40
+ const raw = axis === 'block' ? latest.blockSize : latest.inlineSize
41
+ if (!Number.isFinite(raw)) return null
42
+ return { axis, extent: Math.max(0, Math.round(raw)) }
43
+ }
44
+
45
+ const loop = createMeasureLoop<AdvertisedSize>({
46
+ produce,
47
+ same: (a, b) => a.extent === b.extent, // axis is constant
48
+ sink: (size) => publish(size),
49
+ })
50
+
51
+ const onResize: ResizeObserverCallback = (entries) => {
52
+ const entry = entries[entries.length - 1]
53
+ if (entry) {
54
+ latest = entry.borderBoxSize?.[0] ?? entry.contentBoxSize?.[0] ?? latest
55
+ }
56
+ loop.schedule()
57
+ }
58
+
59
+ // One cheap, once-per-advertiser guard for the textbook feedback-loop footgun.
60
+ const doc = target.ownerDocument
61
+ if (target === doc.body || target === doc.documentElement) {
62
+ console.warn(
63
+ `[view-anchor] size-advertiser: <${target === doc.body ? 'body' : 'html'}>'s ` +
64
+ `${axis} size is the host-given view size, not the content size — the ` +
65
+ `advertiser will never shrink to content. Measure a shrink-to-fit wrapper. ` +
66
+ `See bidirectional-design.md's single-axis-ownership section.`,
67
+ )
68
+ }
69
+
70
+ loop.setActive(true)
71
+ observer = new ResizeObserver(onResize)
72
+ observer.observe(target)
73
+
74
+ return {
75
+ update(nextPublish: (size: AdvertisedSize) => void): void {
76
+ if (disposed) return
77
+ publish = nextPublish
78
+ // Re-advertise the current size to the new sink immediately (mirrors the
79
+ // forward anchor's re-publish on update) so the new channel is not left
80
+ // sizeless until the next ResizeObserver tick.
81
+ const cur = produce()
82
+ if (cur) loop.emitNow(cur)
83
+ },
84
+ dispose(): void {
85
+ if (disposed) return
86
+ disposed = true
87
+ loop.cancel()
88
+ if (observer) {
89
+ observer.disconnect()
90
+ observer = null
91
+ }
92
+ loop.dispose()
93
+ },
94
+ }
95
+ }
package/src/types.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * view-anchor — sync a main-process native view's bounds to a DOM element.
3
+ *
4
+ * Self-contained, engine-agnostic primitive. It knows nothing about
5
+ * Electron (the `publish` callback owns the IPC → `setBounds`), nothing
6
+ * about React (the core is imperative; see `react.ts` for the adapter),
7
+ * and nothing about the host layout engine (the `target` element may come
8
+ * from our own `compile`/`FrameTree`, or a dockview panel's
9
+ * `content.element`; the mechanism is identical).
10
+ *
11
+ * It is the modern, alive replacement for the archived
12
+ * `react-electron-browser-view`: the cross-process bridge that DOM layout
13
+ * libraries (dockview included) deliberately do not provide. dockview's
14
+ * internal `OverlayRenderContainer` does the same getBoundingClientRect →
15
+ * RAF → reposition dance, but its follower is a DOM node; ours is a native
16
+ * `WebContentsView` positioned via the injected `publish`.
17
+ */
18
+
19
+ /** A screen-space rectangle, in CSS pixels. Structurally compatible with
20
+ * the host's `ViewBounds` so a publisher typed against either works. */
21
+ export interface Bounds {
22
+ x: number
23
+ y: number
24
+ width: number
25
+ height: number
26
+ }
27
+
28
+ /**
29
+ * Explicit visibility + geometry for a native view, replacing the legacy
30
+ * magic-`{0,0,0,0}` "hidden" convention (`present:false → ZERO bounds`).
31
+ *
32
+ * Visibility is a DISCRIMINANT, never inferred from geometry. The whole
33
+ * reason this type exists: a genuinely zero-SIZED but on-screen view
34
+ * (`{ visible:true, bounds:{...,width:0,height:0} }`) is now distinct from a
35
+ * detached/hidden one (`{ visible:false }`, which carries no `bounds` at
36
+ * all). Under the old ZERO convention both collapsed to the same value and
37
+ * were indistinguishable.
38
+ */
39
+ export type Placement =
40
+ | { visible: true; bounds: Bounds }
41
+ | { visible: false }
42
+
43
+ export interface ViewAnchorOptions {
44
+ /**
45
+ * Whether the native view should be attached. When `false`, the anchor
46
+ * publishes zero bounds (`{0,0,0,0}`) — the host treats `width === 0 ||
47
+ * height === 0` as "detach the child view but keep its WebContents
48
+ * alive" (detach-but-keep-alive). No DOM measurement is needed in this
49
+ * state.
50
+ */
51
+ present: boolean
52
+ /** Receives the live rect, or `{0,0,0,0}` when detached. Owns IPC. */
53
+ publish: (bounds: Bounds) => void
54
+ }
55
+
56
+ export interface ViewAnchorHandle {
57
+ /**
58
+ * Apply new options. Re-publishes immediately to reflect the new state
59
+ * (present=true → measure + observe; present=false → zero bounds).
60
+ */
61
+ update(opts: ViewAnchorOptions): void
62
+ /** Stop observing and remove listeners. After dispose the anchor never
63
+ * publishes again (every emit reads `disposed` synchronously, so there is
64
+ * no queued frame that could fire late). */
65
+ dispose(): void
66
+ }
67
+
68
+ // ── Reverse direction: size advertiser ───────────────────────────────
69
+ //
70
+ // The mirror of the forward anchor. Runs in a DOWNSTREAM WebContentsView's own
71
+ // renderer: it measures the content's own size and advertises it to the host,
72
+ // which sizes the placeholder accordingly (and the forward anchor then keeps the
73
+ // view positioned). One advertiser owns exactly ONE axis — the other axis is a
74
+ // host-driven, read-only input — so the cross-process loop stays a one-way DAG.
75
+
76
+ /** Which axis this advertiser owns. `block` = height, `inline` = width
77
+ * (logical-property naming, axis-agnostic to writing mode). */
78
+ export type AdvertisedAxis = 'block' | 'inline'
79
+
80
+ /**
81
+ * One frame of advertised size. A pure scalar plus the owning axis — there is
82
+ * deliberately no field for the *other* axis, so "advertise two axes" is not
83
+ * expressible (single-axis ownership is enforced in the type, not at runtime).
84
+ */
85
+ export interface AdvertisedSize {
86
+ /** Mirrors the factory's `axis`; constant across frames. Lets the host
87
+ * whitelist-check the axis it is willing to accept. */
88
+ readonly axis: AdvertisedAxis
89
+ /** The owned axis's content extent, in CSS px — already rounded and clamped
90
+ * to `>= 0`. */
91
+ readonly extent: number
92
+ }
93
+
94
+ export interface SizeAdvertiserOptions {
95
+ /** The single axis this advertiser owns. Fixed for the advertiser's life. */
96
+ axis: AdvertisedAxis
97
+ /** Receives each advertised size. Owns the IPC/postMessage → host. Mirrors
98
+ * the forward `publish` (same role: the injected, transport-owning sink). */
99
+ publish: (size: AdvertisedSize) => void
100
+ }
101
+
102
+ export interface SizeAdvertiserHandle {
103
+ /**
104
+ * Swap the `publish` sink (e.g. a new IPC channel) and immediately
105
+ * re-advertise the current size to it (mirrors the forward anchor's
106
+ * re-publish on update), so the new channel is not left sizeless until the
107
+ * next `ResizeObserver` tick.
108
+ *
109
+ * Takes only the new sink — `axis` is immutable by construction, so it is
110
+ * deliberately not expressible here (you cannot attempt to change it). To
111
+ * advertise a different axis, dispose and create a new advertiser.
112
+ */
113
+ update(publish: (size: AdvertisedSize) => void): void
114
+ /**
115
+ * Stop observing, cancel any pending RAF. After dispose nothing is
116
+ * advertised again. (There is no ZERO/terminal value — collapsing is the
117
+ * host's policy, unlike the forward anchor's `present:false`.)
118
+ *
119
+ * The first advertised value is asynchronous: it awaits the observer's first
120
+ * frame, and a `display:none` target advertises nothing until shown.
121
+ */
122
+ dispose(): void
123
+ }