@netless/app-presentation 0.1.10 → 0.1.12

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,12 +7,29 @@ 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 { getCameraScaleRange, shouldDisableDeviceCameraTransform } from "./camera-options";
11
+ import { cameraToSharedViewport, fitPageSizeToOrigin, getCameraReferenceSize, getFitScale, isValidSharedViewport, isValidSize } from "./camera-reference";
10
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$$'
15
27
 
28
+ export interface PresentationAttributes {
29
+ /** Shared logical camera reference size. New pages are proportionally contained within it. */
30
+ originSize?: Size | null;
31
+ }
32
+
16
33
  interface Viewport {
17
34
  readonly x: number;
18
35
  readonly y: number;
@@ -23,6 +40,8 @@ interface Viewport {
23
40
  export interface PresentationAppOptions {
24
41
  /** Disables user move / scale the image and whiteboard. */
25
42
  disableCameraTransform?: boolean;
43
+ /** Disables camera transforms from local device input without restricting programmatic scaling. */
44
+ disableDeviceCameraTransform?: boolean;
26
45
  /** Max scale = `maxCameraScale` * default scale. Not working when `disableCameraTransform` is true. Default: 3 */
27
46
  maxCameraScale?: number;
28
47
  /** Custom logger. Default: a logger that reports to the whiteboard server. */
@@ -42,7 +61,7 @@ export interface PresentationAppOptions {
42
61
 
43
62
  /** 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
63
  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 */
64
+ /** Shows draggable scrollbars. This does not affect PresentationController.moveCamera(). */
46
65
  useScrollbar?: boolean;
47
66
  /** 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
67
  debounceSync?: boolean;
@@ -63,6 +82,12 @@ export interface PresentationController {
63
82
  prevPage(): boolean;
64
83
  /** Returns false if failed to jump */
65
84
  nextPage(): boolean;
85
+ /** Resolves after the whiteboard View has accepted the target scene path. */
86
+ jumpPageAsync(index: number): Promise<boolean>;
87
+ /** Resolves after the whiteboard View has accepted the previous scene path. */
88
+ prevPageAsync(): Promise<boolean>;
89
+ /** Resolves after the whiteboard View has accepted the next scene path. */
90
+ nextPageAsync(): Promise<boolean>;
66
91
  /** `index` ranges from 0 to `length - 1` */
67
92
  pageState(): { index: number, length: number };
68
93
 
@@ -73,7 +98,7 @@ export interface PresentationController {
73
98
  setDocsViewReadonly: (bol: boolean) => void;
74
99
  /** set the presentation readonly */
75
100
  setReadonly: (bol: boolean) => void;
76
- /** move the camera */
101
+ /** Moves the camera through the API, regardless of whether scrollbars are shown. */
77
102
  moveCamera: (camera: { centerX: number, centerY: number, scale: number }) => void;
78
103
  /** get the origin scale */
79
104
  getOriginScale: () => number;
@@ -85,8 +110,15 @@ export interface PresentationController {
85
110
  screenshotCurrentPageAsync: (context: CanvasRenderingContext2D, width?: number, height?: number) => Promise<void>;
86
111
  }
87
112
 
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
113
+ const ppt2page = (
114
+ ppt: SceneDefinition["ppt"],
115
+ name?: string,
116
+ originSize?: Size
117
+ ): PresentationPage | null => {
118
+ if (!ppt) return null
119
+ const size = fitPageSizeToOrigin(ppt, originSize)
120
+ return { ...size, src: ppt.src, thumbnail: ppt.previewURL, name }
121
+ }
90
122
 
91
123
  const createLogger = (room: Room | undefined): Logger => {
92
124
  if (room && (room as any).logger) {
@@ -95,6 +127,56 @@ const createLogger = (room: Room | undefined): Logger => {
95
127
  return (...args) => console.log(...args)
96
128
  }
97
129
  }
130
+
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 })
135
+ }
136
+
137
+ // 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[]) => {
141
+ try {
142
+ const printer = roomLogger?.[level]
143
+ if (typeof printer === 'function') printer.call(roomLogger, `${prefix}[${event}]`, ...data)
144
+ } catch {
145
+ // Diagnostics must never change the App API result or replace its original error.
146
+ }
147
+ }
148
+ const debouncedByEvent = new Map<string, ReturnType<typeof debounce>>()
149
+ const debouncedInfo = (event: string, payload?: unknown) => {
150
+ let emitDebounced = debouncedByEvent.get(event)
151
+ if (!emitDebounced) {
152
+ emitDebounced = debounce(
153
+ (nextPayload?: unknown) => emit('info', event, nextPayload),
154
+ 300,
155
+ { maxWait: 2000 }
156
+ )
157
+ debouncedByEvent.set(event, emitDebounced)
158
+ }
159
+ emitDebounced(payload)
160
+ }
161
+ 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),
165
+ debouncedInfo,
166
+ flush: () => debouncedByEvent.forEach(logger => logger.flush()),
167
+ }
168
+ }
169
+
170
+ const safeResourceLocation = (value: string): string => {
171
+ if (value.startsWith('data:')) return 'data:[omitted]'
172
+ if (value.startsWith('blob:')) return 'blob:[omitted]'
173
+ try {
174
+ const url = new URL(value)
175
+ return `${url.origin}${url.pathname}`
176
+ } catch {
177
+ return value.split('?')[0]
178
+ }
179
+ }
98
180
  const scenesEqual = (scenes1?: SceneDefinition[], scenes2?: SceneDefinition[]): boolean => {
99
181
  if (!scenes1 || !scenes2) {return false}
100
182
  if (scenes1.length !== scenes2.length) return false;
@@ -107,14 +189,29 @@ const scenesEqual = (scenes1?: SceneDefinition[], scenes2?: SceneDefinition[]):
107
189
  });
