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/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
- // eslint-disable-next-line react-hooks/exhaustive-deps
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;AAOnB;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,qBAAqB,GAC1B,oBAAoB,CAoEtB"}
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"}
@@ -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
- loop.setActive(true);
57
- observer = new ResizeObserver(onResize);
58
- observer.observe(target);
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. */
@@ -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;CAC3B;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;CACnC;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"}
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"}
@@ -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. Cross-process setBounds calls already have a compositor delay;
14
- * adding requestAnimationFrame would add a second frame of visual lag during drag
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;AAkBnG;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,iBAAiB,GAAG,gBAAgB,CAsG/F;AAID,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,wCAAwC;IACxC,OAAO,EAAE,SAAS,CAAC,SAAS,CAAC,CAAA;IAC7B;;;;;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,CAmTvB"}
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"}
@@ -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. Cross-process setBounds calls already have a compositor delay;
26
- * adding requestAnimationFrame would add a second frame of visual lag during drag
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 cannot be sent over IPC).
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
- apply();
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
- apply();
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`)**:宿主测量 DOM 占位元素的位置和尺寸,通过 `publish(bounds)` 发送给主进程或外部容器,更新原生视图(如 `WebContentsView.setBounds`)。
6
- - **反向(`createSizeAdvertiser`)**:下游视图内部测量自身内容尺寸,通过 `publish(size)` 通知宿主调整占位大小,宿主再通过正向更新视图位置。
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
- 原生视图的 `setBounds` 需要跨进程通信,相比渲染进程本身的页面绘制通常已经有大约一帧的延迟。如果测量和发布再走一次 `requestAnimationFrame`,拖拽时就会产生两帧以上的视觉延迟,出现明显的边框脱节。因此正向在 `ResizeObserver` 和窗口 `resize` 回调中**同步测量并发布**,高频触发的防抖则依赖前后数值的比对(相同矩形直接跳过)。
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 只测量并上报它负责的那条轴;另一条轴由宿主通过 `setBounds` 单向传入,下游只读。
55
- - 典型案例:宿主决定宽度,下游决定高度。因为高度是内容流式排版的结果而不是输入,整个尺寸传递是一条单向有向无环图(DAG),更新可以在单步内收敛。
56
- - 如果下游的高度又反过来改变了下游的宽度(或测量了 `<body>`/`<html>`),就会形成跨进程的循环调整,导致界面抖动。
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
- | 发送方身份校验 | 宿主 | 在接收 IPC 或 postMessage 时验证 senderFrame、origin 或 token |
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
- subgraph DOWN["下游渲染进程(如工具栏页面)"]
85
- C["内容容器<br/>高度由自身内容决定"]
86
- end
87
- subgraph HOST["宿主渲染进程"]
88
- H["宿主消息处理器<br/>校验来源、clamp 数值"]
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. **下游**:通过 `createSizeAdvertiser` 测量高度并通过 IPC 发给宿主。
104
- 2. **宿主**:对收到的高度做合规性限制(例如限制在 `minHeight` 和 `maxHeight` 之间),并写入占位 div 的样式。
105
- 3. **宿主**:占位 div 尺寸改变,`createViewAnchor` 的 `ResizeObserver` 触发,测量出新的绝对矩形并发给主进程。
106
- 4. **宿主主进程**:调用 `setBounds` 更新原生视图位置与尺寸。
92
+ 1. **内容区域**:通过 `createSizeAdvertiser` 测量高度并调用 `publish(size)`。
93
+ 2. **应用**:限制收到的高度(例如在 `minHeight` 和 `maxHeight` 之间),然后写入占位元素的样式。
94
+ 3. **占位元素**:尺寸改变后,`createViewAnchor` 的 `ResizeObserver` 会测量新的绝对矩形并调用 `publish(bounds)`。
95
+ 4. **应用**:把矩形用于定位外部画面。
107
96
 
108
- ## 6. 与 Electron preferred-size 的关系
97
+ ## 6. 何时需要反向尺寸上报
109
98
 
110
- 在纯 Electron 环境下,也可以使用 `enablePreferredSizeMode` 和 `preferred-size-changed` 事件由 Electron 主进程自动获取网页期望大小。
99
+ 只有外部画面的内容尺寸需要反过来改变宿主布局时,才需要 `createSizeAdvertiser`。如果应用已经能直接知道或设置这个尺寸,不需要引入反向反馈链路。
111
100
 
112
- view-anchor 的反向方案是平台无关的实现,适用于跨域 iframe、第三方 webview 或需要针对特定内部 DOM 节点测量尺寸的场景。两者并不冲突,可以根据具体的宿主架构按需选择。
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 demo: align anything outside the DOM to a DOM element</title>
7
- <meta name="description" content="view-anchor is a high-performance geometry bridge that keeps an anchored view an Electron WebContentsView, a native webview, a cross-origin iframe aligned to a DOM element. This interactive 3D demo runs the real core in your browser.">
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 (like a real setBounds) — only the
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 an <b style="color:var(--native-bd)">anchored view</b> — an Electron <code>WebContentsView</code>, a native webview, a cross-origin iframe to a DOM element's geometry. Resize the splitter, toggle <code>present</code>, or explode the 3D scene below: the green anchored view is positioned by the real view-anchor core running in this page.</p>
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"><code>present</code></label>
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 <code>Split</code> to resize the placeholder, toggle <code>present</code> to collapse or restore, and toggle <code>Explode</code> for the 3D perspective. The green anchored view's bounds come directly from view-anchor's <code>publish</code> callback.</div>
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. Cross-process setBounds calls already have a compositor delay;
324
- * adding requestAnimationFrame would add a second frame of visual lag during drag
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
- apply();
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
- apply();
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 go IPC WebContentsView.setBounds; here we place the view in the
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
- 让宿主外部的视图(例如 Electron 的 `WebContentsView`)实时对齐某个 DOM 元素的屏幕位置。核心逻辑通过 `getBoundingClientRect()` 测量目标元素,把矩形数据交给注入的 `publish` 回调(通常由调用方转发 IPC 到 `setBounds`),并在元素移动或缩放时同步更新。
3
+ 将外部画面实时对齐到指定的 DOM 元素。核心逻辑通过 `getBoundingClientRect()` 测量目标元素,并把矩形数据交给 `publish` 回调;应用代码决定如何使用这个矩形。
4
4
 
5
- 核心不依赖 React、Electron 或特定的布局引擎;React 相关的逻辑均隔离在 `view-anchor/react` 适配层中。
5
+ 核心不依赖 React、特定宿主或布局引擎;React 相关的逻辑均隔离在 `view-anchor/react` 适配层中。
6
6
 
7
- > 🎮 [3D 交互演示](https://lbb00.github.io/view-anchor/):页面里跑的是真实核心代码。拖动分栏、切换面板显示,看原生视图实时跟随。源码见 [index.html](./index.html)。
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
- subgraph R["渲染进程 · WebContents"]
14
- DIV["占位 div<br/>(CSS 布局,自身不渲染)"]
15
- end
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
- `WebContentsView` 运行在主进程,位置由主进程的 `setBounds` 设置;页面布局则由渲染进程通过 CSS 计算。两者不在同一个进程中,view-anchor 作为桥梁连接它们:占位 div 参与 DOM 布局,外部视图悬浮在上方,由 view-anchor 维持对齐。`publish` 是外部注入的回调,核心模块本身完全不感知 Electron。
18
+ 占位元素参与 DOM 布局,外部画面由应用代码定位。`view-anchor` 只负责测量和调用 `publish(bounds)`;它不创建外部画面,也不决定矩形通过什么方式到达那里。
24
19
 
25
20
  ## createViewAnchor(target, opts)
26
21
 
27
- 命令式核心接口,将原生视图绑定到目标元素,返回 `{ update, dispose }`。
22
+ 命令式核心接口,将外部画面绑定到目标元素,返回 `{ update, dispose }`。
28
23
 
29
24
  ```ts
