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,83 @@
1
+ import { createMeasureLoop } from './measure-loop.js';
2
+ /**
3
+ * Reverse of `createViewAnchor`: runs in a downstream WebContentsView's own
4
+ * renderer, measures the content's own size on ONE owned axis (from the
5
+ * `ResizeObserver` border-box — no `getBoundingClientRect`, no forced reflow),
6
+ * and advertises it via the injected `publish`. Shares the forward primitive's
7
+ * measure/coalesce/dedupe/dispose engine (`createMeasureLoop`).
8
+ *
9
+ * The extent is `Math.round`ed and clamped to `>= 0`; non-finite measurements
10
+ * drop the frame.
11
+ *
12
+ * FOOTGUN — `target` must be shrink-to-fit on the owned axis: its owned-axis
13
+ * size must NOT be driven by the host-applied view size, or the cross-process
14
+ * loop (advertise → host resizes view → remeasure) never converges (it
15
+ * oscillates or stays "stable but wrong"). Measuring `<body>`/`<html>` is the
16
+ * classic mistake — their size *is* the view size. See
17
+ * `docs/bidirectional-design.md`'s single-axis-ownership and trust-boundary
18
+ * sections.
19
+ */
20
+ export function createSizeAdvertiser(target, opts) {
21
+ const axis = opts.axis; // immutable for the advertiser's life
22
+ let publish = opts.publish;
23
+ let observer = null;
24
+ let disposed = false;
25
+ // Latest border-box, stashed by the RO callback and read by `produce` in the
26
+ // RAF body (keep the entry out of the shared, DOM-agnostic loop).
27
+ let latest = null;
28
+ const produce = () => {
29
+ if (!latest)
30
+ return null;
31
+ const raw = axis === 'block' ? latest.blockSize : latest.inlineSize;
32
+ if (!Number.isFinite(raw))
33
+ return null;
34
+ return { axis, extent: Math.max(0, Math.round(raw)) };
35
+ };
36
+ const loop = createMeasureLoop({
37
+ produce,
38
+ same: (a, b) => a.extent === b.extent, // axis is constant
39
+ sink: (size) => publish(size),
40
+ });
41
+ const onResize = (entries) => {
42
+ const entry = entries[entries.length - 1];
43
+ if (entry) {
44
+ latest = entry.borderBoxSize?.[0] ?? entry.contentBoxSize?.[0] ?? latest;
45
+ }
46
+ loop.schedule();
47
+ };
48
+ // One cheap, once-per-advertiser guard for the textbook feedback-loop footgun.
49
+ const doc = target.ownerDocument;
50
+ if (target === doc.body || target === doc.documentElement) {
51
+ console.warn(`[view-anchor] size-advertiser: <${target === doc.body ? 'body' : 'html'}>'s ` +
52
+ `${axis} size is the host-given view size, not the content size — the ` +
53
+ `advertiser will never shrink to content. Measure a shrink-to-fit wrapper. ` +
54
+ `See bidirectional-design.md's single-axis-ownership section.`);
55
+ }
56
+ loop.setActive(true);
57
+ observer = new ResizeObserver(onResize);
58
+ observer.observe(target);
59
+ return {
60
+ update(nextPublish) {
61
+ if (disposed)
62
+ return;
63
+ publish = nextPublish;
64
+ // Re-advertise the current size to the new sink immediately (mirrors the
65
+ // forward anchor's re-publish on update) so the new channel is not left
66
+ // sizeless until the next ResizeObserver tick.
67
+ const cur = produce();
68
+ if (cur)
69
+ loop.emitNow(cur);
70
+ },
71
+ dispose() {
72
+ if (disposed)
73
+ return;
74
+ disposed = true;
75
+ loop.cancel();
76
+ if (observer) {
77
+ observer.disconnect();
78
+ observer = null;
79
+ }
80
+ loop.dispose();
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,111 @@
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
+ /** A screen-space rectangle, in CSS pixels. Structurally compatible with
19
+ * the host's `ViewBounds` so a publisher typed against either works. */
20
+ export interface Bounds {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ }
26
+ /**
27
+ * Explicit visibility + geometry for a native view, replacing the legacy
28
+ * magic-`{0,0,0,0}` "hidden" convention (`present:false → ZERO bounds`).
29
+ *
30
+ * Visibility is a DISCRIMINANT, never inferred from geometry. The whole
31
+ * reason this type exists: a genuinely zero-SIZED but on-screen view
32
+ * (`{ visible:true, bounds:{...,width:0,height:0} }`) is now distinct from a
33
+ * detached/hidden one (`{ visible:false }`, which carries no `bounds` at
34
+ * all). Under the old ZERO convention both collapsed to the same value and
35
+ * were indistinguishable.
36
+ */
37
+ export type Placement = {
38
+ visible: true;
39
+ bounds: Bounds;
40
+ } | {
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
+ export interface ViewAnchorHandle {
56
+ /**
57
+ * Apply new options. Re-publishes immediately to reflect the new state
58
+ * (present=true → measure + observe; present=false → zero bounds).
59
+ */
60
+ update(opts: ViewAnchorOptions): void;
61
+ /** Stop observing and remove listeners. After dispose the anchor never
62
+ * publishes again (every emit reads `disposed` synchronously, so there is
63
+ * no queued frame that could fire late). */
64
+ dispose(): void;
65
+ }
66
+ /** Which axis this advertiser owns. `block` = height, `inline` = width
67
+ * (logical-property naming, axis-agnostic to writing mode). */
68
+ export type AdvertisedAxis = 'block' | 'inline';
69
+ /**
70
+ * One frame of advertised size. A pure scalar plus the owning axis — there is
71
+ * deliberately no field for the *other* axis, so "advertise two axes" is not
72
+ * expressible (single-axis ownership is enforced in the type, not at runtime).
73
+ */
74
+ export interface AdvertisedSize {
75
+ /** Mirrors the factory's `axis`; constant across frames. Lets the host
76
+ * whitelist-check the axis it is willing to accept. */
77
+ readonly axis: AdvertisedAxis;
78
+ /** The owned axis's content extent, in CSS px — already rounded and clamped
79
+ * to `>= 0`. */
80
+ readonly extent: number;
81
+ }
82
+ export interface SizeAdvertiserOptions {
83
+ /** The single axis this advertiser owns. Fixed for the advertiser's life. */
84
+ axis: AdvertisedAxis;
85
+ /** Receives each advertised size. Owns the IPC/postMessage → host. Mirrors
86
+ * the forward `publish` (same role: the injected, transport-owning sink). */
87
+ publish: (size: AdvertisedSize) => void;
88
+ }
89
+ export interface SizeAdvertiserHandle {
90
+ /**
91
+ * Swap the `publish` sink (e.g. a new IPC channel) and immediately
92
+ * re-advertise the current size to it (mirrors the forward anchor's
93
+ * re-publish on update), so the new channel is not left sizeless until the
94
+ * next `ResizeObserver` tick.
95
+ *
96
+ * Takes only the new sink — `axis` is immutable by construction, so it is
97
+ * deliberately not expressible here (you cannot attempt to change it). To
98
+ * advertise a different axis, dispose and create a new advertiser.
99
+ */
100
+ update(publish: (size: AdvertisedSize) => void): void;
101
+ /**
102
+ * Stop observing, cancel any pending RAF. After dispose nothing is
103
+ * advertised again. (There is no ZERO/terminal value — collapsing is the
104
+ * host's policy, unlike the forward anchor's `present:false`.)
105
+ *
106
+ * The first advertised value is asynchronous: it awaits the observer's first
107
+ * frame, and a `display:none` target advertises nothing until shown.
108
+ */
109
+ dispose(): void;
110
+ }
111
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH;yEACyE;AACzE,MAAM,WAAW,MAAM;IACrB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,SAAS,GACjB;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAA;AAEtB,MAAM,WAAW,iBAAiB;IAChC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,sEAAsE;IACtE,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;CAClC;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,MAAM,CAAC,IAAI,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC;;iDAE6C;IAC7C,OAAO,IAAI,IAAI,CAAA;CAChB;AAUD;gEACgE;AAChE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAA;AAE/C;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B;4DACwD;IACxD,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;IAC7B;qBACiB;IACjB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,IAAI,EAAE,cAAc,CAAA;IACpB;kFAC8E;IAC9E,OAAO,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,CAAA;CACxC;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;;OASG;IACH,MAAM,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,GAAG,IAAI,CAAA;IACrD;;;;;;;OAOG;IACH,OAAO,IAAI,IAAI,CAAA;CAChB"}
package/dist/types.js ADDED
@@ -0,0 +1,18 @@
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
+ export {};
@@ -0,0 +1,111 @@
1
+ import type { Placement, ViewAnchorOptions, ViewAnchorHandle } from './types.js';
2
+ /**
3
+ * Create an anchor binding ONE native view's bounds to `target`'s geometry.
4
+ *
5
+ * Imperative core — no React, no Electron. Behaviour:
6
+ * - `present === true`: publish `target.getBoundingClientRect()` (x/y rounded,
7
+ * width/height `Math.max(0, Math.round(...))`) immediately, then re-publish
8
+ * SYNCHRONOUSLY on every `ResizeObserver` tick and window `resize`.
9
+ * - `present === false`: publish `{0,0,0,0}` immediately; do not observe.
10
+ * - `update(opts)`: re-apply synchronously.
11
+ * - `dispose()`: stop observing, never publish again.
12
+ *
13
+ * Synchronous, NOT RAF-deferred: the native overlay is a cross-process
14
+ * `WebContentsView` whose `setBounds` already lands ~1 compositor frame behind
15
+ * the renderer's DOM paint (the two processes composite on different frames).
16
+ * Deferring the measure+publish to a RAF stacked a SECOND frame on top — during
17
+ * a height/splitter drag that read as the overlay visibly trailing the region
18
+ * edge (worst when GROWING, where the not-yet-followed edge exposes background).
19
+ * Publishing in the observer tick itself removes that self-inflicted frame and
20
+ * leaves only the unavoidable cross-process frame (masked by matching the
21
+ * placeholder/desk background colour). The anti-flood role the RAF used to play
22
+ * — collapsing a burst of RO+resize ticks in one frame into one publish — is now
23
+ * served by `lastPublished` dedup: a tick whose measured rect is byte-identical
24
+ * to the last published one is dropped, so a continuous drag still emits at most
25
+ * one publish per distinct rect.
26
+ *
27
+ * Teardown safety: there is no queued frame to outrun a state change — every
28
+ * emit reads `disposed`/`present` synchronously, so a tick after
29
+ * `update`/`dispose` can never write a stale rect over the live one.
30
+ */
31
+ export declare function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle;
32
+ export interface PlacementAnchorOptions {
33
+ /**
34
+ * Caller's INTENT: should the native view be on-screen? `true` →
35
+ * publish the measured rect as `{ visible:true, bounds }`; `false` →
36
+ * publish `{ visible:false }`. Crucially, hiddenness comes from this
37
+ * flag, not from a measured zero size — so a legitimately 0-sized but
38
+ * visible target still publishes `visible:true`.
39
+ */
40
+ visible: boolean;
41
+ /** Receives each explicit Placement. Owns IPC → host. */
42
+ publish: (placement: Placement) => void;
43
+ /**
44
+ * Opt-in geometry detach. When true, a measured zero-area target (no
45
+ * geometry box — display:none / unmounted / unstable first layout) publishes
46
+ * `{ visible:false }` (detach-but-keep) instead of `{ visible:true,
47
+ * bounds:0×0 }`, and an IntersectionObserver is attached so a display:none
48
+ * transition (which ResizeObserver does not report) re-publishes. Default
49
+ * false keeps the legitimate 0×0-visible semantics.
50
+ */
51
+ guardDisplayNone?: boolean;
52
+ /**
53
+ * Opt-in capture-phase ancestor-scroll follow. When true, the anchor
54
+ * listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
55
+ * bubble, but reach `window` while capturing), so an ancestor scroll
56
+ * container scrolling the target re-measures and re-publishes. With
57
+ * `followGeometry` off, the scroll callback does a single synchronous
58
+ * `emit()`; with it on, the scroll OPENS the RAF sentinel window so the
59
+ * follow tracks every frame of a scroll burst. Default false.
60
+ */
61
+ followScroll?: boolean;
62
+ /**
63
+ * Opt-in windowed RAF geometry sentinel. Catches ancestor
64
+ * transform / reflow moves that no DOM event reports. The sentinel is
65
+ * NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
66
+ * splitter pointerdown, or an explicit `pulse()`), polls geometry once per
67
+ * animation frame publishing IN-FRAME, and AUTO-CLOSES once the rect goes
68
+ * steady (a few unchanged frames). While closed it schedules no frame, so the
69
+ * static cost when idle is exactly zero. Default false.
70
+ */
71
+ followGeometry?: boolean;
72
+ }
73
+ export interface PlacementAnchorHandle {
74
+ /** Apply new options; re-publishes immediately (mirrors `createViewAnchor`). */
75
+ update(opts: PlacementAnchorOptions): void;
76
+ /** Stop observing; never publish again. */
77
+ dispose(): void;
78
+ /**
79
+ * Open the RAF sentinel window (animation follow); auto-closes after going
80
+ * steady or after `durationMs`. No-op when `followGeometry` is false.
81
+ */
82
+ pulse(durationMs?: number): void;
83
+ }
84
+ /**
85
+ * Pure measure: read `target`'s rect and wrap it as an explicit visible
86
+ * Placement. Always `{ visible:true }` — hiddenness is a caller decision
87
+ * (see `createPlacementAnchor`), so this never returns `{ visible:false }`
88
+ * and never infers visibility from a 0 size. A collapsed (0×0) but present
89
+ * element therefore yields `{ visible:true, bounds:{...,width:0,height:0} }`,
90
+ * distinct from any hidden Placement.
91
+ */
92
+ export declare function measurePlacement(target: HTMLElement): Placement;
93
+ /**
94
+ * The explicit-Placement mirror of `createViewAnchor`. Same observer/dedup/
95
+ * teardown machinery, but the sink receives a `Placement`:
96
+ * - `visible === true` → publish `measurePlacement(target)` and re-publish
97
+ * SYNCHRONOUSLY on every `ResizeObserver`/`resize` tick.
98
+ * - `visible === false` → publish `{ visible:false }` (NOT a ZERO bounds);
99
+ * do not observe.
100
+ *
101
+ * Dedup carries the discriminant (`samePlacement`), so a visibility flip is
102
+ * never coalesced away.
103
+ *
104
+ * Opt-in `guardDisplayNone` (default false): when on, a measured zero-area
105
+ * target (display:none / unmounted / unstable first layout) publishes
106
+ * `{ visible:false }` instead of `{ visible:true, bounds:0×0 }`, and an
107
+ * IntersectionObserver is attached so a display:none transition (which
108
+ * ResizeObserver does not report) re-publishes.
109
+ */
110
+ export declare function createPlacementAnchor(target: HTMLElement, opts: PlacementAnchorOptions): PlacementAnchorHandle;
111
+ //# sourceMappingURL=view-anchor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view-anchor.d.ts","sourceRoot":"","sources":["../src/view-anchor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,SAAS,EACT,iBAAiB,EACjB,gBAAgB,EACjB,MAAM,YAAY,CAAA;AAwBnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,iBAAiB,GACtB,gBAAgB,CA6ElB;AASD,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,yDAAyD;IACzD,OAAO,EAAE,CAAC,SAAS,EAAE,SAAS,KAAK,IAAI,CAAA;IACvC;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,gFAAgF;IAChF,MAAM,CAAC,IAAI,EAAE,sBAAsB,GAAG,IAAI,CAAA;IAC1C,2CAA2C;IAC3C,OAAO,IAAI,IAAI,CAAA;IACf;;;OAGG;IACH,KAAK,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAM/D;AAiBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,sBAAsB,GAC3B,qBAAqB,CAuPvB"}