108
190
  };
109
191
 
110
- export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions, PresentationController> = {
192
+ export const NetlessAppPresentation: NetlessApp<PresentationAttributes, {}, PresentationAppOptions, PresentationController> = {
111
193
  kind: "Presentation",
112
194
  setup(context) {
195
+ const diagnosticLogger = createDiagnosticLogger(context)
113
196
  const view = context.getView()
114
197
  if (!view)
115
198
  throw new Error("[Presentation]: no whiteboard view, make sure you have added options.scenePath in addApp()")
116
199
 
117
- const pages = context.getScenes()?.map(({ ppt, name }) => ppt2page(ppt, name)).filter(Boolean) as PresentationPage[]
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
206
+ const originSize = isValidSize(configuredOriginSize)
207
+ ? { width: configuredOriginSize.width, height: configuredOriginSize.height }
208
+ : undefined
209
+ if (configuredOriginSize != null && !originSize) {
210
+ warn(`[Presentation] originSize should contain finite positive width and height, got ${JSON.stringify(configuredOriginSize)}`)
211
+ }
212
+ const pages = context.getScenes()
213
+ ?.map(({ ppt, name }) => ppt2page(ppt, name, originSize))
214
+ .filter(Boolean) as PresentationPage[]
118
215
  if (!pages || pages.length === 0)
119
216
  throw new Error("[Presentation]: empty scenes, make sure you have added options.scenes in addApp()")
120
217
  if (pages[0].src.startsWith('ppt'))
@@ -123,19 +220,17 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
123
220
  // Now it must have a blank scene points to "{scenePath}/{scenes[0].name}", e.g. "/pdf/123456/1"
124
221
  // https://github.com/netless-io/window-manager/blob/c87df17/src/index.ts#L465-L476
125
222
  const scenePath = context.getInitScenePath()!
126
-
127
- const options = context.getAppOptions() || {}
128
223
  let maxCameraScale = options.maxCameraScale ?? 3
129
224
  if (!(Number.isFinite(maxCameraScale) && maxCameraScale! > 0)) {
130
- console.warn(`[Presentation] maxCameraScale should be a positive number, got ${options.maxCameraScale}`)
225
+ warn(`[Presentation] maxCameraScale should be a positive number, got ${options.maxCameraScale}`)
131
226
  maxCameraScale = 3
132
227
  }
133
228
 
134
- const log = options.log || createLogger(context.getRoom())
135
229
  log(`[Presentation] new ${context.appId}`)
136
230
 
137
231
  const dispose = disposableStore()
138
232
  dispose.add(() => log(`[Presentation] dispose ${context.appId}`))
233
+ dispose.add(() => diagnosticLogger.flush())
139
234
 
140
235
  const view$$ = context.createStorage('view', { uid: "", originX: 0, originY: 0, width: 0, height: 0 })
141
236
 
@@ -178,63 +273,18 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
178
273
  pageIndex$.dispose();
179
274
  })
180
275
 
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
276
  // let lastIndex = -1
227
277
 
228
278
  const me = context.getRoom()?.uid || context.getDisplayer().observerId + ''
229
279
 
230
280
  let throttleSyncView = 0
231
281
 
232
- const syncPage = async (index: number, logger?: any) => {
282
+ const syncPage = async (index: number, logger?: any): Promise<boolean> => {
233
283
 
234
- if (!context.getIsWritable()) return
284
+ if (!context.getIsWritable()) return false
235
285
 
236
286
  const scenes = context.getDisplayer().entireScenes()[scenePath]
237
- if (!scenes) return
287
+ if (!scenes) return false
238
288
 
239
289
  const p = pages[index];
240
290
  const name = p.name ?? String(index + 1);
@@ -251,58 +301,150 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
251
301
 
252
302
  // Switch to that page.
253
303
  await context.setScenePath(`${scenePath}/${name}`)
304
+ return true
254
305
  }
255
306
 
256
- const jumpPage = (index: number): boolean => {
307
+ const prepareScenes = async (): Promise<void> => {
308
+ if (!context.isAddApp) return
309
+ // Caution: some user may insert a 500-page PDF.
310
+ if (pages.length > 100)
311
+ warn(`[Presentation]: too many pages (${pages.length}), may cause performance issues`)
312
+ if (!room || !room.isWritable) return
313
+ if (pageIndex$.value < 0 || pageIndex$.value >= pages.length) {
314
+ throw new Error(`[Presentation] Invalid page index: ${pageIndex$.value}, scenes length: ${pages.length}`)
315
+ }
316
+
317
+ const scenes = room.entireScenes()[scenePath]
318
+ if (!scenes || !scenes[pageIndex$.value]) {
319
+ throw new Error(`[Presentation]: no initial scene found at ${scenePath}, page index: ${pageIndex$.value}`)
320
+ }
321
+ const { name, ppt } = scenes[pageIndex$.value]
322
+ const nextScenes = pages.map((page, index) => ({
323
+ name: page.name ?? String(index + 1),
324
+ ppt: { width: page.width, height: page.height, src: page.src }
325
+ }))
326
+
327
+ if (!scenesEqual(scenes, nextScenes)) {
328
+ room.removeScenes(scenePath)
329
+ room.putScenes(scenePath, nextScenes)
330
+ }
331
+
332
+ const shouldRedirect = name === nextScenes[pageIndex$.value].name && !ppt
333
+ if (shouldRedirect) {
334
+ await context.addPage({ scene: { name: emptySceneName } })
335
+ log(`[Presentation] setup setScenePath ${scenePath}/${emptySceneName}`)
336
+ await context.setScenePath(`${scenePath}/${emptySceneName}`)
337
+ }
338
+
339
+ await syncPage(pageIndex$.value, (room as any).logger)
340
+ if (shouldRedirect) {
341
+ log(`[Presentation] setup removeScenes ${scenePath}/${emptySceneName}`)
342
+ room.removeScenes(`${scenePath}/${emptySceneName}`)
343
+ }
344
+ }
345
+
346
+ const prepareScenesPromise = prepareScenes()
347
+
348
+ const canJumpPage = (index: number): boolean => {
257
349
  if (!context.getIsWritable()) {
258
- console.warn('[Presentation]: no permission, make sure you have test room.isWritable')
350
+ warn('[Presentation]: no permission, make sure you have test room.isWritable')
259
351
  return false
260
352
  }
261
353
 
262
354
  if (!(0 <= index && index < pages.length)) {
263
- console.warn(`[Presentation]: page ${index + 1} out of bounds [1, ${pages.length}]`)
355
+ warn(`[Presentation]: page ${index + 1} out of bounds [1, ${pages.length}]`)
264
356
  return false
265
357
  }
266
358
 
267
359
  const scenes = context.getDisplayer().entireScenes()[scenePath]
268
360
  if (!scenes) {
269
- console.warn(`[Presentation]: no scenes found at ${scenePath}, make sure you have added options.scenePath in addApp()`)
361
+ warn(`[Presentation]: no scenes found at ${scenePath}, make sure you have added options.scenePath in addApp()`)
270
362
  return false
271
363
  }
272
364
 
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
- }
365
+ return true
366
+ }
279
367
 
280
- syncPage(index);
368
+ const jumpPage = (index: number): boolean => {
369
+ if (!canJumpPage(index)) return false
370
+
371
+ void syncPage(index).catch(error => {
372
+ warn('[Presentation]: failed to sync page', error)
373
+ diagnosticLogger.error('jumpPage.failed', error, {
374
+ index,
375
+ pageIndex: pageIndex$.value,
376
+ focusScenePath: view.focusScenePath,
377
+ })
378
+ })
281
379
  return true
282
380
  }
283
381
 
284
382
  const prevPage = () => jumpPage(pageIndex$.value - 1)
285
383
  const nextPage = () => jumpPage(pageIndex$.value + 1)
384
+ const jumpPageAsync = async (index: number): Promise<boolean> => {
385
+ if (!canJumpPage(index)) return false
386
+ try {
387
+ return await syncPage(index)
388
+ } catch (error) {
389
+ diagnosticLogger.error('jumpPageAsync.failed', error, {
390
+ index,
391
+ pageIndex: pageIndex$.value,
392
+ focusScenePath: view.focusScenePath,
393
+ })
394
+ throw error
395
+ }
396
+ }
397
+ const prevPageAsync = () => jumpPageAsync(pageIndex$.value - 1)
398
+ const nextPageAsync = () => jumpPageAsync(pageIndex$.value + 1)
286
399
  const pageState = () => ({ index: pageIndex$.value, length: pages.length })
287
400
 
288
401
  const scaleDocsToFit = () => {
289
- const { width, height } = app.page() || {}
290
- if (width && height) {
402
+ const page = app.page()
403
+ if (page && isValidSize(page)) {
404
+ const referenceSize = getCameraReferenceSize(originSize, page)
405
+ if (originSize) {
406
+ const fitScale = getFitScale(view.size, referenceSize)
407
+ if (!fitScale) return
408
+ const { minScale, maxScale } = getCameraScaleRange(
409
+ fitScale,
410
+ maxCameraScale,
411
+ options.disableCameraTransform
412
+ )
413
+ view.setCameraBound({
414
+ damping: 1,
415
+ maxContentMode: () => maxScale,
416
+ minContentMode: () => minScale,
417
+ centerX: 0, centerY: 0, width: page.width, height: page.height
418
+ })
419
+ if (isValidSharedViewport(view$$.state)) {
420
+ syncViewFromRemote(true)
421
+ return
422
+ }
423
+ }
291
424
  view.moveCameraToContain({
292
- originX: -width / 2, originY: -height / 2, width, height,
425
+ originX: -referenceSize.width / 2,
426
+ originY: -referenceSize.height / 2,
427
+ width: referenceSize.width,
428
+ height: referenceSize.height,
293
429
  animationMode: 'immediately' as AnimationMode.Immediately
294
430
  })
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
- })
431
+ if (!originSize) {
432
+ const { minScale, maxScale } = getCameraScaleRange(
433
+ view.camera.scale,
434
+ maxCameraScale,
435
+ options.disableCameraTransform
436
+ )
437
+ view.setCameraBound({
438
+ damping: 1,
439
+ maxContentMode: () => maxScale,
440
+ minContentMode: () => minScale,
441
+ centerX: 0, centerY: 0, width: page.width, height: page.height
442
+ })
443
+ }
303
444
  syncViewFromRemote(true)
304
445
  }
305
446
  }
447
+ let pendingMoveCameraRequest: MoveCameraRequest | undefined
306
448
  const syncView = () => {
307
449
  if (context.getIsWritable()) {
308
450
  if (options.debounceSync) {
@@ -310,18 +452,30 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
310
452
  throttleSyncView = 0;
311
453
  }
312
454
  if (throttleSyncView > 0) return
313
- const { width, height } = app.page() || {}
314
- if(width && height){
455
+ const page = app.page()
456
+ if (page && isValidSize(page)) {
315
457
  throttleSyncView = setTimeout(() => {
316
458
  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 })
459
+ try {
460
+ 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 })
464
+ if (pendingMoveCameraRequest) {
465
+ diagnosticLogger.debouncedInfo(
466
+ 'moveCamera',
467
+ getCameraDiagnosticState('moveCamera', pendingMoveCameraRequest)
468
+ )
469
+ pendingMoveCameraRequest = undefined
470
+ }
471
+ } catch (error) {
472
+ diagnosticLogger.error(
473
+ 'syncView.failed',
474
+ error,
475
+ getCameraDiagnosticState('moveCamera', pendingMoveCameraRequest)
476
+ )
477
+ pendingMoveCameraRequest = undefined
478
+ }
325
479
  }, 50)
326
480
  }
