@netless/app-presentation 0.1.9 → 0.1.11

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,4 +1,4 @@
1
- import type { AnimationMode, AppContext, AppPayload, NetlessApp, PublicEvent, ReadonlyTeleBox, Room, SceneDefinition, View, WindowManager } from "@netless/window-manager"
1
+ import type { AnimationMode, AppContext, AppPayload, NetlessApp, PublicEvent, ReadonlyTeleBox, Room, SceneDefinition, Size, View, WindowManager } from "@netless/window-manager"
2
2
 
3
3
  import { disposableStore } from '@wopjs/disposable'
4
4
  import { listen } from '@wopjs/dom'
@@ -7,11 +7,31 @@ import styles from './style.scss?inline';
7
7
  import { Presentation, type PresentationConfig, type PresentationPage } from "./presentation";
8
8
  import { readable, type Readable } from "./store";
9
9
  import { Scrollbar, type ScrollbarEventCallback } from "./scrollbar";
10
- import { debounce } from "lodash";
10
+ import { getCameraScaleRange, shouldDisableDeviceCameraTransform } from "./camera-options";
11
+ import { cameraToSharedViewport, fitPageSizeToOrigin, getCameraReferenceSize, getFitScale, isValidSharedViewport, isValidSize } from "./camera-reference";
12
+ import debounce from "lodash/debounce";
11
13
 
12
14
  export type Logger = (...data: any[]) => void
13
15
 
16
+ interface PresentationDiagnosticLogger {
17
+ info(event: string, payload?: unknown): void;
18
+ warn(event: string, payload?: unknown): void;
19
+ error(event: string, error: unknown, payload?: unknown): void;
20
+ debouncedInfo(event: string, payload?: unknown): void;
21
+ flush(): void;
22
+ }
23
+
24
+ type MoveCameraRequest = { centerX: number, centerY: number, scale: number }
25
+
14
26
  const emptySceneName = '$$empty$$'
27
+ const ORIGIN_SIZE_COORDINATE_VERSION = 2
28
+
29
+ export interface PresentationAttributes {
30
+ /** Shared logical camera reference size. New pages are proportionally contained within it. */
31
+ originSize?: Size | null;
32
+ /** Internal marker for scenes whose ppt size has been normalized to originSize. */
33
+ _originSizeCoordinateVersion?: typeof ORIGIN_SIZE_COORDINATE_VERSION;
34
+ }
15
35
 
