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.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/README.zh-CN.md +114 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/measure-loop.d.ts +43 -0
- package/dist/measure-loop.d.ts.map +1 -0
- package/dist/measure-loop.js +49 -0
- package/dist/react.d.ts +38 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +145 -0
- package/dist/size-advertiser.d.ts +21 -0
- package/dist/size-advertiser.d.ts.map +1 -0
- package/dist/size-advertiser.js +83 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/view-anchor.d.ts +111 -0
- package/dist/view-anchor.d.ts.map +1 -0
- package/dist/view-anchor.js +413 -0
- package/docs/anchor-3d.html +615 -0
- package/docs/bidirectional-design.md +140 -0
- package/docs/mechanism.mdx +119 -0
- package/package.json +83 -0
- package/src/index.ts +39 -0
- package/src/measure-loop.ts +87 -0
- package/src/react.ts +171 -0
- package/src/size-advertiser.ts +95 -0
- package/src/types.ts +123 -0
- package/src/view-anchor.ts +500 -0
|
@@ -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
|
+
}
|