@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.
- package/dist/index.d.ts +70 -1
- package/dist/index.global.js +148 -40
- package/dist/index.js +148 -40
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +148 -41
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/app-presentation.ts +138 -41
- package/src/presentation.ts +18 -1
- package/src/runtime-lifecycle.ts +24 -0
- package/src/style.scss +7 -1
package/src/app-presentation.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
319
|
+
if (!page) return;
|
|
320
|
+
const thumbnailURL = page.thumbnail;
|
|
279
321
|
const img = findBackgroundImage(view);
|
|
280
|
-
if (
|
|
281
|
-
|
|
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.
|
|
302
|
-
//
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
//
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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;
|
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,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
|
+
}
|