@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.
- package/README-zh.md +42 -1
- package/README.md +47 -1
- package/dist/index.d.ts +17 -5
- package/dist/index.global.js +415 -119
- package/dist/index.js +415 -119
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +415 -119
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/app-presentation.ts +409 -117
- package/src/camera-options.ts +19 -0
- package/src/camera-reference.ts +81 -0
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,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
|
-
/**
|
|
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
|
-
/**
|
|
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 = (
|
|
89
|
-
ppt
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
279
367
|
|
|
280
|
-
|
|
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
|
|
290
|
-
if (
|
|
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,
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
314
|
-
if(
|
|
455
|
+
const page = app.page()
|
|
456
|
+
if (page && isValidSize(page)) {
|
|
315
457
|
throttleSyncView = setTimeout(() => {
|
|
316
458
|
throttleSyncView = 0
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
599
|
+
if (shouldDisableDeviceCameraTransform(options)) {
|
|
379
600
|
view.disableCameraTransform = true
|
|
380
601
|
}
|
|
381
602
|
scaleDocsToFit()
|
|
382
603
|
dispose.make(() => {
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
return
|
|
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
|
|
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 => {
|
|
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
|
-
|
|
474
|
-
if (!
|
|
475
|
-
throw new Error('[Presentation]: moveCamera must be called
|
|
720
|
+
try {
|
|
721
|
+
if (!context.getIsWritable()) {
|
|
722
|
+
throw new Error('[Presentation]: moveCamera must be called in writable room')
|
|
476
723
|
}
|
|
477
|
-
|
|
478
|
-
|
|
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)
|
|
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
|
|
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 => {
|
|
549
|
-
|
|
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
|
-
|
|
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 => {
|
|
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
|
-
|
|
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
|
-
|
|
951
|
+
this.warn?.(`[Presentation]: page index ${index} out of bounds [0, ${this.pages.length - 1}]`)
|
|
660
952
|
}
|
|
661
953
|
}
|
|
662
954
|
}
|