view-anchor 0.1.2 → 0.2.0
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/README.md +118 -34
- package/README.zh-CN.md +128 -44
- package/dist/index.d.ts +4 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -14
- package/dist/measure-loop.d.ts +9 -28
- package/dist/measure-loop.d.ts.map +1 -1
- package/dist/measure-loop.js +37 -16
- package/dist/protocol-publisher.d.ts +41 -0
- package/dist/protocol-publisher.d.ts.map +1 -0
- package/dist/protocol-publisher.js +191 -0
- package/dist/protocol-types.d.ts +36 -0
- package/dist/protocol-types.d.ts.map +1 -0
- package/dist/protocol-types.js +10 -0
- package/dist/protocol.d.ts +35 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +131 -0
- package/dist/react.d.ts +18 -30
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +125 -122
- package/dist/size-advertiser.d.ts +9 -14
- package/dist/size-advertiser.d.ts.map +1 -1
- package/dist/size-advertiser.js +20 -28
- package/dist/types.d.ts +29 -73
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -15
- package/dist/view-anchor.d.ts +36 -77
- package/dist/view-anchor.d.ts.map +1 -1
- package/dist/view-anchor.js +206 -174
- package/docs/bidirectional-design.md +64 -96
- package/docs/{anchor-3d.html → index.html} +215 -73
- package/docs/mechanism.mdx +55 -49
- package/docs/performance-report.md +63 -0
- package/docs/protocol.md +79 -0
- package/package.json +30 -4
- package/src/index.ts +6 -15
- package/src/measure-loop.ts +36 -41
- package/src/protocol-publisher.ts +236 -0
- package/src/protocol-types.ts +43 -0
- package/src/protocol.ts +193 -0
- package/src/react.ts +186 -141
- package/src/size-advertiser.ts +24 -31
- package/src/types.ts +34 -79
- package/src/view-anchor.ts +228 -212
package/src/react.ts
CHANGED
|
@@ -1,171 +1,216 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
2
|
+
import {
|
|
3
|
+
createPlacementAnchor,
|
|
4
|
+
createViewAnchor,
|
|
5
|
+
type PlacementAnchorHandle,
|
|
6
|
+
type PlacementAnchorOptions,
|
|
7
|
+
} from './view-anchor.js'
|
|
8
|
+
import type {
|
|
9
|
+
Bounds,
|
|
10
|
+
ViewAnchorHandle,
|
|
11
|
+
ViewAnchorOptions,
|
|
12
|
+
} from './types.js'
|
|
13
|
+
|
|
12
14
|
export interface UseViewAnchorOptions extends ViewAnchorOptions {
|
|
13
15
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
* Values that re-apply the anchor when changed. Keep this array's length
|
|
17
|
+
* stable across renders.
|
|
18
|
+
*/
|
|
19
|
+
deps?: ReadonlyArray<unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Compatible with React 18's null callback and React 19's ref cleanup. */
|
|
23
|
+
export type ViewAnchorRef = (el: HTMLElement | null) => void | (() => void)
|
|
24
|
+
|
|
25
|
+
export interface UsePlacementAnchorOptions extends PlacementAnchorOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Values that re-apply the anchor when changed. Keep this array's length
|
|
28
|
+
* stable across renders.
|
|
19
29
|
*/
|
|
20
30
|
deps?: ReadonlyArray<unknown>
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
// eslint-disable-next-line react-hooks/refs
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 => {
|
|
33
|
+
/** Callback ref for the explicit-visibility Placement API. */
|
|
34
|
+
export type PlacementAnchorRef = ViewAnchorRef
|
|
35
|
+
|
|
36
|
+
type AnchorHandle = { dispose(): void }
|
|
37
|
+
|
|
38
|
+
interface LifecycleAdapter<Options, Handle extends AnchorHandle> {
|
|
39
|
+
create(target: HTMLElement, options: Options): Handle
|
|
40
|
+
update(handle: Handle, options: Options): void
|
|
41
|
+
collapse(handle: Handle, options: Options): void
|
|
42
|
+
isCollapsed(options: Options): boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Callback refs own the imperative anchor because React invokes them during commit.
|
|
46
|
+
// React 19 may call the cleanup returned from a ref and immediately reattach the
|
|
47
|
+
// same element in development mode. Collapse is deferred by one microtask so that
|
|
48
|
+
// immediate reattachment cancels the collapse.
|
|
49
|
+
function useAnchorRef<Options, Handle extends AnchorHandle>(
|
|
50
|
+
options: Options,
|
|
51
|
+
applied: ReadonlyArray<unknown>,
|
|
52
|
+
adapter: LifecycleAdapter<Options, Handle>,
|
|
53
|
+
): ViewAnchorRef {
|
|
54
|
+
const handleRef = useRef<Handle | null>(null)
|
|
55
|
+
const elementRef = useRef<HTMLElement | null>(null)
|
|
56
|
+
const optionsRef = useRef(options)
|
|
57
|
+
// eslint-disable-next-line react-hooks/refs
|
|
58
|
+
optionsRef.current = options
|
|
59
|
+
const adapterRef = useRef(adapter)
|
|
60
|
+
// eslint-disable-next-line react-hooks/refs
|
|
61
|
+
adapterRef.current = adapter
|
|
62
|
+
const appliedRef = useRef(applied)
|
|
63
|
+
const currentAppliedRef = useRef(applied)
|
|
64
|
+
// eslint-disable-next-line react-hooks/refs
|
|
65
|
+
currentAppliedRef.current = applied
|
|
66
|
+
// Options handed to the adapter on the last create/update call.
|
|
67
|
+
// Tracks applied state across renders where the deps array reference changes.
|
|
68
|
+
const lastAppliedOptionsRef = useRef(options)
|
|
69
|
+
const detachTokenRef = useRef(0)
|
|
70
|
+
|
|
71
|
+
const cancelPendingDetach = (): void => {
|
|
72
|
+
detachTokenRef.current++
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const collapseAndDispose = (): void => {
|
|
71
76
|
const handle = handleRef.current
|
|
72
77
|
if (!handle) return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
const adapter = adapterRef.current
|
|
79
|
+
const alreadyCollapsed = adapter.isCollapsed(lastAppliedOptionsRef.current)
|
|
80
|
+
try {
|
|
81
|
+
if (!alreadyCollapsed) adapter.collapse(handle, optionsRef.current)
|
|
82
|
+
} finally {
|
|
83
|
+
handleRef.current = null
|
|
84
|
+
handle.dispose()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const deferDetach = (element: HTMLElement): void => {
|
|
89
|
+
const token = ++detachTokenRef.current
|
|
90
|
+
queueMicrotask(() => {
|
|
91
|
+
if (detachTokenRef.current !== token || elementRef.current !== element) return
|
|
92
|
+
elementRef.current = null
|
|
93
|
+
collapseAndDispose()
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ref = useCallback<ViewAnchorRef>((element) => {
|
|
98
|
+
if (element === elementRef.current) {
|
|
99
|
+
cancelPendingDetach()
|
|
100
|
+
return element ? () => deferDetach(element) : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
cancelPendingDetach()
|
|
104
|
+
const previous = elementRef.current
|
|
81
105
|
if (handleRef.current) {
|
|
82
|
-
if (
|
|
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.
|
|
106
|
+
if (element) {
|
|
87
107
|
handleRef.current.dispose()
|
|
88
108
|
handleRef.current = null
|
|
89
|
-
} else {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
collapseAndDispose.current()
|
|
109
|
+
} else if (previous) {
|
|
110
|
+
deferDetach(previous)
|
|
111
|
+
return undefined
|
|
93
112
|
}
|
|
94
113
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
]
|
|
114
|
+
|
|
115
|
+
if (element) {
|
|
116
|
+
elementRef.current = element
|
|
117
|
+
handleRef.current = adapterRef.current.create(element, optionsRef.current)
|
|
118
|
+
appliedRef.current = currentAppliedRef.current
|
|
119
|
+
lastAppliedOptionsRef.current = optionsRef.current
|
|
120
|
+
return () => deferDetach(element)
|
|
109
121
|
}
|
|
122
|
+
return undefined
|
|
123
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
|
|
110
124
|
}, [])
|
|
111
125
|
|
|
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
126
|
useEffect(() => {
|
|
123
|
-
const
|
|
124
|
-
opts.present,
|
|
125
|
-
opts.publish,
|
|
126
|
-
...(opts.deps ?? []),
|
|
127
|
-
]
|
|
128
|
-
const prev = appliedRef.current
|
|
127
|
+
const previous = appliedRef.current
|
|
129
128
|
const changed =
|
|
130
|
-
|
|
129
|
+
applied.length !== previous.length ||
|
|
130
|
+
applied.some((value, index) => !Object.is(value, previous[index]))
|
|
131
131
|
if (!changed) return
|
|
132
|
-
appliedRef.current =
|
|
133
|
-
|
|
132
|
+
appliedRef.current = applied
|
|
133
|
+
const handle = handleRef.current
|
|
134
|
+
if (handle) {
|
|
135
|
+
adapterRef.current.update(handle, optionsRef.current)
|
|
136
|
+
lastAppliedOptionsRef.current = optionsRef.current
|
|
137
|
+
}
|
|
134
138
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
135
|
-
},
|
|
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.
|
|
139
|
+
}, applied)
|
|
140
|
+
|
|
155
141
|
useEffect(() => {
|
|
156
|
-
|
|
157
|
-
if (elRef.current && !handleRef.current) {
|
|
158
|
-
handleRef.current = createViewAnchor(elRef.current, {
|
|
159
|
-
present: optsRef.current.present,
|
|
160
|
-
publish: optsRef.current.publish,
|
|
161
|
-
})
|
|
162
|
-
}
|
|
142
|
+
cancelPendingDetach()
|
|
163
143
|
return () => {
|
|
164
|
-
|
|
144
|
+
const element = elementRef.current
|
|
145
|
+
if (element) deferDetach(element)
|
|
165
146
|
}
|
|
147
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
|
|
166
148
|
}, [])
|
|
167
149
|
|
|
168
150
|
return ref
|
|
169
151
|
}
|
|
170
152
|
|
|
153
|
+
const viewAdapter: LifecycleAdapter<ViewAnchorOptions, ViewAnchorHandle> = {
|
|
154
|
+
create: createViewAnchor,
|
|
155
|
+
update(handle, options) {
|
|
156
|
+
handle.update(options)
|
|
157
|
+
},
|
|
158
|
+
collapse(handle, options) {
|
|
159
|
+
handle.update({ present: false, publish: options.publish })
|
|
160
|
+
},
|
|
161
|
+
isCollapsed(options) {
|
|
162
|
+
return !options.present
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Bind zero-bounds visibility to a DOM element callback ref. */
|
|
167
|
+
export function useViewAnchor(options: UseViewAnchorOptions): ViewAnchorRef {
|
|
168
|
+
return useAnchorRef(
|
|
169
|
+
options,
|
|
170
|
+
[options.present, options.publish, ...(options.deps ?? [])],
|
|
171
|
+
viewAdapter,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const placementAdapter: LifecycleAdapter<
|
|
176
|
+
PlacementAnchorOptions,
|
|
177
|
+
PlacementAnchorHandle
|
|
178
|
+
> = {
|
|
179
|
+
create: createPlacementAnchor,
|
|
180
|
+
update(handle, options) {
|
|
181
|
+
// In React, an omitted option represents "off" for that render,
|
|
182
|
+
// rather than keeping the previous value.
|
|
183
|
+
handle.update({
|
|
184
|
+
...options,
|
|
185
|
+
guardDisplayNone: options.guardDisplayNone ?? false,
|
|
186
|
+
followScroll: options.followScroll ?? false,
|
|
187
|
+
followGeometry: options.followGeometry ?? false,
|
|
188
|
+
})
|
|
189
|
+
},
|
|
190
|
+
collapse(handle, options) {
|
|
191
|
+
handle.update({ ...options, visible: false })
|
|
192
|
+
},
|
|
193
|
+
isCollapsed(options) {
|
|
194
|
+
return !options.visible
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Bind the explicit Placement API to a DOM element callback ref. */
|
|
199
|
+
export function usePlacementAnchor(
|
|
200
|
+
options: UsePlacementAnchorOptions,
|
|
201
|
+
): PlacementAnchorRef {
|
|
202
|
+
return useAnchorRef(
|
|
203
|
+
options,
|
|
204
|
+
[
|
|
205
|
+
options.visible,
|
|
206
|
+
options.publish,
|
|
207
|
+
options.guardDisplayNone,
|
|
208
|
+
options.followScroll,
|
|
209
|
+
options.followGeometry,
|
|
210
|
+
...(options.deps ?? []),
|
|
211
|
+
],
|
|
212
|
+
placementAdapter,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
171
216
|
export type { Bounds }
|
package/src/size-advertiser.ts
CHANGED
|
@@ -1,51 +1,46 @@
|
|
|
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
|
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
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`).
|
|
10
|
+
* Report content size for a single axis back to the host.
|
|
14
11
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Runs in a downstream document, reads the content size from
|
|
13
|
+
* `ResizeObserverEntry.borderBoxSize` without triggering reflow, and
|
|
14
|
+
* publishes updates through an animation frame loop (`createMeasureLoop`).
|
|
17
15
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* `docs/bidirectional-design.md`'s single-axis-ownership and trust-boundary
|
|
24
|
-
* sections.
|
|
16
|
+
* Measurements are rounded to integer pixels and clamped to >= 0.
|
|
17
|
+
*
|
|
18
|
+
* Note: `target` should be a shrink-to-fit wrapper on the owned axis.
|
|
19
|
+
* If its size is driven by the host view itself (such as `<body>` or `<html>`),
|
|
20
|
+
* updates will not shrink back to content size.
|
|
25
21
|
*/
|
|
26
22
|
export function createSizeAdvertiser(
|
|
27
23
|
target: HTMLElement,
|
|
28
24
|
opts: SizeAdvertiserOptions,
|
|
29
25
|
): SizeAdvertiserHandle {
|
|
30
|
-
const axis = opts.axis
|
|
26
|
+
const axis = opts.axis
|
|
31
27
|
let publish = opts.publish
|
|
32
28
|
let observer: ResizeObserver | null = null
|
|
33
29
|
let disposed = false
|
|
34
|
-
// Latest border-box
|
|
35
|
-
// RAF body (keep the entry out of the shared, DOM-agnostic loop).
|
|
30
|
+
// Latest border-box recorded by the ResizeObserver callback.
|
|
36
31
|
let latest: ResizeObserverSize | null = null
|
|
37
32
|
|
|
38
|
-
const produce = ():
|
|
33
|
+
const produce = (): number | null => {
|
|
39
34
|
if (!latest) return null
|
|
40
35
|
const raw = axis === 'block' ? latest.blockSize : latest.inlineSize
|
|
41
36
|
if (!Number.isFinite(raw)) return null
|
|
42
|
-
return
|
|
37
|
+
return Math.max(0, Math.round(raw))
|
|
43
38
|
}
|
|
44
39
|
|
|
45
|
-
const loop = createMeasureLoop<
|
|
40
|
+
const loop = createMeasureLoop<number>({
|
|
46
41
|
produce,
|
|
47
|
-
same: (a, b) => a
|
|
48
|
-
sink: (
|
|
42
|
+
same: (a, b) => a === b,
|
|
43
|
+
sink: (extent) => publish({ axis, extent }),
|
|
49
44
|
})
|
|
50
45
|
|
|
51
46
|
const onResize: ResizeObserverCallback = (entries) => {
|
|
@@ -56,14 +51,13 @@ export function createSizeAdvertiser(
|
|
|
56
51
|
loop.schedule()
|
|
57
52
|
}
|
|
58
53
|
|
|
59
|
-
//
|
|
54
|
+
// Warn if measuring body or documentElement, whose size matches the view.
|
|
60
55
|
const doc = target.ownerDocument
|
|
61
56
|
if (target === doc.body || target === doc.documentElement) {
|
|
62
57
|
console.warn(
|
|
63
58
|
`[view-anchor] size-advertiser: <${target === doc.body ? 'body' : 'html'}>'s ` +
|
|
64
|
-
`${axis} size is the
|
|
65
|
-
`
|
|
66
|
-
`See bidirectional-design.md's single-axis-ownership section.`,
|
|
59
|
+
`${axis} size is the view size, not content size. The advertiser will ` +
|
|
60
|
+
`never shrink to content; measure a shrink-to-fit wrapper instead.`,
|
|
67
61
|
)
|
|
68
62
|
}
|
|
69
63
|
|
|
@@ -72,14 +66,13 @@ export function createSizeAdvertiser(
|
|
|
72
66
|
observer.observe(target)
|
|
73
67
|
|
|
74
68
|
return {
|
|
75
|
-
update(nextPublish:
|
|
69
|
+
update(nextPublish: Publisher<AdvertisedSize>): void {
|
|
76
70
|
if (disposed) return
|
|
77
71
|
publish = nextPublish
|
|
78
|
-
// Re-
|
|
79
|
-
//
|
|
80
|
-
// sizeless until the next ResizeObserver tick.
|
|
72
|
+
// Re-publish the current size to the new sink immediately so it is not
|
|
73
|
+
// empty until the next ResizeObserver tick.
|
|
81
74
|
const cur = produce()
|
|
82
|
-
if (cur) loop.emitNow(cur)
|
|
75
|
+
if (cur !== null) loop.emitNow(cur)
|
|
83
76
|
},
|
|
84
77
|
dispose(): void {
|
|
85
78
|
if (disposed) return
|
package/src/types.ts
CHANGED
|
@@ -1,23 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
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,16 +10,20 @@ 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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
28
|
export type Placement =
|
|
40
29
|
| { visible: true; bounds: Bounds }
|
|
@@ -42,82 +31,48 @@ export type Placement =
|
|
|
42
31
|
|
|
43
32
|
export interface ViewAnchorOptions {
|
|
44
33
|
/**
|
|
45
|
-
* Whether the native view should be attached. When
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* alive" (detach-but-keep-alive). No DOM measurement is needed in this
|
|
49
|
-
* state.
|
|
34
|
+
* Whether the native view should be attached. When false, publishes
|
|
35
|
+
* zero bounds ({ x: 0, y: 0, width: 0, height: 0 }) so the host can detach
|
|
36
|
+
* the view while keeping its instance alive.
|
|
50
37
|
*/
|
|
51
38
|
present: boolean
|
|
52
|
-
/** Receives the live rect, or
|
|
53
|
-
publish:
|
|
39
|
+
/** Receives the live rect, or zero bounds when detached. */
|
|
40
|
+
publish: Publisher<Bounds>
|
|
54
41
|
}
|
|
55
42
|
|
|
56
43
|
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
|
-
*/
|
|
44
|
+
/** Apply new options and re-publish immediately. */
|
|
61
45
|
update(opts: ViewAnchorOptions): void
|
|
62
|
-
/** Stop observing and
|
|
63
|
-
* publishes again (every emit reads `disposed` synchronously, so there is
|
|
64
|
-
* no queued frame that could fire late). */
|
|
46
|
+
/** Stop observing and clean up listeners. After disposal no further values are published. */
|
|
65
47
|
dispose(): void
|
|
66
48
|
}
|
|
67
49
|
|
|
68
|
-
//
|
|
50
|
+
// --- Reverse direction: size advertiser ---
|
|
69
51
|
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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.
|
|
52
|
+
// Runs in a downstream document to report content size back to the host,
|
|
53
|
+
// allowing the host's DOM placeholder to match the content.
|
|
75
54
|
|
|
76
|
-
/** Which axis this advertiser
|
|
77
|
-
* (logical-property naming, axis-agnostic to writing mode). */
|
|
55
|
+
/** Which axis this advertiser reports: 'block' (height) or 'inline' (width). */
|
|
78
56
|
export type AdvertisedAxis = 'block' | 'inline'
|
|
79
57
|
|
|
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
|
-
*/
|
|
58
|
+
/** One frame of advertised size on the owned axis. */
|
|
85
59
|
export interface AdvertisedSize {
|
|
86
|
-
/**
|
|
87
|
-
* whitelist-check the axis it is willing to accept. */
|
|
60
|
+
/** The axis this advertiser reports ('block' or 'inline'). */
|
|
88
61
|
readonly axis: AdvertisedAxis
|
|
89
|
-
/** The
|
|
90
|
-
* to `>= 0`. */
|
|
62
|
+
/** The content extent in CSS pixels, rounded and non-negative. */
|
|
91
63
|
readonly extent: number
|
|
92
64
|
}
|
|
93
65
|
|
|
94
66
|
export interface SizeAdvertiserOptions {
|
|
95
|
-
/** The single axis this advertiser owns. Fixed for the advertiser's
|
|
67
|
+
/** The single axis this advertiser owns. Fixed for the advertiser's lifetime. */
|
|
96
68
|
axis: AdvertisedAxis
|
|
97
|
-
/** Receives each advertised size.
|
|
98
|
-
|
|
99
|
-
publish: (size: AdvertisedSize) => void
|
|
69
|
+
/** Receives each advertised size. */
|
|
70
|
+
publish: Publisher<AdvertisedSize>
|
|
100
71
|
}
|
|
101
72
|
|
|
102
73
|
export interface SizeAdvertiserHandle {
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
*/
|
|
74
|
+
/** Swap the publish callback and re-advertise the current size immediately. */
|
|
75
|
+
update(publish: Publisher<AdvertisedSize>): void
|
|
76
|
+
/** Stop observing and cancel any pending animation frame. */
|
|
122
77
|
dispose(): void
|
|
123
78
|
}
|