@netless/app-presentation 0.1.12 → 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.
@@ -1,17 +1,43 @@
1
- import type { AnimationMode, AppContext, AppPayload, NetlessApp, PublicEvent, ReadonlyTeleBox, Room, SceneDefinition, Size, View, WindowManager } from "@netless/window-manager"
2
-
3
- import { disposableStore } from '@wopjs/disposable'
4
- import { listen } from '@wopjs/dom'
5
-
6
- import styles from './style.scss?inline';
7
- import { Presentation, type PresentationConfig, type PresentationPage } from "./presentation";
1
+ import type {
2
+ AnimationMode,
3
+ AppContext,
4
+ AppPayload,
5
+ NetlessApp,
6
+ PublicEvent,
7
+ ReadonlyTeleBox,
8
+ Room,
9
+ SceneDefinition,
10
+ Size,
11
+ View,
12
+ WindowManager,
13
+ } from "@netless/window-manager";
14
+
15
+ import { disposableStore } from "@wopjs/disposable";
16
+ import { listen } from "@wopjs/dom";
17
+
18
+ import styles from "./style.scss?inline";
19
+ import {
20
+ Presentation,
21
+ type PresentationConfig,
22
+ type PresentationPage,
23
+ } from "./presentation";
8
24
  import { readable, type Readable } from "./store";
9
25
  import { Scrollbar, type ScrollbarEventCallback } from "./scrollbar";
10
- import { getCameraScaleRange, shouldDisableDeviceCameraTransform } from "./camera-options";
11
- import { cameraToSharedViewport, fitPageSizeToOrigin, getCameraReferenceSize, getFitScale, isValidSharedViewport, isValidSize } from "./camera-reference";
26
+ import {
27
+ getCameraScaleRange,
28
+ shouldDisableDeviceCameraTransform,
29
+ } from "./camera-options";
30
+ import {
31
+ cameraToSharedViewport,
32
+ fitPageSizeToOrigin,
33
+ getCameraReferenceSize,
34
+ getFitScale,
35
+ isValidSharedViewport,
36
+ isValidSize,
37
+ } from "./camera-reference";
12
38
  import debounce from "lodash/debounce";
13
39
 
14
- export type Logger = (...data: any[]) => void
40
+ export type Logger = (...data: any[]) => void;
15
41
 
16
42
  interface PresentationDiagnosticLogger {
17
43
  info(event: string, payload?: unknown): void;
@@ -21,9 +47,9 @@ interface PresentationDiagnosticLogger {
21
47
  flush(): void;
22
48
  }
23
49
 
24
- type MoveCameraRequest = { centerX: number, centerY: number, scale: number }
50
+ type MoveCameraRequest = { centerX: number; centerY: number; scale: number };
25
51
 
26
- const emptySceneName = '$$empty$$'
52
+ const emptySceneName = "$$empty$$";
27
53
 
28
54
  export interface PresentationAttributes {
29
55
  /** Shared logical camera reference size. New pages are proportionally contained within it. */
@@ -65,11 +91,22 @@ export interface PresentationAppOptions {
65
91
  useScrollbar?: boolean;
66
92
  /** debounceSync is used to set the presentation debounce sync, it will be used in the presentation, and the presentation will be debounce sync when the app is initialized */
67
93
  debounceSync?: boolean;
68
- scrollbarEventCallback?: ScrollbarEventCallback
94
+ scrollbarEventCallback?: ScrollbarEventCallback;
69
95
  /** goToPageByClick is used to set the presentation go to page by click, it will be used in the presentation, and the presentation will be go to page by click when the app is initialized */
70
96
  goToPageByClick?: boolean;
71
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 */
72
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;
73
110
  }
74
111
 
75
112
  export interface PresentationController {
@@ -89,9 +126,9 @@ export interface PresentationController {
89
126
  /** Resolves after the whiteboard View has accepted the next scene path. */
90
127
  nextPageAsync(): Promise<boolean>;
91
128
  /** `index` ranges from 0 to `length - 1` */
92
- pageState(): { index: number, length: number };
129
+ pageState(): { index: number; length: number };
93
130
 
94
- toPdf(): Promise<{ pdf: ArrayBuffer, title: string } | null>;
131
+ toPdf(): Promise<{ pdf: ArrayBuffer; title: string } | null>;
95
132
 
96
133
  log: Logger;
97
134
  /** set the docs view readonly */
@@ -99,154 +136,399 @@ export interface PresentationController {
99
136
  /** set the presentation readonly */
100
137
  setReadonly: (bol: boolean) => void;
101
138
  /** Moves the camera through the API, regardless of whether scrollbars are shown. */
102
- moveCamera: (camera: { centerX: number, centerY: number, scale: number }) => void;
139
+ moveCamera: (camera: {
140
+ centerX: number;
141
+ centerY: number;
142
+ scale: number;
143
+ }) => void;
103
144
  /** get the origin scale */
104
145
  getOriginScale: () => number;
105
146
  /** get the view scale */
106
- getScale: () =>number;
147
+ getScale: () => number;
107
148
  /** get the page size */
108
- getPageSize: () => { width: number, height: number };
149
+ getPageSize: () => { width: number; height: number };
109
150
  /** screenshot the current page */
110
- screenshotCurrentPageAsync: (context: CanvasRenderingContext2D, width?: number, height?: number) => Promise<void>;
151
+ screenshotCurrentPageAsync: (
152
+ context: CanvasRenderingContext2D,
153
+ width?: number,
154
+ height?: number,
155
+ ) => Promise<void>;
111
156
  }
112
157
 
113
158
  const ppt2page = (
114
159
  ppt: SceneDefinition["ppt"],
115
160
  name?: string,
116
- originSize?: Size
161
+ originSize?: Size,
117
162
  ): PresentationPage | null => {
118
- if (!ppt) return null
119
- const size = fitPageSizeToOrigin(ppt, originSize)
120
- return { ...size, src: ppt.src, thumbnail: ppt.previewURL, name }
121
- }
163
+ if (!ppt) return null;
164
+ const size = fitPageSizeToOrigin(ppt, originSize);
165
+ return { ...size, src: ppt.src, thumbnail: ppt.previewURL, name };
166
+ };
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
+ };
122
360
 
123
361
  const createLogger = (room: Room | undefined): Logger => {
124
362
  if (room && (room as any).logger) {
125
- return (...args) => (room as any).logger.info(...args)
363
+ return (...args) => (room as any).logger.info(...args);
126
364
  } else {
127
- return (...args) => console.log(...args)
365
+ return (...args) => console.log(...args);
128
366
  }
129
- }
367
+ };
130
368
 
131
- const createDiagnosticLogger = (context: AppContext): PresentationDiagnosticLogger => {
132
- const createAppLogger = (context as any).createLogger
133
- if (typeof createAppLogger === 'function') {
134
- return createAppLogger.call(context, 'camera', { debounceTime: 300, maxWaitTime: 2000 })
369
+ const createDiagnosticLogger = (
370
+ context: AppContext,
371
+ ): PresentationDiagnosticLogger => {
372
+ const createAppLogger = (context as any).createLogger;
373
+ if (typeof createAppLogger === "function") {
374
+ return createAppLogger.call(context, "camera", {
375
+ debounceTime: 300,
376
+ maxWaitTime: 2000,
377
+ });
135
378
  }
136
379
 
137
380
  // Compatibility fallback for WindowManager versions without AppContext.createLogger().
138
- const roomLogger = (context.getRoom() as any)?.logger
139
- const prefix = `[Presentation][${context.appId}][camera]`
140
- const emit = (level: 'info' | 'warn' | 'error', event: string, ...data: unknown[]) => {
381
+ const roomLogger = (context.getRoom() as any)?.logger;
382
+ const prefix = `[Presentation][${context.appId}][camera]`;
383
+ const emit = (
384
+ level: "info" | "warn" | "error",
385
+ event: string,
386
+ ...data: unknown[]
387
+ ) => {
141
388
  try {
142
- const printer = roomLogger?.[level]
143
- if (typeof printer === 'function') printer.call(roomLogger, `${prefix}[${event}]`, ...data)
389
+ const printer = roomLogger?.[level];
390
+ if (typeof printer === "function")
391
+ printer.call(roomLogger, `${prefix}[${event}]`, ...data);
144
392
  } catch {
145
393
  // Diagnostics must never change the App API result or replace its original error.
146
394
  }
147
- }
148
- const debouncedByEvent = new Map<string, ReturnType<typeof debounce>>()
395
+ };
396
+ const debouncedByEvent = new Map<string, ReturnType<typeof debounce>>();
149
397
  const debouncedInfo = (event: string, payload?: unknown) => {
150
- let emitDebounced = debouncedByEvent.get(event)
398
+ let emitDebounced = debouncedByEvent.get(event);
151
399
  if (!emitDebounced) {
152
400
  emitDebounced = debounce(
153
- (nextPayload?: unknown) => emit('info', event, nextPayload),
401
+ (nextPayload?: unknown) => emit("info", event, nextPayload),
154
402
  300,
155
- { maxWait: 2000 }
156
- )
157
- debouncedByEvent.set(event, emitDebounced)
403
+ { maxWait: 2000 },
404
+ );
405
+ debouncedByEvent.set(event, emitDebounced);
158
406
  }
159
- emitDebounced(payload)
160
- }
407
+ emitDebounced(payload);
408
+ };
161
409
  return {
162
- info: (event, payload) => emit('info', event, payload),
163
- warn: (event, payload) => emit('warn', event, payload),
164
- error: (event, error, payload) => emit('error', event, error, payload),
410
+ info: (event, payload) => emit("info", event, payload),
411
+ warn: (event, payload) => emit("warn", event, payload),
412
+ error: (event, error, payload) => emit("error", event, error, payload),
165
413
  debouncedInfo,
166
- flush: () => debouncedByEvent.forEach(logger => logger.flush()),
167
- }
168
- }
414
+ flush: () => debouncedByEvent.forEach((logger) => logger.flush()),
415
+ };
416
+ };
169
417
 
