view-anchor 0.1.2 → 0.2.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.
Files changed (46) hide show
  1. package/README.md +111 -39
  2. package/README.zh-CN.md +119 -47
  3. package/dist/index.d.ts +6 -18
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -15
  6. package/dist/measure-loop.d.ts +9 -28
  7. package/dist/measure-loop.d.ts.map +1 -1
  8. package/dist/measure-loop.js +57 -17
  9. package/dist/protocol-publisher.d.ts +41 -0
  10. package/dist/protocol-publisher.d.ts.map +1 -0
  11. package/dist/protocol-publisher.js +207 -0
  12. package/dist/protocol-types.d.ts +36 -0
  13. package/dist/protocol-types.d.ts.map +1 -0
  14. package/dist/protocol-types.js +10 -0
  15. package/dist/protocol.d.ts +35 -0
  16. package/dist/protocol.d.ts.map +1 -0
  17. package/dist/protocol.js +128 -0
  18. package/dist/react.d.ts +18 -30
  19. package/dist/react.d.ts.map +1 -1
  20. package/dist/react.js +125 -122
  21. package/dist/size-advertiser.d.ts +9 -14
  22. package/dist/size-advertiser.d.ts.map +1 -1
  23. package/dist/size-advertiser.js +29 -28
  24. package/dist/types.d.ts +29 -73
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js +1 -15
  27. package/dist/view-anchor.d.ts +36 -77
  28. package/dist/view-anchor.d.ts.map +1 -1
  29. package/dist/view-anchor.js +230 -181
  30. package/docs/bidirectional-design.md +78 -106
  31. package/docs/index.html +772 -0
  32. package/docs/mechanism.md +116 -0
  33. package/docs/performance-report.md +63 -0
  34. package/docs/protocol.md +108 -0
  35. package/package.json +37 -14
  36. package/src/index.ts +8 -24
  37. package/src/measure-loop.ts +56 -42
  38. package/src/protocol-publisher.ts +254 -0
  39. package/src/protocol-types.ts +43 -0
  40. package/src/protocol.ts +181 -0
  41. package/src/react.ts +175 -139
  42. package/src/size-advertiser.ts +33 -31
  43. package/src/types.ts +35 -82
  44. package/src/view-anchor.ts +259 -236
  45. package/docs/anchor-3d.html +0 -615
  46. package/docs/mechanism.mdx +0 -119
package/src/react.ts CHANGED
@@ -1,171 +1,207 @@
1
1
  import { useCallback, useEffect, useRef } from 'react'
2
- import { createViewAnchor } from './view-anchor.js'
2
+ import {
3
+ createPlacementAnchor,
4
+ createViewAnchor,
5
+ type PlacementAnchorHandle,
6
+ type PlacementAnchorOptions,
7
+ } from './view-anchor.js'
3
8
  import type { Bounds, ViewAnchorHandle, ViewAnchorOptions } from './types.js'
4
9
 
5
- /**
6
- * React adapter over the imperative `createViewAnchor` core.
7
- *
8
- * (React lint forces the `use` prefix on any hook returning a ref
9
- * callback; the library's identity is still the `ViewAnchor` core — this is
10
- * just the React binding.)
11
- */
12
10
  export interface UseViewAnchorOptions extends ViewAnchorOptions {
13
11
  /**
14
- * Non-DOM dependencies that move the target's rect and must force a
15
- * re-publish (layout signature, project path, a tab toggle's
16
- * `display:none`, …). A `ResizeObserver` covers pure geometry; `deps`
17
- * covers state it cannot see. Keep the array length stable across
18
- * renders (React effect-deps rule).
12
+ * Values that re-apply the anchor when changed. Keep this array's length
13
+ * stable across renders.
19
14
  */
20
15
  deps?: ReadonlyArray<unknown>
21
16
  }
22
17
 
