@netless/app-presentation 0.1.13-beta.1 → 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;
@@ -177,11 +178,17 @@ const findBackgroundImage = (view: View): HTMLImageElement | null => {
177
178
  };
178
179
 
179
180
  /**
180
- * Degradation only applies when the window cannot be visible: maximized
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
181
185
  * (covered by the top app) or minimized. Normal mode may show several
182
186
  * windows side by side, so a blurred app there must keep the full image.
183
187
  */
184
188
  const isBlurDegradationAllowed = (context: AppContext): boolean => {
189
+ if ((context.getWindowManager() as any)?.lazySetupInMaximizedMode !== true) {
190
+ return false;
191
+ }
185
192
  const boxStatus = context.getBoxStatus();
186
193
  if (boxStatus) {
187
194
  return boxStatus !== "normal";
@@ -243,13 +250,37 @@ const resolveImageUrl = (url: string): string => {
243
250
  }
244
251
  };
245
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
+
246
276
  /**
247
277
  * Blur thumbnail degradation (see lazy-setup design doc §15): when the app
248
278
  * loses focus but stays cached (multi-runtime cache mode), swap the scene
249
279
  * background `<img>` src to the low-res `PptDescription.previewURL`
250
280
  * (`page.thumbnail`) via a pure DOM change and pause full-image preloading;
251
- * restore the full image when focus comes back. Skipped when the page has no
252
- * thumbnail.
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.
253
284
  *
254
285
  * While degraded, every load of a background `<img>` is re-degraded
255
286
  * immediately via a capture-phase `load` listener on the view container
@@ -257,7 +288,7 @@ const resolveImageUrl = (url: string): string => {
257
288
  * SDK React re-renders and the SDK's own background-image onError retries —
258
289
  * any path that (re)renders the full-size image while the app stays blurred.
259
290
  */
260
- const setupBlurThumbnailDegradation = (config: {
291
+ export const setupBlurThumbnailDegradation = (config: {
261
292
  context: AppContext;
262
293
  view: View;
263
294
  pages: PresentationPage[];
@@ -266,19 +297,43 @@ const setupBlurThumbnailDegradation = (config: {
266
297
  dispose: ReturnType<typeof disposableStore>;
267
298
  }): void => {
268
299
  const { context, view, pages, pageIndex$, app, dispose } = config;
300
+ const box = context.getBox();
301
+ const boxBlurClass = box.wrapClassName("blur");
269
302
  // Two separate flags: `blurEpisode` covers the whole blurred period (pages
270
303
  // without a thumbnail don't end it, so a later page degrades again), while
271
304
  // `reDegradeEnabled` only turns off when the degraded thumbnail itself
272
305
  // fails, so we don't fight the SDK's background onError retry.
273
306
  let blurEpisode = false;
274
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
+ };
275
316
 
276
317
  const degrade = (): void => {
277
318
  const page = pages[pageIndex$.value];
278
- if (!page?.thumbnail) return; // keep the full image as-is
319
+ if (!page) return;
320
+ const thumbnailURL = page.thumbnail;
279
321
  const img = findBackgroundImage(view);
280
- if (img && resolveImageUrl(img.src) !== resolveImageUrl(page.thumbnail)) {
281
- img.src = page.thumbnail;
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;
282
337
  }
283
338
  };
284
339
 
@@ -288,6 +343,24 @@ const setupBlurThumbnailDegradation = (config: {
288
343
  if (page && img && resolveImageUrl(img.src) !== resolveImageUrl(page.src)) {
289
344
  img.src = page.src;
290
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);
291
364
  };
292
365
 
293
366
  const isBackgroundImg = (target: EventTarget | null): boolean => {
@@ -298,11 +371,20 @@ const setupBlurThumbnailDegradation = (config: {
298
371
  };
299
372
 
300
373
  // Re-degrade any background img that finishes loading a full-size image
301
- // while the app is blurred. Scoped to the view container so the app's own
302
- // preview-panel thumbnails are untouched.
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
+
303
385
  const onImgLoad = (ev: Event): void => {
304
386
  if (!blurEpisode || !reDegradeEnabled) return;
305
- if (!isBackgroundImg(ev.target)) return;
387
+ if (!ownsTarget(ev.target)) return;
306
388
  degrade();
307
389
  };
308
390
 
@@ -311,51 +393,56 @@ const setupBlurThumbnailDegradation = (config: {
311
393
  // still force-restores afterwards.
312
394
  const onImgError = (ev: Event): void => {
313
395
  if (!blurEpisode || !reDegradeEnabled) return;
314
- if (!isBackgroundImg(ev.target)) return;
396
+ if (!ownsTarget(ev.target)) return;
315
397
  reDegradeEnabled = false;
316
398
  };
317
399
 
318
400
  const offFocus = context.emitter.on("focus", (isFocused: boolean) => {
319
401
  if (isFocused) {
320
- if (blurEpisode) {
321
- blurEpisode = false;
322
- reDegradeEnabled = false;
323
- restore();
324
- app.preload.resume(pageIndex$.value);
325
- }
402
+ restoreFocusedResources();
326
403
  } else {
327
404
  if (!isBlurDegradationAllowed(context)) return;
328
405
  blurEpisode = true;
329
406
  reDegradeEnabled = true;
330
407
  degrade();
331
408
  app.preload.pause();
409
+ suspendCachedResources();
332
410
  }
333
411
  });
334
412
  dispose.add(offFocus);
335
413
 
336
- const viewEl = (view as any).divElement as HTMLElement | undefined | null;
337
- if (viewEl && typeof viewEl.addEventListener === "function") {
338
- viewEl.addEventListener("load", onImgLoad, true);
339
- viewEl.addEventListener("error", onImgError, true);
340
- dispose.add(() => {
341
- viewEl.removeEventListener("load", onImgLoad, true);
342
- viewEl.removeEventListener("error", onImgError, true);
343
- });
344
- } else {
345
- // View not mounted yet (should not happen after context.mountView):
346
- // keep the degradation on blur only, without re-degrade coverage.
347
- warnOnceMissingViewContainer(view);
348
- }
349
- };
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
+ });
350
428
 
351
- // One-shot diagnostic for an unexpected missing view container.
352
- const degradedWarnedViews = new WeakSet<object>();
353
- const warnOnceMissingViewContainer = (view: View): void => {
354
- if (degradedWarnedViews.has(view)) return;
355
- degradedWarnedViews.add(view);
356
- console.warn(
357
- "[Presentation]: blur thumbnail degradation runs without re-degrade coverage (view container unavailable)",
358
- );
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
+ });
359
446
  };
360
447
 
361
448
  const createLogger = (room: Room | undefined): Logger => {
@@ -1155,13 +1242,23 @@ export const NetlessAppPresentation: NetlessApp<
1155
1242
 
1156
1243
  let disposed = false;
1157
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
+ );
1158
1255
  const teardown = () => {
1159
1256
  if (disposed) return;
1160
1257
  disposed = true;
1161
1258
  const removeDestroy = offDestroy;
1162
1259
  offDestroy = undefined;
1163
1260
  removeDestroy?.();
1164
- dispose();
1261
+ teardownRuntime();
1165
1262
  };
1166
1263
  offDestroy = context.emitter.on("destroy", teardown);
1167
1264
  teardownByContext.set(context, teardown);
@@ -1426,7 +1523,7 @@ export const NetlessAppPresentation: NetlessApp<
1426
1523
  // Keep the serial setup queue honest: resolve only once the current
1427
1524
  // page image is actually visible (or the bounded wait gives up).
1428
1525
  const loaded = await waitForCurrentPageImage(view, setupReadyTimeout, () => disposed);
1429
- if (!loaded) {
1526
+ if (!loaded && !disposed) {
1430
1527
  warn(`[Presentation] setup ready wait timed out after ${setupReadyTimeout}ms`);
1431
1528
  }
1432
1529
  return controller;
@@ -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
+ }