170
418
  const safeResourceLocation = (value: string): string => {
171
- if (value.startsWith('data:')) return 'data:[omitted]'
172
- if (value.startsWith('blob:')) return 'blob:[omitted]'
419
+ if (value.startsWith("data:")) return "data:[omitted]";
420
+ if (value.startsWith("blob:")) return "blob:[omitted]";
173
421
  try {
174
- const url = new URL(value)
175
- return `${url.origin}${url.pathname}`
422
+ const url = new URL(value);
423
+ return `${url.origin}${url.pathname}`;
176
424
  } catch {
177
- return value.split('?')[0]
425
+ return value.split("?")[0];
426
+ }
427
+ };
428
+ const scenesEqual = (
429
+ scenes1?: SceneDefinition[],
430
+ scenes2?: SceneDefinition[],
431
+ ): boolean => {
432
+ if (!scenes1 || !scenes2) {
433
+ return false;
178
434
  }
179
- }
180
- const scenesEqual = (scenes1?: SceneDefinition[], scenes2?: SceneDefinition[]): boolean => {
181
- if (!scenes1 || !scenes2) {return false}
182
435
  if (scenes1.length !== scenes2.length) return false;
183
436
  return scenes1.every((scene, index) => {
184
437
  const scene2 = scenes2[index];
185
- return scene.name === scene2.name &&
186
- scene.ppt?.width === scene2.ppt?.width &&
187
- scene.ppt?.height === scene2.ppt?.height &&
188
- scene.ppt?.src === scene2.ppt?.src;
438
+ return (
439
+ scene.name === scene2.name &&
440
+ scene.ppt?.width === scene2.ppt?.width &&
441
+ scene.ppt?.height === scene2.ppt?.height &&
442
+ scene.ppt?.src === scene2.ppt?.src
443
+ );
189
444
  });
190
445
  };
191
446
 
192
- export const NetlessAppPresentation: NetlessApp<PresentationAttributes, {}, PresentationAppOptions, PresentationController> = {
447
+ const teardownByContext = new WeakMap<object, () => void>();
448
+
449
+ export const NetlessAppPresentation: NetlessApp<
450
+ PresentationAttributes,
451
+ {},
452
+ PresentationAppOptions,
453
+ PresentationController
454
+ > & {
455
+ teardown(context: AppContext): void;
456
+ } = {
193
457
  kind: "Presentation",
194
458
  setup(context) {
195
- const diagnosticLogger = createDiagnosticLogger(context)
196
- const view = context.getView()
459
+ const diagnosticLogger = createDiagnosticLogger(context);
460
+ const view = context.getView();
197
461
  if (!view)
198
- throw new Error("[Presentation]: no whiteboard view, make sure you have added options.scenePath in addApp()")
199
-
200
- const options = context.getAppOptions() || {}
201
- const room = context.getRoom()
202
- const log = options.log || createLogger(room)
203
- const roomLogger = (room as any)?.logger
204
- const warn: Logger = (...data) => roomLogger?.warn ? roomLogger.warn(...data) : log(...data)
205
- const configuredOriginSize = context.storage.state.originSize
462
+ throw new Error(
463
+ "[Presentation]: no whiteboard view, make sure you have added options.scenePath in addApp()",
464
+ );
465
+
466
+ const options = context.getAppOptions() || {};
467
+ const room = context.getRoom();
468
+ const log = options.log || createLogger(room);
469
+ const roomLogger = (room as any)?.logger;
470
+ const warn: Logger = (...data) =>
471
+ roomLogger?.warn ? roomLogger.warn(...data) : log(...data);
472
+ const configuredOriginSize = context.storage.state.originSize;
206
473
  const originSize = isValidSize(configuredOriginSize)
207
- ? { width: configuredOriginSize.width, height: configuredOriginSize.height }
208
- : undefined
474
+ ? {
475
+ width: configuredOriginSize.width,
476
+ height: configuredOriginSize.height,
477
+ }
478
+ : undefined;
209
479
  if (configuredOriginSize != null && !originSize) {
210
- warn(`[Presentation] originSize should contain finite positive width and height, got ${JSON.stringify(configuredOriginSize)}`)
480
+ warn(
481
+ `[Presentation] originSize should contain finite positive width and height, got ${JSON.stringify(configuredOriginSize)}`,
482
+ );
211
483
  }
212
- const pages = context.getScenes()
484
+ const pages = context
485
+ .getScenes()
213
486
  ?.map(({ ppt, name }) => ppt2page(ppt, name, originSize))
214
- .filter(Boolean) as PresentationPage[]
487
+ .filter(Boolean) as PresentationPage[];
215
488
  if (!pages || pages.length === 0)
216
- throw new Error("[Presentation]: empty scenes, make sure you have added options.scenes in addApp()")
217
- if (pages[0].src.startsWith('ppt'))
218
- throw new Error("[Presentation]: legacy dynamic PPT is unsupported, please use the projector converter and @netless/slide to render it")
489
+ throw new Error(
490
+ "[Presentation]: empty scenes, make sure you have added options.scenes in addApp()",
491
+ );
492
+ if (pages[0].src.startsWith("ppt"))
493
+ throw new Error(
494
+ "[Presentation]: legacy dynamic PPT is unsupported, please use the projector converter and @netless/slide to render it",
495
+ );
219
496
 
220
497
  // Now it must have a blank scene points to "{scenePath}/{scenes[0].name}", e.g. "/pdf/123456/1"
221
498
  // https://github.com/netless-io/window-manager/blob/c87df17/src/index.ts#L465-L476
222
- const scenePath = context.getInitScenePath()!
223
- let maxCameraScale = options.maxCameraScale ?? 3
499
+ const scenePath = context.getInitScenePath()!;
500
+ let maxCameraScale = options.maxCameraScale ?? 3;
224
501
  if (!(Number.isFinite(maxCameraScale) && maxCameraScale! > 0)) {
225
- warn(`[Presentation] maxCameraScale should be a positive number, got ${options.maxCameraScale}`)
226
- maxCameraScale = 3
502
+ warn(
503
+ `[Presentation] maxCameraScale should be a positive number, got ${options.maxCameraScale}`,
504
+ );
505
+ maxCameraScale = 3;
227
506
  }
228
507
 
229
- log(`[Presentation] new ${context.appId}`)
230
-
231
- const dispose = disposableStore()
232
- dispose.add(() => log(`[Presentation] dispose ${context.appId}`))
233
- dispose.add(() => diagnosticLogger.flush())
508
+ log(`[Presentation] new ${context.appId}`);
234
509
 
235
- const view$$ = context.createStorage('view', { uid: "", originX: 0, originY: 0, width: 0, height: 0 })
510
+ const dispose = disposableStore();
511
+ dispose.add(() => log(`[Presentation] dispose ${context.appId}`));
512
+ dispose.add(() => diagnosticLogger.flush());
236
513
 
514
+ const view$$ = context.createStorage("view", {
515
+ uid: "",
516
+ originX: 0,
517
+ originY: 0,
518
+ width: 0,
519
+ height: 0,
520
+ });
237
521
 
238
- const _addScenePathListener = (
239
- name: keyof PublicEvent,
240
- listener: any
241
- ) => {
242
- const windowManger = (context as any).manager.windowManger as WindowManager;
243
- windowManger.emitter.on(name, listener)
244
- return () => windowManger?.emitter.off(name, listener)
245
- }
522
+ const _addScenePathListener = (name: keyof PublicEvent, listener: any) => {
523
+ const windowManger = (context as any).manager
524
+ .windowManger as WindowManager;
525
+ windowManger.emitter.on(name, listener);
526
+ return () => windowManger?.emitter.off(name, listener);
527
+ };
246
528
 
247
529
  const getPageIndex = (view: View) => {
248
530
  const focusScenePath = view.focusScenePath;
249
- const name = focusScenePath?.split('/').pop();
531
+ const name = focusScenePath?.split("/").pop();
250
532
  let _pageIndex = pages.findIndex((page, index) => {
251
533
  const n = page.name ?? String(index + 1);
252
534
  return n === name;
@@ -255,44 +537,51 @@ export const NetlessAppPresentation: NetlessApp<PresentationAttributes, {}, Pres
255
537
  _pageIndex = 0;
256
538
  }
257
539
  return _pageIndex;
258
- }
540
+ };
259
541
 
260
542
  let pageIndex = getPageIndex(view);
261
- const pageIndex$ = readable<number>(pageIndex, set => {
543
+ const pageIndex$ = readable<number>(pageIndex, (set) => {
262
544
  set(pageIndex);
263
- return _addScenePathListener("onAppScenePathChange", (payload: AppPayload)=>{
264
- const { appId } = payload;
265
- if (appId === context.appId) {
266
- const _pageIndex = getPageIndex(payload.view);
267
- set(_pageIndex)
268
- }
269
- });
545
+ return _addScenePathListener(
546
+ "onAppScenePathChange",
547
+ (payload: AppPayload) => {
548
+ const { appId } = payload;
549
+ if (appId === context.appId) {
550
+ const _pageIndex = getPageIndex(payload.view);
551
+ set(_pageIndex);
552
+ }
553
+ },
554
+ );
270
555
  });
271
556
 
272
557
  dispose.add(() => {
273
558
  pageIndex$.dispose();
274
- })
559
+ });
275
560
 
276
561
  // let lastIndex = -1
277
562
 
278
- const me = context.getRoom()?.uid || context.getDisplayer().observerId + ''
563
+ const me = context.getRoom()?.uid || context.getDisplayer().observerId + "";
279
564
 
280
- let throttleSyncView = 0
565
+ let throttleSyncView = 0;
281
566
 
282
567
  const syncPage = async (index: number, logger?: any): Promise<boolean> => {
568
+ if (!context.getIsWritable()) return false;
283
569
 
284
- if (!context.getIsWritable()) return false
285
-
286
- const scenes = context.getDisplayer().entireScenes()[scenePath]
287
- if (!scenes) return false
570
+ const scenes = context.getDisplayer().entireScenes()[scenePath];
571
+ if (!scenes) return false;
288
572
 
289
573
  const p = pages[index];
290
574
  const name = p.name ?? String(index + 1);
291
575
 
292
576
  // "Prepare scenes" may not run correctly if the user suddenly disconnected after adding the app.
293
577
  // So here we add the missing pages again if not found. This is rare to happen.
294
- if (!scenes.some(scene => scene.name === name)) {
295
- await context.addPage({ scene: { name, ppt: { width: p.width, height: p.height, src: p.src } } })
578
+ if (!scenes.some((scene) => scene.name === name)) {
579
+ await context.addPage({
580
+ scene: {
581
+ name,
582
+ ppt: { width: p.width, height: p.height, src: p.src },
583
+ },
584
+ });
296
585
  }
297
586
 
298
587
  if (logger) {
@@ -300,125 +589,140 @@ export const NetlessAppPresentation: NetlessApp<PresentationAttributes, {}, Pres
300
589
  }
301
590
 
302
591
  // Switch to that page.
303
- await context.setScenePath(`${scenePath}/${name}`)
304
- return true
305
- }
592
+ await context.setScenePath(`${scenePath}/${name}`);
593
+ return true;
594
+ };
306
595
 
307
596
  const prepareScenes = async (): Promise<void> => {
308
- if (!context.isAddApp) return
597
+ if (!context.isAddApp) return;
309
598
  // Caution: some user may insert a 500-page PDF.
310
599
  if (pages.length > 100)
311
- warn(`[Presentation]: too many pages (${pages.length}), may cause performance issues`)
312
- if (!room || !room.isWritable) return
600
+ warn(
601
+ `[Presentation]: too many pages (${pages.length}), may cause performance issues`,
602
+ );
603
+ if (!room || !room.isWritable) return;
313
604
  if (pageIndex$.value < 0 || pageIndex$.value >= pages.length) {
314
- throw new Error(`[Presentation] Invalid page index: ${pageIndex$.value}, scenes length: ${pages.length}`)
605
+ throw new Error(
606
+ `[Presentation] Invalid page index: ${pageIndex$.value}, scenes length: ${pages.length}`,
607
+ );
315
608
  }
316
609
 
317
- const scenes = room.entireScenes()[scenePath]
610
+ const scenes = room.entireScenes()[scenePath];
318
611
  if (!scenes || !scenes[pageIndex$.value]) {
319
- throw new Error(`[Presentation]: no initial scene found at ${scenePath}, page index: ${pageIndex$.value}`)
612
+ throw new Error(
613
+ `[Presentation]: no initial scene found at ${scenePath}, page index: ${pageIndex$.value}`,
614
+ );
320
615
  }
321
- const { name, ppt } = scenes[pageIndex$.value]
616
+ const { name, ppt } = scenes[pageIndex$.value];
322
617
  const nextScenes = pages.map((page, index) => ({
323
618
  name: page.name ?? String(index + 1),
324
- ppt: { width: page.width, height: page.height, src: page.src }
325
- }))
619
+ ppt: { width: page.width, height: page.height, src: page.src },
620
+ }));
326
621
 
327
622
  if (!scenesEqual(scenes, nextScenes)) {
328
- room.removeScenes(scenePath)
329
- room.putScenes(scenePath, nextScenes)
623
+ room.removeScenes(scenePath);
624
+ room.putScenes(scenePath, nextScenes);
330
625
  }
331
626
 
332
- const shouldRedirect = name === nextScenes[pageIndex$.value].name && !ppt
627
+ const shouldRedirect = name === nextScenes[pageIndex$.value].name && !ppt;
333
628
  if (shouldRedirect) {
334
- await context.addPage({ scene: { name: emptySceneName } })
335
- log(`[Presentation] setup setScenePath ${scenePath}/${emptySceneName}`)
336
- await context.setScenePath(`${scenePath}/${emptySceneName}`)
629
+ await context.addPage({ scene: { name: emptySceneName } });
630
+ log(`[Presentation] setup setScenePath ${scenePath}/${emptySceneName}`);
631
+ await context.setScenePath(`${scenePath}/${emptySceneName}`);
337
632
  }
338
633
 
339
- await syncPage(pageIndex$.value, (room as any).logger)
634
+ await syncPage(pageIndex$.value, (room as any).logger);
340
635
  if (shouldRedirect) {
341
- log(`[Presentation] setup removeScenes ${scenePath}/${emptySceneName}`)
342
- room.removeScenes(`${scenePath}/${emptySceneName}`)
636
+ log(`[Presentation] setup removeScenes ${scenePath}/${emptySceneName}`);
637
+ room.removeScenes(`${scenePath}/${emptySceneName}`);
343
638
  }
344
- }
639
+ };
345
640
 