23
- export type ViewAnchorRef = (el: HTMLElement | null) => void
24
-
25
- /**
26
- * Bind a native view's bounds to whichever DOM element the returned ref
27
- * callback is attached to. On attach → `createViewAnchor(el, opts)`; on
28
- * detach (`null`) publish ZERO then `dispose()`; on `opts`/`deps` change
29
- * `update`; on unmount → publish ZERO then `dispose`.
30
- *
31
- * Why ZERO on disappearance: the anchor's follower is a *main-process*
32
- * `WebContentsView`, not a DOM node. When the anchored element vanishes, core
33
- * `dispose()` only stops observing — it deliberately never publishes again
34
- * (its Contract 6/7). But the host only collapses the native view when it
35
- * receives `{0,0,0,0}` (isHidden). In production the debug cell is *unmounted*
36
- * (not `display:none`) when hidden, so the ref goes to `null` and the native
37
- * view would otherwise stay frozen at its last bounds, floating on
38
- * top and occluding content. So the adapter (not core) must emit one ZERO via
39
- * the already-tested `update({ present:false })` path before disposing.
40
- */
41
- export function useViewAnchor(opts: UseViewAnchorOptions): ViewAnchorRef {
42
- const handleRef = useRef<ViewAnchorHandle | null>(null)
43
- const elRef = useRef<HTMLElement | null>(null)
44
- // Latest opts, read by the stable ref callback when it creates the anchor.
45
- // Synced render-synchronously (NOT in an effect): the ref callback reads
46
- // `optsRef.current` during *commit* (when the element attaches), which runs
47
- // before passive effects. An effect-synced ref would be one render stale at
48
- // that point, so a hidden→shown remount (`present` flips false→true together
49
- // with the element re-mounting, exactly what the debug cell does) would
50
- // create the anchor with the old `present:false` and emit a spurious ZERO
51
- // before the real rect. A render write keeps it current at commit, and is
52
- // idempotent under StrictMode's double render.
53
- const optsRef = useRef(opts)
54
- // eslint-disable-next-line react-hooks/refs -- see above: must be current at commit, before effects run
55
- optsRef.current = opts
56
-
57
- // Baseline for the re-apply effect's change detection. Declared here (before
58
- // the ref callback) so the callback can re-seed it on (re)create.
59
- const appliedRef = useRef<ReadonlyArray<unknown>>([
60
- opts.present,
61
- opts.publish,
62
- ...(opts.deps ?? []),
63
- ])
64
-
65
- // Collapse the native view (publish ZERO) and tear the anchor down. Reuse
66
- // the existing, tested `update({ present:false })` path: it synchronously
67
- // publishes `{0,0,0,0}` and stops observing (core Contract 5), then
68
- // `dispose()` makes the anchor inert. Idempotent via the `handleRef.current`
69
- // null-check so the two callers below can never double-emit ZERO.
70
- const collapseAndDispose = useRef((): void => {
18
+ /** Compatible with React 18's null callback and React 19's ref cleanup. */
19
+ export type ViewAnchorRef = (el: HTMLElement | null) => void | (() => void)
20
+
21
+ export interface UsePlacementAnchorOptions extends PlacementAnchorOptions {
22
+ /**
23
+ * Values that re-apply the anchor when changed. Keep this array's length
24
+ * stable across renders.
25
+ */
26
+ deps?: ReadonlyArray<unknown>
27
+ }
28
+
29
+ /** Callback ref for the explicit-visibility Placement API. */
30
+ export type PlacementAnchorRef = ViewAnchorRef
31
+
32
+ type AnchorHandle = { dispose(): void }
33
+
34
+ interface LifecycleAdapter<Options, Handle extends AnchorHandle> {
35
+ create(target: HTMLElement, options: Options): Handle
36
+ update(handle: Handle, options: Options): void
37
+ collapse(handle: Handle, options: Options): void
38
+ isCollapsed(options: Options): boolean
39
+ }
40
+
41
+ // Callback refs own the imperative anchor because React invokes them during commit.
42
+ // React 19 may call the cleanup returned from a ref and immediately reattach the
43
+ // same element in development mode. Collapse is deferred by one microtask so that
44
+ // immediate reattachment cancels the collapse.
45
+ function useAnchorRef<Options, Handle extends AnchorHandle>(
46
+ options: Options,
47
+ applied: ReadonlyArray<unknown>,
48
+ adapter: LifecycleAdapter<Options, Handle>,
49
+ ): ViewAnchorRef {
50
+ const handleRef = useRef<Handle | null>(null)
51
+ const elementRef = useRef<HTMLElement | null>(null)
52
+ const optionsRef = useRef(options)
53
+ // eslint-disable-next-line react-hooks/refs
54
+ optionsRef.current = options
55
+ const adapterRef = useRef(adapter)
56
+ // eslint-disable-next-line react-hooks/refs
57
+ adapterRef.current = adapter
58
+ const appliedRef = useRef(applied)
59
+ const currentAppliedRef = useRef(applied)
60
+ // eslint-disable-next-line react-hooks/refs
61
+ currentAppliedRef.current = applied
62
+ // Options handed to the adapter on the last create/update call.
63
+ // Tracks applied state across renders where the deps array reference changes.
64
+ const lastAppliedOptionsRef = useRef(options)
65
+ const detachTokenRef = useRef(0)
66
+
67
+ const cancelPendingDetach = (): void => {
68
+ detachTokenRef.current++
69
+ }
70
+
71
+ const collapseAndDispose = (): void => {
71
72
  const handle = handleRef.current
72
73
  if (!handle) return
73
- handle.update({ present: false, publish: optsRef.current.publish })
74
- handle.dispose()
75
- handleRef.current = null
76
- })
77
-
78
- const ref = useCallback<ViewAnchorRef>((el) => {
79
- if (el === elRef.current) return
80
- elRef.current = el
74
+ const adapter = adapterRef.current
75
+ const alreadyCollapsed = adapter.isCollapsed(lastAppliedOptionsRef.current)
76
+ try {
77
+ if (!alreadyCollapsed) adapter.collapse(handle, optionsRef.current)
78
+ } finally {
79
+ handleRef.current = null
80
+ handle.dispose()
81
+ }
82
+ }
83
+
84
+ const deferDetach = (element: HTMLElement): void => {
85
+ const token = ++detachTokenRef.current
86
+ queueMicrotask(() => {
87
+ if (detachTokenRef.current !== token || elementRef.current !== element) return
88
+ elementRef.current = null
89
+ collapseAndDispose()
90
+ })
91
+ }
92
+
93
+ const ref = useCallback<ViewAnchorRef>((element) => {
94
+ if (element === elementRef.current) {
95
+ cancelPendingDetach()
96
+ return element ? () => deferDetach(element) : undefined
97
+ }
98
+
99
+ cancelPendingDetach()
100
+ const previous = elementRef.current
81
101
  if (handleRef.current) {
82
- if (el) {
83
- // Swapping to *another* live element: dispose the old anchor without a
84
- // ZERO. The new element publishes its real rect immediately below, so
85
- // a transient ZERO between the two would only cause a needless
86
- // detach/re-attach flicker of the native view.
102
+ if (element) {
87
103
  handleRef.current.dispose()
88
104
  handleRef.current = null
89
- } else {
90
- // Element detached (ref → null): the anchor point is gone, so collapse
91
- // the native view (one ZERO) before disposing.
92
- collapseAndDispose.current()
105
+ } else if (previous) {
106
+ deferDetach(previous)
107
+ return undefined
93
108
  }
94
109
  }
95
- if (el) {
96
- handleRef.current = createViewAnchor(el, {
97
- present: optsRef.current.present,
98
- publish: optsRef.current.publish,
99
- })
100
- // The anchor was just created at the current (present, publish, deps), so
101
- // seed the re-apply baseline to match. Otherwise the post-commit re-apply
102
- // effect would see this fresh state as a change and publish a second time
103
- // — on a remount with a changed `present` that is a double-emit.
104
- appliedRef.current = [
105
- optsRef.current.present,
106
- optsRef.current.publish,
107
- ...(optsRef.current.deps ?? []),
108
- ]
110
+
111
+ if (element) {
112
+ elementRef.current = element
113
+ handleRef.current = adapterRef.current.create(element, optionsRef.current)
114
+ appliedRef.current = currentAppliedRef.current
115
+ lastAppliedOptionsRef.current = optionsRef.current
116
+ return () => deferDetach(element)
109
117
  }
118
+ return undefined
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
110
120
  }, [])
111
121
 
112
- // Re-apply on opts/deps change. We must `update` whenever the
113
- // (present, publish, …deps) tuple actually changes, but NOT on the mount run
114
- // (the ref callback already created the anchor and published once) and NOT on
115
- // a StrictMode replay (dev double-fires this effect's setup with the *same*
116
- // tuple — a blind `update` then re-publishes the mount rect a second time).
117
- // So instead of guessing "is this the first run?", compare against the
118
- // last-applied tuple and apply only on a genuine change. The tuple is seeded
119
- // with the mount opts, so the mount run and its StrictMode replay both see
120
- // "unchanged" and skip — idempotent by construction. `deps` keeps a stable
121
- // length across renders (documented above), so positional compare is sound.
122
122
  useEffect(() => {
123
- const next: ReadonlyArray<unknown> = [
124
- opts.present,
125
- opts.publish,
126
- ...(opts.deps ?? []),
127
- ]
128
- const prev = appliedRef.current
123
+ const previous = appliedRef.current
129
124
  const changed =
130
- next.length !== prev.length || next.some((v, i) => !Object.is(v, prev[i]))
125
+ applied.length !== previous.length ||
126
+ applied.some((value, index) => !Object.is(value, previous[index]))
131
127
  if (!changed) return
132
- appliedRef.current = next
133
- handleRef.current?.update({ present: opts.present, publish: opts.publish })
128
+ appliedRef.current = applied
129
+ const handle = handleRef.current
130
+ if (handle) {
131
+ adapterRef.current.update(handle, optionsRef.current)
132
+ lastAppliedOptionsRef.current = optionsRef.current
133
+ }
134
134
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
- }, [opts.present, opts.publish, ...(opts.deps ?? [])])
136
-
137
- // Collapse the native view + dispose on teardown.
138
- //
139
- // StrictMode-safe lifecycle: this effect's setup/cleanup is double-fired in
140
- // dev (setup → cleanup → setup). The anchor itself is created/owned by the
141
- // ref callback, which in React 18 fires exactly once on mount and once with
142
- // `null` on a real detach — it is NOT replayed by StrictMode. So this effect
143
- // must not destroy the ref-owned anchor on a *throwaway* unmount, or the
144
- // re-setup would have nothing to restore.
145
- //
146
- // Discriminator: on a real teardown React detaches the element first
147
- // (`ref(null)` → `elRef.current === null`, and that path already emitted the
148
- // single ZERO + disposed); on a StrictMode throwaway unmount the element is
149
- // still attached (`elRef.current !== null`, ref never fired `null`). So we
150
- // only collapse here when the element is genuinely gone, and otherwise leave
151
- // the live anchor intact for the immediate re-setup.
152
- //
153
- // The setup re-establishes the anchor if a prior cleanup ever tore it down
154
- // while the element is still attached, keeping setup/cleanup symmetric.
135
+ }, applied)
136
+
155
137
  useEffect(() => {
156
- const collapse = collapseAndDispose.current
157
- if (elRef.current && !handleRef.current) {
158
- handleRef.current = createViewAnchor(elRef.current, {
159
- present: optsRef.current.present,
160
- publish: optsRef.current.publish,
161
- })
162
- }
138
+ cancelPendingDetach()
163
139
  return () => {
164
- if (elRef.current === null) collapse()
140
+ const element = elementRef.current
141
+ if (element) deferDetach(element)
165
142
  }
143
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
166
144
  }, [])
167
145
 
168
146
  return ref
169
147
  }
170
148
 
149
+ const viewAdapter: LifecycleAdapter<ViewAnchorOptions, ViewAnchorHandle> = {
150
+ create: createViewAnchor,
151
+ update(handle, options) {
152
+ handle.update(options)
153
+ },
154
+ collapse(handle, options) {
155
+ handle.update({ present: false, publish: options.publish })
156
+ },
157
+ isCollapsed(options) {
158
+ return !options.present
159
+ },
160
+ }
161
+
162
+ /** Bind zero-bounds visibility to a DOM element callback ref. */
163
+ export function useViewAnchor(options: UseViewAnchorOptions): ViewAnchorRef {
164
+ return useAnchorRef(
165
+ options,
166
+ [options.present, options.publish, ...(options.deps ?? [])],
167
+ viewAdapter,
168
+ )
169
+ }
170
+
171
+ const placementAdapter: LifecycleAdapter<PlacementAnchorOptions, PlacementAnchorHandle> = {
172
+ create: createPlacementAnchor,
173
+ update(handle, options) {
174
+ // In React, an omitted option represents "off" for that render,
175
+ // rather than keeping the previous value.
176
+ handle.update({
177
+ ...options,
178
+ guardDisplayNone: options.guardDisplayNone ?? false,
179
+ followScroll: options.followScroll ?? false,
180
+ followGeometry: options.followGeometry ?? false,
181
+ })
182
+ },
183
+ collapse(handle, options) {
184
+ handle.update({ ...options, visible: false })
185
+ },
186
+ isCollapsed(options) {
187
+ return !options.visible
188
+ },
189
+ }
190
+
191
+ /** Bind the explicit Placement API to a DOM element callback ref. */
192
+ export function usePlacementAnchor(options: UsePlacementAnchorOptions): PlacementAnchorRef {
193
+ return useAnchorRef(
194
+ options,
195
+ [
196
+ options.visible,
197
+ options.publish,
198
+ options.guardDisplayNone,
199
+ options.followScroll,
200
+ options.followGeometry,
201
+ ...(options.deps ?? []),
202
+ ],
203
+ placementAdapter,
204
+ )
205
+ }
206
+
171
207
  export type { Bounds }
@@ -1,54 +1,56 @@
1
1
  import type {
2
2
  AdvertisedSize,
3
+ Publisher,
3
4
  SizeAdvertiserOptions,
4
5
  SizeAdvertiserHandle,
5
6
  } from './types.js'
6
7
  import { createMeasureLoop } from './measure-loop.js'
7
8
 
9
+ // Replaces a disposed instance's publish callback so a retained handle does
10
+ // not keep the caller's original callback (and whatever it captured) alive.
11
+ const NOOP_PUBLISH = (): false => false
12
+
8
13
  /**
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
+ * Report content size for a single axis back to the host.
15
+ *
16
+ * Runs in a downstream document, reads the content size from
17
+ * `ResizeObserverEntry.borderBoxSize` without triggering reflow, and
18
+ * publishes updates through an animation frame loop (`createMeasureLoop`).
14
19
  *
15
- * The extent is `Math.round`ed and clamped to `>= 0`; non-finite measurements
16
- * drop the frame.
20
+ * Measurements are rounded to integer pixels and clamped to >= 0.
17
21
  *
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.
22
+ * Note: `target` should be a shrink-to-fit wrapper on the owned axis.
23
+ * If its size is driven by the host view itself (such as `<body>` or `<html>`),
24
+ * updates will not shrink back to content size.
25
25
  */
26
26
  export function createSizeAdvertiser(
27
27
  target: HTMLElement,
28
28
  opts: SizeAdvertiserOptions,
29
29
  ): SizeAdvertiserHandle {
30
- const axis = opts.axis // immutable for the advertiser's life
30
+ const axis = opts.axis
31
31
  let publish = opts.publish
32
32
  let observer: ResizeObserver | null = null
33
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).
34
+ // Latest border-box recorded by the ResizeObserver callback.
36
35
  let latest: ResizeObserverSize | null = null
37
36
 
38
- const produce = (): AdvertisedSize | null => {
37
+ const produce = (): number | null => {
39
38
  if (!latest) return null
40
39
  const raw = axis === 'block' ? latest.blockSize : latest.inlineSize
41
40
  if (!Number.isFinite(raw)) return null
42
- return { axis, extent: Math.max(0, Math.round(raw)) }
41
+ return Math.max(0, Math.round(raw))
43
42
  }
44
43
 
45
- const loop = createMeasureLoop<AdvertisedSize>({
44
+ const loop = createMeasureLoop<number>({
46
45
  produce,
47
- same: (a, b) => a.extent === b.extent, // axis is constant
48
- sink: (size) => publish(size),
46
+ same: (a, b) => a === b,
47
+ sink: (extent) => publish({ axis, extent }),
49
48
  })
50
49
 
51
50
  const onResize: ResizeObserverCallback = (entries) => {
51
+ // A callback queued before disconnect() can still fire once more; do not
52
+ // let it write `latest` after dispose() has already cleared it.
53
+ if (disposed) return
52
54
  const entry = entries[entries.length - 1]
53
55
  if (entry) {
54
56
  latest = entry.borderBoxSize?.[0] ?? entry.contentBoxSize?.[0] ?? latest
@@ -56,14 +58,13 @@ export function createSizeAdvertiser(
56
58
  loop.schedule()
57
59
  }
58
60
 
59
- // One cheap, once-per-advertiser guard for the textbook feedback-loop footgun.
61
+ // Warn if measuring body or documentElement, whose size matches the view.
60
62
  const doc = target.ownerDocument
61
63
  if (target === doc.body || target === doc.documentElement) {
62
64
  console.warn(
63
65
  `[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.`,
66
+ `${axis} size is the view size, not content size. The advertiser will ` +
67
+ `never shrink to content; measure a shrink-to-fit wrapper instead.`,
67
68
  )
68
69
  }
69
70
 
@@ -72,14 +73,13 @@ export function createSizeAdvertiser(
72
73
  observer.observe(target)
73
74
 
74
75
  return {
75
- update(nextPublish: (size: AdvertisedSize) => void): void {
76
+ update(nextPublish: Publisher<AdvertisedSize>): void {
76
77
  if (disposed) return
77
78
  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.
79
+ // Re-publish the current size to the new sink immediately so it is not
80
+ // empty until the next ResizeObserver tick.
81
81
  const cur = produce()
82
- if (cur) loop.emitNow(cur)
82
+ if (cur !== null) loop.emitNow(cur)
83
83
  },
84
84
  dispose(): void {
85
85
  if (disposed) return
@@ -90,6 +90,8 @@ export function createSizeAdvertiser(
90
90
  observer = null
91
91
  }
92
92
  loop.dispose()
93
+ publish = NOOP_PUBLISH
94
+ latest = null
93
95
  },
94
96
  }
95
97
  }
package/src/types.ts CHANGED
@@ -1,23 +1,8 @@
1
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`.
2
+ * Core geometry and transport types for view-anchor.
17
3
  */
18
4
 
19
- /** A screen-space rectangle, in CSS pixels. Structurally compatible with
20
- * the host's `ViewBounds` so a publisher typed against either works. */
5
+ /** A screen-space rectangle in CSS pixels. */
21
6
  export interface Bounds {
22
7
  x: number
23
8
  y: number
@@ -25,99 +10,67 @@ export interface Bounds {
25
10
  height: number
26
11
  }
27
12
 
13
+ /** Returning false declines a value; true and void accept it. */
14
+ export type PublishResult = void | boolean
15
+
28
16
  /**
29
- * Explicit visibility + geometry for a native view, replacing the legacy
30
- * magic-`{0,0,0,0}` "hidden" convention (`present:false ZERO bounds`).
17
+ * Synchronous publish callback. A batching transport returns true once
18
+ * the value is queued; subsequent delivery is handled by the transport.
19
+ */
20
+ export type Publisher<T> = (value: T) => PublishResult
21
+
22
+ /**
23
+ * Explicit visibility and bounds for an anchored view.
31
24
  *
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.
25
+ * Distinguishes an intentionally visible but zero-sized element ({ visible: true, bounds: 0x0 })
26
+ * from a hidden or detached element ({ visible: false }).
38
27
  */
39
- export type Placement =
40
- | { visible: true; bounds: Bounds }
41
- | { visible: false }
28
+ export type Placement = { visible: true; bounds: Bounds } | { visible: false }
42
29
 
43
30
  export interface ViewAnchorOptions {
44
31
  /**
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.
32
+ * Whether the native view should be attached. When false, publishes
33
+ * zero bounds ({ x: 0, y: 0, width: 0, height: 0 }) so the host can detach
34
+ * the view while keeping its instance alive.
50
35
  */
51
36
  present: boolean
52
- /** Receives the live rect, or `{0,0,0,0}` when detached. Owns IPC. */
53
- publish: (bounds: Bounds) => void
37
+ /** Receives the live rect, or zero bounds when detached. */
38
+ publish: Publisher<Bounds>
54
39
  }
55
40
 
56
41
  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
- */
42
+ /** Apply new options and re-publish immediately. */
61
43
  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). */
44
+ /** Stop observing and clean up listeners. After disposal no further values are published. */
65
45
  dispose(): void
66
46
  }
67
47
 
68
- // ── Reverse direction: size advertiser ───────────────────────────────
48
+ // --- Reverse direction: size advertiser ---
69
49
  //
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.
50
+ // Runs in a downstream document to report content size back to the host,
51
+ // allowing the host's DOM placeholder to match the content.
75
52
 
76
- /** Which axis this advertiser owns. `block` = height, `inline` = width
77
- * (logical-property naming, axis-agnostic to writing mode). */
53
+ /** Which axis this advertiser reports: 'block' (height) or 'inline' (width). */
78
54
  export type AdvertisedAxis = 'block' | 'inline'
79
55
 
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
- */
56
+ /** One frame of advertised size on the owned axis. */
85
57
  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. */
58
+ /** The axis this advertiser reports ('block' or 'inline'). */
88
59
  readonly axis: AdvertisedAxis
89
- /** The owned axis's content extent, in CSS px — already rounded and clamped
90
- * to `>= 0`. */
60
+ /** The content extent in CSS pixels, rounded and non-negative. */
91
61
  readonly extent: number
92
62
  }
93
63
 
94
64
  export interface SizeAdvertiserOptions {
95
- /** The single axis this advertiser owns. Fixed for the advertiser's life. */
65
+ /** The single axis this advertiser owns. Fixed for the advertiser's lifetime. */
96
66
  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
67
+ /** Receives each advertised size. */
68
+ publish: Publisher<AdvertisedSize>
100
69
  }
101
70
 
102
71
  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
- */
72
+ /** Swap the publish callback and re-advertise the current size immediately. */
73
+ update(publish: Publisher<AdvertisedSize>): void
74
+ /** Stop observing and cancel any pending animation frame. */
122
75
  dispose(): void
123
76
  }