@netless/app-presentation 0.1.13-beta.1 → 0.1.13-beta.3

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,8 @@ import {
35
35
  isValidSharedViewport,
36
36
  isValidSize,
37
37
  } from "./camera-reference";
38
+ import { createPresentationRuntimeTeardown } from "./runtime-lifecycle";
39
+ import { waitForCurrentPageImage } from "./setup-ready";
38
40
  import debounce from "lodash/debounce";
39
41
 
40
42
  export type Logger = (...data: any[]) => void;
@@ -177,11 +179,17 @@ const findBackgroundImage = (view: View): HTMLImageElement | null => {
177
179
  };
178
180
 
179
181
  /**
180
- * Degradation only applies when the window cannot be visible: maximized
182
+ * Degradation is a lazy-mode cache optimization only: inactive unless the
183
+ * WindowManager runs with lazySetupInMaximizedMode enabled (read dynamically
184
+ * — lazy can be disabled at runtime, e.g. when forceMaximized is cleared).
185
+ * It also only applies when the window cannot be visible: maximized
181
186
  * (covered by the top app) or minimized. Normal mode may show several
182
187
  * windows side by side, so a blurred app there must keep the full image.
183
188
  */
184
189
  const isBlurDegradationAllowed = (context: AppContext): boolean => {
190
+ if ((context.getWindowManager() as any)?.lazySetupInMaximizedMode !== true) {
191
+ return false;
192
+ }
185
193
  const boxStatus = context.getBoxStatus();
186
194
  if (boxStatus) {
187
195
  return boxStatus !== "normal";
@@ -192,44 +200,6 @@ const isBlurDegradationAllowed = (context: AppContext): boolean => {
192
200
 
193
201
  const DEFAULT_SETUP_READY_TIMEOUT = 5_000;
194
202
 
195
- /**
196
- * Resolve once the current page background image has loaded (or after
197
- * `timeoutMs`). `false` means timeout or the app was disposed mid-wait;
198
- * callers must treat it as "continue loading in background", not an error.
199
- */
200
- const waitForCurrentPageImage = (
201
- view: View,
202
- timeoutMs: number,
203
- isDisposed: () => boolean,
204
- ): Promise<boolean> => {
205
- return new Promise<boolean>(resolve => {
206
- let settled = false;
207
- let pollTimer: number | undefined;
208
- const settle = (loaded: boolean) => {
209
- if (settled) return;
210
- settled = true;
211
- window.clearTimeout(timeoutTimer);
212
- if (pollTimer !== undefined) window.clearInterval(pollTimer);
213
- resolve(loaded);
214
- };
215
- const timeoutTimer = window.setTimeout(() => settle(false), timeoutMs);
216
- const check = () => {
217
- if (isDisposed()) {
218
- settle(false);
219
- return;
220
- }
221
- const img = findBackgroundImage(view);
222
- if (img && img.complete && img.naturalWidth > 0) {
223
- settle(true);
224
- }
225
- };
226
- check();
227
- if (!settled) {
228
- pollTimer = window.setInterval(check, 100);
229
- }
230
- });
231
- };
232
-
233
203
  /**
234
204
  * Compare image URLs by resolved absolute href so relative/absolute
235
205
  * representations of the same URL do not look different (and to keep the
@@ -243,21 +213,46 @@ const resolveImageUrl = (url: string): string => {
243
213
  }
244
214
  };
245
215
 
216
+ const TRANSPARENT_BACKGROUND =
217
+ "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
218
+ const BACKGROUND_SUSPENDED_CLASS =
219
+ "netless-app-presentation-background-suspended";
220
+
221
+ const hasUsableThumbnail = (page: PresentationPage): boolean => {
222
+ const thumbnailURL = page.thumbnail;
223
+ if (!thumbnailURL) return false;
224
+ try {
225
+ const source = new URL(page.src, window.location.href);
226
+ const thumbnail = new URL(thumbnailURL, window.location.href);
227
+ const sameResourcePath =
228
+ source.origin === thumbnail.origin &&
229
+ source.pathname === thumbnail.pathname;
230
+ // OSS image resize URLs can return the complete animated GIF unchanged.
231
+ // Treat query-only GIF variants as the original resource, not a thumbnail.
232
+ if (/\.gif$/i.test(source.pathname) && sameResourcePath) return false;
233
+ } catch {
234
+ if (/\.gif(?:$|[?#])/i.test(page.src)) return false;
235
+ }
236
+ return resolveImageUrl(thumbnailURL) !== resolveImageUrl(page.src);
237
+ };
238
+
246
239
  /**
247
240
  * Blur thumbnail degradation (see lazy-setup design doc §15): when the app
248
241
  * loses focus but stays cached (multi-runtime cache mode), swap the scene
249
242
  * background `<img>` src to the low-res `PptDescription.previewURL`
250
243
  * (`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.
244
+ * restore the full image when focus comes back. A page without a real
245
+ * thumbnail keeps its measurable view container, but its background layer is
246
+ * hidden and replaced with a static pixel after TeleBox commits the blur.
253
247
  *
254
248
  * While degraded, every load of a background `<img>` is re-degraded
255
- * immediately via a capture-phase `load` listener on the view container
249
+ * immediately via a capture-phase `load` listener on the document, filtered
250
+ * to the current view container
256
251
  * (`load` does not bubble but capture works). This covers remote page turns,
257
252
  * SDK React re-renders and the SDK's own background-image onError retries —
258
253
  * any path that (re)renders the full-size image while the app stays blurred.
259
254
  */
260
- const setupBlurThumbnailDegradation = (config: {
255
+ export const setupBlurThumbnailDegradation = (config: {
261
256
  context: AppContext;
262
257
  view: View;
263
258
  pages: PresentationPage[];
@@ -266,19 +261,43 @@ const setupBlurThumbnailDegradation = (config: {
266
261
  dispose: ReturnType<typeof disposableStore>;
267
262
  }): void => {
268
263
  const { context, view, pages, pageIndex$, app, dispose } = config;
264
+ const box = context.getBox();
265
+ const boxBlurClass = box.wrapClassName("blur");
269
266
  // Two separate flags: `blurEpisode` covers the whole blurred period (pages
270
267
  // without a thumbnail don't end it, so a later page degrades again), while
271
268
  // `reDegradeEnabled` only turns off when the degraded thumbnail itself
272
269
  // fails, so we don't fight the SDK's background onError retry.
273
270
  let blurEpisode = false;
274
271
  let reDegradeEnabled = false;
272
+ let boxBlurred = box.$box.classList.contains(boxBlurClass);
273
+
274
+ const setBackgroundSuspended = (suspended: boolean): void => {
275
+ app.whiteboardDOM.classList.toggle(
276
+ BACKGROUND_SUSPENDED_CLASS,
277
+ suspended,
278
+ );
279
+ };
275
280
 
276
281
  const degrade = (): void => {
277
282
  const page = pages[pageIndex$.value];
278
- if (!page?.thumbnail) return; // keep the full image as-is
283
+ if (!page) return;
284
+ const thumbnailURL = page.thumbnail;
279
285
  const img = findBackgroundImage(view);
280
- if (img && resolveImageUrl(img.src) !== resolveImageUrl(page.thumbnail)) {
281
- img.src = page.thumbnail;
286
+ if (!thumbnailURL || !hasUsableThumbnail(page)) {
287
+ if (boxBlurred) {
288
+ setBackgroundSuspended(true);
289
+ if (
290
+ img &&
291
+ resolveImageUrl(img.src) !== resolveImageUrl(TRANSPARENT_BACKGROUND)
292
+ ) {
293
+ img.src = TRANSPARENT_BACKGROUND;
294
+ }
295
+ }
296
+ return;
297
+ }
298
+ setBackgroundSuspended(false);
299
+ if (img && resolveImageUrl(img.src) !== resolveImageUrl(thumbnailURL)) {
300
+ img.src = thumbnailURL;
282
301
  }
283
302
  };
284
303
 
@@ -288,6 +307,24 @@ const setupBlurThumbnailDegradation = (config: {
288
307
  if (page && img && resolveImageUrl(img.src) !== resolveImageUrl(page.src)) {
289
308
  img.src = page.src;
290
309
  }
310
+ setBackgroundSuspended(false);
311
+ };
312
+
313
+ const suspendCachedResources = (): void => {
314
+ if (!blurEpisode || !boxBlurred) return;
315
+ app.suspendPreviewResources();
316
+ const page = pages[pageIndex$.value];
317
+ if (!page || !hasUsableThumbnail(page) || reDegradeEnabled) {
318
+ degrade();
319
+ }
320
+ };
321
+
322
+ const restoreFocusedResources = (): void => {
323
+ if (!blurEpisode) return;
324
+ blurEpisode = false;
325
+ reDegradeEnabled = false;
326
+ restore();
327
+ app.preload.resume(pageIndex$.value);
291
328
  };
292
329
 
293
330
  const isBackgroundImg = (target: EventTarget | null): boolean => {
@@ -298,11 +335,20 @@ const setupBlurThumbnailDegradation = (config: {
298
335
  };
299
336
 
300
337
  // 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.
338
+ // while the app is blurred. Listeners sit at document level (capture
339
+ // phase - load/error do not bubble but capture descends from document)
340
+ // and resolve the view container lazily per event, so a rebinding of
341
+ // view.divElement (bindContainer etc.) is followed automatically. The
342
+ // contains() check keeps the app's own preview-panel thumbnails untouched.
343
+ const ownsTarget = (target: EventTarget | null): target is HTMLImageElement => {
344
+ if (!isBackgroundImg(target)) return false;
345
+ const el = (view as any).divElement as HTMLElement | undefined | null;
346
+ return !!(el && el.contains(target as Node));
347
+ };
348
+
303
349
  const onImgLoad = (ev: Event): void => {
304
350
  if (!blurEpisode || !reDegradeEnabled) return;
305
- if (!isBackgroundImg(ev.target)) return;
351
+ if (!ownsTarget(ev.target)) return;
306
352
  degrade();
307
353
  };
308
354
 
@@ -311,51 +357,56 @@ const setupBlurThumbnailDegradation = (config: {
311
357
  // still force-restores afterwards.
312
358
  const onImgError = (ev: Event): void => {
313
359
  if (!blurEpisode || !reDegradeEnabled) return;
314
- if (!isBackgroundImg(ev.target)) return;
360
+ if (!ownsTarget(ev.target)) return;
315
361
  reDegradeEnabled = false;
316
362
  };
317
363
 
318
364
  const offFocus = context.emitter.on("focus", (isFocused: boolean) => {
319
365
  if (isFocused) {
320
- if (blurEpisode) {
321
- blurEpisode = false;
322
- reDegradeEnabled = false;
323
- restore();
324
- app.preload.resume(pageIndex$.value);
325
- }
366
+ restoreFocusedResources();
326
367
  } else {
327
368
  if (!isBlurDegradationAllowed(context)) return;
328
369
  blurEpisode = true;
329
370
  reDegradeEnabled = true;
330
371
  degrade();
331
372
  app.preload.pause();
373
+ suspendCachedResources();
332
374
  }
333
375
  });
334
376
  dispose.add(offFocus);
335
377
 
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
- };
378
+ const updateBoxBlurred = (blurred: boolean): void => {
379
+ if (boxBlurred === blurred) return;
380
+ boxBlurred = blurred;
381
+ if (blurred) suspendCachedResources();
382
+ else restoreFocusedResources();
383
+ };
384
+ const onBoxBlur = (): void => updateBoxBlurred(true);
385
+ const onBoxFocus = (): void => updateBoxBlurred(false);
386
+ box.events.on("blur", onBoxBlur);
387
+ box.events.on("focus", onBoxFocus);
388
+ dispose.add(() => {
389
+ box.events.off("blur", onBoxBlur);
390
+ box.events.off("focus", onBoxFocus);
391
+ });
350
392
 
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
- );
393
+ // WindowManager may update TeleBox focus with skipUpdate=true. In that
394
+ // path the visual class changes but TeleBox intentionally emits no event.
395
+ const boxClassObserver = new MutationObserver(() => {
396
+ updateBoxBlurred(box.$box.classList.contains(boxBlurClass));
397
+ });
398
+ boxClassObserver.observe(box.$box, {
399
+ attributes: true,
400
+ attributeFilter: ["class"],
401
+ });
402
+ dispose.add(() => boxClassObserver.disconnect());
403
+
404
+ document.addEventListener("load", onImgLoad, true);
405
+ document.addEventListener("error", onImgError, true);
406
+ dispose.add(() => {
407
+ document.removeEventListener("load", onImgLoad, true);
408
+ document.removeEventListener("error", onImgError, true);
409
+ });
359
410
  };
360
411
 
361
412
  const createLogger = (room: Room | undefined): Logger => {
@@ -1155,13 +1206,23 @@ export const NetlessAppPresentation: NetlessApp<
1155
1206
 
1156
1207
  let disposed = false;
1157
1208
  let offDestroy: (() => void) | undefined;
1209
+ // The public readonly box type hides host lifecycle mutators, while the
1210
+ // TeleBox runtime exposes them for app mount cleanup.
1211
+ const teardownRuntime = createPresentationRuntimeTeardown(
1212
+ box as ReadonlyTeleBox & {
1213
+ unmountContent(): unknown;
1214
+ unmountFooter(): unknown;
1215
+ unmountStyles(): unknown;
1216
+ },
1217
+ dispose,
1218
+ );
1158
1219
  const teardown = () => {
1159
1220
  if (disposed) return;
1160
1221
  disposed = true;
1161
1222
  const removeDestroy = offDestroy;
1162
1223
  offDestroy = undefined;
1163
1224
  removeDestroy?.();
1164
- dispose();
1225
+ teardownRuntime();
1165
1226
  };
1166
1227
  offDestroy = context.emitter.on("destroy", teardown);
1167
1228
  teardownByContext.set(context, teardown);
@@ -1425,9 +1486,13 @@ export const NetlessAppPresentation: NetlessApp<
1425
1486
  return prepareScenesPromise.then(async () => {
1426
1487
  // Keep the serial setup queue honest: resolve only once the current
1427
1488
  // page image is actually visible (or the bounded wait gives up).
1428
- const loaded = await waitForCurrentPageImage(view, setupReadyTimeout, () => disposed);
1429
- if (!loaded) {
1489
+ const result = await waitForCurrentPageImage(
1490
+ () => findBackgroundImage(view), setupReadyTimeout, () => disposed,
1491
+ );
1492
+ if (result === "timeout" && !disposed) {
1430
1493
  warn(`[Presentation] setup ready wait timed out after ${setupReadyTimeout}ms`);
1494
+ } else if (result === "error" && !disposed) {
1495
+ warn("[Presentation] setup page image failed to load; continuing setup");
1431
1496
  }
1432
1497
  return controller;
1433
1498
  }) as unknown as PresentationController;
package/src/preload.ts CHANGED
@@ -38,6 +38,7 @@ export class Preload implements IDisposable {
38
38
  // 暂停后不再创建预加载链接(app 失焦降级时调用);已插入的 link 会被移除,
39
39
  // 挂起的 idle 回调重新进入 touch() 时也会被跳过
40
40
  paused: boolean = false;
41
+ private disposed = false;
41
42
  constructor(readonly pages: PresentationPage[]) {
42
43
  this.preloadMap = new Map(pages.map((e,index) => [index, {
43
44
  src: e.src,
@@ -52,23 +53,23 @@ export class Preload implements IDisposable {
52
53
  this.destroySomeLink();
53
54
  }
54
55
  resume(index?: number) {
55
- if (!this.paused) return;
56
+ if (!this.paused || this.disposed) return;
56
57
  this.paused = false;
57
58
  this.touch(index ?? this.touchIndex, true);
58
59
  }
59
60
  touch(index: number, force: boolean = false) {
60
- if (this.paused) return;
61
+ if (this.paused || this.disposed) return;
61
62
  if (index >= this.preloadSize) {
62
63
  this.touchIndex = 0;
63
64
  } else {
64
65
  this.touchIndex = index;
65
66
  }
66
67
  const value = this.preloadMap.get(this.touchIndex);
67
- if (value && value.state === ELoadState.unloaded) {
68
+ if (value && (value.state === ELoadState.unloaded || (force && value.state === ELoadState.error))) {
68
69
  this.createLink(this.touchIndex, force);
69
70
  }
70
71
  if (this.loadingLinks.size < Preload.maxLinks) {
71
- const willLoad = [...this.preloadMap.entries()].find(([i, e]) => i > this.touchIndex && e.state !== ELoadState.loaded);
72
+ const willLoad = [...this.preloadMap.entries()].find(([i, e]) => i > this.touchIndex && e.state === ELoadState.unloaded);
72
73
  if (willLoad) {
73
74
  this.requestAsyncCallBack(()=>{this.touch(willLoad[0], false)}, 100);
74
75
  }
@@ -100,21 +101,25 @@ export class Preload implements IDisposable {
100
101
  linkDom.as = 'image';
101
102
  linkDom.href = value.src;
102
103
  linkDom.dataset.order = index + '';
103
- document.head.appendChild(linkDom);
104
104
  linkDom.onload = () => {
105
+ if (this.loadingLinks.get(index)?.link !== linkDom) return;
105
106
  const value = this.preloadMap.get(index);
106
107
  if (value) {
107
108
  value.state = ELoadState.loaded;
108
109
  this.preloadMap.set(index, value);
109
- document.head.contains(linkDom) && document.head.removeChild(linkDom);
110
- this.loadingLinks.delete(index);
110
+ this.removeLink(index, linkDom);
111
111
  this.touch(this.touchIndex + 1, false);
112
112
  }
113
113
  }
114
114
  linkDom.onerror = () => {
115
+ if (this.loadingLinks.get(index)?.link !== linkDom) return;
115
116
  const value = this.preloadMap.get(index);
116
- if (value?.src) {
117
- linkDom.href = value.src;
117
+ if (value) {
118
+ // Preloading is best effort. Do not retry in an error loop;
119
+ // revisiting the page explicitly may retry after network recovery.
120
+ value.state = ELoadState.error;
121
+ this.removeLink(index, linkDom);
122
+ this.touch(this.touchIndex + 1, false);
118
123
  }
119
124
  }
120
125
  this.loadingLinks.set(index, {
@@ -123,18 +128,24 @@ export class Preload implements IDisposable {
123
128
  });
124
129
  value.state = ELoadState.unloaded;
125
130
  this.preloadMap.set(index, value);
131
+ document.head.appendChild(linkDom);
126
132
  return ELoadState.unloaded;
127
133
  }
128
134
  return ELoadState.error;
129
135
  }
130
136
 
137
+ private removeLink(index: number, link: HTMLLinkElement) {
138
+ link.onload = null;
139
+ link.onerror = null;
140
+ document.head.contains(link) && document.head.removeChild(link);
141
+ this.loadingLinks.delete(index);
142
+ }
131
143
  private destroySomeLink(excludeIndex?: number) {
132
144
  for (const [index, { link }] of this.loadingLinks) {
133
145
  if (excludeIndex !== undefined && index === excludeIndex) {
134
146
  continue;
135
147
  }
136
- document.head.contains(link) && document.head.removeChild(link);
137
- this.loadingLinks.delete(index);
148
+ this.removeLink(index, link);
138
149
  }
139
150
  }
140
151
  private async requestAsyncCallBack (callBack:()=>void, timeout:number):Promise<void> {
@@ -152,6 +163,7 @@ export class Preload implements IDisposable {
152
163
  callBack();
153
164
  }
154
165
  dispose() {
166
+ this.disposed = true;
155
167
  this.destroySomeLink();
156
168
  this.preloadMap.clear();
157
169
  }
@@ -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,31 @@
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
+ // A partial host API or a failing mount must not skip other cleanup.
20
+ try {
21
+ box.unmountContent?.();
22
+ } finally {
23
+ try {
24
+ box.unmountFooter?.();
25
+ } finally {
26
+ box.unmountStyles?.();
27
+ }
28
+ }
29
+ }
30
+ };
31
+ };
@@ -0,0 +1,39 @@
1
+ export type SetupImageResult = "loaded" | "error" | "timeout" | "disposed";
2
+
3
+ /** Wait for a terminal image state without blocking setup indefinitely. */
4
+ export const waitForCurrentPageImage = (
5
+ getImage: () => HTMLImageElement | null,
6
+ timeoutMs: number,
7
+ isDisposed: () => boolean,
8
+ ): Promise<SetupImageResult> => {
9
+ return new Promise(resolve => {
10
+ let settled = false;
11
+ let pollTimer: number | undefined;
12
+ const settle = (result: SetupImageResult) => {
13
+ if (settled) return;
14
+ settled = true;
15
+ window.clearTimeout(timeoutTimer);
16
+ if (pollTimer !== undefined) window.clearInterval(pollTimer);
17
+ resolve(result);
18
+ };
19
+ const timeoutTimer = window.setTimeout(() => settle("timeout"), timeoutMs);
20
+ const check = () => {
21
+ if (isDisposed()) {
22
+ settle("disposed");
23
+ return;
24
+ }
25
+ const img = getImage();
26
+ if (img?.complete) {
27
+ if (img.naturalWidth > 0) {
28
+ settle("loaded");
29
+ } else if (img.currentSrc || img.getAttribute("src")?.trim() || img.getAttribute("srcset")?.trim()) {
30
+ // complete is also true before src is assigned. Only an actual
31
+ // image request with zero decoded width represents a failed load.
32
+ settle("error");
33
+ }
34
+ }
35
+ };
36
+ check();
37
+ if (!settled) pollTimer = window.setInterval(check, 100);
38
+ });
39
+ };
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
+ }