346
- const prepareScenesPromise = prepareScenes()
641
+ const prepareScenesPromise = prepareScenes();
347
642
 
348
643
  const canJumpPage = (index: number): boolean => {
349
644
  if (!context.getIsWritable()) {
350
- warn('[Presentation]: no permission, make sure you have test room.isWritable')
351
- return false
645
+ warn(
646
+ "[Presentation]: no permission, make sure you have test room.isWritable",
647
+ );
648
+ return false;
352
649
  }
353
650
 
354
651
  if (!(0 <= index && index < pages.length)) {
355
- warn(`[Presentation]: page ${index + 1} out of bounds [1, ${pages.length}]`)
356
- return false
652
+ warn(
653
+ `[Presentation]: page ${index + 1} out of bounds [1, ${pages.length}]`,
654
+ );
655
+ return false;
357
656
  }
358
657
 
359
- const scenes = context.getDisplayer().entireScenes()[scenePath]
658
+ const scenes = context.getDisplayer().entireScenes()[scenePath];
360
659
  if (!scenes) {
361
- warn(`[Presentation]: no scenes found at ${scenePath}, make sure you have added options.scenePath in addApp()`)
362
- return false
660
+ warn(
661
+ `[Presentation]: no scenes found at ${scenePath}, make sure you have added options.scenePath in addApp()`,
662
+ );
663
+ return false;
363
664
  }
364
665
 
365
- return true
366
- }
666
+ return true;
667
+ };
367
668
 
368
669
  const jumpPage = (index: number): boolean => {
369
- if (!canJumpPage(index)) return false
670
+ if (!canJumpPage(index)) return false;
370
671
 
371
- void syncPage(index).catch(error => {
372
- warn('[Presentation]: failed to sync page', error)
373
- diagnosticLogger.error('jumpPage.failed', error, {
672
+ void syncPage(index).catch((error) => {
673
+ warn("[Presentation]: failed to sync page", error);
674
+ diagnosticLogger.error("jumpPage.failed", error, {
374
675
  index,
375
676
  pageIndex: pageIndex$.value,
376
677
  focusScenePath: view.focusScenePath,
377
- })
378
- })
379
- return true
380
- }
678
+ });
679
+ });
680
+ return true;
681
+ };
381
682
 
382
- const prevPage = () => jumpPage(pageIndex$.value - 1)
383
- const nextPage = () => jumpPage(pageIndex$.value + 1)
683
+ const prevPage = () => jumpPage(pageIndex$.value - 1);
684
+ const nextPage = () => jumpPage(pageIndex$.value + 1);
384
685
  const jumpPageAsync = async (index: number): Promise<boolean> => {
385
- if (!canJumpPage(index)) return false
686
+ if (!canJumpPage(index)) return false;
386
687
  try {
387
- return await syncPage(index)
688
+ return await syncPage(index);
388
689
  } catch (error) {
389
- diagnosticLogger.error('jumpPageAsync.failed', error, {
690
+ diagnosticLogger.error("jumpPageAsync.failed", error, {
390
691
  index,
391
692
  pageIndex: pageIndex$.value,
392
693
  focusScenePath: view.focusScenePath,
393
- })
394
- throw error
694
+ });
695
+ throw error;
395
696
  }
396
- }
397
- const prevPageAsync = () => jumpPageAsync(pageIndex$.value - 1)
398
- const nextPageAsync = () => jumpPageAsync(pageIndex$.value + 1)
399
- const pageState = () => ({ index: pageIndex$.value, length: pages.length })
697
+ };
698
+ const prevPageAsync = () => jumpPageAsync(pageIndex$.value - 1);
699
+ const nextPageAsync = () => jumpPageAsync(pageIndex$.value + 1);
700
+ const pageState = () => ({ index: pageIndex$.value, length: pages.length });
400
701
 
