@netless/app-slide 0.2.105 → 0.2.106-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netless/app-slide",
3
- "version": "0.2.105",
3
+ "version": "0.2.106-beta.1",
4
4
  "main": "dist/main.cjs.js",
5
5
  "module": "dist/main.es.js",
6
6
  "types": "dist/index.d.ts",
@@ -17,11 +17,11 @@
17
17
  "README-zh.md"
18
18
  ],
19
19
  "devDependencies": {
20
- "@netless/slide": "1.4.60",
20
+ "@juggle/resize-observer": "^3.3.1",
21
+ "@netless/slide": "1.4.61-beta.0",
21
22
  "@types/color-string": "^1.5.2",
22
23
  "color-string": "^1.9.1",
23
24
  "jspdf": "2.5.1",
24
- "@juggle/resize-observer": "^3.3.1",
25
25
  "side-effect-manager": "^1.2.1",
26
26
  "vanilla-lazyload": "^17.8.3"
27
27
  },
@@ -35,6 +35,7 @@ export class ResizableContainer {
35
35
  enableResize: boolean,
36
36
  _box?: ReadonlyTeleBox
37
37
  ) {
38
+ void _box;
38
39
  this.enableResize = enableResize;
39
40
  this.parent = parent;
40
41
  this.context = context;
@@ -130,9 +131,6 @@ export class ResizableContainer {
130
131
  }
131
132
  }
132
133
 
