@netless/window-manager 1.0.18 → 1.0.19

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.
@@ -0,0 +1,144 @@
1
+ # `boxSizeChange` 接入指南
2
+
3
+ 本文面向 Netless App(特别是 `@netless/app-slide`)维护者,说明如何使用
4
+ WindowManager 提供的 `boxSizeChange` 事件同步 App 内部渲染器尺寸。
5
+
6
+ ## 适用场景
7
+
8
+ 部分旧 Android WebView 虽然提供 `ResizeObserver` API,但在 CSS transition 或连续布局变化时,
9
+ 回调可能只包含中间尺寸。此时 Box DOM 已经达到最终尺寸,App 内部渲染器仍可能保留错误的宽高。
10
+
11
+ WindowManager 会在 Box 布局稳定后读取 App 内容 DOM 的实际尺寸,并通过
12
+ `context.emitter` 发送 `boxSizeChange`。App 应使用该事件更新自己的 viewer、canvas 或 viewport,
13
+ 不再以 App 内部的 `ResizeObserver` 作为最终尺寸的唯一来源。
14
+
15
+ ## API
16
+
17
+ ```ts
18
+ type BoxSizeChangePayload = {
19
+ appId: string;
20
+ width: number;
21
+ height: number;
22
+ };
23
+
24
+ context.emitter.on(
25
+ "boxSizeChange",
26
+ (payload: BoxSizeChangePayload) => void | Promise<void>
27
+ ): () => void;
28
+ ```
29
+
30
+ 字段说明:
31
+
32
+ | 字段 | 类型 | 说明 |
33
+ | --- | --- | --- |
34
+ | `appId` | `string` | 当前 App 实例 ID,与 `context.appId` 相同 |
35
+ | `width` | `number` | App 实际内容 DOM 的宽度,单位为 CSS pixel |
36
+ | `height` | `number` | App 实际内容 DOM 的高度,单位为 CSS pixel |
37
+
38
+ `width` 和 `height` 均为大于 0 的有限数值。它们不是 TeleBox 的相对模型尺寸,
39
+ 也不是包含标题栏和边框的整个窗口尺寸。
40
+
41
+ ## 触发时机
42
+
43
+ 以下变化完成后可能触发事件:
44
+
45
+ - App setup 或 View mount;
46
+ - Box resize 或最小尺寸约束改变;
47
+ - 最大化、最小化、恢复和 Box 状态切换;
48
+ - WindowManager 容器尺寸变化;
49
+ - TeleBox 的实际视觉尺寸变化。
50
+
51
+ 事件采用 trailing debounce,不是逐帧 resize 事件。当前实现会在最后一次触发后等待 650ms,
52
+ 再通过两次间隔 100ms 的 DOM 采样确认尺寸。相同尺寸在 0.5px 容差内不会重复通知。
53
+
54
+ 因此,App 不应依赖该事件实现拖拽过程中的逐帧动画,只应把它作为最终布局尺寸的同步信号。
55
+
56
+ ## app-slide 接入示例
57
+
58
+ 建议在 `setup(context)` 开始阶段立即注册监听。Slide viewer 可能异步创建,事件到达时如果 viewer
59
+ 尚未就绪,应缓存最后一次尺寸,并在 viewer 创建完成后应用。
60
+
61
+ ```ts
62
+ type BoxSize = { width: number; height: number };
63
+
64
+ export async function setup(context: AppContext): Promise<void> {
65
+ let viewer: SlideViewer | undefined;
66
+ let latestBoxSize: BoxSize | undefined;
67
+
68
+ const applyBoxSize = (size: BoxSize): void => {
69
+ latestBoxSize = size;
70
+ if (!viewer) return;
71
+
72
+ // 替换为 app-slide 当前 viewer 的实际尺寸更新 API。
73
+ viewer.resize(size.width, size.height);
74
+ };
75
+
76
+ const offBoxSizeChange = context.emitter.on(
77
+ "boxSizeChange",
78
+ ({ appId, width, height }) => {
79
+ if (appId !== context.appId) return;
80
+ applyBoxSize({ width, height });
81
+ }
82
+ );
83
+
84
+ const offDestroy = context.emitter.on("destroy", () => {
85
+ offBoxSizeChange();
86
+ offDestroy();
87
+ });
88
+
89
+ viewer = await createSlideViewer(context);
90
+
91
+ if (latestBoxSize) {
92
+ applyBoxSize(latestBoxSize);
93
+ }
94
+ }
95
+ ```
96
+
97
+ 如果 viewer 的尺寸 API 接收容器而不是数值,应在事件回调中调用对应的 layout/resize 方法,
98
+ 但尺寸来源仍应以事件的 `width`、`height` 为准。
99
+
100
+ ## 与 white-web-sdk View 的关系
101
+
102
+ WindowManager 在发送事件前会同步 App 对应的 white-web-sdk `View.size`:
103
+
104
+ ```text
105
+ App 内容 DOM 最终尺寸
106
+ |
107
+ +--> white-web-sdk View.refreshSize(width, height)
108
+ |
109
+ +--> context.emitter: boxSizeChange
110
+ |
111
+ +--> app-slide viewer.resize(width, height)
112
+ ```
113
+
114
+ App 不需要调用 white-web-sdk 的 `View.refreshSize()`。该步骤由 WindowManager 负责;App 只需要
115
+ 更新自身的 Slide viewer。两者解决的是不同层级的内部尺寸状态。
116
+
117
+ ## 接入注意事项
118
+
119
+ 1. 不要使用 `intrinsicWidth`、`intrinsicHeight` 替代事件尺寸。它们是相对 BoxManager 容器的模型值。
120
+ 2. 不要在事件回调中再次修改 Box 宽高,否则可能形成“通知 -> 改 Box -> 再通知”的循环。
121
+ 3. viewer 初始化期间只保留最新尺寸,不需要排队执行所有历史尺寸。
122
+ 4. resize 操作应保持幂等;同一尺寸重复应用不应改变页面状态或课件同步状态。
123
+ 5. 不要把本地像素尺寸写入协作 attributes。不同客户端的容器尺寸可能不同,该尺寸仅用于本地布局。
124
+ 6. 监听器如果执行异步操作,应自行捕获业务异常。WindowManager 会记录监听器 rejection,
125
+ 但无法替 App 恢复失败的 viewer 状态。
126
+
127
+ ## 验收建议
128
+
129
+ 至少覆盖以下场景:
130
+
131
+ - App 首次打开后,viewer 尺寸与内容 DOM 一致;
132
+ - 普通 Box resize 后,viewer 最终使用事件中的尺寸;
133
+ - 最大化、恢复、最小化后恢复均能得到正确尺寸;
134
+ - WindowManager 容器横竖屏切换或宿主尺寸变化后能重新同步;
135
+ - viewer 晚于首次事件创建时,缓存的最后尺寸会被应用;
136
+ - 连续收到相同尺寸时不会重复重建 viewer 或重置课件状态;
137
+ - Android 8.1 / WebView 71 中 PPT 最终铺满 App 内容区域;
138
+ - Room 与 Player、可写与只读模式下均不会因尺寸事件产生协作状态写入。
139
+
140
+ ## 版本要求
141
+
142
+ 接入方需要使用包含 `boxSizeChange` 的 WindowManager 版本或构建产物。仅升级 app-slide 而仍使用
143
+ 旧 WindowManager 时不会收到该事件。white-web-sdk 的强制 View 尺寸同步由 WindowManager 兼容层处理,
144
+ 不影响 app-slide 的事件监听代码。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netless/window-manager",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "description": "Multi-window mode for Netless Whiteboard",
5
5
  "author": "l1shen <lishen1635@gmail.com> (https://github.com/l1shen)",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "peerDependencies": {
25
25
  "jspdf": "2.5.1",
26
- "white-web-sdk": "^2.16.56"
26
+ "white-web-sdk": "^2.16.57"
27
27
  },