401
702
  const scaleDocsToFit = () => {
402
- const page = app.page()
703
+ const page = app.page();
403
704
  if (page && isValidSize(page)) {
404
- const referenceSize = getCameraReferenceSize(originSize, page)
705
+ const referenceSize = getCameraReferenceSize(originSize, page);
405
706
  if (originSize) {
406
- const fitScale = getFitScale(view.size, referenceSize)
407
- if (!fitScale) return
707
+ const fitScale = getFitScale(view.size, referenceSize);
708
+ if (!fitScale) return;
408
709
  const { minScale, maxScale } = getCameraScaleRange(
409
710
  fitScale,
410
711
  maxCameraScale,
411
- options.disableCameraTransform
412
- )
712
+ options.disableCameraTransform,
713
+ );
413
714
  view.setCameraBound({
414
715
  damping: 1,
415
716
  maxContentMode: () => maxScale,
416
717
  minContentMode: () => minScale,
417
- centerX: 0, centerY: 0, width: page.width, height: page.height
418
- })
718
+ centerX: 0,
719
+ centerY: 0,
720
+ width: page.width,
721
+ height: page.height,
722
+ });
419
723
  if (isValidSharedViewport(view$$.state)) {
420
- syncViewFromRemote(true)
421
- return
724
+ syncViewFromRemote(true);
725
+ return;
422
726
  }
423
727
  }
424
728
  view.moveCameraToContain({
@@ -426,119 +730,161 @@ export const NetlessAppPresentation: NetlessApp<PresentationAttributes, {}, Pres
426
730
  originY: -referenceSize.height / 2,
427
731
  width: referenceSize.width,
428
732
  height: referenceSize.height,
429
- animationMode: 'immediately' as AnimationMode.Immediately
430
- })
733
+ animationMode: "immediately" as AnimationMode.Immediately,
734
+ });
431
735
  if (!originSize) {
432
736
  const { minScale, maxScale } = getCameraScaleRange(
433
737
  view.camera.scale,
434
738
  maxCameraScale,
435
- options.disableCameraTransform
436
- )
739
+ options.disableCameraTransform,
740
+ );
437
741
  view.setCameraBound({
438
742
  damping: 1,
439
743
  maxContentMode: () => maxScale,
440
744
  minContentMode: () => minScale,
441
- centerX: 0, centerY: 0, width: page.width, height: page.height
442
- })
745
+ centerX: 0,
746
+ centerY: 0,
747
+ width: page.width,
748
+ height: page.height,
749
+ });
443
750
  }
444
- syncViewFromRemote(true)
751
+ syncViewFromRemote(true);
445
752
  }
446
- }
447
- let pendingMoveCameraRequest: MoveCameraRequest | undefined
753
+ };
754
+ let pendingMoveCameraRequest: MoveCameraRequest | undefined;
448
755
  const syncView = () => {
449
756
  if (context.getIsWritable()) {
450
757
  if (options.debounceSync) {
451
758
  clearTimeout(throttleSyncView);
452
759
  throttleSyncView = 0;
453
760
  }
454
- if (throttleSyncView > 0) return
455
- const page = app.page()
761
+ if (throttleSyncView > 0) return;
762
+ const page = app.page();
456
763
  if (page && isValidSize(page)) {
457
764
  throttleSyncView = setTimeout(() => {
458
- throttleSyncView = 0
765
+ throttleSyncView = 0;
459
766
  try {
460
767
  const { camera, size } = view;
461
- const referenceSize = getCameraReferenceSize(originSize, page)
462
- const viewport = cameraToSharedViewport(camera, size, referenceSize)
463
- if (viewport) view$$.setState({ uid: me, ...viewport })
768
+ const referenceSize = getCameraReferenceSize(originSize, page);
769
+ const viewport = cameraToSharedViewport(
770
+ camera,
771
+ size,
772
+ referenceSize,
773
+ );
774
+ if (viewport) view$$.setState({ uid: me, ...viewport });
464
775
  if (pendingMoveCameraRequest) {
465
776
  diagnosticLogger.debouncedInfo(
466
- 'moveCamera',
467
- getCameraDiagnosticState('moveCamera', pendingMoveCameraRequest)
468
- )
469
- pendingMoveCameraRequest = undefined
777
+ "moveCamera",
778
+ getCameraDiagnosticState(
779
+ "moveCamera",
780
+ pendingMoveCameraRequest,
781
+ ),
782
+ );
783
+ pendingMoveCameraRequest = undefined;
470
784
  }
471
785
  } catch (error) {
472
786
  diagnosticLogger.error(
473
- 'syncView.failed',
787
+ "syncView.failed",
474
788
  error,
475
- getCameraDiagnosticState('moveCamera', pendingMoveCameraRequest)
476
- )
477
- pendingMoveCameraRequest = undefined
789
+ getCameraDiagnosticState(
790
+ "moveCamera",
791
+ pendingMoveCameraRequest,
792
+ ),
793
+ );
794
+ pendingMoveCameraRequest = undefined;
478
795
  }
479
- }, 50)
796
+ }, 50);
480
797
  }
481
798
  }
482
- }
799
+ };
483
800
 
484
801
  dispose.add(() => {
485
- clearTimeout(throttleSyncView)
486
- throttleSyncView = 0
487
- pendingMoveCameraRequest = undefined
488
- })
802
+ clearTimeout(throttleSyncView);
803
+ throttleSyncView = 0;
804
+ pendingMoveCameraRequest = undefined;
805
+ });
489
806
 
490
807
  const syncViewFromRemote = (force = false, animate = false) => {
491
- const { uid, originX, originY, width, height } = view$$.state
808
+ const { uid, originX, originY, width, height } = view$$.state;
492
809
  if ((force || uid !== me) && width > 0 && height > 0) {
493
810
  view.moveCameraToContain({
494
- originX, originY, width, height,
495
- animationMode: (animate ? 'continuous' : 'immediately') as AnimationMode
496
- })
811
+ originX,
812
+ originY,
813
+ width,
814
+ height,
815
+ animationMode: (animate
816
+ ? "continuous"
817
+ : "immediately") as AnimationMode,
818
+ });
497
819
  }
498
- }
820
+ };
499
821
 
500
- dispose.add(view$$.addStateChangedListener(() => syncViewFromRemote(false, true)))
822
+ dispose.add(
823
+ view$$.addStateChangedListener(() => syncViewFromRemote(false, true)),
824
+ );
501
825
 
502
826
  const getPageSize = () => {
503
- const { width, height } = app.page() || {}
504
- return { width: width || 0, height: height || 0 }
827
+ const { width, height } = app.page() || {};
828
+ return { width: width || 0, height: height || 0 };
829
+ };
830
+
831
+ const box = context.getBox();
832
+ const app = dispose.add(
833
+ createPresentation(
834
+ box,
835
+ pages,
836
+ jumpPage,
837
+ pageIndex$,
838
+ view,
839
+ options.thumbnail,
840
+ options.useClipView,
841
+ ),
842
+ );
843
+ app.contentDOM.dataset.appPresentationVersion = __VERSION__;
844
+ app.scaleDocsToFit = scaleDocsToFit;
845
+ app.log = log;
846
+ app.warn = warn;
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);
505
852
  }
506
853
 
507
- const box = context.getBox()
508
- const app = dispose.add(createPresentation(box, pages, jumpPage, pageIndex$, view, options.thumbnail, options.useClipView))
509
- app.contentDOM.dataset.appPresentationVersion = __VERSION__
510
- app.scaleDocsToFit = scaleDocsToFit
511
- app.log = log
512
- app.warn = warn
513
-
514
854
  const getCameraDiagnosticState = (
515
- reason: 'initialize' | 'moveCamera',
516
- requestedCamera?: MoveCameraRequest
855
+ reason: "initialize" | "moveCamera",
856
+ requestedCamera?: MoveCameraRequest,
517
857
  ) => {
518
- const page = app.page()
519
- const pageSize = page && isValidSize(page)
520
- ? { width: page.width, height: page.height }
521
- : undefined
858
+ const page = app.page();
859
+ const pageSize =
860
+ page && isValidSize(page)
861
+ ? { width: page.width, height: page.height }
862
+ : undefined;
522
863
  const referenceSize = pageSize
523
864
  ? getCameraReferenceSize(originSize, pageSize)
524
- : undefined
525
- const viewSize = { width: view.size.width, height: view.size.height }
865
+ : undefined;
866
+ const viewSize = { width: view.size.width, height: view.size.height };
526
867
  const viewCamera = {
527
868
  centerX: view.camera.centerX,
528
869
  centerY: view.camera.centerY,
529
870
  scale: view.camera.scale,
530
- }
531
- const sharedViewport = { ...view$$.state }
532
- const originScale = referenceSize ? getFitScale(viewSize, referenceSize) : undefined
533
- const normalizedScale = originScale && originScale > 0
534
- ? viewCamera.scale / originScale
535
- : undefined
536
- const sharedScaleX = referenceSize && sharedViewport.width > 0
537
- ? referenceSize.width / sharedViewport.width
538
- : undefined
539
- const sharedScaleY = referenceSize && sharedViewport.height > 0
540
- ? referenceSize.height / sharedViewport.height
541
- : undefined
871
+ };
872
+ const sharedViewport = { ...view$$.state };
873
+ const originScale = referenceSize
874
+ ? getFitScale(viewSize, referenceSize)
875
+ : undefined;
876
+ const normalizedScale =
877
+ originScale && originScale > 0
878
+ ? viewCamera.scale / originScale
879
+ : undefined;
880
+ const sharedScaleX =
881
+ referenceSize && sharedViewport.width > 0
882
+ ? referenceSize.width / sharedViewport.width
883
+ : undefined;
884
+ const sharedScaleY =
885
+ referenceSize && sharedViewport.height > 0
886
+ ? referenceSize.height / sharedViewport.height
887
+ : undefined;
542
888
 