327
481
  }
@@ -330,6 +484,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
330
484
  dispose.add(() => {
331
485
  clearTimeout(throttleSyncView)
332
486
  throttleSyncView = 0
487
+ pendingMoveCameraRequest = undefined
333
488
  })
334
489
 
335
490
  const syncViewFromRemote = (force = false, animate = false) => {
@@ -354,12 +509,78 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
354
509
  app.contentDOM.dataset.appPresentationVersion = __VERSION__
355
510
  app.scaleDocsToFit = scaleDocsToFit
356
511
  app.log = log
512
+ app.warn = warn
513
+
514
+ const getCameraDiagnosticState = (
515
+ reason: 'initialize' | 'moveCamera',
516
+ requestedCamera?: MoveCameraRequest
517
+ ) => {
518
+ const page = app.page()
519
+ const pageSize = page && isValidSize(page)
520
+ ? { width: page.width, height: page.height }
521
+ : undefined
522
+ const referenceSize = pageSize
523
+ ? getCameraReferenceSize(originSize, pageSize)
524
+ : undefined
525
+ const viewSize = { width: view.size.width, height: view.size.height }
526
+ const viewCamera = {
527
+ centerX: view.camera.centerX,
528
+ centerY: view.camera.centerY,
529
+ 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
542
+
543
+ return {
544
+ reason,
545
+ requestedCamera,
546
+ storageOriginSize: context.storage.state.originSize,
547
+ sharedViewport,
548
+ pageSize,
549
+ referenceSize,
550
+ viewSize,
551
+ viewCamera,
552
+ originScale,
553
+ normalizedScale,
554
+ sharedScaleX,
555
+ sharedScaleY,
556
+ focusScenePath: view.focusScenePath,
557
+ 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', {
566
+ source,
567
+ ...getCameraDiagnosticState('initialize'),
568
+ })
569
+ }
570
+
571
+ if (originSize) {
572
+ let previousPageIndex = pageIndex$.value
573
+ dispose.add(pageIndex$.subscribe(nextPageIndex => {
574
+ if (nextPageIndex === previousPageIndex) return
575
+ previousPageIndex = nextPageIndex
576
+ scaleDocsToFit()
577
+ }))
578
+ }
357
579
 
358
580
  if (options.justDocsViewReadonly) {
359
581
  app.setDocsViewReadonly(true)
360
582
  }
361
583
 
362
- const room = context.getRoom();
363
584
  const goToPageByClick = () => {
364
585
  const currentApplianceName = context.getRoom()?.state?.memberState?.currentApplianceName ?? '';
365
586
  if (!app.readonly && currentApplianceName === 'clicker') {
@@ -375,13 +596,17 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
375
596
  }
376
597
 
377
598
  context.mountView(app.whiteboardDOM)
378
- if (options.disableCameraTransform) {
599
+ if (shouldDisableDeviceCameraTransform(options)) {
379
600
  view.disableCameraTransform = true
380
601
  }
381
602
  scaleDocsToFit()
382
603
  dispose.make(() => {
383
- view.callbacks.on('onSizeUpdated', scaleDocsToFit)
384
- return () => view.callbacks.off('onSizeUpdated', scaleDocsToFit)
604
+ const onSizeUpdated = () => {
605
+ scaleDocsToFit()
606
+ reportInitializedCamera('onSizeUpdated')
607
+ }
608
+ view.callbacks.on('onSizeUpdated', onSizeUpdated)
609
+ return () => view.callbacks.off('onSizeUpdated', onSizeUpdated)
385
610
  })
386
611
 
387
612
  // Init viewport if provided `viewport`.
@@ -404,24 +629,23 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
404
629
  })
405
630
 
406
631
  syncViewFromRemote(true)
632
+ reportInitializedCamera('setup')
407
633
 
408
634
  dispose.add(context.emitter.on("writableChange", (isWritable: boolean): void => {
409
635
  app.setReadonly(!isWritable)
410
636
  }))
411
637
 
412
638
  const getOriginScale = () => {
413
- const { size } = view;
414
- const { width, height } = getPageSize();
415
- return Math.min(size.height / height, size.width / width);
639
+ const page = app.page()
640
+ if (!page || !isValidSize(page)) return 0
641
+ return getFitScale(view.size, getCameraReferenceSize(originSize, page)) || 0
416
642
  }
417
643
 
418
644
  const getScale =() => {
419
645
  return view.camera.scale
420
646
  }
421
647
 
422
- const screenshotCurrentPageAsync = async (_context: CanvasRenderingContext2D, _width?: number, _height?: number) => {
423
- const uuid = room?.calibrationTimestamp?.toString() ?? Date.now().toString();
424
-
648
+ const screenshotCurrentPage = async (_context: CanvasRenderingContext2D, _width?: number, _height?: number) => {
425
649
  const currentPage = pages[pageIndex$.value];
426
650
  if (!currentPage) {
427
651
  throw new Error('[Presentation]: current page not found')
@@ -432,7 +656,13 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
432
656
  img.width = width;
433
657
  img.height = height;
434
658
  img.crossOrigin = 'Anonymous';
435
- await new Promise(resolve => { img.onload = resolve; img.src = src })
659
+ 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
+ })
436
666
  _context.drawImage(img, 0, 0, width, height, 0, 0, _width || width, _height || height);
437
667
  const currentScenePath = view.focusScenePath;
438
668
  if (!currentScenePath) {
@@ -454,6 +684,23 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
454
684
  }
455
685
  }
456
686
 
687
+ const screenshotCurrentPageAsync = async (
688
+ _context: CanvasRenderingContext2D,
689
+ _width?: number,
690
+ _height?: number
691
+ ) => {
692
+ try {
693
+ await screenshotCurrentPage(_context, _width, _height)
694
+ } catch (error) {
695
+ diagnosticLogger.error('screenshotCurrentPage.failed', error, {
696
+ pageIndex: pageIndex$.value,
697
+ outputSize: { width: _width, height: _height },
698
+ camera: getCameraDiagnosticState('initialize'),
699
+ })
700
+ throw error
701
+ }
702
+ }
703
+
457
704
  let scrollbar:Scrollbar | undefined;
458
705
  if (options.useScrollbar) {
459
706
  dispose.make(() => {
@@ -470,14 +717,29 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
470
717
  }
471
718
 
472
719
  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')
720
+ try {
721
+ if (!context.getIsWritable()) {
722
+ throw new Error('[Presentation]: moveCamera must be called in writable room')
476
723
  }
477
- scrollbar.moveCamera(camera);
478
- return;
724
+ pendingMoveCameraRequest = { ...camera }
725
+ if (scrollbar) {
726
+ scrollbar.moveCamera(camera);
727
+ return;
728
+ }
729
+ view.moveCamera({
730
+ ...camera,
731
+ animationMode: 'immediately' as AnimationMode.Immediately
732
+ })
733
+ syncView()
734
+ } catch (error) {
735
+ pendingMoveCameraRequest = undefined
736
+ diagnosticLogger.error(
737
+ 'moveCamera.failed',
738
+ error,
739
+ getCameraDiagnosticState('moveCamera', camera)
740
+ )
741
+ throw error
479
742
  }
480
- throw new Error('[Presentation]: moveCamera must be called in writable room')
481
743
  }
482
744
 
483
745
  context.emitter.on('destroy', () => dispose())
@@ -495,7 +757,11 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
495
757
  } catch {}
496
758
 
497
759
  const data = await fetch(url)
498
- if (!data.ok) throw new Error(`[Presentation]: failed to fetch ${url} - ${await data.text()}`)
760
+ if (!data.ok) {
761
+ throw new Error(
762
+ `[Presentation]: failed to fetch ${safeResourceLocation(url)}, status: ${data.status} ${data.statusText}`
763
+ )
764
+ }
499
765
 
500
766
  const blob = await data.blob()
501
767
  const reader = new FileReader()
@@ -506,7 +772,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
506
772
  })