28
28
  "peerDependenciesMeta": {
29
29
  "jspdf": {
@@ -73,6 +73,6 @@
73
73
  "typescript": "^4.5.5",
74
74
  "vite": "^2.9.9",
75
75
  "vitest": "^0.14.1",
76
- "white-web-sdk": "^2.16.56"
76
+ "white-web-sdk": "^2.16.57"
77
77
  }
78
78
  }
@@ -0,0 +1,124 @@
1
+ import type { View } from "white-web-sdk";
2
+
3
+ export type AppBoxSize = { width: number; height: number };
4
+ export type AppBoxSizeChange = AppBoxSize & { appId: string };
5
+
6
+ type ViewWithRefreshSize = View & {
7
+ refreshSize?: (width: number, height: number) => void;
8
+ screen?: {
9
+ refreshSize?: (width: number, height: number) => void;
10
+ resizeObserver?: {
11
+ disconnect?: () => void;
12
+ observe?: (target: Element) => void;
13
+ };
14
+ };
15
+ };
16
+
17
+ const BOX_SIZE_EPSILON = 0.5;
18
+ const BOX_SIZE_SETTLE_DELAY = 650;
19
+ const BOX_SIZE_CONFIRM_DELAY = 100;
20
+
21
+ const sizesEqual = (left: AppBoxSize | undefined, right: AppBoxSize): boolean =>
22
+ Boolean(
23
+ left &&
24
+ Math.abs(left.width - right.width) <= BOX_SIZE_EPSILON &&
25
+ Math.abs(left.height - right.height) <= BOX_SIZE_EPSILON
26
+ );
27
+
28
+ export class AppBoxSizeSynchronizer {
29
+ private frame?: number;
30
+ private timer?: ReturnType<typeof setTimeout>;
31
+ private pendingSize?: AppBoxSize;
32
+ private lastNotifiedSize?: AppBoxSize;
33
+ private destroyed = false;
34
+
35
+ public constructor(
36
+ private readonly appId: string,
37
+ private readonly getView: () => View | undefined,
38
+ private readonly getElement: () => Element | undefined,
39
+ private readonly notify: (payload: AppBoxSizeChange) => void,
40
+ private readonly onError?: (error: unknown) => void
41
+ ) {}
42
+
43
+ public schedule = (): void => {
44
+ if (this.destroyed) return;
45
+ if (this.timer != null) clearTimeout(this.timer);
46
+ if (this.frame != null) {
47
+ cancelAnimationFrame(this.frame);
48
+ this.frame = undefined;
49
+ }
50
+ this.pendingSize = undefined;
51
+ this.timer = setTimeout(() => {
52
+ this.timer = undefined;
53
+ this.frame = requestAnimationFrame(this.confirmDOMSize);
54
+ }, BOX_SIZE_SETTLE_DELAY);
55
+ };
56
+
57
+ public destroy(): void {
58
+ this.destroyed = true;
59
+ if (this.timer != null) {
60
+ clearTimeout(this.timer);
61
+ this.timer = undefined;
62
+ }
63
+ if (this.frame != null) {
64
+ cancelAnimationFrame(this.frame);
65
+ this.frame = undefined;
66
+ }
67
+ this.pendingSize = undefined;
68
+ this.lastNotifiedSize = undefined;
69
+ }
70
+
71
+ private confirmDOMSize = (): void => {
72
+ this.frame = undefined;
73
+ if (this.destroyed) return;
74
+
75
+ const element = this.getElement();
76
+ if (!element) return;
77
+
78
+ const rect = element.getBoundingClientRect();
79
+ const size = { width: rect.width, height: rect.height };
80
+ if (
81
+ !Number.isFinite(size.width) ||
82
+ !Number.isFinite(size.height) ||
83
+ size.width <= 0 ||
84
+ size.height <= 0
85
+ ) {
86
+ this.pendingSize = undefined;
87
+ return;
88
+ }
89
+
90
+ if (!sizesEqual(this.pendingSize, size)) {
91
+ this.pendingSize = size;
92
+ this.timer = setTimeout(() => {
93
+ this.timer = undefined;
94
+ this.frame = requestAnimationFrame(this.confirmDOMSize);
95
+ }, BOX_SIZE_CONFIRM_DELAY);
96
+ return;
97
+ }
98
+
99
+ this.pendingSize = undefined;
100
+ const view = this.getView() as ViewWithRefreshSize | undefined;
101
+ if (view && !sizesEqual(view.size, size)) {
102
+ try {
103
+ if (typeof view.refreshSize === "function") {
104
+ view.refreshSize(size.width, size.height);
105
+ } else if (typeof view.screen?.refreshSize === "function") {
106
+ const resizeObserver = view.screen.resizeObserver;
107
+ resizeObserver?.disconnect?.();
108
+ try {
109
+ view.screen.refreshSize(size.width, size.height);
110
+ } finally {
111
+ if (view.divElement) resizeObserver?.observe?.(view.divElement);
112
+ }
113
+ }
114
+ } catch (error) {
115
+ this.onError?.(error);
116
+ }
117
+ }
118
+
119
+ if (!sizesEqual(this.lastNotifiedSize, size)) {
120
+ this.lastNotifiedSize = size;
121
+ this.notify({ appId: this.appId, ...size });
122
+ }
123
+ };
124
+ }
@@ -113,9 +113,11 @@ export class AppContext<TAttributes extends {} = any, TMagixEventPayloads = any,
113
113
  const view = this.getView();