543
889
  return {
544
890
  reason,
@@ -555,367 +901,542 @@ export const NetlessAppPresentation: NetlessApp<PresentationAttributes, {}, Pres
555
901
  sharedScaleY,
556
902
  focusScenePath: view.focusScenePath,
557
903
  isWritable: context.getIsWritable(),
558
- }
559
- }
560
-
561
- let didReportInitializedCamera = false
562
- const reportInitializedCamera = (source: 'setup' | 'onSizeUpdated') => {
563
- if (didReportInitializedCamera || !isValidSize(view.size) || !isValidSize(app.page())) return
564
- didReportInitializedCamera = true
565
- diagnosticLogger.info('initialize', {
904
+ };
905
+ };
906
+
907
+ let didReportInitializedCamera = false;
908
+ const reportInitializedCamera = (source: "setup" | "onSizeUpdated") => {
909
+ if (
910
+ didReportInitializedCamera ||
911
+ !isValidSize(view.size) ||
912
+ !isValidSize(app.page())
913
+ )
914
+ return;
915
+ didReportInitializedCamera = true;
916
+ diagnosticLogger.info("initialize", {
566
917
  source,
567
- ...getCameraDiagnosticState('initialize'),
568
- })
569
- }
918
+ ...getCameraDiagnosticState("initialize"),
919
+ });
920
+ };
570
921
 
571
922
  if (originSize) {
572
- let previousPageIndex = pageIndex$.value
573
- dispose.add(pageIndex$.subscribe(nextPageIndex => {
574
- if (nextPageIndex === previousPageIndex) return
575
- previousPageIndex = nextPageIndex
576
- scaleDocsToFit()
577
- }))
923
+ let previousPageIndex = pageIndex$.value;
924
+ dispose.add(
925
+ pageIndex$.subscribe((nextPageIndex) => {
926
+ if (nextPageIndex === previousPageIndex) return;
927
+ previousPageIndex = nextPageIndex;
928
+ scaleDocsToFit();
929
+ }),
930
+ );
578
931
  }
579
932
 
580
933
  if (options.justDocsViewReadonly) {
581
- app.setDocsViewReadonly(true)
934
+ app.setDocsViewReadonly(true);
582
935
  }
583
936
 
584
937
  const goToPageByClick = () => {
585
- const currentApplianceName = context.getRoom()?.state?.memberState?.currentApplianceName ?? '';
586
- if (!app.readonly && currentApplianceName === 'clicker') {
938
+ const currentApplianceName =
939
+ context.getRoom()?.state?.memberState?.currentApplianceName ?? "";
940
+ if (!app.readonly && currentApplianceName === "clicker") {
587
941
  nextPage();
588
942
  }
589
- }
943
+ };
590
944
 
591
- if(room && options.goToPageByClick && app.whiteboardDOM) {
945
+ if (room && options.goToPageByClick && app.whiteboardDOM) {
592
946
  dispose.make(() => {
593
- app.whiteboardDOM.addEventListener('click', goToPageByClick);
594
- return () => app.whiteboardDOM.removeEventListener('click', goToPageByClick);
595
- })
947
+ app.whiteboardDOM.addEventListener("click", goToPageByClick);
948
+ return () =>
949
+ app.whiteboardDOM.removeEventListener("click", goToPageByClick);
950
+ });
596
951
  }
597
952
 
598
- context.mountView(app.whiteboardDOM)
953
+ context.mountView(app.whiteboardDOM);
599
954
  if (shouldDisableDeviceCameraTransform(options)) {
600
- view.disableCameraTransform = true
955
+ view.disableCameraTransform = true;
601
956
  }
602
- scaleDocsToFit()
957
+ scaleDocsToFit();
603
958
  dispose.make(() => {
604
959
  const onSizeUpdated = () => {
605
- scaleDocsToFit()
606
- reportInitializedCamera('onSizeUpdated')
607
- }
608
- view.callbacks.on('onSizeUpdated', onSizeUpdated)
609
- return () => view.callbacks.off('onSizeUpdated', onSizeUpdated)
610
- })
960
+ scaleDocsToFit();
961
+ reportInitializedCamera("onSizeUpdated");
962
+ };
963
+ view.callbacks.on("onSizeUpdated", onSizeUpdated);
964
+ return () => view.callbacks.off("onSizeUpdated", onSizeUpdated);
965
+ });
611
966
 
612
967
  // Init viewport if provided `viewport`.
613
968
  if (options.viewport && context.isAddApp && app.page()) {
614
- const page = app.page()!
615
- const viewport = typeof options.viewport === 'function' ? options.viewport(page) : options.viewport
616
- const fix = (i: number, x: number) => i == 0 ? i : i <= 1 ? i * x : i
969
+ const page = app.page()!;
970
+ const viewport =
971
+ typeof options.viewport === "function"
972
+ ? options.viewport(page)
973
+ : options.viewport;
974
+ const fix = (i: number, x: number) => (i == 0 ? i : i <= 1 ? i * x : i);
617
975
  view$$.setState({
618
976
  uid: me,
619
977
  originX: fix(viewport.x, page.width) - page.width / 2,
620
978
  originY: fix(viewport.y, page.height) - page.height / 2,
621
979
  width: fix(viewport.width, page.width),
622
980
  height: fix(viewport.height, page.height),
623
- })
981
+ });
624
982
  }
625
983
 
626
984
  dispose.make(() => {
627
- view.callbacks.on('onCameraUpdatedByDevice', syncView)
628
- return () => view.callbacks.off('onCameraUpdatedByDevice', syncView)
629
- })
630
-
631
- syncViewFromRemote(true)
632
- reportInitializedCamera('setup')
985
+ view.callbacks.on("onCameraUpdatedByDevice", syncView);
986
+ return () => view.callbacks.off("onCameraUpdatedByDevice", syncView);
987
+ });
633
988
 
634
- dispose.add(context.emitter.on("writableChange", (isWritable: boolean): void => {
635
- app.setReadonly(!isWritable)
636
- }))
989
+ syncViewFromRemote(true);
990
+ reportInitializedCamera("setup");
637
991
 
638
- const getOriginScale = () => {
639
- const page = app.page()
640
- if (!page || !isValidSize(page)) return 0
641
- return getFitScale(view.size, getCameraReferenceSize(originSize, page)) || 0
642
- }
992
+ dispose.add(
993
+ context.emitter.on("writableChange", (isWritable: boolean): void => {
994
+ app.setReadonly(!isWritable);
995
+ }),
996
+ );
643
997
 
644
- const getScale =() => {
645
- return view.camera.scale
998
+ if (!options.disableBlurThumbnailDegradation) {
999
+ setupBlurThumbnailDegradation({ context, view, pages, pageIndex$, app, dispose });
646
1000
  }
647
1001
 
648
- const screenshotCurrentPage = async (_context: CanvasRenderingContext2D, _width?: number, _height?: number) => {
1002
+ const getOriginScale = () => {
1003
+ const page = app.page();
1004
+ if (!page || !isValidSize(page)) return 0;
1005
+ return (
1006
+ getFitScale(view.size, getCameraReferenceSize(originSize, page)) || 0
1007
+ );
1008
+ };
1009
+
1010
+ const getScale = () => {
1011
+ return view.camera.scale;
1012
+ };
1013
+
1014
+ const screenshotCurrentPage = async (
1015
+ _context: CanvasRenderingContext2D,
1016
+ _width?: number,
1017
+ _height?: number,
1018
+ ) => {
649
1019
  const currentPage = pages[pageIndex$.value];
650
1020
  if (!currentPage) {
651
- throw new Error('[Presentation]: current page not found')
1021
+ throw new Error("[Presentation]: current page not found");
652
1022
  }
653
1023
  const { width, height, src } = currentPage;
654
1024
 
655
- const img = document.createElement('img')
1025
+ const img = document.createElement("img");
656
1026
  img.width = width;
657
1027
  img.height = height;
658
- img.crossOrigin = 'Anonymous';
1028
+ img.crossOrigin = "Anonymous";
659
1029
  await new Promise<void>((resolve, reject) => {
660
- img.onload = () => resolve()
661
- img.onerror = () => reject(new Error(
662
- `[Presentation]: failed to load screenshot page image: ${safeResourceLocation(src)}`
663
- ))
664
- img.src = src
665
- })
666
- _context.drawImage(img, 0, 0, width, height, 0, 0, _width || width, _height || height);
1030
+ img.onload = () => resolve();
1031
+ img.onerror = () =>
1032
+ reject(
1033
+ new Error(
1034
+ `[Presentation]: failed to load screenshot page image: ${safeResourceLocation(src)}`,
1035
+ ),
1036
+ );
1037
+ img.src = src;
1038
+ });
1039
+ _context.drawImage(
1040
+ img,
1041
+ 0,
1042
+ 0,
1043
+ width,
1044
+ height,
1045
+ 0,
1046
+ 0,
1047
+ _width || width,
1048
+ _height || height,
1049
+ );
667
1050
  const currentScenePath = view.focusScenePath;
668
1051
  if (!currentScenePath) {
669
- throw new Error('[Presentation]: current scene path not found')
1052
+ throw new Error("[Presentation]: current scene path not found");
670
1053
  }
671
1054
  const windowManger = context.getWindowManager() as any;
672
1055
  if (windowManger._appliancePlugin) {
673
- await windowManger._appliancePlugin.screenshotToCanvasAsync(_context, currentScenePath, _width || width, _height || height, {
674
- centerX: 0,
675
- centerY: 0,
676
- scale: _width && _height ? Math.min(_width / width, _height / height) : 1,
677
- });
1056
+ await windowManger._appliancePlugin.screenshotToCanvasAsync(
1057
+ _context,
1058
+ currentScenePath,
1059
+ _width || width,
1060
+ _height || height,
1061
+ {
1062
+ centerX: 0,
1063
+ centerY: 0,
1064
+ scale:
1065
+ _width && _height
1066
+ ? Math.min(_width / width, _height / height)
1067
+ : 1,
1068
+ },
1069
+ );
678
1070
  } else {
679
- await view.screenshotToCanvasAsync(_context, currentScenePath, _width || width, _height || height, {
680
- centerX: 0,
681
- centerY: 0,
682
- scale: _width && _height ? Math.min(_width / width, _height / height) : 1,
683
- });
1071
+ await view.screenshotToCanvasAsync(
1072
+ _context,
1073
+ currentScenePath,
1074
+ _width || width,
1075
+ _height || height,
1076
+ {
1077
+ centerX: 0,
1078
+ centerY: 0,
1079
+ scale:
1080
+ _width && _height
1081
+ ? Math.min(_width / width, _height / height)
1082
+ : 1,
1083
+ },
1084
+ );
684
1085
  }
685
- }
1086
+ };
686
1087
 