507
773
  }
508
774
 
509
- const toPdf = async (): Promise<{ pdf: ArrayBuffer, title: string } | null> => {
775
+ const toPdfInternal = async (): Promise<{ pdf: ArrayBuffer, title: string } | null> => {
510
776
  const MAX = 1920
511
777
  const firstPage = pages[0]
512
778
  const { width, height } = firstPage
@@ -520,6 +786,9 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
520
786
  pdfWidth = Math.floor(width * pdfHeight / height)
521
787
  }
522
788
  const scenes = context.getDisplayer().entireScenes()[scenePath]
789
+ if (!scenes) {
790
+ throw new Error(`[Presentation]: no scenes found while exporting PDF: ${scenePath}`)
791
+ }
523
792
 
524
793
  const stage_canvas = document.createElement('canvas')
525
794
  stage_canvas.width = pdfWidth
@@ -545,8 +814,14 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
545
814
 
546
815
  const url = await base64url(src)
547
816
  const img = document.createElement('img')
548
- await new Promise(resolve => { img.onload = resolve; img.src = url })
549
- stage.drawImage(img, 0, 0)
817
+ 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)
550
825
 
551
826
  wb.clearRect(0, 0, pdfWidth, pdfHeight)
552
827
  const name = p.name ?? String(index + 1)