114
114
  if (view) {
115
115
  view.divElement = dom as HTMLDivElement;
116
+ this.appProxy.scheduleBoxSizeSync();
116
117
  setTimeout(() => {
117
118
  // 渲染需要时间,延迟 refresh
118
119
  this.getRoom()?.refreshViewSize();
120
+ this.appProxy.scheduleBoxSizeSync();
119
121
  callbacks.emit("onAppViewMounted", { appId: this.appId, view });
120
122
  }, 1000);
121
123
  }
@@ -34,6 +34,7 @@ import { calculateNextIndex } from "../Page";
34
34
  import { boxEmitter } from "../BoxEmitter";
35
35
  import { callbacks } from "../callback";
36
36
  import { getExtendClass } from "../Utils/extendClass";
37
+ import { AppBoxSizeSynchronizer } from "./AppBoxSizeSynchronizer";
37
38
 
38
39
  export type AppEmitter = Emittery<AppEmitterEvent>;
39
40
 
@@ -56,6 +57,7 @@ export class AppProxy implements PageRemoveService {
56
57
  private stateKey: string;
57
58
  private _pageState: AppPageStateImpl;
58
59
  private _prevFullPath: string | undefined;
60
+ private boxSizeSynchronizer: AppBoxSizeSynchronizer;
59
61
 
60
62
  public appResult?: NetlessApp<any>;
61
63
  public appContext?: AppContext<any, any>;
@@ -71,6 +73,21 @@ export class AppProxy implements PageRemoveService {
71
73
  this.stateKey = `${this.id}_state`;
72
74
  this.appProxies.set(this.id, this);
73
75
  this.appEmitter = new Emittery();
76
+ this.boxSizeSynchronizer = new AppBoxSizeSynchronizer(
77
+ this.id,
78
+ () => this.view,
79
+ () => this.view?.divElement || this.box?.$content,
80
+ payload => {
81
+ this.appEmitter.emit("boxSizeChange", payload).catch(error => {
82
+ this.Logger?.error(
83
+ `[WindowManager]: failed to notify app box size change: ${error}`
84
+ );
85
+ });
86
+ },
87
+ error => {
88
+ this.Logger?.error(`[WindowManager]: failed to refresh app view size: ${error}`);
89
+ }
90
+ );
74
91
  this.appListener = this.makeAppEventListener(this.id);
75
92
  this.isAddApp = isAddApp;
76
93
 
@@ -104,6 +121,10 @@ export class AppProxy implements PageRemoveService {
104
121
  return this.manager.viewManager.getView(this.id);
105
122
  }
106
123
 
124
+ public scheduleBoxSizeSync = (): void => {
125
+ if (this.status !== "destroyed") this.boxSizeSynchronizer.schedule();
126
+ };
127
+
107
128
  public get viewIndex(): number | undefined {
108
129
  return this.view?.focusSceneIndex;
109
130
  }
@@ -227,6 +248,7 @@ export class AppProxy implements PageRemoveService {
227
248
  );
228
249
  const result = await app.setup(context);
229
250
  this.appResult = result;
251
+ this.scheduleBoxSizeSync();
230
252
  appRegister.notifyApp(this.kind, "created", { appId, result });
231
253
  this.afterSetupApp(boxInitState);
232
254
  this.fixMobileSize();
@@ -542,6 +564,7 @@ export class AppProxy implements PageRemoveService {
542
564
  ) {
543
565
  if (this.status === "destroyed") return;
544
566
  this.status = "destroyed";
567
+ this.boxSizeSynchronizer.destroy();
545
568
  try {
546
569
  await appRegister.notifyApp(this.kind, "destroy", { appId: this.id });
547
570
  await this.appEmitter.emit("destroy", { error });
package/src/AppManager.ts CHANGED
@@ -441,6 +441,15 @@ export class AppManager {
441
441
  });
442
442
  callbacks.emit("onBoxResize", payload);
443
443
  }
444
+ this.scheduleAppBoxSizeSync(payload.appId);
445
+ };
446
+
447
+ public scheduleAppBoxSizeSync = (appId?: string): void => {
448
+ if (appId) {
449
+ this.appProxies.get(appId)?.scheduleBoxSizeSync();
450
+ } else {
451
+ this.appProxies.forEach(appProxy => appProxy.scheduleBoxSizeSync());
452
+ }
444
453
  };
445
454
 
446
455
  private onBoxFocus = (payload: BoxFocusPayload) => {
package/src/BoxManager.ts CHANGED
@@ -66,6 +66,7 @@ export type BoxManagerContext = {
66
66
  callbacks: CallbacksType;
67
67
  canOperate: () => boolean;
68
68
  notifyContainerRectUpdate: (rect: TeleBoxRect) => void;
69
+ scheduleAppBoxSizeSync: (appId?: string) => void;
69
70
  cleanFocus: () => void;
70
71
  setAppFocus: (appId: string) => void;
71
72
  };
@@ -96,6 +97,8 @@ export const createBoxManager = (
96
97
  canOperate: () => manager.canOperate,
97
98
  notifyContainerRectUpdate: (rect: TeleBoxRect) =>
98
99
  manager.appManager?.notifyContainerRectUpdate(rect),
100
+ scheduleAppBoxSizeSync: (appId?: string) =>
101
+ manager.appManager?.scheduleAppBoxSizeSync(appId),
99
102
  cleanFocus: () => manager.appManager?.store.cleanFocus(),
100
103
  setAppFocus: (appId: string) => manager.appManager?.store.setAppFocus(appId, true),
101
104
  callbacks,
@@ -180,6 +183,9 @@ export class BoxManager {
180
183
  });
181
184
  }, 200)