687
1088
  const screenshotCurrentPageAsync = async (
688
1089
  _context: CanvasRenderingContext2D,
689
1090
  _width?: number,
690
- _height?: number
1091
+ _height?: number,
691
1092
  ) => {
692
1093
  try {
693
- await screenshotCurrentPage(_context, _width, _height)
1094
+ await screenshotCurrentPage(_context, _width, _height);
694
1095
  } catch (error) {
695
- diagnosticLogger.error('screenshotCurrentPage.failed', error, {
1096
+ diagnosticLogger.error("screenshotCurrentPage.failed", error, {
696
1097
  pageIndex: pageIndex$.value,
697
1098
  outputSize: { width: _width, height: _height },
698
- camera: getCameraDiagnosticState('initialize'),
699
- })
700
- throw error
1099
+ camera: getCameraDiagnosticState("initialize"),
1100
+ });
1101
+ throw error;
701
1102
  }
702
- }
1103
+ };
703
1104
 
704
- let scrollbar:Scrollbar | undefined;
1105
+ let scrollbar: Scrollbar | undefined;
705
1106
  if (options.useScrollbar) {
706
1107
  dispose.make(() => {
707
- scrollbar = new Scrollbar(app.contentDOM, {
708
- appId: context.appId,
709
- getPageSize,
710
- getOriginScale,
711
- syncView,
712
- getWritable: () => context.getIsWritable(),
713
- scrollbarEventCallback: options.scrollbarEventCallback
714
- }, view)
715
- return () => scrollbar?.destroy()
716
- })
1108
+ scrollbar = new Scrollbar(
1109
+ app.contentDOM,
1110
+ {
1111
+ appId: context.appId,
1112
+ getPageSize,
1113
+ getOriginScale,
1114
+ syncView,
1115
+ getWritable: () => context.getIsWritable(),
1116
+ scrollbarEventCallback: options.scrollbarEventCallback,
1117
+ },
1118
+ view,
1119
+ );
1120
+ return () => scrollbar?.destroy();
1121
+ });
717
1122
  }
718
1123
 
719
- const moveCamera = (camera: { centerX: number, centerY: number, scale: number }) => {
1124
+ const moveCamera = (camera: {
1125
+ centerX: number;
1126
+ centerY: number;
1127
+ scale: number;
1128
+ }) => {
720
1129
  try {
721
1130
  if (!context.getIsWritable()) {
722
- throw new Error('[Presentation]: moveCamera must be called in writable room')
1131
+ throw new Error(
1132
+ "[Presentation]: moveCamera must be called in writable room",
1133
+ );
723
1134
  }
724
- pendingMoveCameraRequest = { ...camera }
1135
+ pendingMoveCameraRequest = { ...camera };
725
1136
  if (scrollbar) {
726
1137
  scrollbar.moveCamera(camera);
727
1138
  return;
728
1139
  }
729
1140
  view.moveCamera({
730
1141
  ...camera,
731
- animationMode: 'immediately' as AnimationMode.Immediately
732
- })
733
- syncView()
1142
+ animationMode: "immediately" as AnimationMode.Immediately,
1143
+ });
1144
+ syncView();
734
1145
  } catch (error) {
735
- pendingMoveCameraRequest = undefined
1146
+ pendingMoveCameraRequest = undefined;
736
1147
  diagnosticLogger.error(
737
- 'moveCamera.failed',
1148
+ "moveCamera.failed",
738
1149
  error,
739
- getCameraDiagnosticState('moveCamera', camera)
740
- )
741
- throw error
1150
+ getCameraDiagnosticState("moveCamera", camera),
1151
+ );
1152
+ throw error;
742
1153
  }
743
- }
744
-
745
- context.emitter.on('destroy', () => dispose())
746
-
747
- const reportProgress = (progress: number, result: { pdf: ArrayBuffer, title: string } | null) => {
748
- window.postMessage({ type: '@netless/_result_save_pdf_', appId: context.appId, progress, result }, '*')
749
- return result
750
- }
1154
+ };
1155
+
1156
+ let disposed = false;
1157
+ let offDestroy: (() => void) | undefined;
1158
+ const teardown = () => {
1159
+ if (disposed) return;
1160
+ disposed = true;
1161
+ const removeDestroy = offDestroy;
1162
+ offDestroy = undefined;
1163
+ removeDestroy?.();
1164
+ dispose();
1165
+ };
1166
+ offDestroy = context.emitter.on("destroy", teardown);
1167
+ teardownByContext.set(context, teardown);
1168
+
1169
+ const reportProgress = (
1170
+ progress: number,
1171
+ result: { pdf: ArrayBuffer; title: string } | null,
1172
+ ) => {
1173
+ window.postMessage(
1174
+ {
1175
+ type: "@netless/_result_save_pdf_",
1176
+ appId: context.appId,
1177
+ progress,
1178
+ result,
1179
+ },
1180
+ "*",
1181
+ );
1182
+ return result;
1183
+ };
751
1184
 
752
1185
  const base64url = async (url: string): Promise<string> => {
753
1186
  try {
754
- const a = new URL(url)
755
- a.searchParams.set('t', Date.now().toString())
756
- url = a.toString()
1187
+ const a = new URL(url);
1188
+ a.searchParams.set("t", Date.now().toString());
1189
+ url = a.toString();
757
1190
  } catch {}
758
1191
 
759
- const data = await fetch(url)
1192
+ const data = await fetch(url);
760
1193
  if (!data.ok) {
761
1194
  throw new Error(
762
- `[Presentation]: failed to fetch ${safeResourceLocation(url)}, status: ${data.status} ${data.statusText}`
763
- )
1195
+ `[Presentation]: failed to fetch ${safeResourceLocation(url)}, status: ${data.status} ${data.statusText}`,
1196
+ );
764
1197
  }
765
1198
 
766
- const blob = await data.blob()
767
- const reader = new FileReader()
1199
+ const blob = await data.blob();
1200
+ const reader = new FileReader();
768
1201
  return new Promise((resolve, reject) => {
769
- reader.onload = () => resolve(reader.result as string)
770
- reader.onerror = () => reject(reader.error)
771
- reader.readAsDataURL(blob)
772
- })
773
- }
774
-
775
- const toPdfInternal = async (): Promise<{ pdf: ArrayBuffer, title: string } | null> => {
776
- const MAX = 1920
777
- const firstPage = pages[0]
778
- const { width, height } = firstPage
779
- let pdfWidth = Math.floor(width), pdfHeight = Math.floor(height)
1202
+ reader.onload = () => resolve(reader.result as string);
1203
+ reader.onerror = () => reject(reader.error);
1204
+ reader.readAsDataURL(blob);
1205
+ });
1206
+ };
1207
+
1208
+ const toPdfInternal = async (): Promise<{
1209
+ pdf: ArrayBuffer;
1210
+ title: string;
1211
+ } | null> => {
1212
+ const MAX = 1920;
1213
+ const firstPage = pages[0];
1214
+ const { width, height } = firstPage;
1215
+ let pdfWidth = Math.floor(width),
1216
+ pdfHeight = Math.floor(height);
780
1217
  if (pdfWidth > MAX) {
781
- pdfWidth = MAX
782
- pdfHeight = Math.floor(height * pdfWidth / width)
1218
+ pdfWidth = MAX;
1219
+ pdfHeight = Math.floor((height * pdfWidth) / width);
783
1220
  }
784
1221
  if (pdfHeight > MAX) {
785
- pdfHeight = MAX
786
- pdfWidth = Math.floor(width * pdfHeight / height)
1222
+ pdfHeight = MAX;
1223
+ pdfWidth = Math.floor((width * pdfHeight) / height);
787
1224
  }
788
- const scenes = context.getDisplayer().entireScenes()[scenePath]
1225
+ const scenes = context.getDisplayer().entireScenes()[scenePath];
789
1226
  if (!scenes) {
790
- throw new Error(`[Presentation]: no scenes found while exporting PDF: ${scenePath}`)
1227
+ throw new Error(
1228
+ `[Presentation]: no scenes found while exporting PDF: ${scenePath}`,
1229
+ );
791
1230
  }
792
1231
 
793
- const stage_canvas = document.createElement('canvas')
794
- stage_canvas.width = pdfWidth
795
- stage_canvas.height = pdfHeight
796
- const stage = stage_canvas.getContext('2d')
1232
+ const stage_canvas = document.createElement("canvas");
1233
+ stage_canvas.width = pdfWidth;
1234
+ stage_canvas.height = pdfHeight;
1235
+ const stage = stage_canvas.getContext("2d");
797
1236
 
798
- const wb_canvas = document.createElement('canvas')
799
- wb_canvas.width = pdfWidth
800
- wb_canvas.height = pdfHeight
801
- const wb = wb_canvas.getContext('2d')
802
- if (!wb || !stage) return reportProgress(100, null)
1237
+ const wb_canvas = document.createElement("canvas");
1238
+ wb_canvas.width = pdfWidth;
1239
+ wb_canvas.height = pdfHeight;
1240
+ const wb = wb_canvas.getContext("2d");
1241
+ if (!wb || !stage) return reportProgress(100, null);
803
1242
 
804
- const { jsPDF } = await import("jspdf")
1243
+ const { jsPDF } = await import("jspdf");
805
1244
  const pdf = new jsPDF({
806
1245
  format: [firstPage.width, firstPage.height],
807
- orientation: firstPage.width > firstPage.height ? 'l' : 'p',
1246
+ orientation: firstPage.width > firstPage.height ? "l" : "p",
808
1247
  compress: true,
809
- })
1248
+ });
810
1249
 
