@netless/app-presentation 0.1.13-beta.0 → 0.1.13-beta.1
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 +14 -0
- package/dist/index.global.js +168 -8
- package/dist/index.js +168 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +168 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/app-presentation.ts +224 -3
- package/src/preload.ts +14 -0
package/src/app-presentation.ts
CHANGED
|
@@ -96,6 +96,17 @@ export interface PresentationAppOptions {
|
|
|
96
96
|
goToPageByClick?: boolean;
|
|
97
97
|
/** 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
98
|
useClipView?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Max time (ms) `setup()` waits for the first visible page image to load
|
|
101
|
+
* before resolving anyway. Default: 5_000.
|
|
102
|
+
*/
|
|
103
|
+
setupReadyTimeout?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Blur thumbnail degradation keeps cached (focused-lost) presentations on
|
|
106
|
+
* the low-res `previewURL` thumbnail to save memory. Enabled by default in
|
|
107
|
+
* maximized/minimized states; set `true` to disable it.
|
|
108
|
+
*/
|
|
109
|
+
disableBlurThumbnailDegradation?: boolean;
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
export interface PresentationController {
|
|
@@ -154,6 +165,199 @@ const ppt2page = (
|
|
|
154
165
|
return { ...size, src: ppt.src, thumbnail: ppt.previewURL, name };
|
|
155
166
|
};
|
|
156
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The whiteboard SDK renders the scene ppt as a real `<img alt="background">`
|
|
170
|
+
* inside the background HTML engine layer of the view. All blur degradation
|
|
171
|
+
* below works directly on that DOM node; no scene API is ever called.
|
|
172
|
+
*/
|
|
173
|
+
const findBackgroundImage = (view: View): HTMLImageElement | null => {
|
|
174
|
+
const container = (view as any).divElement as HTMLElement | undefined | null;
|
|
175
|
+
if (!container || typeof container.querySelector !== "function") return null;
|
|
176
|
+
return container.querySelector('img[alt="background"]');
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Degradation only applies when the window cannot be visible: maximized
|
|
181
|
+
* (covered by the top app) or minimized. Normal mode may show several
|
|
182
|
+
* windows side by side, so a blurred app there must keep the full image.
|
|
183
|
+
*/
|
|
184
|
+
const isBlurDegradationAllowed = (context: AppContext): boolean => {
|
|
185
|
+
const boxStatus = context.getBoxStatus();
|
|
186
|
+
if (boxStatus) {
|
|
187
|
+
return boxStatus !== "normal";
|
|
188
|
+
}
|
|
189
|
+
const boxState = context.getWindowManager().boxState;
|
|
190
|
+
return boxState !== undefined && boxState !== "normal";
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const DEFAULT_SETUP_READY_TIMEOUT = 5_000;
|
|
194
|
+
|
|
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
|
+
/**
|
|
234
|
+
* Compare image URLs by resolved absolute href so relative/absolute
|
|
235
|
+
* representations of the same URL do not look different (and to keep the
|
|
236
|
+
* load-capture re-degrade loop terminating).
|
|
237
|
+
*/
|
|
238
|
+
const resolveImageUrl = (url: string): string => {
|
|
239
|
+
try {
|
|
240
|
+
return new URL(url, window.location.href).href;
|
|
241
|
+
} catch {
|
|
242
|
+
return url;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Blur thumbnail degradation (see lazy-setup design doc §15): when the app
|
|
248
|
+
* loses focus but stays cached (multi-runtime cache mode), swap the scene
|
|
249
|
+
* background `<img>` src to the low-res `PptDescription.previewURL`
|
|
250
|
+
* (`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.
|
|
253
|
+
*
|
|
254
|
+
* While degraded, every load of a background `<img>` is re-degraded
|
|
255
|
+
* immediately via a capture-phase `load` listener on the view container
|
|
256
|
+
* (`load` does not bubble but capture works). This covers remote page turns,
|
|
257
|
+
* SDK React re-renders and the SDK's own background-image onError retries —
|
|
258
|
+
* any path that (re)renders the full-size image while the app stays blurred.
|
|
259
|
+
*/
|
|
260
|
+
const setupBlurThumbnailDegradation = (config: {
|
|
261
|
+
context: AppContext;
|
|
262
|
+
view: View;
|
|
263
|
+
pages: PresentationPage[];
|
|
264
|
+
pageIndex$: Readable<number>;
|
|
265
|
+
app: Presentation;
|
|
266
|
+
dispose: ReturnType<typeof disposableStore>;
|
|
267
|
+
}): void => {
|
|
268
|
+
const { context, view, pages, pageIndex$, app, dispose } = config;
|
|
269
|
+
// Two separate flags: `blurEpisode` covers the whole blurred period (pages
|
|
270
|
+
// without a thumbnail don't end it, so a later page degrades again), while
|
|
271
|
+
// `reDegradeEnabled` only turns off when the degraded thumbnail itself
|
|
272
|
+
// fails, so we don't fight the SDK's background onError retry.
|
|
273
|
+
let blurEpisode = false;
|
|
274
|
+
let reDegradeEnabled = false;
|
|
275
|
+
|
|
276
|
+
const degrade = (): void => {
|
|
277
|
+
const page = pages[pageIndex$.value];
|
|
278
|
+
if (!page?.thumbnail) return; // keep the full image as-is
|
|
279
|
+
const img = findBackgroundImage(view);
|
|
280
|
+
if (img && resolveImageUrl(img.src) !== resolveImageUrl(page.thumbnail)) {
|
|
281
|
+
img.src = page.thumbnail;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const restore = (): void => {
|
|
286
|
+
const page = pages[pageIndex$.value];
|
|
287
|
+
const img = findBackgroundImage(view);
|
|
288
|
+
if (page && img && resolveImageUrl(img.src) !== resolveImageUrl(page.src)) {
|
|
289
|
+
img.src = page.src;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const isBackgroundImg = (target: EventTarget | null): boolean => {
|
|
294
|
+
const el = target as HTMLImageElement | null;
|
|
295
|
+
return Boolean(
|
|
296
|
+
el && el.tagName === "IMG" && el.matches?.('img[alt="background"]'),
|
|
297
|
+
);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// 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.
|
|
303
|
+
const onImgLoad = (ev: Event): void => {
|
|
304
|
+
if (!blurEpisode || !reDegradeEnabled) return;
|
|
305
|
+
if (!isBackgroundImg(ev.target)) return;
|
|
306
|
+
degrade();
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// The degraded thumbnail failed to load: stop re-degrading for this blur
|
|
310
|
+
// episode so the SDK's onError retry can settle on the full image. Focus
|
|
311
|
+
// still force-restores afterwards.
|
|
312
|
+
const onImgError = (ev: Event): void => {
|
|
313
|
+
if (!blurEpisode || !reDegradeEnabled) return;
|
|
314
|
+
if (!isBackgroundImg(ev.target)) return;
|
|
315
|
+
reDegradeEnabled = false;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const offFocus = context.emitter.on("focus", (isFocused: boolean) => {
|
|
319
|
+
if (isFocused) {
|
|
320
|
+
if (blurEpisode) {
|
|
321
|
+
blurEpisode = false;
|
|
322
|
+
reDegradeEnabled = false;
|
|
323
|
+
restore();
|
|
324
|
+
app.preload.resume(pageIndex$.value);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
if (!isBlurDegradationAllowed(context)) return;
|
|
328
|
+
blurEpisode = true;
|
|
329
|
+
reDegradeEnabled = true;
|
|
330
|
+
degrade();
|
|
331
|
+
app.preload.pause();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
dispose.add(offFocus);
|
|
335
|
+
|
|
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
|
+
};
|
|
350
|
+
|
|
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
|
+
);
|
|
359
|
+
};
|
|
360
|
+
|
|
157
361
|
const createLogger = (room: Room | undefined): Logger => {
|
|
158
362
|
if (room && (room as any).logger) {
|
|
159
363
|
return (...args) => (room as any).logger.info(...args);
|
|
@@ -641,6 +845,12 @@ export const NetlessAppPresentation: NetlessApp<
|
|
|
641
845
|
app.log = log;
|
|
642
846
|
app.warn = warn;
|
|
643
847
|
|
|
848
|
+
// Setup only touches the current page's full image; the rest of the deck
|
|
849
|
+
// keeps loading through idle callbacks after the setup queue moves on.
|
|
850
|
+
if (pageIndex$.value !== 0) {
|
|
851
|
+
app.preload.touch(pageIndex$.value, true);
|
|
852
|
+
}
|
|
853
|
+
|
|
644
854
|
const getCameraDiagnosticState = (
|
|
645
855
|
reason: "initialize" | "moveCamera",
|
|
646
856
|
requestedCamera?: MoveCameraRequest,
|
|
@@ -785,6 +995,10 @@ export const NetlessAppPresentation: NetlessApp<
|
|
|
785
995
|
}),
|
|
786
996
|
);
|
|
787
997
|
|
|
998
|
+
if (!options.disableBlurThumbnailDegradation) {
|
|
999
|
+
setupBlurThumbnailDegradation({ context, view, pages, pageIndex$, app, dispose });
|
|
1000
|
+
}
|
|
1001
|
+
|
|
788
1002
|
const getOriginScale = () => {
|
|
789
1003
|
const page = app.page();
|
|
790
1004
|
if (!page || !isValidSize(page)) return 0;
|
|
@@ -1207,9 +1421,16 @@ export const NetlessAppPresentation: NetlessApp<
|
|
|
1207
1421
|
// Older WindowManager declarations model setup as synchronous even though
|
|
1208
1422
|
// AppProxy awaits its result. Keep that source compatibility until the new
|
|
1209
1423
|
// `SetupResult | Promise<SetupResult>` declaration is the minimum version.
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1424
|
+
const setupReadyTimeout = options.setupReadyTimeout ?? DEFAULT_SETUP_READY_TIMEOUT;
|
|
1425
|
+
return prepareScenesPromise.then(async () => {
|
|
1426
|
+
// Keep the serial setup queue honest: resolve only once the current
|
|
1427
|
+
// page image is actually visible (or the bounded wait gives up).
|
|
1428
|
+
const loaded = await waitForCurrentPageImage(view, setupReadyTimeout, () => disposed);
|
|
1429
|
+
if (!loaded) {
|
|
1430
|
+
warn(`[Presentation] setup ready wait timed out after ${setupReadyTimeout}ms`);
|
|
1431
|
+
}
|
|
1432
|
+
return controller;
|
|
1433
|
+
}) as unknown as PresentationController;
|
|
1213
1434
|
},
|
|
1214
1435
|
teardown(context) {
|
|
1215
1436
|
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 {
|