@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.
- package/README-zh.md +18 -0
- package/README.md +22 -0
- package/dist/index.d.ts +73 -1
- package/dist/index.global.js +223 -82
- package/dist/index.js +223 -82
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +223 -83
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/app-presentation.ts +146 -81
- package/src/preload.ts +23 -11
- package/src/presentation.ts +18 -1
- package/src/runtime-lifecycle.ts +31 -0
- package/src/setup-ready.ts +39 -0
- package/src/style.scss +7 -1
package/src/app-presentation.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
283
|
+
if (!page) return;
|
|
284
|
+
const thumbnailURL = page.thumbnail;
|
|
279
285
|
const img = findBackgroundImage(view);
|
|
280
|
-
if (
|
|
281
|
-
|
|
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.
|
|
302
|
-
//
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
//
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
|
1429
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/presentation.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|