811
1250
  for (let index = 0; index < pages.length; ++index) {
812
1251
  const p = pages[index];
813
1252
  const { width, height, src } = p;
814
1253
 
815
- const url = await base64url(src)
816
- const img = document.createElement('img')
1254
+ const url = await base64url(src);
1255
+ const img = document.createElement("img");
817
1256
  await new Promise<void>((resolve, reject) => {
818
- img.onload = () => resolve()
819
- img.onerror = () => reject(new Error(
820
- `[Presentation]: failed to load PDF page image, page index: ${index}`
821
- ))
822
- img.src = url
823
- })
824
- stage.drawImage(img, 0, 0, width, height)
825
-
826
- wb.clearRect(0, 0, pdfWidth, pdfHeight)
827
- const name = p.name ?? String(index + 1)
828
- if (scenes.some(scene => scene.name == name)) {
829
- const camera = { centerX: 0, centerY: 0, scale: Math.min(wb_canvas.width / width, wb_canvas.height / height) };
1257
+ img.onload = () => resolve();
1258
+ img.onerror = () =>
1259
+ reject(
1260
+ new Error(
1261
+ `[Presentation]: failed to load PDF page image, page index: ${index}`,
1262
+ ),
1263
+ );
1264
+ img.src = url;
1265
+ });
1266
+ stage.drawImage(img, 0, 0, width, height);
1267
+
1268
+ wb.clearRect(0, 0, pdfWidth, pdfHeight);
1269
+ const name = p.name ?? String(index + 1);
1270
+ if (scenes.some((scene) => scene.name == name)) {
1271
+ const camera = {
1272
+ centerX: 0,
1273
+ centerY: 0,
1274
+ scale: Math.min(wb_canvas.width / width, wb_canvas.height / height),
1275
+ };
830
1276
  const sPath = `${scenePath}/${name}`;
831
1277
  // appliancePlugin is a performance optimization for whiteboard;
832
1278
  const windowManger = context.getWindowManager() as any;
833
1279
  if (windowManger._appliancePlugin) {
834
- await (windowManger as any)._appliancePlugin.screenshotToCanvasAsync(wb, sPath, wb_canvas.width, wb_canvas.height, camera);
1280
+ await (
1281
+ windowManger as any
1282
+ )._appliancePlugin.screenshotToCanvasAsync(
1283
+ wb,
1284
+ sPath,
1285
+ wb_canvas.width,
1286
+ wb_canvas.height,
1287
+ camera,
1288
+ );
835
1289
  } else {
836
1290
  await view.screenshotToCanvasAsync(
837
- wb, sPath,
838
- wb_canvas.width, wb_canvas.height,
1291
+ wb,
1292
+ sPath,
1293
+ wb_canvas.width,
1294
+ wb_canvas.height,
839
1295
  camera,
840
- )
1296
+ );
841
1297
  }
842
1298
 
843
1299
  try {
844
- const wb_url = wb_canvas.toDataURL('image/png')
845
- const wb_img = document.createElement('img')
846
- await new Promise(resolve => { wb_img.onload = resolve; wb_img.src = wb_url })
847
- stage.drawImage(wb_img, 0, 0, pdfWidth, pdfHeight)
1300
+ const wb_url = wb_canvas.toDataURL("image/png");
1301
+ const wb_img = document.createElement("img");
1302
+ await new Promise((resolve) => {
1303
+ wb_img.onload = resolve;
1304
+ wb_img.src = wb_url;
1305
+ });
1306
+ stage.drawImage(wb_img, 0, 0, pdfWidth, pdfHeight);
848
1307
  } catch (err) {
849
- warn(err)
1308
+ warn(err);
850
1309
  }
851
1310
  }
852
1311
 
853
- const output = stage_canvas.toDataURL('image/jpeg', 0.6)
854
- if (index > 0) pdf.addPage()
1312
+ const output = stage_canvas.toDataURL("image/jpeg", 0.6);
1313
+ if (index > 0) pdf.addPage();
855
1314
 
856
- pdf.addImage(output, 'JPEG', 0, 0, pdfWidth, pdfHeight, "", "FAST")
857
- stage.clearRect(0, 0, pdfWidth, pdfHeight)
858
- const progress = Math.ceil((index + 1) / pages.length * 100)
859
- if (progress < 100) reportProgress(progress, null)
1315
+ pdf.addImage(output, "JPEG", 0, 0, pdfWidth, pdfHeight, "", "FAST");
1316
+ stage.clearRect(0, 0, pdfWidth, pdfHeight);
1317
+ const progress = Math.ceil(((index + 1) / pages.length) * 100);
1318
+ if (progress < 100) reportProgress(progress, null);
860
1319
  }
861
1320
 
862
- const data = pdf.output('arraybuffer')
863
- const title = box.title
864
- return reportProgress(100, { pdf: data, title })
865
- }
1321
+ const data = pdf.output("arraybuffer");
1322
+ const title = box.title;
1323
+ return reportProgress(100, { pdf: data, title });
1324
+ };
866
1325
 
867
- const toPdf = async (): Promise<{ pdf: ArrayBuffer, title: string } | null> => {
1326
+ const toPdf = async (): Promise<{
1327
+ pdf: ArrayBuffer;
1328
+ title: string;
1329
+ } | null> => {
868
1330
  try {
869
- return await toPdfInternal()
1331
+ return await toPdfInternal();
870
1332
  } catch (error) {
871
- diagnosticLogger.error('toPdf.failed', error, {
1333
+ diagnosticLogger.error("toPdf.failed", error, {
872
1334
  pageIndex: pageIndex$.value,
873
1335
  pageCount: pages.length,
874
1336
  focusScenePath: view.focusScenePath,
875
- })
876
- throw error
877
- }
878
- }
879
-
880
- dispose.add(listen(window, 'message', (ev: MessageEvent<{ appId: string, type: "@netless/_request_save_pdf_" }>) => {
881
- if (ev.data && ev.data.type == '@netless/_request_save_pdf_' && ev.data.appId == context.appId) {
882
- toPdf().catch(err => { warn(err); reportProgress(100, null) })
1337
+ });
1338
+ throw error;
883
1339
  }
884
- }))
1340
+ };
1341
+
1342
+ dispose.add(
1343
+ listen(
1344
+ window,
1345
+ "message",
1346
+ (
1347
+ ev: MessageEvent<{
1348
+ appId: string;
1349
+ type: "@netless/_request_save_pdf_";
1350
+ }>,
1351
+ ) => {
1352
+ if (
1353
+ ev.data &&
1354
+ ev.data.type == "@netless/_request_save_pdf_" &&
1355
+ ev.data.appId == context.appId
1356
+ ) {
1357
+ toPdf().catch((err) => {
1358
+ warn(err);
1359
+ reportProgress(100, null);
1360
+ });
1361
+ }
1362
+ },
1363
+ ),
1364
+ );
885
1365
 
886
1366
  const setDocsViewReadonly = (bol: boolean) => {
887
- app.setDocsViewReadonly(bol)
888
- }
1367
+ app.setDocsViewReadonly(bol);
1368
+ };
889
1369
 