133
- this.translateX = 0.5;
134
- this.translateY = 0.5;
135
-
136
134
  this.renderScrollBar(
137
135
  parentBounds.width,
138
136
  parentBounds.width * this.scale,
@@ -141,7 +139,8 @@ export class ResizableContainer {
141
139
  );
142
140
  this.handleNormalizeTranslate(this.translateX, this.translateY, {
143
141
  triggerScrollBar: true,
144
- triggerSync: true,
142
+ triggerSync: false,
143
+ force: true,
145
144
  });
146
145
 
147
146
  // enableScale / scaleView 只改变内部 container 的像素尺寸,box 本身不变。
@@ -158,12 +157,14 @@ export class ResizableContainer {
158
157
  options: {
159
158
  triggerScrollBar: boolean;
160
159
  triggerSync: boolean;
160
+ force?: boolean;
161
161
  }
162
162
  ) {
163
163
  if (
164
164
  Math.abs(x - this.translateX) < 0.001 &&
165
165
  Math.abs(y - this.translateY) < 0.001 &&
166
- !options.triggerSync
166
+ !options.triggerSync &&
167
+ !options.force
167
168
  ) {
168
169
  return;
169
170
  }
@@ -230,6 +231,7 @@ export class ResizableContainer {
230
231
  }
231
232
 
232
233
  public destroy(_box?: ReadonlyTeleBox): void {
234
+ void _box;
233
235
  this.cancelScheduledLayoutUpdate();
234
236
  this.onLayoutUpdated = null;
235
237
  this.scaleChangedListeners.clear();
@@ -355,14 +355,16 @@ export class SlideControllerBase {
355
355
  }
356
356
 
357
357
  protected destroyed = false;
358
+ protected destroyPromise?: Promise<void>;
358
359
 
359
- public destroy() {
360
+ public destroy(): Promise<void> {
360
361
  this.sideEffect.flushAll();
361
362
  if (!this.destroyed) {
362
363
  log("[Slide] destroy slide (once)");
363
- this.slide.destroy();
364
364
  this.destroyed = true;
365
+ this.destroyPromise = Promise.resolve(this.slide.destroy());
365
366
  }
367
+ return this.destroyPromise ?? Promise.resolve();
366
368
  }
367
369
 
368
370
  public timestamp = () => {
@@ -378,28 +380,30 @@ export class SlideControllerBase {
378
380
  public isFrozen = false;
379
381
  protected _toFreeze: -1 | 0 | 1 = 0; // -1: unfreeze, 0: no change, 1: freeze
380
382
 
381
- public freeze = () => {
383
+ public freeze = (): Promise<void> => {
382
384
  this.isFrozen = true;
383
385
  if (this.ready) {
384
386
  log("[Slide] freeze", this.context.appId);
385
387
  if (this.invisibleBehavior === "frozen") {
386
- this.slide.frozen();
388
+ return Promise.resolve(this.slide.frozen());
387
389
  } else {
388
390
  this.slide.pause();
389
391
  }
390
392
  } else {
391
393
  this._toFreeze = 1;
392
394
  }
395
+ return Promise.resolve();
393
396
  };
394
397
 
395
- public unfreeze = async () => {
398
+ public unfreeze = async (): Promise<void> => {
396
399
  if (!this.visible) return;
400
+ if (!this.isFrozen && this.ready) return;
397
401
  this.isFrozen = false;
398
402
  if (this.ready) {
399
403
  let isNeedSyncState = false;
400
404
  log("[Slide] unfreeze", this.context.appId);
401
405
  if (this.invisibleBehavior === "frozen") {
402
- this.slide.release(() => {
406
+ await this.slide.release(() => {
403
407
  // release 会重建 player,内部 observer 已关闭,需要按当前 frame 尺寸重绘
404
408
  this.slide.notifyFrameResize();
405
409
  });
@@ -163,6 +163,13 @@ export class SlideDocsViewer {
163
163
  console.log("[SlideDocsViewer] No initial slideScale found");
164
164
  }
165
165
 
166
+ const initialTranslateX = this.context.storage.state.translateX ?? 0.5;
167
+ const initialTranslateY = this.context.storage.state.translateY ?? 0.5;
168
+ this.resizableContainer.handleNormalizeTranslate(initialTranslateX, initialTranslateY, {
169
+ triggerScrollBar: true,
170
+ triggerSync: false,
171
+ });
172
+
166
173
  const handler: StorageStateChangedListener<Attributes> = diff => {
167
174
  if (diff.slideScale !== undefined) {
168
175
  // slideScale 是一个包含 newValue 和 oldValue 的对象
@@ -221,7 +228,7 @@ export class SlideDocsViewer {
221
228
  this.viewer.$content,
222
229
  this.context,
223
230
  this.enableScale,
224
- this.box,
231
+ this.box
225
232
  );
226
233
  this.resizableContainer.onLayoutUpdated = () => {
227
234
  this.scaleDocsToFit();
@@ -384,7 +391,10 @@ export class SlideDocsViewer {
384
391
  this.latestBoxSize = { width: payload.width, height: payload.height };
385
392
 
386
393
  // observer 已经先处理过同一尺寸时,忽略晚到的 event
387
- if (sizesEqual(this.lastObserverSize, this.latestBoxSize) || sizesEqual(this.lastAppliedSize, this.latestBoxSize)) {
394
+ if (
395
+ sizesEqual(this.lastObserverSize, this.latestBoxSize) ||
396
+ sizesEqual(this.lastAppliedSize, this.latestBoxSize)
397
+ ) {
388
398
  console.log("[app-slide] boxSizeChange skipped, observer already applied", {
389
399
  appId: this.appId,
390
400
  eventCount: this.boxSizeEventCount,
@@ -455,14 +465,15 @@ export class SlideDocsViewer {
455
465
  return (page > 0 ? page : 1) - 1;
456
466
  }
457
467
 
458
- public unmount() {
468
+ public async unmount(): Promise<this> {
459
469
  this.offBoxSizeChange?.();
460
470
  this.offBoxSizeChange = undefined;
461
471
  this.contentResizeObserver?.disconnect();
462
472
  this.contentResizeObserver = undefined;
463
473
  if (this.slideController) {
464
- this.slideController.destroy();
474
+ const controller = this.slideController;
465
475
  this.slideController = null;
476
+ await controller.destroy();
466
477
  }
467
478
  this.viewer.unmount();
468
479
  this.resizableContainer.destroy(this.box);
@@ -473,9 +484,9 @@ export class SlideDocsViewer {
473
484
  this.viewer.setReadonly(readonly);
474
485
  }
475
486
 
476
- public destroy() {
487
+ public async destroy(): Promise<void> {
477
488
  this.sideEffect.flushAll();
478
- this.unmount();
489
+ await this.unmount();
479
490
  this.viewer.destroy();
480
491
  }
481
492
 
@@ -489,9 +500,10 @@ export class SlideDocsViewer {
489
500
  const { width: slideWidth, height: slideHeight } = this.slideController.slide;
490
501
  if (slideWidth && slideHeight) {
491
502
  const originSize = this.context.storage.state.originSize;
492
- const { width, height } = originSize && originSize.width > 0 && originSize.height > 0
493
- ? originSize
494
- : { width: slideWidth, height: slideHeight };
503
+ const { width, height } =
504
+ originSize && originSize.width > 0 && originSize.height > 0
505
+ ? originSize
506
+ : { width: slideWidth, height: slideHeight };
495
507
  if (width > 0 && height > 0) {
496
508
  this.whiteboardView.moveCameraToContain({
497
509
  originX: -width / 2,
@@ -184,12 +184,14 @@ export class SlidePreviewer {
184
184
  };
185
185
 
186
186
  protected destroyed = false;
187
- public destroy() {
187
+ protected destroyPromise?: Promise<void>;
188
+ public async destroy(): Promise<void> {
188
189
  this.sideEffect.flushAll();
189
190
  if (this.slide && !this.destroyed) {
190
- this.slide.destroy();
191
191
  this.destroyed = true;
192
+ this.destroyPromise = Promise.resolve(this.slide.destroy());
192
193
  }
194
+ await this.destroyPromise;
193
195
  this.viewer.destroy();
194
196
  }
195
197
 
package/src/index.ts CHANGED
@@ -37,6 +37,8 @@ export const usePlugin: (plugin: any) => any = /* @__PURE__ */ Slide.usePlugin.b
37
37
 
38
38
  export const version = __APP_VERSION__;
39
39
 
40
+ const DEFAULT_SLIDE_SETUP_READY_TIMEOUT = 5_000;
41
+
40
42
  export { DefaultUrl, apps, FreezerLength, addHooks, useFreezer, log, logger };
41
43
 
42
44
  export { setFreezerLength, getFreezerLength, onCreated, onDestroyed } from "./utils/freezer";
@@ -87,6 +89,11 @@ export interface AppOptions
87
89
  enableScale?: boolean;
88
90
  resourceMaxRetries?: number;
89
91
  onResourceMaxRetries: (url: string, error: Error) => void;
92
+ /**
93
+ * Max time (ms) `setup()` waits for the first `renderEnd` before resolving
94
+ * anyway (the slide keeps loading in background). Default: 5_000.
95
+ */
96
+ setupReadyTimeout?: number;
90
97
  }
91
98
 
92
99
  export interface ILogger {
@@ -115,7 +122,9 @@ export interface AppResult {
115
122
  playPptMedia: () => void;
116
123
  }
117
124
 
118
- const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> = {
125
+ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
126
+ teardown(context: import("@netless/window-manager").AppContext): Promise<void>;
127
+ } = {
119
128
  kind: "Slide",
120
129
  setup(context) {
121
130
  console.log("[Slide] setup @ " + version);
@@ -133,6 +142,8 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> = {
133
142
  throw new Error("[Slide] no view, please set scenePath on addApp()");
134
143
  }
135
144
  view.disableCameraTransform = true;
145
+ const isLazySetupMode = (): boolean =>
146
+ (context.getWindowManager() as any)?.lazySetupInMaximizedMode === true;
136
147
 
137
148
  const box = context.getBox();
138
149
  box.mountStyles(styles);
@@ -146,6 +157,10 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> = {
146
157
  const baseScenePath = context.getInitScenePath() as string;
147
158
 
148
159
  let docsViewer: SlideDocsViewer | null = null;
160
+ // Serial setup queue support: capture the controller so setup() can await
161
+ // its first render before WindowManager starts the next app's setup.
162
+ let slideControllerRef: SlideController | null | undefined;
163
+ let firstRenderFailed = false;
149
164
 
150
165
  const onPageChanged = (page: number) => {
151
166
  const room = context.getRoom();
@@ -174,17 +189,29 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> = {
174
189
  ...options,
175
190
  onPageChanged,
176
191
  onNavigate: options.onNavigate,
177
- onRenderError: appOptions.onRenderError,
192
+ // Wrap the user callback: a render failure before the first
193
+ // renderEnd also fails the setup wait (serial setup queue).
194
+ onRenderError: (error, pageIndex) => {
195
+ firstRenderFailed = true;
196
+ appOptions.onRenderError?.(error, pageIndex);
197
+ },
178
198
  showRenderError: appOptions.showRenderError,
179
199
  invisibleBehavior: appOptions.invisibleBehavior,
180
200
  });
181
- if (useFreezer) apps.set(context.appId, slideController, box);
201
+ slideControllerRef = slideController;
202
+ // The legacy addHooks freezer is not awaitable across App instances.
203
+ // Lazy WindowManager mode owns freeze/release ordering through the
204
+ // per-App focus lifecycle instead.
205
+ if (useFreezer && !isLazySetupMode()) apps.set(context.appId, slideController, box);
182
206
  logger.setAppController(context.appId, slideController);
183
207
  if (import.meta.env.DEV) {
184
208
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
185
209
  (window as any).slideController = slideController;
186
210
  }
187
211
  slideController.readyPromise.then(options.onReady).then(() => {
212
+ // Teardown may have destroyed the box while this deferred callback
213
+ // was pending; syncing scenes against a destroyed app throws.
214
+ if (disposed) return;
188
215
  const room = context.getRoom();
189
216
  let synced = false;
190
217
  if (room && context.getIsWritable()) {
@@ -249,19 +276,91 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> = {
249
276
  });
250
277
  }
251
278
 
252
- context.emitter.on("destroy", () => {
279
+ // Blur freeze (same semantics as the Presentation blur thumbnail
280
+ // degradation): while the app is cached and loses focus in a
281
+ // maximized/minimized workspace, freeze the slide (destroy the player,
282
+ // keep a snapshot); on focus, unfreeze. Freezing is skipped in normal
283
+ // mode where several windows are visible side by side. The whole flow is
284
+ // a lazy-mode-only cache optimization: inactive unless the WindowManager
285
+ // runs with lazySetupInMaximizedMode enabled (read dynamically — lazy can
286
+ // be disabled at runtime, e.g. when forceMaximized is cleared).
287
+ const isBlurFreezeAllowed = (): boolean => {
288
+ const boxStatus = context.getBoxStatus();
289
+ if (boxStatus) return boxStatus !== "normal";
290
+ const boxState = context.getWindowManager()?.boxState;
291
+ return boxState != null && boxState !== "normal";
292
+ };
293
+ sideEffect.add(() =>
294
+ context.emitter.on("focus", async (isFocused: boolean) => {
295
+ if (disposed) return;
296
+ if (!isLazySetupMode()) return;
297
+ const controller = docsViewer?.slideController;
298
+ if (!controller) return;
299
+ if (!isFocused) {
300
+ if (!isBlurFreezeAllowed()) return;
301
+ if (!controller.isFrozen) {
302
+ log("[Slide] blur freeze", context.appId);
303
+ await controller.freeze();
304
+ }
305
+ return;
306
+ }
307
+ if (controller.isFrozen) {
308
+ log("[Slide] focus unfreeze", context.appId);
309
+ await controller.unfreeze();
310
+ }
311
+ })
312
+ );
313
+
314
+ let disposed = false;
315
+ let offDestroy: (() => void) | undefined;
316
+ const teardown = async (): Promise<void> => {
317
+ if (disposed) return;
318
+ disposed = true;
319
+ const removeDestroy = offDestroy;
320
+ offDestroy = undefined;
321
+ removeDestroy?.();
253
322
  log("[Slide] destroy", context.appId);
254
323
  if (useFreezer) apps.delete(context.appId);
255
324
  sideEffect.flushAll();
256
325
  if (docsViewer) {
257
- docsViewer.destroy();
326
+ const viewer = docsViewer;
258
327
  docsViewer = null;
328
+ await viewer.destroy();
259
329
  }
260
- });
330
+ };
331
+ offDestroy = context.emitter.on("destroy", teardown);
261
332
 
262
333
  docsViewer.mount();
263
334
 
264
- return {
335
+ (SlideApp as any).__teardownByContext ||= new WeakMap<object, () => Promise<void>>();
336
+ (SlideApp as any).__teardownByContext.set(context, teardown);
337
+
338
+ // Resolve once the SlideController finished its first render (renderEnd).
339
+ // `false` means timeout or teardown; the slide then keeps loading in the
340
+ // background without blocking WindowManager's serial setup queue.
341
+ const waitForFirstRender = (timeoutMs: number): Promise<boolean> =>
342
+ new Promise<boolean>(resolve => {
343
+ let settled = false;
344
+ const settle = (loaded: boolean) => {
345
+ if (settled) return;
346
+ settled = true;
347
+ window.clearTimeout(timeoutTimer);
348
+ window.clearInterval(pollTimer);
349
+ resolve(loaded);
350
+ };
351
+ const timeoutTimer = window.setTimeout(() => settle(false), timeoutMs);
352
+ // Success path resolves straight from readyPromise (no polling lag);
353
+ // the poll only catches teardown and pre-ready render failures.
354
+ slideControllerRef?.readyPromise.then(
355
+ () => settle(!firstRenderFailed),
356
+ () => settle(false)
357
+ );
358
+ const pollTimer = window.setInterval(() => {
359
+ if (disposed || firstRenderFailed) settle(false);
360
+ }, 100);
361
+ });
362
+
363
+ const appResult: AppResult = {
265
364
  onPptMediaPermissionRequest: callback => {
266
365
  const slide = docsViewer?.slideController?.slide;
267
366
  return slide?.onPptMediaPermissionRequest(callback) ?? (() => undefined);
@@ -286,6 +385,10 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> = {
286
385
  translateX: 0.5,
287
386
  translateY: 0.5,
288
387
  });
388
+ container.handleNormalizeTranslate(0.5, 0.5, {
389
+ triggerScrollBar: true,
390
+ triggerSync: false,
391
+ });
289
392
  container.scaleContainer(applyScale);
290
393
  },
291
394
  getViewScale: () => {
@@ -392,6 +495,21 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> = {
392
495
  return false;
393
496
  },
394
497
  };
498
+
499
+ const setupReadyTimeout = appOptions?.setupReadyTimeout ?? DEFAULT_SLIDE_SETUP_READY_TIMEOUT;
500
+ return waitForFirstRender(setupReadyTimeout).then(() => {
501
+ if (firstRenderFailed) {
502
+ throw new Error("[Slide] first render failed before ready");
503
+ }
504
+ if (!disposed && !slideControllerRef?.ready) {
505
+ log("[Slide] setup ready wait timed out, slide keeps loading in background");
506
+ }
507
+ return appResult;
508
+ }) as unknown as AppResult;
509
+ },
510
+ async teardown(context) {
511
+ await (SlideApp as any).__teardownByContext?.get(context)?.();
512
+ (SlideApp as any).__teardownByContext?.delete(context);
395
513
  },
396
514
  };
397
515