@@ -571,7 +846,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
571
846
  await new Promise(resolve => { wb_img.onload = resolve; wb_img.src = wb_url })
572
847
  stage.drawImage(wb_img, 0, 0, pdfWidth, pdfHeight)
573
848
  } catch (err) {
574
- console.warn(err)
849
+ warn(err)
575
850
  }
576
851
  }
577
852
 
@@ -589,9 +864,22 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
589
864
  return reportProgress(100, { pdf: data, title })
590
865
  }
591
866
 
867
+ const toPdf = async (): Promise<{ pdf: ArrayBuffer, title: string } | null> => {
868
+ try {
869
+ return await toPdfInternal()
870
+ } catch (error) {
871
+ diagnosticLogger.error('toPdf.failed', error, {
872
+ pageIndex: pageIndex$.value,
873
+ pageCount: pages.length,
874
+ focusScenePath: view.focusScenePath,
875
+ })
876
+ throw error
877
+ }
878
+ }
879
+
592
880
  dispose.add(listen(window, 'message', (ev: MessageEvent<{ appId: string, type: "@netless/_request_save_pdf_" }>) => {
593
881
  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) })
882
+ toPdf().catch(err => { warn(err); reportProgress(100, null) })
595
883
  }
596
884
  }))
597
885
 
@@ -609,7 +897,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
609
897
  }