16
36
  interface Viewport {
17
37
  readonly x: number;
@@ -23,6 +43,8 @@ interface Viewport {
23
43
  export interface PresentationAppOptions {
24
44
  /** Disables user move / scale the image and whiteboard. */
25
45
  disableCameraTransform?: boolean;
46
+ /** Disables camera transforms from local device input without restricting programmatic scaling. */
47
+ disableDeviceCameraTransform?: boolean;
26
48
  /** Max scale = `maxCameraScale` * default scale. Not working when `disableCameraTransform` is true. Default: 3 */
27
49
  maxCameraScale?: number;
28
50
  /** Custom logger. Default: a logger that reports to the whiteboard server. */
@@ -42,7 +64,7 @@ export interface PresentationAppOptions {
42
64
 
43
65
  /** justDocsViewReadonly is used to set the presentation readonly, it will be used in the presentation, and the presentation will be readonly when the app is initialized */
44
66
  justDocsViewReadonly?: true;
45
- /** useScrollbar is used to set the presentation use scrollbar, it will be used in the presentation, and the presentation will be use scrollbar when the app is initialized */
67
+ /** Shows draggable scrollbars. This does not affect PresentationController.moveCamera(). */
46
68
  useScrollbar?: boolean;
47
69
  /** 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 */
48
70
  debounceSync?: boolean;
@@ -63,6 +85,12 @@ export interface PresentationController {
63
85
  prevPage(): boolean;
64
86
  /** Returns false if failed to jump */
65
87
  nextPage(): boolean;
88
+ /** Resolves after the whiteboard View has accepted the target scene path. */
89
+ jumpPageAsync(index: number): Promise<boolean>;
90
+ /** Resolves after the whiteboard View has accepted the previous scene path. */
91
+ prevPageAsync(): Promise<boolean>;
92
+ /** Resolves after the whiteboard View has accepted the next scene path. */
93
+ nextPageAsync(): Promise<boolean>;
66
94
  /** `index` ranges from 0 to `length - 1` */
67
95
  pageState(): { index: number, length: number };
68
96
 
@@ -73,7 +101,7 @@ export interface PresentationController {
73
101
  setDocsViewReadonly: (bol: boolean) => void;
74
102
  /** set the presentation readonly */
75
103
  setReadonly: (bol: boolean) => void;
76
- /** move the camera */
104
+ /** Moves the camera through the API, regardless of whether scrollbars are shown. */
77
105
  moveCamera: (camera: { centerX: number, centerY: number, scale: number }) => void;
78
106
  /** get the origin scale */
79
107
  getOriginScale: () => number;
@@ -85,8 +113,15 @@ export interface PresentationController {
85
113
  screenshotCurrentPageAsync: (context: CanvasRenderingContext2D, width?: number, height?: number) => Promise<void>;
86
114
  }
87
115
 
88
- const ppt2page = (ppt: SceneDefinition["ppt"], name?: string): PresentationPage | null =>
89
- ppt ? { width: ppt.width, height: ppt.height, src: ppt.src, thumbnail: ppt.previewURL, name } : null
116
+ const ppt2page = (
117
+ ppt: SceneDefinition["ppt"],
118
+ name?: string,
119
+ originSize?: Size
120
+ ): PresentationPage | null => {
121
+ if (!ppt) return null
122
+ const size = fitPageSizeToOrigin(ppt, originSize)
123
+ return { ...size, src: ppt.src, thumbnail: ppt.previewURL, name }
124
+ }
90
125
 
91
126
  const createLogger = (room: Room | undefined): Logger => {
92
127
  if (room && (room as any).logger) {
@@ -95,6 +130,56 @@ const createLogger = (room: Room | undefined): Logger => {
95
130
  return (...args) => console.log(...args)
96
131
  }
97
132
  }
133
+
134
+ const createDiagnosticLogger = (context: AppContext): PresentationDiagnosticLogger => {
135
+ const createAppLogger = (context as any).createLogger
136
+ if (typeof createAppLogger === 'function') {
137
+ return createAppLogger.call(context, 'camera', { debounceTime: 300, maxWaitTime: 2000 })
138
+ }
139
+
140
+ // Compatibility fallback for WindowManager versions without AppContext.createLogger().
141
+ const roomLogger = (context.getRoom() as any)?.logger
142
+ const prefix = `[Presentation][${context.appId}][camera]`
143
+ const emit = (level: 'info' | 'warn' | 'error', event: string, ...data: unknown[]) => {
144
+ try {
145
+ const printer = roomLogger?.[level]
146
+ if (typeof printer === 'function') printer.call(roomLogger, `${prefix}[${event}]`, ...data)
147
+ } catch {
148
+ // Diagnostics must never change the App API result or replace its original error.
149
+ }
150
+ }
151
+ const debouncedByEvent = new Map<string, ReturnType<typeof debounce>>()
152
+ const debouncedInfo = (event: string, payload?: unknown) => {
153
+ let emitDebounced = debouncedByEvent.get(event)
154
+ if (!emitDebounced) {
155
+ emitDebounced = debounce(
156
+ (nextPayload?: unknown) => emit('info', event, nextPayload),
157
+ 300,
158
+ { maxWait: 2000 }
159
+ )
160
+ debouncedByEvent.set(event, emitDebounced)
161
+ }
162
+ emitDebounced(payload)
163
+ }
164
+ return {
165
+ info: (event, payload) => emit('info', event, payload),
166
+ warn: (event, payload) => emit('warn', event, payload),
167
+ error: (event, error, payload) => emit('error', event, error, payload),
168
+ debouncedInfo,
169
+ flush: () => debouncedByEvent.forEach(logger => logger.flush()),
170
+ }
171
+ }
172
+
173
+ const safeResourceLocation = (value: string): string => {
174
+ if (value.startsWith('data:')) return 'data:[omitted]'
175
+ if (value.startsWith('blob:')) return 'blob:[omitted]'
176
+ try {
177
+ const url = new URL(value)
178
+ return `${url.origin}${url.pathname}`
179
+ } catch {
180
+ return value.split('?')[0]
181
+ }
182
+ }
98
183
  const scenesEqual = (scenes1?: SceneDefinition[], scenes2?: SceneDefinition[]): boolean => {
99
184
  if (!scenes1 || !scenes2) {return false}
100
185
  if (scenes1.length !== scenes2.length) return false;
@@ -107,14 +192,40 @@ const scenesEqual = (scenes1?: SceneDefinition[], scenes2?: SceneDefinition[]):
107
192
  });
108
193
  };
109
194
 
110
- export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions, PresentationController> = {
195
+ export const NetlessAppPresentation: NetlessApp<PresentationAttributes, {}, PresentationAppOptions, PresentationController> = {
111
196
  kind: "Presentation",
112
197
  setup(context) {
198
+ const diagnosticLogger = createDiagnosticLogger(context)
113
199
  const view = context.getView()
114
200
  if (!view)
115
201
  throw new Error("[Presentation]: no whiteboard view, make sure you have added options.scenePath in addApp()")
116
202
 
117
- const pages = context.getScenes()?.map(({ ppt, name }) => ppt2page(ppt, name)).filter(Boolean) as PresentationPage[]
203
+ const options = context.getAppOptions() || {}
204
+ const room = context.getRoom()
205
+ const log = options.log || createLogger(room)
206
+ const roomLogger = (room as any)?.logger
207
+ const warn: Logger = (...data) => roomLogger?.warn ? roomLogger.warn(...data) : log(...data)
208
+ const configuredOriginSize = context.storage.state.originSize
209
+ const originSize = isValidSize(configuredOriginSize)
210
+ ? { width: configuredOriginSize.width, height: configuredOriginSize.height }
211
+ : undefined
212
+ if (configuredOriginSize != null && !originSize) {
213
+ warn(`[Presentation] originSize should contain finite positive width and height, got ${JSON.stringify(configuredOriginSize)}`)
214
+ }
215
+ const useOriginSizeCoordinates = Boolean(
216
+ originSize && (
217
+ context.isAddApp ||
218
+ context.storage.state._originSizeCoordinateVersion === ORIGIN_SIZE_COORDINATE_VERSION
219
+ )
220
+ )
221
+ if (originSize && context.isAddApp && context.getIsWritable()) {
222
+ context.storage.setState({
223
+ _originSizeCoordinateVersion: ORIGIN_SIZE_COORDINATE_VERSION,
224
+ })
225
+ }
226
+ const pages = context.getScenes()
227
+ ?.map(({ ppt, name }) => ppt2page(ppt, name, useOriginSizeCoordinates ? originSize : undefined))
228
+ .filter(Boolean) as PresentationPage[]
118
229
  if (!pages || pages.length === 0)
119
230
  throw new Error("[Presentation]: empty scenes, make sure you have added options.scenes in addApp()")
120
231
  if (pages[0].src.startsWith('ppt'))
@@ -123,19 +234,17 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
123
234
  // Now it must have a blank scene points to "{scenePath}/{scenes[0].name}", e.g. "/pdf/123456/1"
124
235
  // https://github.com/netless-io/window-manager/blob/c87df17/src/index.ts#L465-L476
125
236
  const scenePath = context.getInitScenePath()!
126
-
127
- const options = context.getAppOptions() || {}
128
237
  let maxCameraScale = options.maxCameraScale ?? 3
129
238
  if (!(Number.isFinite(maxCameraScale) && maxCameraScale! > 0)) {
130
- console.warn(`[Presentation] maxCameraScale should be a positive number, got ${options.maxCameraScale}`)
239
+ warn(`[Presentation] maxCameraScale should be a positive number, got ${options.maxCameraScale}`)
131
240
  maxCameraScale = 3
132
241
  }
133
242
 
134
- const log = options.log || createLogger(context.getRoom())
135
243
  log(`[Presentation] new ${context.appId}`)
136
244
 
137
245
  const dispose = disposableStore()
138
246
  dispose.add(() => log(`[Presentation] dispose ${context.appId}`))
247
+ dispose.add(() => diagnosticLogger.flush())
139
248
 
140
249
  const view$$ = context.createStorage('view', { uid: "", originX: 0, originY: 0, width: 0, height: 0 })
141
250
 
@@ -178,63 +287,18 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
178
287
  pageIndex$.dispose();
179
288
  })
180
289
 
181
- // Prepare scenes.
182
- // Caution: some user may insert a 500-page PDF.
183
- if (context.isAddApp) {
184
- if (pages.length > 100)
185
- console.warn(`[Presentation]: too many pages (${pages.length}), may cause performance issues`)
186
-
187
- let redirectResolve: ((bol:boolean) => void) | undefined = undefined;
188
- const room = context.getRoom();
189
- if (room && room.isWritable) {
190
- const scenes = room.entireScenes()[scenePath];
191
- if (pageIndex$.value < 0 || pageIndex$.value >= pages.length) {
192
- throw new Error(`[Presentation] Invalid page index: ${pageIndex$.value}, scenes length: ${pages.length}`);
193
- }
194
- new Promise((resolve) => {
195
- const {name, ppt} = scenes[pageIndex$.value];
196
- redirectResolve = resolve;
197
- const _scenes = pages.map((p, index) => ({
198
- name: p.name ?? String(index + 1),
199
- ppt: { width: p.width, height: p.height, src: p.src }
200
- }))
201
-
202
- if (!scenesEqual(scenes, _scenes)) {
203
- room.removeScenes(scenePath)
204
- room.putScenes(scenePath, _scenes)
205
- }
206
- if(name === _scenes[pageIndex$.value].name && !ppt){
207
- context.addPage({ scene: { name: emptySceneName } }).then(() => {
208
- log(`[Presentation] setup setScenePath ${scenePath}/${emptySceneName}`);
209
- context.setScenePath(`${scenePath}/${emptySceneName}`).then(()=>{
210
- redirectResolve && redirectResolve(true);
211
- })
212
- });
213
- } else {
214
- redirectResolve && redirectResolve(false)
215
- }
216
- }).then(async(bol)=>{
217
- await syncPage(pageIndex$.value, (room as any).logger);
218
- if (bol) {
219
- log(`[Presentation] setup removeScenes ${scenePath}/${emptySceneName}`);
220
- room.removeScenes(`${scenePath}/${emptySceneName}`);
221
- }
222
- });
223
- }
224
- }
225
-
226
290
  // let lastIndex = -1
227
291
 
228
292
  const me = context.getRoom()?.uid || context.getDisplayer().observerId + ''
229
293
 
230
294
  let throttleSyncView = 0
231
295
 
232
- const syncPage = async (index: number, logger?: any) => {
296
+ const syncPage = async (index: number, logger?: any): Promise<boolean> => {
233
297
 
234
- if (!context.getIsWritable()) return
298
+ if (!context.getIsWritable()) return false
235
299
 
236
300
  const scenes = context.getDisplayer().entireScenes()[scenePath]
237
- if (!scenes) return
301
+ if (!scenes) return false
238
302
 
239
303
  const p = pages[index];
240
304
  const name = p.name ?? String(index + 1);
@@ -251,58 +315,150 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
251
315
 
252
316
  // Switch to that page.
253
317
  await context.setScenePath(`${scenePath}/${name}`)
318
+ return true
254
319
  }
255
320
 
256
- const jumpPage = (index: number): boolean => {
321
+ const prepareScenes = async (): Promise<void> => {
322
+ if (!context.isAddApp) return
323
+ // Caution: some user may insert a 500-page PDF.
324
+ if (pages.length > 100)
325
+ warn(`[Presentation]: too many pages (${pages.length}), may cause performance issues`)
326
+ if (!room || !room.isWritable) return
327
+ if (pageIndex$.value < 0 || pageIndex$.value >= pages.length) {
328
+ throw new Error(`[Presentation] Invalid page index: ${pageIndex$.value}, scenes length: ${pages.length}`)
329
+ }
330
+
331
+ const scenes = room.entireScenes()[scenePath]
332
+ if (!scenes || !scenes[pageIndex$.value]) {
333
+ throw new Error(`[Presentation]: no initial scene found at ${scenePath}, page index: ${pageIndex$.value}`)
334
+ }
335
+ const { name, ppt } = scenes[pageIndex$.value]
336
+ const nextScenes = pages.map((page, index) => ({
337
+ name: page.name ?? String(index + 1),
338
+ ppt: { width: page.width, height: page.height, src: page.src }
339
+ }))
340
+
341
+ if (!scenesEqual(scenes, nextScenes)) {
342
+ room.removeScenes(scenePath)
343
+ room.putScenes(scenePath, nextScenes)
344
+ }
345
+
346
+ const shouldRedirect = name === nextScenes[pageIndex$.value].name && !ppt
347
+ if (shouldRedirect) {
348
+ await context.addPage({ scene: { name: emptySceneName } })
349
+ log(`[Presentation] setup setScenePath ${scenePath}/${emptySceneName}`)
350
+ await context.setScenePath(`${scenePath}/${emptySceneName}`)
351
+ }
352
+
353
+ await syncPage(pageIndex$.value, (room as any).logger)
354
+ if (shouldRedirect) {
355
+ log(`[Presentation] setup removeScenes ${scenePath}/${emptySceneName}`)
356
+ room.removeScenes(`${scenePath}/${emptySceneName}`)
357
+ }
358
+ }
359
+
360
+ const prepareScenesPromise = prepareScenes()
361
+
362
+ const canJumpPage = (index: number): boolean => {
257
363
  if (!context.getIsWritable()) {
258
- console.warn('[Presentation]: no permission, make sure you have test room.isWritable')
364
+ warn('[Presentation]: no permission, make sure you have test room.isWritable')
259
365
  return false
260
366
  }
261
367
 
262
368
  if (!(0 <= index && index < pages.length)) {
263
- console.warn(`[Presentation]: page ${index + 1} out of bounds [1, ${pages.length}]`)
369
+ warn(`[Presentation]: page ${index + 1} out of bounds [1, ${pages.length}]`)
264
370
  return false
265
371
  }
266
372
 
267
373
  const scenes = context.getDisplayer().entireScenes()[scenePath]
268
374
  if (!scenes) {
269
- console.warn(`[Presentation]: no scenes found at ${scenePath}, make sure you have added options.scenePath in addApp()`)
375
+ warn(`[Presentation]: no scenes found at ${scenePath}, make sure you have added options.scenePath in addApp()`)
270
376
  return false
271
377
  }
272
378
 
273
- const p = pages[index];
274
- const name = p.name ?? String(index + 1);
275
-
276
- if (!scenes.some(scene => scene.name === name)) {
277
- context.addPage({ scene: { name, ppt: { width: p.width, height: p.height, src: p.src } } })
278
- }
379
+ return true
380
+ }
279
381
 
280
- syncPage(index);
382
+ const jumpPage = (index: number): boolean => {
383
+ if (!canJumpPage(index)) return false
384
+
385
+ void syncPage(index).catch(error => {
386
+ warn('[Presentation]: failed to sync page', error)
387
+ diagnosticLogger.error('jumpPage.failed', error, {
388
+ index,
389
+ pageIndex: pageIndex$.value,
390
+ focusScenePath: view.focusScenePath,
391
+ })
392
+ })
281
393
  return true
282
394
  }
283
395
 
284
396
  const prevPage = () => jumpPage(pageIndex$.value - 1)
285
397
  const nextPage = () => jumpPage(pageIndex$.value + 1)
398
+ const jumpPageAsync = async (index: number): Promise<boolean> => {
399
+ if (!canJumpPage(index)) return false
400
+ try {
401
+ return await syncPage(index)
402
+ } catch (error) {
403
+ diagnosticLogger.error('jumpPageAsync.failed', error, {
404
+ index,
405
+ pageIndex: pageIndex$.value,
406
+ focusScenePath: view.focusScenePath,
407
+ })
408
+ throw error
409
+ }
410
+ }
411
+ const prevPageAsync = () => jumpPageAsync(pageIndex$.value - 1)
412
+ const nextPageAsync = () => jumpPageAsync(pageIndex$.value + 1)
286
413
  const pageState = () => ({ index: pageIndex$.value, length: pages.length })
287
414
 
288
415
  const scaleDocsToFit = () => {
289
- const { width, height } = app.page() || {}
290
- if (width && height) {
416
+ const page = app.page()
417
+ if (page && isValidSize(page)) {
418
+ const referenceSize = getCameraReferenceSize(originSize, page)
419
+ if (originSize) {
420
+ const fitScale = getFitScale(view.size, referenceSize)
421
+ if (!fitScale) return
422
+ const { minScale, maxScale } = getCameraScaleRange(
423
+ fitScale,
424
+ maxCameraScale,
425
+ options.disableCameraTransform
426
+ )
427
+ view.setCameraBound({
428
+ damping: 1,
429
+ maxContentMode: () => maxScale,
430
+ minContentMode: () => minScale,
431
+ centerX: 0, centerY: 0, width: page.width, height: page.height
432
+ })
433
+ if (isValidSharedViewport(view$$.state)) {
434
+ syncViewFromRemote(true)
435
+ return
436
+ }
437
+ }
291
438
  view.moveCameraToContain({
292
- originX: -width / 2, originY: -height / 2, width, height,
439
+ originX: -referenceSize.width / 2,
440
+ originY: -referenceSize.height / 2,
441
+ width: referenceSize.width,
442
+ height: referenceSize.height,
293
443
  animationMode: 'immediately' as AnimationMode.Immediately
294
444
  })
295
- const maxScale = view.camera.scale * (options.disableCameraTransform ? 1 : maxCameraScale)
296
- const minScale = view.camera.scale
297
- view.setCameraBound({
298
- damping: 1,
299
- maxContentMode: () => maxScale,
300
- minContentMode: () => minScale,
301
- centerX: 0, centerY: 0, width, height
302
- })
445
+ if (!originSize) {
446
+ const { minScale, maxScale } = getCameraScaleRange(
447
+ view.camera.scale,
448
+ maxCameraScale,
449
+ options.disableCameraTransform
450
+ )
451
+ view.setCameraBound({
452
+ damping: 1,
453
+ maxContentMode: () => maxScale,
454
+ minContentMode: () => minScale,
455
+ centerX: 0, centerY: 0, width: page.width, height: page.height
456
+ })
457
+ }
303
458
  syncViewFromRemote(true)
304
459
  }
305
460
  }
461
+ let pendingMoveCameraRequest: MoveCameraRequest | undefined
306
462
  const syncView = () => {
307
463
  if (context.getIsWritable()) {
308
464
  if (options.debounceSync) {
@@ -310,18 +466,30 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
310
466
  throttleSyncView = 0;
311
467
  }
312
468
  if (throttleSyncView > 0) return
313
- const { width, height } = app.page() || {}
314
- if(width && height){
469
+ const page = app.page()
470
+ if (page && isValidSize(page)) {
315
471
  throttleSyncView = setTimeout(() => {
316
472
  throttleSyncView = 0
317
- const { camera, size } = view;
318
- const fixedW = Math.min(size.width, size.height * width / height)
319
- const fixedH = Math.min(size.height, size.width * height / width)
320
- const w = fixedW / camera.scale
321
- const h = fixedH / camera.scale
322
- const x = camera.centerX - w / 2
323
- const y = camera.centerY - h / 2
324
- view$$.setState({ uid: me, originX: x, originY: y, width: w, height: h })
473
+ try {
474
+ const { camera, size } = view;
475
+ const referenceSize = getCameraReferenceSize(originSize, page)
476
+ const viewport = cameraToSharedViewport(camera, size, referenceSize)
477
+ if (viewport) view$$.setState({ uid: me, ...viewport })
478
+ if (pendingMoveCameraRequest) {
479
+ diagnosticLogger.debouncedInfo(
480
+ 'moveCamera',
481
+ getCameraDiagnosticState('moveCamera', pendingMoveCameraRequest)
482
+ )
483
+ pendingMoveCameraRequest = undefined
484
+ }
485
+ } catch (error) {
486
+ diagnosticLogger.error(
487
+ 'syncView.failed',
488
+ error,
489
+ getCameraDiagnosticState('moveCamera', pendingMoveCameraRequest)
490
+ )
491
+ pendingMoveCameraRequest = undefined
492
+ }
325
493
  }, 50)
326
494
  }
327
495
  }
@@ -330,6 +498,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
330
498
  dispose.add(() => {
331
499
  clearTimeout(throttleSyncView)
332
500
  throttleSyncView = 0
501
+ pendingMoveCameraRequest = undefined
333
502
  })
334
503
 
335
504
  const syncViewFromRemote = (force = false, animate = false) => {
@@ -354,12 +523,78 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
354
523
  app.contentDOM.dataset.appPresentationVersion = __VERSION__
355
524
  app.scaleDocsToFit = scaleDocsToFit
356
525
  app.log = log
526
+ app.warn = warn
527
+
528
+ const getCameraDiagnosticState = (
529
+ reason: 'initialize' | 'moveCamera',
530
+ requestedCamera?: MoveCameraRequest
531
+ ) => {
532
+ const page = app.page()
533
+ const pageSize = page && isValidSize(page)
534
+ ? { width: page.width, height: page.height }
535
+ : undefined
536
+ const referenceSize = pageSize
537
+ ? getCameraReferenceSize(originSize, pageSize)
538
+ : undefined
539
+ const viewSize = { width: view.size.width, height: view.size.height }
540
+ const viewCamera = {
541
+ centerX: view.camera.centerX,
542
+ centerY: view.camera.centerY,
543
+ scale: view.camera.scale,
544
+ }
545
+ const sharedViewport = { ...view$$.state }
546
+ const originScale = referenceSize ? getFitScale(viewSize, referenceSize) : undefined
547
+ const normalizedScale = originScale && originScale > 0
548
+ ? viewCamera.scale / originScale
549
+ : undefined
550
+ const sharedScaleX = referenceSize && sharedViewport.width > 0
551
+ ? referenceSize.width / sharedViewport.width
552
+ : undefined
553
+ const sharedScaleY = referenceSize && sharedViewport.height > 0
554
+ ? referenceSize.height / sharedViewport.height
555
+ : undefined
556
+
557
+ return {
558
+ reason,
559
+ requestedCamera,
560
+ storageOriginSize: context.storage.state.originSize,
561
+ sharedViewport,
562
+ pageSize,
563
+ referenceSize,
564
+ viewSize,
565
+ viewCamera,
566
+ originScale,
567
+ normalizedScale,
568
+ sharedScaleX,
569
+ sharedScaleY,
570
+ focusScenePath: view.focusScenePath,
571
+ isWritable: context.getIsWritable(),
572
+ }
573
+ }
574
+
575
+ let didReportInitializedCamera = false
576
+ const reportInitializedCamera = (source: 'setup' | 'onSizeUpdated') => {
577
+ if (didReportInitializedCamera || !isValidSize(view.size) || !isValidSize(app.page())) return
578
+ didReportInitializedCamera = true
579
+ diagnosticLogger.info('initialize', {
580
+ source,
581
+ ...getCameraDiagnosticState('initialize'),
582
+ })
583
+ }
584
+
585
+ if (originSize) {
586
+ let previousPageIndex = pageIndex$.value
587
+ dispose.add(pageIndex$.subscribe(nextPageIndex => {
588
+ if (nextPageIndex === previousPageIndex) return
589
+ previousPageIndex = nextPageIndex
590
+ scaleDocsToFit()
591
+ }))
592
+ }
357
593
 
358
594
  if (options.justDocsViewReadonly) {
359
595
  app.setDocsViewReadonly(true)
360
596
  }
361
597
 
362
- const room = context.getRoom();
363
598
  const goToPageByClick = () => {
364
599
  const currentApplianceName = context.getRoom()?.state?.memberState?.currentApplianceName ?? '';
365
600
  if (!app.readonly && currentApplianceName === 'clicker') {
@@ -375,13 +610,17 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
375
610
  }
376
611
 
377
612
  context.mountView(app.whiteboardDOM)
378
- if (options.disableCameraTransform) {
613
+ if (shouldDisableDeviceCameraTransform(options)) {
379
614
  view.disableCameraTransform = true
380
615
  }
381
616
  scaleDocsToFit()
382
617
  dispose.make(() => {
383
- view.callbacks.on('onSizeUpdated', scaleDocsToFit)
384
- return () => view.callbacks.off('onSizeUpdated', scaleDocsToFit)
618
+ const onSizeUpdated = () => {
619
+ scaleDocsToFit()
620
+ reportInitializedCamera('onSizeUpdated')
621
+ }
622
+ view.callbacks.on('onSizeUpdated', onSizeUpdated)
623
+ return () => view.callbacks.off('onSizeUpdated', onSizeUpdated)
385
624
  })
386
625
 
387
626
  // Init viewport if provided `viewport`.
@@ -404,24 +643,23 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
404
643
  })
405
644
 
406
645
  syncViewFromRemote(true)
646
+ reportInitializedCamera('setup')
407
647
 
408
648
  dispose.add(context.emitter.on("writableChange", (isWritable: boolean): void => {
409
649
  app.setReadonly(!isWritable)
410
650
  }))
411
651
 
412
652
  const getOriginScale = () => {
413
- const { size } = view;
414
- const { width, height } = getPageSize();
415
- return Math.min(size.height / height, size.width / width);
653
+ const page = app.page()
654
+ if (!page || !isValidSize(page)) return 0
655
+ return getFitScale(view.size, getCameraReferenceSize(originSize, page)) || 0
416
656
  }
417
657
 
418
658
  const getScale =() => {
419
659
  return view.camera.scale
420
660
  }
421
661
 
422
- const screenshotCurrentPageAsync = async (_context: CanvasRenderingContext2D, _width?: number, _height?: number) => {
423
- const uuid = room?.calibrationTimestamp?.toString() ?? Date.now().toString();
424
-
662
+ const screenshotCurrentPage = async (_context: CanvasRenderingContext2D, _width?: number, _height?: number) => {
425
663
  const currentPage = pages[pageIndex$.value];
426
664
  if (!currentPage) {
427
665
  throw new Error('[Presentation]: current page not found')
@@ -432,7 +670,13 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
432
670
  img.width = width;
433
671
  img.height = height;
434
672
  img.crossOrigin = 'Anonymous';
435
- await new Promise(resolve => { img.onload = resolve; img.src = src })
673
+ await new Promise<void>((resolve, reject) => {
674
+ img.onload = () => resolve()
675
+ img.onerror = () => reject(new Error(
676
+ `[Presentation]: failed to load screenshot page image: ${safeResourceLocation(src)}`
677
+ ))
678
+ img.src = src
679
+ })
436
680
  _context.drawImage(img, 0, 0, width, height, 0, 0, _width || width, _height || height);
437
681
  const currentScenePath = view.focusScenePath;
438
682
  if (!currentScenePath) {
@@ -454,6 +698,23 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
454
698
  }
455
699
  }
456
700
 
701
+ const screenshotCurrentPageAsync = async (
702
+ _context: CanvasRenderingContext2D,
703
+ _width?: number,
704
+ _height?: number
705
+ ) => {
706
+ try {
707
+ await screenshotCurrentPage(_context, _width, _height)
708
+ } catch (error) {
709
+ diagnosticLogger.error('screenshotCurrentPage.failed', error, {
710
+ pageIndex: pageIndex$.value,
711
+ outputSize: { width: _width, height: _height },
712
+ camera: getCameraDiagnosticState('initialize'),
713
+ })
714
+ throw error
715
+ }
716
+ }
717
+
457
718
  let scrollbar:Scrollbar | undefined;
458
719
  if (options.useScrollbar) {
459
720
  dispose.make(() => {
@@ -470,14 +731,29 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
470
731
  }
471
732
 
472
733
  const moveCamera = (camera: { centerX: number, centerY: number, scale: number }) => {
473
- if (context.getIsWritable() && scrollbar) {
474
- if (!scrollbar) {
475
- throw new Error('[Presentation]: moveCamera must be called when appOptions: useScrollbar is true')
734
+ try {
735
+ if (!context.getIsWritable()) {
736
+ throw new Error('[Presentation]: moveCamera must be called in writable room')
476
737
  }
477
- scrollbar.moveCamera(camera);
478
- return;
738
+ pendingMoveCameraRequest = { ...camera }
739
+ if (scrollbar) {
740
+ scrollbar.moveCamera(camera);
741
+ return;
742
+ }
743
+ view.moveCamera({
744
+ ...camera,
745
+ animationMode: 'immediately' as AnimationMode.Immediately
746
+ })
747
+ syncView()
748
+ } catch (error) {
749
+ pendingMoveCameraRequest = undefined
750
+ diagnosticLogger.error(
751
+ 'moveCamera.failed',
752
+ error,
753
+ getCameraDiagnosticState('moveCamera', camera)
754
+ )
755
+ throw error
479
756
  }
480
- throw new Error('[Presentation]: moveCamera must be called in writable room')
481
757
  }
482
758
 
483
759
  context.emitter.on('destroy', () => dispose())
@@ -495,7 +771,11 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
495
771
  } catch {}
496
772
 
497
773
  const data = await fetch(url)
498
- if (!data.ok) throw new Error(`[Presentation]: failed to fetch ${url} - ${await data.text()}`)
774
+ if (!data.ok) {
775
+ throw new Error(
776
+ `[Presentation]: failed to fetch ${safeResourceLocation(url)}, status: ${data.status} ${data.statusText}`
777
+ )
778
+ }
499
779
 
500
780
  const blob = await data.blob()
501
781
  const reader = new FileReader()
@@ -506,7 +786,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
506
786
  })
507
787
  }
508
788
 
509
- const toPdf = async (): Promise<{ pdf: ArrayBuffer, title: string } | null> => {
789
+ const toPdfInternal = async (): Promise<{ pdf: ArrayBuffer, title: string } | null> => {
510
790
  const MAX = 1920
511
791
  const firstPage = pages[0]
512
792
  const { width, height } = firstPage
@@ -520,6 +800,9 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
520
800
  pdfWidth = Math.floor(width * pdfHeight / height)
521
801
  }
522
802
  const scenes = context.getDisplayer().entireScenes()[scenePath]
803
+ if (!scenes) {
804
+ throw new Error(`[Presentation]: no scenes found while exporting PDF: ${scenePath}`)
805
+ }
523
806
 
524
807
  const stage_canvas = document.createElement('canvas')
525
808
  stage_canvas.width = pdfWidth
@@ -545,8 +828,14 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
545
828
 
546
829
  const url = await base64url(src)
547
830
  const img = document.createElement('img')
548
- await new Promise(resolve => { img.onload = resolve; img.src = url })
549
- stage.drawImage(img, 0, 0)
831
+ await new Promise<void>((resolve, reject) => {
832
+ img.onload = () => resolve()
833
+ img.onerror = () => reject(new Error(
834
+ `[Presentation]: failed to load PDF page image, page index: ${index}`
835
+ ))
836
+ img.src = url
837
+ })
838
+ stage.drawImage(img, 0, 0, width, height)
550
839
 
551
840
  wb.clearRect(0, 0, pdfWidth, pdfHeight)
552
841
  const name = p.name ?? String(index + 1)
@@ -571,7 +860,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
571
860
  await new Promise(resolve => { wb_img.onload = resolve; wb_img.src = wb_url })
572
861
  stage.drawImage(wb_img, 0, 0, pdfWidth, pdfHeight)
573
862
  } catch (err) {
574
- console.warn(err)
863
+ warn(err)
575
864
  }
576
865
  }
577
866
 
@@ -589,9 +878,22 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
589
878
  return reportProgress(100, { pdf: data, title })
590
879
  }
591
880
 
881
+ const toPdf = async (): Promise<{ pdf: ArrayBuffer, title: string } | null> => {
882
+ try {
883
+ return await toPdfInternal()
884
+ } catch (error) {
885
+ diagnosticLogger.error('toPdf.failed', error, {
886
+ pageIndex: pageIndex$.value,
887
+ pageCount: pages.length,
888
+ focusScenePath: view.focusScenePath,
889
+ })
890
+ throw error
891
+ }
892
+ }
893
+
592
894
  dispose.add(listen(window, 'message', (ev: MessageEvent<{ appId: string, type: "@netless/_request_save_pdf_" }>) => {
593
895
  if (ev.data && ev.data.type == '@netless/_request_save_pdf_' && ev.data.appId == context.appId) {
594
- toPdf().catch(err => { console.warn(err); reportProgress(100, null) })
896
+ toPdf().catch(err => { warn(err); reportProgress(100, null) })
595
897
  }
596
898
  }))
597
899
 
@@ -609,7 +911,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
609
911
  }
610
912
  }
611
913
 
612
- const controller: PresentationController = { app, view, context, jumpPage, prevPage, nextPage, pageState, toPdf, log, setDocsViewReadonly, setReadonly, moveCamera, getOriginScale, getScale, getPageSize, screenshotCurrentPageAsync }
914
+ const controller: PresentationController = { app, view, context, jumpPage, prevPage, nextPage, jumpPageAsync, prevPageAsync, nextPageAsync, pageState, toPdf, log, setDocsViewReadonly, setReadonly, moveCamera, getOriginScale, getScale, getPageSize, screenshotCurrentPageAsync }
613
915
 
614
916
  dispose.add(listen(window, 'message', (ev: MessageEvent<"@netless/_presentation_">) => {
615
917
  if (ev.data === "@netless/_presentation_") {
@@ -622,7 +924,10 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
622
924
  }
623
925
  }))
624
926
 
625
- return controller
927
+ // Older WindowManager declarations model setup as synchronous even though
928
+ // AppProxy awaits its result. Keep that source compatibility until the new
929
+ // `SetupResult | Promise<SetupResult>` declaration is the minimum version.
930
+ return prepareScenesPromise.then(() => controller) as unknown as PresentationController
626
931
  }
627
932
  }
628
933
 
@@ -631,6 +936,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
631
936
  */
632
937
  class AppPresentation extends Presentation {
633
938
  log?: Logger;
939
+ warn?: Logger;
634
940
  box?: ReadonlyTeleBox;
635
941
  scaleDocsToFit?: () => void;
636
942
  readonly jumpPage: (index: number) => void
@@ -656,7 +962,7 @@ class AppPresentation extends Presentation {
656
962
  if (0 <= index && index < this.pages.length) {
657
963
  this.jumpPage(index)
658
964
  } else {
659
- console.warn(`[Presentation]: page index ${index} out of bounds [0, ${this.pages.length - 1}]`)
965
+ this.warn?.(`[Presentation]: page index ${index} out of bounds [0, ${this.pages.length - 1}]`)
660
966
  }
661
967
  }
662
968
  }