@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.
- package/README-zh.md +42 -1
- package/README.md +47 -1
- package/dist/index.d.ts +21 -7
- package/dist/index.global.js +718 -5606
- package/dist/index.js +718 -5606
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +718 -5606
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -5
- package/src/app-presentation.ts +424 -118
- package/src/camera-options.ts +19 -0
- package/src/camera-reference.ts +81 -0
- package/src/scrollbar/index.ts +3 -2
package/src/app-presentation.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 = (
|
|
89
|
-
ppt
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
if (
|
|
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,
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
314
|
-
if(
|
|
469
|
+
const page = app.page()
|
|
470
|
+
if (page && isValidSize(page)) {
|
|
315
471
|
throttleSyncView = setTimeout(() => {
|
|
316
472
|
throttleSyncView = 0
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
613
|
+
if (shouldDisableDeviceCameraTransform(options)) {
|
|
379
614
|
view.disableCameraTransform = true
|
|
380
615
|
}
|
|
381
616
|
scaleDocsToFit()
|
|
382
617
|
dispose.make(() => {
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
return
|
|
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
|
|
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 => {
|
|
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
|
-
|
|
474
|
-
if (!
|
|
475
|
-
throw new Error('[Presentation]: moveCamera must be called
|
|
734
|
+
try {
|
|
735
|
+
if (!context.getIsWritable()) {
|
|
736
|
+
throw new Error('[Presentation]: moveCamera must be called in writable room')
|
|
476
737
|
}
|
|
477
|
-
|
|
478
|
-
|
|
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)
|
|
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
|
|
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 => {
|
|
549
|
-
|
|
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
|
-
|
|
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 => {
|
|
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
|
-
|
|
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
|
-
|
|
965
|
+
this.warn?.(`[Presentation]: page index ${index} out of bounds [0, ${this.pages.length - 1}]`)
|
|
660
966
|
}
|
|
661
967
|
}
|
|
662
968
|
}
|