610
898
  }
611
899
 
612
- const controller: PresentationController = { app, view, context, jumpPage, prevPage, nextPage, pageState, toPdf, log, setDocsViewReadonly, setReadonly, moveCamera, getOriginScale, getScale, getPageSize, screenshotCurrentPageAsync }
900
+ const controller: PresentationController = { app, view, context, jumpPage, prevPage, nextPage, jumpPageAsync, prevPageAsync, nextPageAsync, pageState, toPdf, log, setDocsViewReadonly, setReadonly, moveCamera, getOriginScale, getScale, getPageSize, screenshotCurrentPageAsync }
613
901
 
614
902
  dispose.add(listen(window, 'message', (ev: MessageEvent<"@netless/_presentation_">) => {
615
903
  if (ev.data === "@netless/_presentation_") {
@@ -622,7 +910,10 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
622
910
  }
623
911
  }))
624
912
 
625
- return controller
913
+ // Older WindowManager declarations model setup as synchronous even though
914
+ // AppProxy awaits its result. Keep that source compatibility until the new
915
+ // `SetupResult | Promise<SetupResult>` declaration is the minimum version.
916
+ return prepareScenesPromise.then(() => controller) as unknown as PresentationController
626
917
  }
627
918
  }
628
919
 
@@ -631,6 +922,7 @@ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions,
631
922
  */
632
923
  class AppPresentation extends Presentation {
633
924
  log?: Logger;
925
+ warn?: Logger;
634
926
  box?: ReadonlyTeleBox;
635
927
  scaleDocsToFit?: () => void;
636
928
  readonly jumpPage: (index: number) => void
@@ -656,7 +948,7 @@ class AppPresentation extends Presentation {
656
948
  if (0 <= index && index < this.pages.length) {
657
949
  this.jumpPage(index)
658
950
  } else {
659
- console.warn(`[Presentation]: page index ${index} out of bounds [0, ${this.pages.length - 1}]`)
951
+ this.warn?.(`[Presentation]: page index ${index} out of bounds [0, ${this.pages.length - 1}]`)
660
952
  }
661
953
  }
662
954
  }