890
1370
  const setReadonly = (bol: boolean) => {
891
- app.setReadonly(bol)
1371
+ app.setReadonly(bol);
892
1372
  if (options.goToPageByClick) {
893
-
894
- }
895
- if(scrollbar) {
896
- scrollbar.setReadonly(bol)
897
1373
  }
898
- }
899
-
900
- const controller: PresentationController = { app, view, context, jumpPage, prevPage, nextPage, jumpPageAsync, prevPageAsync, nextPageAsync, pageState, toPdf, log, setDocsViewReadonly, setReadonly, moveCamera, getOriginScale, getScale, getPageSize, screenshotCurrentPageAsync }
901
-
902
- dispose.add(listen(window, 'message', (ev: MessageEvent<"@netless/_presentation_">) => {
903
- if (ev.data === "@netless/_presentation_") {
904
- if (typeof window !== 'undefined') dispose.make(() => {
905
- const debug: Set<PresentationController> = ((window as any)._presentation_ ||= new Set())
906
- debug.add(controller)
907
- return () => debug.delete(controller)
908
- })
909
- console.log(controller)
1374
+ if (scrollbar) {
1375
+ scrollbar.setReadonly(bol);
910
1376
  }
911
- }))
1377
+ };
1378
+
1379
+ const controller: PresentationController = {
1380
+ app,
1381
+ view,
1382
+ context,
1383
+ jumpPage,
1384
+ prevPage,
1385
+ nextPage,
1386
+ jumpPageAsync,
1387
+ prevPageAsync,
1388
+ nextPageAsync,
1389
+ pageState,
1390
+ toPdf,
1391
+ log,
1392
+ setDocsViewReadonly,
1393
+ setReadonly,
1394
+ moveCamera,
1395
+ getOriginScale,
1396
+ getScale,
1397
+ getPageSize,
1398
+ screenshotCurrentPageAsync,
1399
+ };
1400
+
1401
+ dispose.add(
1402
+ listen(
1403
+ window,
1404
+ "message",
1405
+ (ev: MessageEvent<"@netless/_presentation_">) => {
1406
+ if (ev.data === "@netless/_presentation_") {
1407
+ if (typeof window !== "undefined")
1408
+ dispose.make(() => {
1409
+ const debug: Set<PresentationController> = ((
1410
+ window as any
1411
+ )._presentation_ ||= new Set());
1412
+ debug.add(controller);
1413
+ return () => debug.delete(controller);
1414
+ });
1415
+ console.log(controller);
1416
+ }
1417
+ },
1418
+ ),
1419
+ );
912
1420
 
913
1421
  // Older WindowManager declarations model setup as synchronous even though
914
1422
  // AppProxy awaits its result. Keep that source compatibility until the new
915
1423
  // `SetupResult | Promise<SetupResult>` declaration is the minimum version.
916
- return prepareScenesPromise.then(() => controller) as unknown as PresentationController
917
- }
918
- }
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;
1434
+ },
1435
+ teardown(context) {
1436
+ teardownByContext.get(context)?.();
1437
+ teardownByContext.delete(context);
1438
+ },
1439
+ };
919
1440
 
920
1441
  /**
921
1442
  * Add synchronization to the local presentation.
@@ -925,11 +1446,13 @@ class AppPresentation extends Presentation {
925
1446
  warn?: Logger;
926
1447
  box?: ReadonlyTeleBox;
927
1448
  scaleDocsToFit?: () => void;
928
- readonly jumpPage: (index: number) => void
1449
+ readonly jumpPage: (index: number) => void;
929
1450
 
930
- constructor(config: PresentationConfig & { jumpPage: (index: number) => void }) {
931
- super(config)
932
- this.jumpPage = config.jumpPage
1451
+ constructor(
1452
+ config: PresentationConfig & { jumpPage: (index: number) => void },
1453
+ ) {
1454
+ super(config);
1455
+ this.jumpPage = config.jumpPage;
933
1456
  // this.image.style.display = 'none'
934
1457
  }
935
1458
 
@@ -938,17 +1461,21 @@ class AppPresentation extends Presentation {
938
1461
  super.updateImage();
939
1462
  }
940
1463
 
941
- override onNewPageIndex(index: number, origin: "navigation" | "keydown" | "input" | "preview") {
1464
+ override onNewPageIndex(
1465
+ index: number,
1466
+ origin: "navigation" | "keydown" | "input" | "preview",
1467
+ ) {
942
1468
  // If it is triggered by global keydown (left or right arrow),
943
1469
  // only the focused one should work
944
- if (origin === "keydown" && this.box && !this.box.focus)
945
- return
1470
+ if (origin === "keydown" && this.box && !this.box.focus) return;
946
1471
  if (this.log)
947
- this.log("[Presentation] user navigate to", index + 1, `(${origin})`)
1472
+ this.log("[Presentation] user navigate to", index + 1, `(${origin})`);
948
1473
  if (0 <= index && index < this.pages.length) {
949
- this.jumpPage(index)
1474
+ this.jumpPage(index);
950
1475
  } else {
951
- this.warn?.(`[Presentation]: page index ${index} out of bounds [0, ${this.pages.length - 1}]`)
1476
+ this.warn?.(
1477
+ `[Presentation]: page index ${index} out of bounds [0, ${this.pages.length - 1}]`,
1478
+ );
952
1479
  }
953
1480
  }
954
1481
  }
@@ -962,44 +1489,56 @@ function createPresentation(
962
1489
  thumbnail?: (src: string) => string,
963
1490
  useClipView?: boolean,
964
1491
  ): AppPresentation {
965
- box.mountStyles(styles)
1492
+ box.mountStyles(styles);
966
1493
 
967
- const app = new AppPresentation({ pages, readonly: box.readonly, jumpPage, thumbnail})
968
- app.box = box
969
- box.mountContent(app.contentDOM)
970
- box.mountFooter(app.footerDOM)
1494
+ const app = new AppPresentation({
1495
+ pages,
1496
+ readonly: box.readonly,
1497
+ jumpPage,
1498
+ thumbnail,
1499
+ });
1500
+ app.box = box;
1501
+ box.mountContent(app.contentDOM);
1502
+ box.mountFooter(app.footerDOM);
971
1503
 
972
- app.dispose.add(pageIndex$.subscribe(pageIndex => { app.setPageIndex(pageIndex) }))
1504
+ app.dispose.add(
1505
+ pageIndex$.subscribe((pageIndex) => {
1506
+ app.setPageIndex(pageIndex);
1507
+ }),
1508
+ );
973
1509
 
974
- if(useClipView && view){
1510
+ if (useClipView && view) {
975
1511
  const onCameraUpdatedEffectForMaskView = debounce(() => {
976
1512
  const { width: pageWidth, height: pageHeight } = app.page() || {};
977
- if(!pageWidth || !pageHeight) {
1513
+ if (!pageWidth || !pageHeight) {
978
1514
  return;
979
1515
  }
980
1516
  const { scale } = view.camera;
981
1517
  const { width, height } = view.size;
982
- const pageRatioX = Math.round((1 - (pageWidth * scale / width)) / 2 * 100);
983
- const pageRatioY = Math.round((1 - (pageHeight * scale / height)) / 2 * 100);
1518
+ const pageRatioX = Math.round(
1519
+ ((1 - (pageWidth * scale) / width) / 2) * 100,
1520
+ );
1521
+ const pageRatioY = Math.round(
1522
+ ((1 - (pageHeight * scale) / height) / 2) * 100,
1523
+ );
984
1524
  app.whiteboardDOM.style.clipPath = `inset(${pageRatioY}% ${pageRatioX}%)`;
985
- }, 50)
1525
+ }, 50);
986
1526
  onCameraUpdatedEffectForMaskView();
987
1527
  const onCameraUpdatedEffect = () => {
988
- view.callbacks.on("onSizeUpdated", onCameraUpdatedEffectForMaskView)
989
- view.callbacks.on("onCameraUpdated", onCameraUpdatedEffectForMaskView)
1528
+ view.callbacks.on("onSizeUpdated", onCameraUpdatedEffectForMaskView);
1529
+ view.callbacks.on("onCameraUpdated", onCameraUpdatedEffectForMaskView);
990
1530
  return () => {
991
1531
  view.callbacks.off("onCameraUpdated", onCameraUpdatedEffectForMaskView);
992
1532
  view.callbacks.off("onSizeUpdated", onCameraUpdatedEffectForMaskView);
993
- }
994
- }
995
- app.dispose.make(onCameraUpdatedEffect)
1533
+ };
1534
+ };
1535
+ app.dispose.make(onCameraUpdatedEffect);
996
1536
  }
997
-
998
1537
 
999
- return app
1538
+ return app;
1000
1539
  }
1001
1540
 
1002
- export type RegisterFn = typeof WindowManager["register"]
1541
+ export type RegisterFn = (typeof WindowManager)["register"];
1003
1542
 
1004
1543
  export interface InstallOptions {
1005
1544
  /**
@@ -1008,11 +1547,11 @@ export interface InstallOptions {
1008
1547
  *
1009
1548
  * @example "DocsViewer"
1010
1549
  */
1011
- as?: string
1550
+ as?: string;
1012
1551
  /**
1013
1552
  * Options to customize the local app (not synced to others).
1014
1553
  */
1015
- appOptions?: PresentationAppOptions
1554
+ appOptions?: PresentationAppOptions;
1016
1555
  }
1017
1556
 
1018
1557
  /**
@@ -1021,10 +1560,13 @@ export interface InstallOptions {
1021
1560
  *
1022
1561
  * @example install(register, { as: "DocsViewer" })
1023
1562
  */
1024
- export const install = (register: RegisterFn, options: InstallOptions = {}): Promise<void> => {
1025
- let app = NetlessAppPresentation
1563
+ export const install = (
1564
+ register: RegisterFn,
1565
+ options: InstallOptions = {},
1566
+ ): Promise<void> => {
1567
+ let app = NetlessAppPresentation;
1026
1568
  if (options.as) {
1027
- app = Object.assign({}, app, { kind: options.as })
1569
+ app = Object.assign({}, app, { kind: options.as });
1028
1570
  }
1029
- return register({ kind: app.kind, src: app, appOptions: options.appOptions })
1030
- }
1571
+ return register({ kind: app.kind, src: app, appOptions: options.appOptions });
1572
+ };