view-anchor 0.2.1 → 0.2.2
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 +57 -25
- package/README.zh-CN.md +90 -77
- package/dist/abort.d.ts +3 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/abort.js +9 -0
- package/dist/protocol-publisher.d.ts +4 -2
- package/dist/protocol-publisher.d.ts.map +1 -1
- package/dist/protocol-publisher.js +25 -14
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -6
- package/dist/size-advertiser.d.ts.map +1 -1
- package/dist/size-advertiser.js +26 -16
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/view-anchor.d.ts +4 -2
- package/dist/view-anchor.d.ts.map +1 -1
- package/dist/view-anchor.js +42 -23
- package/docs/bidirectional-design.md +21 -32
- package/docs/index.html +54 -28
- package/docs/mechanism.md +22 -22
- package/docs/performance-report.md +2 -2
- package/docs/protocol.md +7 -7
- package/package.json +10 -16
- package/src/abort.ts +9 -0
- package/src/protocol-publisher.ts +26 -14
- package/src/react.ts +1 -6
- package/src/size-advertiser.ts +26 -15
- package/src/types.ts +4 -0
- package/src/view-anchor.ts +44 -21
package/dist/react.js
CHANGED
|
@@ -8,14 +8,11 @@ function useAnchorRef(options, applied, adapter) {
|
|
|
8
8
|
const handleRef = useRef(null);
|
|
9
9
|
const elementRef = useRef(null);
|
|
10
10
|
const optionsRef = useRef(options);
|
|
11
|
-
// eslint-disable-next-line react-hooks/refs
|
|
12
11
|
optionsRef.current = options;
|
|
13
12
|
const adapterRef = useRef(adapter);
|
|
14
|
-
// eslint-disable-next-line react-hooks/refs
|
|
15
13
|
adapterRef.current = adapter;
|
|
16
14
|
const appliedRef = useRef(applied);
|
|
17
15
|
const currentAppliedRef = useRef(applied);
|
|
18
|
-
// eslint-disable-next-line react-hooks/refs
|
|
19
16
|
currentAppliedRef.current = applied;
|
|
20
17
|
// Options handed to the adapter on the last create/update call.
|
|
21
18
|
// Tracks applied state across renders where the deps array reference changes.
|
|
@@ -73,7 +70,6 @@ function useAnchorRef(options, applied, adapter) {
|
|
|
73
70
|
return () => deferDetach(element);
|
|
74
71
|
}
|
|
75
72
|
return undefined;
|
|
76
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
|
|
77
73
|
}, []);
|
|
78
74
|
useEffect(() => {
|
|
79
75
|
const previous = appliedRef.current;
|
|
@@ -87,7 +83,7 @@ function useAnchorRef(options, applied, adapter) {
|
|
|
87
83
|
adapterRef.current.update(handle, optionsRef.current);
|
|
88
84
|
lastAppliedOptionsRef.current = optionsRef.current;
|
|
89
85
|
}
|
|
90
|
-
//
|
|
86
|
+
// oxlint-disable-next-line react/exhaustive-deps
|
|
91
87
|
}, applied);
|
|
92
88
|
useEffect(() => {
|
|
93
89
|
cancelPendingDetach();
|
|
@@ -96,7 +92,6 @@ function useAnchorRef(options, applied, adapter) {
|
|
|
96
92
|
if (element)
|
|
97
93
|
deferDetach(element);
|
|
98
94
|
};
|
|
99
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- helpers only read stable refs
|
|
100
95
|
}, []);
|
|
101
96
|
return ref;
|
|
102
97
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"size-advertiser.d.ts","sourceRoot":"","sources":["../src/size-advertiser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"size-advertiser.d.ts","sourceRoot":"","sources":["../src/size-advertiser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,YAAY,CAAA;AAQnB;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,qBAAqB,GAC1B,oBAAoB,CA8EtB"}
|
package/dist/size-advertiser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { watchAbort } from './abort.js';
|
|
1
2
|
import { createMeasureLoop } from './measure-loop.js';
|
|
2
3
|
// Replaces a disposed instance's publish callback so a retained handle does
|
|
3
4
|
// not keep the caller's original callback (and whatever it captured) alive.
|
|
@@ -53,9 +54,30 @@ export function createSizeAdvertiser(target, opts) {
|
|
|
53
54
|
`${axis} size is the view size, not content size. The advertiser will ` +
|
|
54
55
|
`never shrink to content; measure a shrink-to-fit wrapper instead.`);
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
let removeAbortListener = () => { };
|
|
58
|
+
const dispose = () => {
|
|
59
|
+
if (disposed)
|
|
60
|
+
return;
|
|
61
|
+
disposed = true;
|
|
62
|
+
removeAbortListener();
|
|
63
|
+
removeAbortListener = () => { };
|
|
64
|
+
loop.cancel();
|
|
65
|
+
if (observer) {
|
|
66
|
+
observer.disconnect();
|
|
67
|
+
observer = null;
|
|
68
|
+
}
|
|
69
|
+
loop.dispose();
|
|
70
|
+
publish = NOOP_PUBLISH;
|
|
71
|
+
latest = null;
|
|
72
|
+
};
|
|
73
|
+
if (opts.signal?.aborted)
|
|
74
|
+
dispose();
|
|
75
|
+
else {
|
|
76
|
+
removeAbortListener = watchAbort(opts.signal, dispose);
|
|
77
|
+
loop.setActive(true);
|
|
78
|
+
observer = new ResizeObserver(onResize);
|
|
79
|
+
observer.observe(target);
|
|
80
|
+
}
|
|
59
81
|
return {
|
|
60
82
|
update(nextPublish) {
|
|
61
83
|
if (disposed)
|
|
@@ -67,18 +89,6 @@ export function createSizeAdvertiser(target, opts) {
|
|
|
67
89
|
if (cur !== null)
|
|
68
90
|
loop.emitNow(cur);
|
|
69
91
|
},
|
|
70
|
-
dispose
|
|
71
|
-
if (disposed)
|
|
72
|
-
return;
|
|
73
|
-
disposed = true;
|
|
74
|
-
loop.cancel();
|
|
75
|
-
if (observer) {
|
|
76
|
-
observer.disconnect();
|
|
77
|
-
observer = null;
|
|
78
|
-
}
|
|
79
|
-
loop.dispose();
|
|
80
|
-
publish = NOOP_PUBLISH;
|
|
81
|
-
latest = null;
|
|
82
|
-
},
|
|
92
|
+
dispose,
|
|
83
93
|
};
|
|
84
94
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface ViewAnchorOptions {
|
|
|
36
36
|
present: boolean;
|
|
37
37
|
/** Receives the live rect, or zero bounds when detached. */
|
|
38
38
|
publish: Publisher<Bounds>;
|
|
39
|
+
/** Stops this anchor when aborted. An already-aborted signal starts no work. */
|
|
40
|
+
signal?: AbortSignal;
|
|
39
41
|
}
|
|
40
42
|
export interface ViewAnchorHandle {
|
|
41
43
|
/** Apply new options and re-publish immediately. */
|
|
@@ -57,6 +59,8 @@ export interface SizeAdvertiserOptions {
|
|
|
57
59
|
axis: AdvertisedAxis;
|
|
58
60
|
/** Receives each advertised size. */
|
|
59
61
|
publish: Publisher<AdvertisedSize>;
|
|
62
|
+
/** Stops this advertiser when aborted. An already-aborted signal starts no work. */
|
|
63
|
+
signal?: AbortSignal;
|
|
60
64
|
}
|
|
61
65
|
export interface SizeAdvertiserHandle {
|
|
62
66
|
/** Swap the publish callback and re-advertise the current size immediately. */
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,8CAA8C;AAC9C,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,iEAAiE;AACjE,MAAM,MAAM,aAAa,GAAG,IAAI,GAAG,OAAO,CAAA;AAE1C;;;GAGG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,KAAK,aAAa,CAAA;AAEtD;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAA;AAE9E,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,4DAA4D;IAC5D,OAAO,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,8CAA8C;AAC9C,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,iEAAiE;AACjE,MAAM,MAAM,aAAa,GAAG,IAAI,GAAG,OAAO,CAAA;AAE1C;;;GAGG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,KAAK,aAAa,CAAA;AAEtD;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAA;AAE9E,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,4DAA4D;IAC5D,OAAO,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IAC1B,gFAAgF;IAChF,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oDAAoD;IACpD,MAAM,CAAC,IAAI,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC,6FAA6F;IAC7F,OAAO,IAAI,IAAI,CAAA;CAChB;AAOD,gFAAgF;AAChF,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAA;AAE/C,sDAAsD;AACtD,MAAM,WAAW,cAAc;IAC7B,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,IAAI,EAAE,cAAc,CAAA;IACpB,qCAAqC;IACrC,OAAO,EAAE,SAAS,CAAC,cAAc,CAAC,CAAA;IAClC,oFAAoF;IACpF,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,MAAM,WAAW,oBAAoB;IACnC,+EAA+E;IAC/E,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,cAAc,CAAC,GAAG,IAAI,CAAA;IAChD,6DAA6D;IAC7D,OAAO,IAAI,IAAI,CAAA;CAChB"}
|
package/dist/view-anchor.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ import type { Placement, Publisher, ViewAnchorOptions, ViewAnchorHandle } from '
|
|
|
10
10
|
* - `dispose()`: stops observing and prevents any further publishes.
|
|
11
11
|
*
|
|
12
12
|
* Synchronous publishing: measurement and publishing occur directly in the
|
|
13
|
-
* observer tick.
|
|
14
|
-
* adding requestAnimationFrame would add
|
|
13
|
+
* observer tick. Applying geometry outside the DOM may already be delayed;
|
|
14
|
+
* adding requestAnimationFrame would add another frame of visual lag during drag
|
|
15
15
|
* operations. High-frequency updates are deduplicated against the last accepted rect.
|
|
16
16
|
*/
|
|
17
17
|
export declare function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle;
|
|
@@ -23,6 +23,8 @@ export interface PlacementAnchorOptions {
|
|
|
23
23
|
visible: boolean;
|
|
24
24
|
/** Receives each explicit Placement. */
|
|
25
25
|
publish: Publisher<Placement>;
|
|
26
|
+
/** Stops this anchor when aborted. An already-aborted signal starts no work. */
|
|
27
|
+
signal?: AbortSignal;
|
|
26
28
|
/**
|
|
27
29
|
* When true, targets with zero area (such as display: none or unmounted elements)
|
|
28
30
|
* publish { visible: false } instead of { visible: true, bounds: 0x0 }, and an
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"view-anchor.d.ts","sourceRoot":"","sources":["../src/view-anchor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,SAAS,EAAE,SAAS,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"view-anchor.d.ts","sourceRoot":"","sources":["../src/view-anchor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,SAAS,EAAE,SAAS,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAmBnG;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,iBAAiB,GAAG,gBAAgB,CAgH/F;AAID,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,wCAAwC;IACxC,OAAO,EAAE,SAAS,CAAC,SAAS,CAAC,CAAA;IAC7B,gFAAgF;IAChF,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,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;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAM/D;AAeD;;GAEG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,sBAAsB,GAC3B,qBAAqB,CA6TvB"}
|
package/dist/view-anchor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { watchAbort } from './abort.js';
|
|
1
2
|
const ZERO = { x: 0, y: 0, width: 0, height: 0 };
|
|
2
3
|
// Replaces a disposed instance's publish callback so a retained handle does
|
|
3
4
|
// not keep the caller's original callback (and whatever it captured) alive.
|
|
@@ -22,8 +23,8 @@ const clampRect = (r) => ({
|
|
|
22
23
|
* - `dispose()`: stops observing and prevents any further publishes.
|
|
23
24
|
*
|
|
24
25
|
* Synchronous publishing: measurement and publishing occur directly in the
|
|
25
|
-
* observer tick.
|
|
26
|
-
* adding requestAnimationFrame would add
|
|
26
|
+
* observer tick. Applying geometry outside the DOM may already be delayed;
|
|
27
|
+
* adding requestAnimationFrame would add another frame of visual lag during drag
|
|
27
28
|
* operations. High-frequency updates are deduplicated against the last accepted rect.
|
|
28
29
|
*/
|
|
29
30
|
export function createViewAnchor(target, opts) {
|
|
@@ -40,7 +41,7 @@ export function createViewAnchor(target, opts) {
|
|
|
40
41
|
let disposed = false;
|
|
41
42
|
const measure = () => {
|
|
42
43
|
const r = targetRef.getBoundingClientRect();
|
|
43
|
-
// Drop ticks with non-finite values (NaN / Infinity
|
|
44
|
+
// Drop ticks with non-finite values (NaN / Infinity are not usable geometry).
|
|
44
45
|
if (!Number.isFinite(r.left) ||
|
|
45
46
|
!Number.isFinite(r.top) ||
|
|
46
47
|
!Number.isFinite(r.width) ||
|
|
@@ -108,7 +109,24 @@ export function createViewAnchor(target, opts) {
|
|
|
108
109
|
publishCandidate(ZERO);
|
|
109
110
|
}
|
|
110
111
|
};
|
|
111
|
-
|
|
112
|
+
let removeAbortListener = () => { };
|
|
113
|
+
const dispose = () => {
|
|
114
|
+
if (disposed)
|
|
115
|
+
return;
|
|
116
|
+
disposed = true;
|
|
117
|
+
removeAbortListener();
|
|
118
|
+
removeAbortListener = () => { };
|
|
119
|
+
stopObserving();
|
|
120
|
+
targetRef = null;
|
|
121
|
+
publish = NOOP_PUBLISH;
|
|
122
|
+
lastPublished = null;
|
|
123
|
+
};
|
|
124
|
+
if (opts.signal?.aborted)
|
|
125
|
+
dispose();
|
|
126
|
+
else {
|
|
127
|
+
removeAbortListener = watchAbort(opts.signal, dispose);
|
|
128
|
+
apply();
|
|
129
|
+
}
|
|
112
130
|
return {
|
|
113
131
|
update(next) {
|
|
114
132
|
if (disposed)
|
|
@@ -117,15 +135,7 @@ export function createViewAnchor(target, opts) {
|
|
|
117
135
|
present = next.present;
|
|
118
136
|
apply();
|
|
119
137
|
},
|
|
120
|
-
dispose
|
|
121
|
-
if (disposed)
|
|
122
|
-
return;
|
|
123
|
-
disposed = true;
|
|
124
|
-
stopObserving();
|
|
125
|
-
targetRef = null;
|
|
126
|
-
publish = NOOP_PUBLISH;
|
|
127
|
-
lastPublished = null;
|
|
128
|
-
},
|
|
138
|
+
dispose,
|
|
129
139
|
};
|
|
130
140
|
}
|
|
131
141
|
/**
|
|
@@ -422,7 +432,24 @@ export function createPlacementAnchor(target, opts) {
|
|
|
422
432
|
publishCandidate(hidden);
|
|
423
433
|
}
|
|
424
434
|
};
|
|
425
|
-
|
|
435
|
+
let removeAbortListener = () => { };
|
|
436
|
+
const dispose = () => {
|
|
437
|
+
if (disposed)
|
|
438
|
+
return;
|
|
439
|
+
disposed = true;
|
|
440
|
+
removeAbortListener();
|
|
441
|
+
removeAbortListener = () => { };
|
|
442
|
+
stopObserving();
|
|
443
|
+
targetRef = null;
|
|
444
|
+
publish = NOOP_PUBLISH;
|
|
445
|
+
lastPublished = null;
|
|
446
|
+
};
|
|
447
|
+
if (opts.signal?.aborted)
|
|
448
|
+
dispose();
|
|
449
|
+
else {
|
|
450
|
+
removeAbortListener = watchAbort(opts.signal, dispose);
|
|
451
|
+
apply();
|
|
452
|
+
}
|
|
426
453
|
return {
|
|
427
454
|
update(next) {
|
|
428
455
|
if (disposed)
|
|
@@ -440,15 +467,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
440
467
|
}
|
|
441
468
|
apply();
|
|
442
469
|
},
|
|
443
|
-
dispose
|
|
444
|
-
if (disposed)
|
|
445
|
-
return;
|
|
446
|
-
disposed = true;
|
|
447
|
-
stopObserving();
|
|
448
|
-
targetRef = null;
|
|
449
|
-
publish = NOOP_PUBLISH;
|
|
450
|
-
lastPublished = null;
|
|
451
|
-
},
|
|
470
|
+
dispose,
|
|
452
471
|
pulse(durationMs) {
|
|
453
472
|
if (disposed || !followGeometry)
|
|
454
473
|
return;
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
view-anchor 支持双向几何同步:
|
|
4
4
|
|
|
5
|
-
- **正向(`createViewAnchor
|
|
6
|
-
- **反向(`createSizeAdvertiser
|
|
5
|
+
- **正向(`createViewAnchor`)**:测量 DOM 占位元素的位置和尺寸,通过 `publish(bounds)` 把 `{ x, y, width, height }` 交给应用,由应用更新外部画面。
|
|
6
|
+
- **反向(`createSizeAdvertiser`)**:内容区域测量自身尺寸,通过 `publish(size)` 把 `{ axis, extent }` 交给应用,由应用调整占位元素大小。
|
|
7
7
|
|
|
8
8
|
常见场景:嵌套在宿主中的工具栏或面板,其宽度由宿主布局决定,高度则由子视图自身的内容决定。
|
|
9
9
|
|
|
@@ -12,9 +12,9 @@ view-anchor 支持双向几何同步:
|
|
|
12
12
|
两个方向在性能和交互上的要求不同,因此没有共用同一套调度逻辑:
|
|
13
13
|
|
|
14
14
|
- **正向(`createViewAnchor`)采用同步发布。**
|
|
15
|
-
|
|
15
|
+
外部画面的更新本身可能已有延迟;测量后再等一次 `requestAnimationFrame` 会增加拖拽时的跟随延迟。因此正向在 `ResizeObserver` 和窗口 `resize` 回调中**同步测量并调用 `publish(bounds)`**,相同矩形直接跳过。
|
|
16
16
|
- **反向(`createSizeAdvertiser`)采用 RAF 调度(`createMeasureLoop`)。**
|
|
17
|
-
|
|
17
|
+
反向构成一条反馈环:内容上报尺寸 → 应用调整占位大小 → 内容重新布局与测量 → 再次上报。在这个链路中,将上报频率限制在每帧至多一次(与屏幕刷新率对齐)能够有效避免高频震荡,同时提供平滑的缓冲。
|
|
18
18
|
|
|
19
19
|
## 2. 反向接口说明
|
|
20
20
|
|
|
@@ -29,6 +29,7 @@ export interface AdvertisedSize {
|
|
|
29
29
|
export interface SizeAdvertiserOptions {
|
|
30
30
|
axis: AdvertisedAxis // 创建后固定,每个 advertiser 只负责一条轴
|
|
31
31
|
publish: Publisher<AdvertisedSize> // 接收尺寸发布的回调
|
|
32
|
+
signal?: AbortSignal // abort 后停止监听并取消已排队的帧
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export interface SizeAdvertiserHandle {
|
|
@@ -51,9 +52,9 @@ export function createSizeAdvertiser(
|
|
|
51
52
|
|
|
52
53
|
为了避免死循环,必须遵循单轴控制原则:
|
|
53
54
|
|
|
54
|
-
- 一个 advertiser
|
|
55
|
-
-
|
|
56
|
-
-
|
|
55
|
+
- 一个 advertiser 只测量并上报它负责的那条轴;另一条轴由应用单向设置,内容区域只读。
|
|
56
|
+
- 典型案例:宿主决定宽度,下游决定高度。因为高度是内容流式排版的结果而不是输入,整个尺寸传递构成一个有向无环图(DAG),更新在单步内即可收敛。
|
|
57
|
+
- 如果内容的高度又反过来改变宽度(或测量了 `<body>`/`<html>`),就会形成循环调整,导致界面抖动。
|
|
57
58
|
|
|
58
59
|
## 4. 职责与信任边界
|
|
59
60
|
|
|
@@ -65,7 +66,7 @@ export function createSizeAdvertiser(
|
|
|
65
66
|
| 过滤 NaN / Infinity | view-anchor | 丢弃异常无效数值 |
|
|
66
67
|
| 负数归零 | view-anchor | 保证尺寸非负,反映真实测量结果 |
|
|
67
68
|
| 视口限制(clamp) | 宿主 | 依据当前窗口可用空间对上报值做范围约束,防止异常大值 |
|
|
68
|
-
|
|
|
69
|
+
| 来源身份校验 | 应用 | 在接收数据前验证调用方或通道是否可信 |
|
|
69
70
|
| 轴白名单校验 | 宿主 | 检查 `axis` 是否与宿主预期的控制轴一致 |
|
|
70
71
|
| 位置与层级锁定 | 宿主 | 下游不能擅自修改自身的坐标位置或 z-index |
|
|
71
72
|
|
|
@@ -81,32 +82,20 @@ export function createSizeAdvertiser(
|
|
|
81
82
|
|
|
82
83
|
```mermaid
|
|
83
84
|
flowchart LR
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
DIV["占位 div"]
|
|
90
|
-
VA["createViewAnchor"]
|
|
91
|
-
end
|
|
92
|
-
subgraph MAIN["宿主主进程"]
|
|
93
|
-
NV["WebContentsView / 原生视图"]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
C -->|"① publish(size)"| H
|
|
97
|
-
H -->|"② clamp 后写入 style.height"| DIV
|
|
98
|
-
DIV -->|"ResizeObserver 观测"| VA
|
|
99
|
-
VA -->|"③ 测出新矩形,publish(bounds)"| NV
|
|
100
|
-
NV -->|"④ setBounds 后下游视口变化,内容重排"| C
|
|
85
|
+
C["内容容器\n高度由自身内容决定"] -->|"① publish(size)"| H["应用回调\n校验并限制数值"]
|
|
86
|
+
H -->|"② 写入 style.height"| DIV["占位元素"]
|
|
87
|
+
DIV -->|"ResizeObserver 观测"| VA["createViewAnchor"]
|
|
88
|
+
VA -->|"③ publish(bounds)"| A["应用回调"]
|
|
89
|
+
A -->|"④ 应用矩形"| S["外部画面"]
|
|
101
90
|
```
|
|
102
91
|
|
|
103
|
-
1.
|
|
104
|
-
2.
|
|
105
|
-
3.
|
|
106
|
-
4.
|
|
92
|
+
1. **内容区域**:通过 `createSizeAdvertiser` 测量高度并调用 `publish(size)`。
|
|
93
|
+
2. **应用**:限制收到的高度(例如在 `minHeight` 和 `maxHeight` 之间),然后写入占位元素的样式。
|
|
94
|
+
3. **占位元素**:尺寸改变后,`createViewAnchor` 的 `ResizeObserver` 会测量新的绝对矩形并调用 `publish(bounds)`。
|
|
95
|
+
4. **应用**:把矩形用于定位外部画面。
|
|
107
96
|
|
|
108
|
-
## 6.
|
|
97
|
+
## 6. 何时需要反向尺寸上报
|
|
109
98
|
|
|
110
|
-
|
|
99
|
+
只有外部画面的内容尺寸需要反过来改变宿主布局时,才需要 `createSizeAdvertiser`。如果应用已经能直接知道或设置这个尺寸,不需要引入反向反馈链路。
|
|
111
100
|
|
|
112
|
-
|
|
101
|
+
反向方案适用于需要测量特定内部 DOM 节点的场景。保持“一个方向只控制一个轴”,并在应用侧限制接收值,能避免尺寸互相驱动导致的抖动。
|
package/docs/index.html
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>view-anchor 3D
|
|
7
|
-
<meta name="description" content="
|
|
6
|
+
<title>view-anchor 3D Demo: Keep External Surfaces Aligned with DOM Elements</title>
|
|
7
|
+
<meta name="description" content="Interactive 3D demo showing view-anchor tracking DOM elements live in the browser, with zero frame lag and no external dependencies.">
|
|
8
8
|
<style>
|
|
9
9
|
:root {
|
|
10
10
|
--bg: hsl( 0 0% 7%);
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
background: linear-gradient(180deg, hsl(160 40% 20%), hsl(160 38% 13%));
|
|
126
126
|
border: 1.5px solid var(--native-bd);
|
|
127
127
|
box-shadow: 0 0 0 1px hsl(160 72% 60% / 0.25), inset 0 0 24px hsl(160 72% 50% / 0.12);
|
|
128
|
-
/* position/size snap each frame
|
|
128
|
+
/* position/size snap each frame — only the
|
|
129
129
|
explode lift + collapse fade animate; otherwise tracking rubber-bands. */
|
|
130
130
|
transition: transform 900ms cubic-bezier(0.6,0,0.2,1), box-shadow 900ms, opacity 300ms;
|
|
131
131
|
overflow: hidden;
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
<img alt="Node" src="https://img.shields.io/badge/node-%3E%3D24-339933">
|
|
214
214
|
</div>
|
|
215
215
|
<h1>view-anchor: interactive 3D demo</h1>
|
|
216
|
-
<p class="tag-en">Align
|
|
216
|
+
<p class="tag-en">Align external surfaces (native views, canvases, iframes) to DOM elements in real time. Drag the splitter or toggle controls below: the green surface is positioned live by view-anchor.</p>
|
|
217
217
|
<div class="actions">
|
|
218
218
|
<a href="https://github.com/lbb00/view-anchor">GitHub</a>
|
|
219
219
|
<a href="https://www.npmjs.com/package/view-anchor">npm</a>
|
|
@@ -253,7 +253,7 @@
|
|
|
253
253
|
<input type="checkbox" id="present" checked>
|
|
254
254
|
<span class="track"></span><span class="thumb"></span>
|
|
255
255
|
</span>
|
|
256
|
-
<label for="present"
|
|
256
|
+
<label for="present">Mount (<code>present</code>)</label>
|
|
257
257
|
</div>
|
|
258
258
|
<div class="ctl">
|
|
259
259
|
<label for="split">Split</label>
|
|
@@ -264,7 +264,7 @@
|
|
|
264
264
|
<input type="checkbox" id="explode" checked>
|
|
265
265
|
<span class="track"></span><span class="thumb"></span>
|
|
266
266
|
</span>
|
|
267
|
-
<label for="explode">Explode</label>
|
|
267
|
+
<label for="explode">3D Explode</label>
|
|
268
268
|
</div>
|
|
269
269
|
<div class="readout" id="readout"></div>
|
|
270
270
|
</div>
|
|
@@ -282,7 +282,7 @@
|
|
|
282
282
|
<a href="https://www.npmjs.com/package/view-anchor">npm · view-anchor</a>
|
|
283
283
|
<span style="color:var(--text-3)">MIT licensed</span>
|
|
284
284
|
</div>
|
|
285
|
-
<div class="hint">Drag <
|
|
285
|
+
<div class="hint">Drag <b>Split</b> to resize the placeholder, or toggle <b>3D Explode</b> to inspect the Z-space layering. The green overlay tracks the layout synchronously via the <code>publish</code> callback.</div>
|
|
286
286
|
</footer>
|
|
287
287
|
|
|
288
288
|
</div>
|
|
@@ -295,6 +295,16 @@
|
|
|
295
295
|
/* __VIEW_ANCHOR_CORE_START__ — generated from src/view-anchor.ts by `pnpm build:docs`; do not edit */
|
|
296
296
|
var __viewAnchorCore = (function(exports) {
|
|
297
297
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
298
|
+
//#region src/abort.ts
|
|
299
|
+
const NOOP = () => {};
|
|
300
|
+
/** Attach a one-shot abort listener and return a function that detaches it. */
|
|
301
|
+
function watchAbort(signal, dispose) {
|
|
302
|
+
if (!signal) return NOOP;
|
|
303
|
+
const onAbort = () => dispose();
|
|
304
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
305
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
298
308
|
//#region src/view-anchor.ts
|
|
299
309
|
const ZERO = {
|
|
300
310
|
x: 0,
|
|
@@ -320,8 +330,8 @@ var __viewAnchorCore = (function(exports) {
|
|
|
320
330
|
* - `dispose()`: stops observing and prevents any further publishes.
|
|
321
331
|
*
|
|
322
332
|
* Synchronous publishing: measurement and publishing occur directly in the
|
|
323
|
-
* observer tick.
|
|
324
|
-
* adding requestAnimationFrame would add
|
|
333
|
+
* observer tick. Applying geometry outside the DOM may already be delayed;
|
|
334
|
+
* adding requestAnimationFrame would add another frame of visual lag during drag
|
|
325
335
|
* operations. High-frequency updates are deduplicated against the last accepted rect.
|
|
326
336
|
*/
|
|
327
337
|
function createViewAnchor(target, opts) {
|
|
@@ -387,7 +397,22 @@ var __viewAnchorCore = (function(exports) {
|
|
|
387
397
|
publishCandidate(ZERO);
|
|
388
398
|
}
|
|
389
399
|
};
|
|
390
|
-
|
|
400
|
+
let removeAbortListener = () => {};
|
|
401
|
+
const dispose = () => {
|
|
402
|
+
if (disposed) return;
|
|
403
|
+
disposed = true;
|
|
404
|
+
removeAbortListener();
|
|
405
|
+
removeAbortListener = () => {};
|
|
406
|
+
stopObserving();
|
|
407
|
+
targetRef = null;
|
|
408
|
+
publish = NOOP_PUBLISH;
|
|
409
|
+
lastPublished = null;
|
|
410
|
+
};
|
|
411
|
+
if (opts.signal?.aborted) dispose();
|
|
412
|
+
else {
|
|
413
|
+
removeAbortListener = watchAbort(opts.signal, dispose);
|
|
414
|
+
apply();
|
|
415
|
+
}
|
|
391
416
|
return {
|
|
392
417
|
update(next) {
|
|
393
418
|
if (disposed) return;
|
|
@@ -395,14 +420,7 @@ var __viewAnchorCore = (function(exports) {
|
|
|
395
420
|
present = next.present;
|
|
396
421
|
apply();
|
|
397
422
|
},
|
|
398
|
-
dispose
|
|
399
|
-
if (disposed) return;
|
|
400
|
-
disposed = true;
|
|
401
|
-
stopObserving();
|
|
402
|
-
targetRef = null;
|
|
403
|
-
publish = NOOP_PUBLISH;
|
|
404
|
-
lastPublished = null;
|
|
405
|
-
}
|
|
423
|
+
dispose
|
|
406
424
|
};
|
|
407
425
|
}
|
|
408
426
|
/**
|
|
@@ -656,7 +674,22 @@ var __viewAnchorCore = (function(exports) {
|
|
|
656
674
|
publishCandidate({ visible: false });
|
|
657
675
|
}
|
|
658
676
|
};
|
|
659
|
-
|
|
677
|
+
let removeAbortListener = () => {};
|
|
678
|
+
const dispose = () => {
|
|
679
|
+
if (disposed) return;
|
|
680
|
+
disposed = true;
|
|
681
|
+
removeAbortListener();
|
|
682
|
+
removeAbortListener = () => {};
|
|
683
|
+
stopObserving();
|
|
684
|
+
targetRef = null;
|
|
685
|
+
publish = NOOP_PUBLISH;
|
|
686
|
+
lastPublished = null;
|
|
687
|
+
};
|
|
688
|
+
if (opts.signal?.aborted) dispose();
|
|
689
|
+
else {
|
|
690
|
+
removeAbortListener = watchAbort(opts.signal, dispose);
|
|
691
|
+
apply();
|
|
692
|
+
}
|
|
660
693
|
return {
|
|
661
694
|
update(next) {
|
|
662
695
|
if (disposed) return;
|
|
@@ -671,14 +704,7 @@ var __viewAnchorCore = (function(exports) {
|
|
|
671
704
|
}
|
|
672
705
|
apply();
|
|
673
706
|
},
|
|
674
|
-
dispose
|
|
675
|
-
if (disposed) return;
|
|
676
|
-
disposed = true;
|
|
677
|
-
stopObserving();
|
|
678
|
-
targetRef = null;
|
|
679
|
-
publish = NOOP_PUBLISH;
|
|
680
|
-
lastPublished = null;
|
|
681
|
-
},
|
|
707
|
+
dispose,
|
|
682
708
|
pulse(durationMs) {
|
|
683
709
|
if (disposed || !followGeometry) return;
|
|
684
710
|
if (durationMs !== void 0 && durationMs > 0) {
|
|
@@ -723,7 +749,7 @@ var __viewAnchorCore = (function(exports) {
|
|
|
723
749
|
}
|
|
724
750
|
|
|
725
751
|
// The host-side publish — view-anchor hands it the measured rect. A real app
|
|
726
|
-
// would
|
|
752
|
+
// application code would use the bounds here; this demo places the view in the
|
|
727
753
|
// deck's local coordinates.
|
|
728
754
|
function publish(bounds) {
|
|
729
755
|
if (bounds.width === 0 || bounds.height === 0) {
|
package/docs/mechanism.md
CHANGED
|
@@ -1,35 +1,32 @@
|
|
|
1
1
|
# view-anchor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
将外部画面实时对齐到指定的 DOM 元素。核心逻辑通过 `getBoundingClientRect()` 测量目标元素,并把矩形数据交给 `publish` 回调;应用代码决定如何使用这个矩形。
|
|
4
4
|
|
|
5
|
-
核心不依赖 React
|
|
5
|
+
核心不依赖 React、特定宿主或布局引擎;React 相关的逻辑均隔离在 `view-anchor/react` 适配层中。
|
|
6
6
|
|
|
7
|
-
>
|
|
7
|
+
> **在线演示**:[3D 交互演示](https://lbb00.github.io/view-anchor/) 运行真实核心代码。拖动分栏、切换面板显示,看外部视图实时跟随。源码见 [index.html](./index.html)。
|
|
8
8
|
|
|
9
9
|
## 运行机制
|
|
10
10
|
|
|
11
11
|
```mermaid
|
|
12
12
|
flowchart LR
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
subgraph M["主进程"]
|
|
17
|
-
WCV["WebContentsView<br/>(原生图层,覆盖在网页之上)"]
|
|
18
|
-
end
|
|
19
|
-
DIV -->|"getBoundingClientRect()"| VA["view-anchor"]
|
|
20
|
-
VA -->|"publish(bounds)<br/>IPC → setBounds"| WCV
|
|
13
|
+
DIV["占位元素\n参与 DOM 布局"] -->|"getBoundingClientRect()"| VA["view-anchor"]
|
|
14
|
+
VA -->|"publish(bounds)"| A["应用提供的回调"]
|
|
15
|
+
A -->|"应用矩形"| S["外部画面"]
|
|
21
16
|
```
|
|
22
17
|
|
|
23
|
-
|
|
18
|
+
占位元素参与 DOM 布局,外部画面由应用代码定位。`view-anchor` 只负责测量和调用 `publish(bounds)`;它不创建外部画面,也不决定矩形通过什么方式到达那里。
|
|
24
19
|
|
|
25
20
|
## createViewAnchor(target, opts)
|
|
26
21
|
|
|
27
|
-
|
|
22
|
+
命令式核心接口,将外部画面绑定到目标元素,返回 `{ update, dispose }`。
|
|
28
23
|
|
|
29
24
|
```ts
|
|
30
25
|
const handle = createViewAnchor(target, {
|
|
31
|
-
present: true,
|
|
32
|
-
publish
|
|
26
|
+
present: true,
|
|
27
|
+
publish(bounds) {
|
|
28
|
+
applyBounds(bounds)
|
|
29
|
+
},
|
|
33
30
|
})
|
|
34
31
|
```
|
|
35
32
|
|
|
@@ -38,18 +35,18 @@ const handle = createViewAnchor(target, {
|
|
|
38
35
|
| `present: true` | 立即发布测量矩形,之后每次 `ResizeObserver` 触发或窗口 `resize` 时同步重发。 |
|
|
39
36
|
| `present: false` | 停止观察,发布一次 `{ x: 0, y: 0, width: 0, height: 0 }`。 |
|
|
40
37
|
| `update(opts)` | 应用新选项,重置去重缓存并立即重新发布一次。 |
|
|
41
|
-
| `dispose()` | 停止所有监听并释放资源,此后不再发布。 |
|
|
38
|
+
| `dispose()` / `AbortController.abort()` | 停止所有监听并释放资源,此后不再发布。 |
|
|
42
39
|
|
|
43
40
|
测量结果使用 `Math.round` 取整。`width` 和 `height` 会限制为 `>= 0`(0 代表收起),但 `x` 和 `y` 允许为负数。当元素滚动出视口上边缘或左边缘时,原点自然会是负值,保留负值可以让视图正常跟随元素滚出屏幕。
|
|
44
41
|
|
|
45
|
-
**为什么同步发布而不走 RAF:**
|
|
42
|
+
**为什么同步发布而不走 RAF:** 外部画面的更新本身可能已经有延迟。若测量后再等一帧,拖拽时会多出一帧跟随延迟。在 observer 回调中直接测量并调用 `publish` 可以避免这一步等待。同帧多次触发时,只要 4 个矩形数值有一项发生变化便会调用 `publish`,与上一帧完全一致则跳过。调用 `update` 会清除去重基线,因此即使矩形不变,也能把新的应用状态重新交给回调。
|
|
46
43
|
|
|
47
|
-
调用 `dispose()` 或更新为 `present: false` 后,内部状态会立即置为停用,后续任何异步触发都会直接返回,避免过期数据覆盖新状态。
|
|
44
|
+
调用 `dispose()`、`AbortController.abort()` 或更新为 `present: false` 后,内部状态会立即置为停用,后续任何异步触发都会直接返回,避免过期数据覆盖新状态。
|
|
48
45
|
|
|
49
46
|
## 收起与零矩形
|
|
50
47
|
|
|
51
|
-
- **`present
|
|
52
|
-
- **`{ x: 0, y: 0, width: 0, height: 0 }
|
|
48
|
+
- **`present`**:标识外部画面当前是否需要显示。
|
|
49
|
+
- **`{ x: 0, y: 0, width: 0, height: 0 }`(零矩形)**:收起信号。应用可以把它解释为隐藏、移除,或仅保留最后一个状态。
|
|
53
50
|
- **dispose 行为**:`dispose()` 只停止监听,不补发零矩形。如果需要在元素移除时通知宿主收起视图,应当在 dispose 之前调用 `update({ present: false, ... })`,React 适配层已自动处理了该生命周期。
|
|
54
51
|
|
|
55
52
|
## 显式可见性(createPlacementAnchor)
|
|
@@ -106,11 +103,14 @@ React 18 在卸载时会传入 `ref(null)`,React 19 支持 ref 清理函数。
|
|
|
106
103
|
|
|
107
104
|
| 文件 | 用途 |
|
|
108
105
|
|---|---|
|
|
109
|
-
| `src/view-anchor.ts` | 正向命令式核心:`createViewAnchor`、`createPlacementAnchor`、`measurePlacement`。不含 React
|
|
106
|
+
| `src/view-anchor.ts` | 正向命令式核心:`createViewAnchor`、`createPlacementAnchor`、`measurePlacement`。不含 React 或宿主依赖。 |
|
|
110
107
|
| `src/react.ts` | React 适配层:`useViewAnchor` 与 `usePlacementAnchor`。 |
|
|
111
108
|
| `src/size-advertiser.ts` | 反向核心:`createSizeAdvertiser`。 |
|
|
112
109
|
| `src/measure-loop.ts` | 反向专用的 RAF 调度与去重循环(内部实现,不对外导出)。 |
|
|
113
110
|
| `src/types.ts` | 类型定义(`Bounds`、`Placement`、各模块配置与句柄)。 |
|
|
114
|
-
| `src/
|
|
111
|
+
| `src/abort.ts` | 内部实现:标准 `AbortSignal` 监听与注销辅助。 |
|
|
112
|
+
| `src/index.ts` | 根入口,导出核心几何方法并兼容性重导出 `useViewAnchor`。 |
|
|
113
|
+
|
|
114
|
+
消息协议模块(`src/protocol.ts`、`src/protocol-publisher.ts` 等)详见 [通信协议设计文档](./protocol.md)。
|
|
115
115
|
|
|
116
116
|
核心运行时仅依赖标准 Web API(`ResizeObserver`、`getBoundingClientRect`、`addEventListener`);`requestAnimationFrame` 仅在反向模块与可选的 `followGeometry` 中按需使用。
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
本报告只保留当前工作树可由 `pnpm benchmark` 直接重建的绝对数据。命令启动 3 个全新的 Node.js 进程;每个进程预热 2 次、保留 7 个样本。CPU 表的“当前中位数”是三个进程中位数的中位数,完整 JSON 含全部 21 个原始样本。
|
|
4
4
|
|
|
5
|
-
本次环境:Node.js 24.18.0、macOS arm64、Apple M4(10
|
|
5
|
+
本次环境:Node.js 24.18.0、macOS arm64、Apple M4(10 个逻辑核心)。这些数字只适合同机比较,不是浏览器布局、序列化或数据传递的耗时承诺。
|
|
6
6
|
|
|
7
7
|
## CPU
|
|
8
8
|
|
|
@@ -60,4 +60,4 @@
|
|
|
60
60
|
|
|
61
61
|
运行 `pnpm benchmark:v8 > /tmp/view-anchor-v8.log 2>&1` 时,三个新的 benchmark 进程会继承 `--trace-opt`、`--trace-deopt` 与 `--trace-turbo-inlining`。本文没有保留无对应当前 trace 工件的历史优化/反优化结论。
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
当前测量不覆盖真实浏览器布局、序列化、数据传递或生产工作负载;这些路径需在目标运行时另行测量。
|