@netless/app-presentation 0.1.13-beta.0 → 0.1.13-beta.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.
@@ -35,6 +35,7 @@ import {
35
35
  isValidSharedViewport,
36
36
  isValidSize,
37
37
  } from "./camera-reference";
38
+ import { createPresentationRuntimeTeardown } from "./runtime-lifecycle";
38
39
  import debounce from "lodash/debounce";
39
40
 
40
41
  export type Logger = (...data: any[]) => void;
@@ -96,6 +97,17 @@ export interface PresentationAppOptions {
96
97
  goToPageByClick?: boolean;
97
98
  /** useClipView is used to set the presentation use clip view, it will be used in the presentation, and the presentation will be use clip view when the app is initialized */
98
99
  useClipView?: boolean;
100
+ /**
101
+ * Max time (ms) `setup()` waits for the first visible page image to load
102
+ * before resolving anyway. Default: 5_000.
103
+ */
104
+ setupReadyTimeout?: number;
105
+ /**
106
+ * Blur thumbnail degradation keeps cached (focused-lost) presentations on
107
+ * the low-res `previewURL` thumbnail to save memory. Enabled by default in
108
+ * maximized/minimized states; set `true` to disable it.
109
+ */
110
+ disableBlurThumbnailDegradation?: boolean;
99
111
  }
100
112
 