30
25
  const handle = createViewAnchor(target, {
31
- present: true, // 是否显示原生视图
32
- publish: (bounds) => { ... }, // 接收最新矩形,转发给 setBounds
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:** 原生 overlay 处于另一个进程,IPC 传递到 `setBounds` 相比渲染进程本身的绘制已经有一帧左右的合成延迟。如果测量和发布再进一次 RAF,拖拽时就会叠加第二帧延迟,造成明显的跟随拖尾。在 observer 回调中直接同步测量和发布可以省掉这层额外延迟。同帧多次触发的防抖则交由同值去重处理:只有矩形数值与上一次不同时才触发 `publish`。调用 `update` 时会先清除去重基线,确保外部状态变化(如缩放系数变化)时即使几何数据未变也能重新发布。
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 }`(零矩形)**:收起信号。宿主可以将零面积理解为“从窗口中移出视图,但保留其 `WebContents` 实例”,实现秒开复用而不是反复销毁重建。
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 Electron 依赖。 |
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/index.ts` | 核心入口,默认不引入 React。 |
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 个逻辑核心)。这些数字只适合同机比较,不是浏览器、Electron IPC 或 DOM layout 的耗时承诺。
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
- 当前测量不覆盖真实浏览器/Electron、DOM layout、structured clone、IPC 或生产工作负载;这些路径需在目标运行时另行测量。
63
+ 当前测量不覆盖真实浏览器布局、序列化、数据传递或生产工作负载;这些路径需在目标运行时另行测量。