182
185
  );
186
+ this.teleBoxManager.events.on("visual_resize", (box: ReadonlyTeleBox): void => {
187
+ this.context.scheduleAppBoxSizeSync(box.id);
188
+ });
183
189
  this.teleBoxManager.events.on("focused", box => {
184
190
  if (box) {
185
191
  if (this.canOperate) {
@@ -202,6 +208,7 @@ export class BoxManager {
202
208
  this.teleBoxManager.events.on(
203
209
  "box_status",
204
210
  (box: { id: string; boxStatus?: TeleBoxState }) => {
211
+ this.context.scheduleAppBoxSizeSync(box.id);
205
212
  if (this.canOperate) {
206
213
  this.context.setBoxStatus(box.id, box.boxStatus);
207
214
  }
@@ -299,6 +306,7 @@ export class BoxManager {
299
306
  public setBoxesStatus(status?: Record<string, TeleBoxState>): void {
300
307
  const map = new Map(Object.entries(status ?? {}));
301
308
  this.teleBoxManager.setBoxesStatus(map, true);
309
+ this.context.scheduleAppBoxSizeSync();
302
310
  this.context.callbacks.emit("onBoxesStatusChange", map);
303
311
  this.context.emitter.emit("boxesStatusChange", map);
304
312
  }
@@ -409,6 +417,7 @@ export class BoxManager {
409
417
  if (state.minimized != null) {
410
418
  this.teleBoxManager.setMinimized(Boolean(state.minimized), true);
411
419
  }
420
+ this.context.scheduleAppBoxSizeSync(box.id);
412
421
  }, 50);
413
422
  if (!state.boxStatus) {
414
423
  this.context.callbacks.emit("boxStateChange", this.teleBoxManager.state);
@@ -422,6 +431,7 @@ export class BoxManager {
422
431
  const containerRect = { x: 0, y: 0, width: rect.width, height: rect.height };
423
432
  this.teleBoxManager.setContainerRect(containerRect);
424
433
  this.context.notifyContainerRectUpdate(this.teleBoxManager.containerRect);
434
+ this.context.scheduleAppBoxSizeSync();
425
435
  }
426
436
  }
427
437
 
@@ -435,6 +445,7 @@ export class BoxManager {
435
445
 
436
446
  public resizeBox({ appId, width, height, skipUpdate }: ResizeBoxParams): void {
437
447
  this.teleBoxManager.update(appId, { width, height }, skipUpdate);
448
+ this.context.scheduleAppBoxSizeSync(appId);
438
449
  }
439
450
 
440
451
  public setBoxMinSize(params: SetBoxMinSizeParams): void {
@@ -446,6 +457,7 @@ export class BoxManager {
446
457
  },
447
458
  true
448
459
  );
460
+ this.context.scheduleAppBoxSizeSync(params.appId);
449
461
  }
450
462
 
451
463
  public setBoxTitle(params: SetBoxTitleParams): void {
@@ -463,11 +475,13 @@ export class BoxManager {
463
475
  public setMaximized(maximized: boolean, skipUpdate = true): void {
464
476
  if (maximized !== this.maximized) {
465
477
  this.teleBoxManager.setMaximized(maximized, skipUpdate);
478
+ this.context.scheduleAppBoxSizeSync();
466
479
  }
467
480
  }
468
481
 
469
482
  public setMinimized(minimized: boolean, skipUpdate = true) {
470
483
  this.teleBoxManager.setMinimized(minimized, skipUpdate);
484
+ this.context.scheduleAppBoxSizeSync();
471
485
  }
472
486
 
473
487
  public focusTopBox(): void {
@@ -482,6 +496,7 @@ export class BoxManager {
482
496
 
483
497
  public updateBox(id: string, payload: TeleBoxConfig, skipUpdate = true): void {
484
498
  this.teleBoxManager.update(id, payload, skipUpdate);
499
+ this.context.scheduleAppBoxSizeSync(id);
485
500
  }
486
501
 
487
502
  public setReadonly(readonly: boolean) {
package/src/typings.ts CHANGED
@@ -59,6 +59,8 @@ export type AppEmitterEvent<T = any> = {
59
59
  seek: number;
60
60
  pageStateChange: PageState;
61
61
  boxStatusChange: { appId: string; status: TeleBoxState };
62
+ /** Actual app content size in CSS pixels after the box layout is applied. */
63
+ boxSizeChange: { appId: string; width: number; height: number };
62
64
  };
63
65
 
64
66
  export type RegisterEventData = {