101
113
  export interface PresentationController {
@@ -154,6 +166,285 @@ const ppt2page = (
154
166
  return { ...size, src: ppt.src, thumbnail: ppt.previewURL, name };
155
167
  };
156
168
 
169
+ /**
170
+ * The whiteboard SDK renders the scene ppt as a real `<img alt="background">`
171
+ * inside the background HTML engine layer of the view. All blur degradation
172
+ * below works directly on that DOM node; no scene API is ever called.
173
+ */
174
+ const findBackgroundImage = (view: View): HTMLImageElement | null => {
175
+ const container = (view as any).divElement as HTMLElement | undefined | null;
176
+ if (!container || typeof container.querySelector !== "function") return null;
177
+ return container.querySelector('img[alt="background"]');
178
+ };
179
+
180
+ /**
181
+ * Degradation is a lazy-mode cache optimization only: inactive unless the
182
+ * WindowManager runs with lazySetupInMaximizedMode enabled (read dynamically
183
+ * — lazy can be disabled at runtime, e.g. when forceMaximized is cleared).
184
+ * It also only applies when the window cannot be visible: maximized
185
+ * (covered by the top app) or minimized. Normal mode may show several
186
+ * windows side by side, so a blurred app there must keep the full image.
187
+ */
188
+ const isBlurDegradationAllowed = (context: AppContext): boolean => {
189
+ if ((context.getWindowManager() as any)?.lazySetupInMaximizedMode !== true) {
190
+ return false;
191
+ }
192
+ const boxStatus = context.getBoxStatus();
193
+ if (boxStatus) {
194
+ return boxStatus !== "normal";
195
+ }
196
+ const boxState = context.getWindowManager().boxState;
197
+ return boxState !== undefined && boxState !== "normal";
198
+ };
199
+
200
+ const DEFAULT_SETUP_READY_TIMEOUT = 5_000;
201
+
202
+ /**
203
+ * Resolve once the current page background image has loaded (or after
204
+ * `timeoutMs`). `false` means timeout or the app was disposed mid-wait;
205
+ * callers must treat it as "continue loading in background", not an error.
206
+ */
207
+ const waitForCurrentPageImage = (
208
+ view: View,
209
+ timeoutMs: number,
210
+ isDisposed: () => boolean,
211
+ ): Promise<boolean> => {
212
+ return new Promise<boolean>(resolve => {
213
+ let settled = false;
214
+ let pollTimer: number | undefined;
215
+ const settle = (loaded: boolean) => {
216
+ if (settled) return;
217
+ settled = true;
218
+ window.clearTimeout(timeoutTimer);
219
+ if (pollTimer !== undefined) window.clearInterval(pollTimer);
220
+ resolve(loaded);
221
+ };
222
+ const timeoutTimer = window.setTimeout(() => settle(false), timeoutMs);
223
+ const check = () => {
224
+ if (isDisposed()) {
225
+ settle(false);
226
+ return;
227
+ }
228
+ const img = findBackgroundImage(view);
229
+ if (img && img.complete && img.naturalWidth > 0) {
230
+ settle(true);
231
+ }
232
+ };
233
+ check();
234
+ if (!settled) {
235
+ pollTimer = window.setInterval(check, 100);
236
+ }
237
+ });
238
+ };
239
+
240
+ /**
241
+ * Compare image URLs by resolved absolute href so relative/absolute
242
+ * representations of the same URL do not look different (and to keep the
243
+ * load-capture re-degrade loop terminating).
244
+ */
245
+ const resolveImageUrl = (url: string): string => {
246
+ try {
247
+ return new URL(url, window.location.href).href;
248
+ } catch {
249
+ return url;
250
+ }
251
+ };
252
+
253
+ const TRANSPARENT_BACKGROUND =
254
+ "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
255
+ const BACKGROUND_SUSPENDED_CLASS =
256
+ "netless-app-presentation-background-suspended";
257
+
258
+ const hasUsableThumbnail = (page: PresentationPage): boolean => {
259
+ const thumbnailURL = page.thumbnail;
260
+ if (!thumbnailURL) return false;
261
+ try {
262
+ const source = new URL(page.src, window.location.href);
263
+ const thumbnail = new URL(thumbnailURL, window.location.href);
264
+ const sameResourcePath =
265
+ source.origin === thumbnail.origin &&
266
+ source.pathname === thumbnail.pathname;
267
+ // OSS image resize URLs can return the complete animated GIF unchanged.
268
+ // Treat query-only GIF variants as the original resource, not a thumbnail.
269
+ if (/\.gif$/i.test(source.pathname) && sameResourcePath) return false;
270
+ } catch {
271
+ if (/\.gif(?:$|[?#])/i.test(page.src)) return false;
272
+ }
273
+ return resolveImageUrl(thumbnailURL) !== resolveImageUrl(page.src);
274
+ };
275
+
276
+ /**
277
+ * Blur thumbnail degradation (see lazy-setup design doc §15): when the app
278
+ * loses focus but stays cached (multi-runtime cache mode), swap the scene
279
+ * background `<img>` src to the low-res `PptDescription.previewURL`
280
+ * (`page.thumbnail`) via a pure DOM change and pause full-image preloading;
281
+ * restore the full image when focus comes back. A page without a real
282
+ * thumbnail keeps its measurable view container, but its background layer is
283
+ * hidden and replaced with a static pixel after TeleBox commits the blur.
284
+ *
285
+ * While degraded, every load of a background `<img>` is re-degraded
286
+ * immediately via a capture-phase `load` listener on the view container
287
+ * (`load` does not bubble but capture works). This covers remote page turns,
288
+ * SDK React re-renders and the SDK's own background-image onError retries —
289
+ * any path that (re)renders the full-size image while the app stays blurred.
290
+ */
291
+ export const setupBlurThumbnailDegradation = (config: {
292
+ context: AppContext;
293
+ view: View;
294
+ pages: PresentationPage[];
295
+ pageIndex$: Readable<number>;
296
+ app: Presentation;
297
+ dispose: ReturnType<typeof disposableStore>;
298
+ }): void => {
299
+ const { context, view, pages, pageIndex$, app, dispose } = config;
300
+ const box = context.getBox();
301
+ const boxBlurClass = box.wrapClassName("blur");
302
+ // Two separate flags: `blurEpisode` covers the whole blurred period (pages
303
+ // without a thumbnail don't end it, so a later page degrades again), while
304
+ // `reDegradeEnabled` only turns off when the degraded thumbnail itself
305
+ // fails, so we don't fight the SDK's background onError retry.
306
+ let blurEpisode = false;
307
+ let reDegradeEnabled = false;
308
+ let boxBlurred = box.$box.classList.contains(boxBlurClass);
309
+
310
+ const setBackgroundSuspended = (suspended: boolean): void => {
311
+ app.whiteboardDOM.classList.toggle(
312
+ BACKGROUND_SUSPENDED_CLASS,
313
+ suspended,
314
+ );
315
+ };
316
+
317
+ const degrade = (): void => {
318
+ const page = pages[pageIndex$.value];
319
+ if (!page) return;
320
+ const thumbnailURL = page.thumbnail;
321
+ const img = findBackgroundImage(view);
322
+ if (!thumbnailURL || !hasUsableThumbnail(page)) {
323
+ if (boxBlurred) {
324
+ setBackgroundSuspended(true);
325
+ if (
326
+ img &&
327
+ resolveImageUrl(img.src) !== resolveImageUrl(TRANSPARENT_BACKGROUND)
328
+ ) {
329
+ img.src = TRANSPARENT_BACKGROUND;
330
+ }
331
+ }
332
+ return;
333
+ }
334
+ setBackgroundSuspended(false);
335
+ if (img && resolveImageUrl(img.src) !== resolveImageUrl(thumbnailURL)) {
336
+ img.src = thumbnailURL;
337
+ }
338
+ };
339
+
340
+ const restore = (): void => {
341
+ const page = pages[pageIndex$.value];
342
+ const img = findBackgroundImage(view);
343
+ if (page && img && resolveImageUrl(img.src) !== resolveImageUrl(page.src)) {
344
+ img.src = page.src;
345
+ }
346
+ setBackgroundSuspended(false);
347
+ };
348
+
349
+ const suspendCachedResources = (): void => {
350
+ if (!blurEpisode || !boxBlurred) return;
351
+ app.suspendPreviewResources();
352
+ const page = pages[pageIndex$.value];
353
+ if (!page || !hasUsableThumbnail(page) || reDegradeEnabled) {
354
+ degrade();
355
+ }
356
+ };
357
+
358
+ const restoreFocusedResources = (): void => {
359
+ if (!blurEpisode) return;
360
+ blurEpisode = false;
361
+ reDegradeEnabled = false;
362
+ restore();
363
+ app.preload.resume(pageIndex$.value);
364
+ };
365
+
366
+ const isBackgroundImg = (target: EventTarget | null): boolean => {
367
+ const el = target as HTMLImageElement | null;
368
+ return Boolean(
369
+ el && el.tagName === "IMG" && el.matches?.('img[alt="background"]'),
370
+ );
371
+ };
372
+
373
+ // Re-degrade any background img that finishes loading a full-size image
374
+ // while the app is blurred. Listeners sit at document level (capture
375
+ // phase - load/error do not bubble but capture descends from document)
376
+ // and resolve the view container lazily per event, so a rebinding of
377
+ // view.divElement (bindContainer etc.) is followed automatically. The
378
+ // contains() check keeps the app's own preview-panel thumbnails untouched.
379
+ const ownsTarget = (target: EventTarget | null): target is HTMLImageElement => {
380
+ if (!isBackgroundImg(target)) return false;
381
+ const el = (view as any).divElement as HTMLElement | undefined | null;
382
+ return !!(el && el.contains(target as Node));
383
+ };
384
+
385
+ const onImgLoad = (ev: Event): void => {
386
+ if (!blurEpisode || !reDegradeEnabled) return;
387
+ if (!ownsTarget(ev.target)) return;
388
+ degrade();
389
+ };
390
+
391
+ // The degraded thumbnail failed to load: stop re-degrading for this blur
392
+ // episode so the SDK's onError retry can settle on the full image. Focus
393
+ // still force-restores afterwards.
394
+ const onImgError = (ev: Event): void => {
395
+ if (!blurEpisode || !reDegradeEnabled) return;
396
+ if (!ownsTarget(ev.target)) return;
397
+ reDegradeEnabled = false;
398
+ };
399
+
400
+ const offFocus = context.emitter.on("focus", (isFocused: boolean) => {
401
+ if (isFocused) {
402
+ restoreFocusedResources();
403
+ } else {
404
+ if (!isBlurDegradationAllowed(context)) return;
405
+ blurEpisode = true;
406
+ reDegradeEnabled = true;
407
+ degrade();
408
+ app.preload.pause();
409
+ suspendCachedResources();
410
+ }
411
+ });
412
+ dispose.add(offFocus);
413
+
414
+ const updateBoxBlurred = (blurred: boolean): void => {
415
+ if (boxBlurred === blurred) return;
416
+ boxBlurred = blurred;
417
+ if (blurred) suspendCachedResources();
418
+ else restoreFocusedResources();
419
+ };
420
+ const onBoxBlur = (): void => updateBoxBlurred(true);
421
+ const onBoxFocus = (): void => updateBoxBlurred(false);
422
+ box.events.on("blur", onBoxBlur);
423
+ box.events.on("focus", onBoxFocus);
424
+ dispose.add(() => {
425
+ box.events.off("blur", onBoxBlur);
426
+ box.events.off("focus", onBoxFocus);
427
+ });
428
+
429
+ // WindowManager may update TeleBox focus with skipUpdate=true. In that
430
+ // path the visual class changes but TeleBox intentionally emits no event.
431
+ const boxClassObserver = new MutationObserver(() => {
432
+ updateBoxBlurred(box.$box.classList.contains(boxBlurClass));
433
+ });
434
+ boxClassObserver.observe(box.$box, {
435
+ attributes: true,
436
+ attributeFilter: ["class"],
437
+ });
438
+ dispose.add(() => boxClassObserver.disconnect());
439
+
440
+ document.addEventListener("load", onImgLoad, true);
441
+ document.addEventListener("error", onImgError, true);
442
+ dispose.add(() => {
443
+ document.removeEventListener("load", onImgLoad, true);
444
+ document.removeEventListener("error", onImgError, true);
445
+ });
446
+ };
447
+
157
448
  const createLogger = (room: Room | undefined): Logger => {
158
449
  if (room && (room as any).logger) {
159
450
  return (...args) => (room as any).logger.info(...args);
@@ -641,6 +932,12 @@ export const NetlessAppPresentation: NetlessApp<
641
932
  app.log = log;
642
933
  app.warn = warn;
643
934
 
935
+ // Setup only touches the current page's full image; the rest of the deck
936
+ // keeps loading through idle callbacks after the setup queue moves on.
937
+ if (pageIndex$.value !== 0) {
938
+ app.preload.touch(pageIndex$.value, true);
939
+ }
940
+
644
941
  const getCameraDiagnosticState = (
645
942
  reason: "initialize" | "moveCamera",
646
943
  requestedCamera?: MoveCameraRequest,
@@ -785,6 +1082,10 @@ export const NetlessAppPresentation: NetlessApp<
785
1082
  }),
786
1083
  );
787
1084
 
1085
+ if (!options.disableBlurThumbnailDegradation) {
1086
+ setupBlurThumbnailDegradation({ context, view, pages, pageIndex$, app, dispose });
1087
+ }
1088
+
788
1089
  const getOriginScale = () => {
789
1090
  const page = app.page();
790
1091
  if (!page || !isValidSize(page)) return 0;
@@ -941,13 +1242,23 @@ export const NetlessAppPresentation: NetlessApp<
941
1242
 
942
1243
  let disposed = false;
943
1244
  let offDestroy: (() => void) | undefined;
1245
+ // The public readonly box type hides host lifecycle mutators, while the
1246
+ // TeleBox runtime exposes them for app mount cleanup.
1247
+ const teardownRuntime = createPresentationRuntimeTeardown(
1248
+ box as ReadonlyTeleBox & {
1249
+ unmountContent(): unknown;
1250
+ unmountFooter(): unknown;
1251
+ unmountStyles(): unknown;
1252
+ },
1253
+ dispose,
1254
+ );
944
1255
  const teardown = () => {
945
1256
  if (disposed) return;
946
1257
  disposed = true;
947
1258
  const removeDestroy = offDestroy;
948
1259
  offDestroy = undefined;
949
1260
  removeDestroy?.();
950
- dispose();
1261
+ teardownRuntime();
951
1262
  };
952
1263
  offDestroy = context.emitter.on("destroy", teardown);
953
1264
  teardownByContext.set(context, teardown);
@@ -1207,9 +1518,16 @@ export const NetlessAppPresentation: NetlessApp<
1207
1518
  // Older WindowManager declarations model setup as synchronous even though
1208
1519
  // AppProxy awaits its result. Keep that source compatibility until the new
1209
1520
  // `SetupResult | Promise<SetupResult>` declaration is the minimum version.
1210
- return prepareScenesPromise.then(
1211
- () => controller,
1212
- ) as unknown as PresentationController;
1521
+ const setupReadyTimeout = options.setupReadyTimeout ?? DEFAULT_SETUP_READY_TIMEOUT;
1522
+ return prepareScenesPromise.then(async () => {
1523
+ // Keep the serial setup queue honest: resolve only once the current
1524
+ // page image is actually visible (or the bounded wait gives up).
1525
+ const loaded = await waitForCurrentPageImage(view, setupReadyTimeout, () => disposed);
1526
+ if (!loaded && !disposed) {
1527
+ warn(`[Presentation] setup ready wait timed out after ${setupReadyTimeout}ms`);
1528
+ }
1529
+ return controller;
1530
+ }) as unknown as PresentationController;
1213
1531
  },
1214
1532
  teardown(context) {
1215
1533
  teardownByContext.get(context)?.();
package/src/preload.ts CHANGED
@@ -35,6 +35,9 @@ export class Preload implements IDisposable {
35
35
  // 当前触摸的索引
36
36
  touchIndex: number = 0;
37
37
  preloadSize: number = 0;
38
+ // 暂停后不再创建预加载链接(app 失焦降级时调用);已插入的 link 会被移除,
39
+ // 挂起的 idle 回调重新进入 touch() 时也会被跳过
40
+ paused: boolean = false;
38
41
  constructor(readonly pages: PresentationPage[]) {
39
42
  this.preloadMap = new Map(pages.map((e,index) => [index, {
40
43
  src: e.src,
@@ -43,7 +46,18 @@ export class Preload implements IDisposable {
43
46
  this.preloadSize = this.preloadMap.size;
44
47
  this.touch(0, true);
45
48
  }
49
+ pause() {
50
+ if (this.paused) return;
51
+ this.paused = true;
52
+ this.destroySomeLink();
53
+ }
54
+ resume(index?: number) {
55
+ if (!this.paused) return;
56
+ this.paused = false;
57
+ this.touch(index ?? this.touchIndex, true);
58
+ }
46
59
  touch(index: number, force: boolean = false) {
60
+ if (this.paused) return;
47
61
  if (index >= this.preloadSize) {
48
62
  this.touchIndex = 0;
49
63
  } else {
@@ -110,7 +110,7 @@ export class Presentation implements IDisposable<void> {
110
110
  if (this.initialized) return
111
111
  this.initialized = true
112
112
 
113
- this.dispose.add(() => this.previewLazyload?.destroy())
113
+ this.dispose.add(() => this.destroyPreviewLazyload())
114
114
 
115
115
  this.contentDOM.classList.toggle(this.c('readonly'), this.readonly)
116
116
  this.dom.appendChild(this.contentDOM)
@@ -276,6 +276,18 @@ export class Presentation implements IDisposable<void> {
276
276
  }
277
277
  }
278
278
 
279
+ /** Release preview images while this presentation stays in the runtime cache. */
280
+ suspendPreviewResources() {
281
+ this.togglePreview(false)
282
+ this.destroyPreviewLazyload()
283
+ this.previewDOM.querySelectorAll<HTMLImageElement>('img').forEach(img => {
284
+ img.removeAttribute('src')
285
+ img.removeAttribute('srcset')
286
+ img.removeAttribute('sizes')
287
+ LazyLoad.resetStatus(img)
288
+ })
289
+ }
290
+
279
291
  // [origin] means the cause:
280
292
  // - navigation: user clicked on the left / right button on footer
281
293
  // - input: user filled the bottom-right page number input box
@@ -322,6 +334,11 @@ export class Presentation implements IDisposable<void> {
322
334
  }
323
335
  }
324
336
 
337
+ private destroyPreviewLazyload() {
338
+ this.previewLazyload?.destroy()
339
+ this.previewLazyload = null
340
+ }
341
+
325
342
  private c(className: string): string {
326
343
  return `${this.namespace}-${className}`
327
344
  }
@@ -0,0 +1,24 @@
1
+ export interface PresentationRuntimeBox {
2
+ unmountContent(): unknown;
3
+ unmountFooter(): unknown;
4
+ unmountStyles(): unknown;
5
+ }
6
+
7
+ /** Build an idempotent runtime teardown while preserving the TeleBox shell. */
8
+ export const createPresentationRuntimeTeardown = (
9
+ box: PresentationRuntimeBox,
10
+ dispose: () => void,
11
+ ): (() => void) => {
12
+ let tornDown = false;
13
+ return () => {
14
+ if (tornDown) return;
15
+ tornDown = true;
16
+ try {
17
+ dispose();
18
+ } finally {
19
+ box.unmountContent();
20
+ box.unmountFooter();
21
+ box.unmountStyles();
22
+ }
23
+ };
24
+ };
package/src/style.scss CHANGED
@@ -12,6 +12,12 @@ $namespace: 'netless-app-presentation';
12
12
  overflow: hidden;
13
13
  }
14
14
 
15
+ // Keep the view container measurable while releasing an unfocused cached
16
+ // page's background from painting and compositing.
17
+ .#{$namespace}-background-suspended .background {
18
+ display: none !important;
19
+ }
20
+
15
21
  .#{$namespace}-preview-mask {
16
22
  display: none;
17
23
  position: absolute;
@@ -307,4 +313,4 @@ $namespace: 'netless-app-presentation';
307
313
 
308
314
  .#{$namespace}-scrollbar-y {
309
315
  width: 6px;
310
- }
316
+ }