@netless/app-presentation 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netless/app-presentation",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Netless App for presentation slides (images)",
5
5
  "author": "netless",
6
6
  "license": "MIT",
@@ -27,17 +27,17 @@
27
27
  "@hyrious/configs": "^0.1.1",
28
28
  "@hyrious/dts": "^0.2.1",
29
29
  "@hyrious/esbuild-dev": "^0.10.5",
30
- "@netless/fastboard": "^0.3.10",
31
- "@netless/window-manager": "^0.4.67",
32
- "@types/node": "^20.11.10",
33
- "@wopjs/disposable": "^0.1.4",
30
+ "@netless/fastboard": "^0.3.14",
31
+ "@netless/window-manager": "^0.4.70",
32
+ "@types/node": "^20.11.30",
33
+ "@wopjs/disposable": "^0.1.5",
34
34
  "@wopjs/dom": "^0.1.3",
35
- "esbuild": "^0.19.12",
35
+ "esbuild": "^0.20.2",
36
36
  "jspdf": "^2.5.1",
37
- "rollup": "^4.9.6",
38
- "sass": "^1.70.0",
39
- "vanilla-lazyload": "^17.8.5",
40
- "vite": "^5.0.12"
37
+ "rollup": "^4.13.1",
38
+ "sass": "^1.72.0",
39
+ "vanilla-lazyload": "^18.0.0",
40
+ "vite": "^5.2.6"
41
41
  },
42
42
  "scripts": {
43
43
  "dev": "vite",
@@ -8,13 +8,32 @@ import { Presentation, type PresentationConfig, type PresentationPage } from "./
8
8
 
9
9
  export type Logger = (...data: any[]) => void
10
10
 
11
+ interface Viewport {
12
+ readonly x: number;
13
+ readonly y: number;
14
+ readonly width: number;
15
+ readonly height: number;
16
+ }
17
+
11
18
  export interface PresentationAppOptions {
12
19
  /** Disables user move / scale the image and whiteboard. */
13
20
  disableCameraTransform?: boolean;
14
21
  /** Max scale = `maxCameraScale` * default scale. Not working when `disableCameraTransform` is true. Default: 3 */
15
22
  maxCameraScale?: number;
16
- /** Custom logger. Default: `console.log` */
23
+ /** Custom logger. Default: a logger that reports to the whiteboard server. */
17
24
  log?: Logger;
25
+ /** Custom thumbnail generator. Default is appending `"?x-oss-process=image/resize,l_50"` to `src`. */
26
+ thumbnail?: (src: string) => string;
27
+ /**
28
+ * Custom viewport to set on the first time the presentation was added. Default is full page.
29
+ * Numbers range in 0 to 1 is considered a ratio to multiply the real page size.
30
+ * Example settings:
31
+ *
32
+ * - Full page: `{ x: 0, y: 0, width: 1, height: 1 }`
33
+ * - Half page: `{ x: 0, y: 0, width: 1, height: 0.5 }`
34
+ * - Absolute top-left area of the page: `{ x: 0, y: 0, width: 100, height: 100 }`
35
+ */
36
+ viewport?: Viewport | ((page: PresentationPage) => Viewport);
18
37
  }
19
38
 
20
39
  export interface PresentationController {
@@ -48,7 +67,7 @@ const createLogger = (room: Room | undefined): Logger => {
48
67
  }
49
68
  }
50
69
 
51
- export const NetlessAppPresentation: NetlessApp<{}, unknown, PresentationAppOptions, PresentationController> = {
70
+ export const NetlessAppPresentation: NetlessApp<{}, {}, PresentationAppOptions, PresentationController> = {
52
71
  kind: "Presentation",
53
72
  setup(context) {
54
73
  const view = context.getView()
@@ -66,8 +85,10 @@ export const NetlessAppPresentation: NetlessApp<{}, unknown, PresentationAppOpti
66
85
  const scenePath = context.getInitScenePath()!
67
86
 
68
87
  const options = context.getAppOptions() || {}
69
- if (!(Number.isFinite(options.maxCameraScale) && options.maxCameraScale! > 0)) {
88
+ let maxCameraScale = options.maxCameraScale ?? 3
89
+ if (!(Number.isFinite(maxCameraScale) && maxCameraScale! > 0)) {
70
90
  console.warn(`[Presentation] maxCameraScale should be a positive number, got ${options.maxCameraScale}`)
91
+ maxCameraScale = 3
71
92
  }
72
93
 
73
94
  const log = options.log || createLogger(context.getRoom())
@@ -96,6 +117,7 @@ export const NetlessAppPresentation: NetlessApp<{}, unknown, PresentationAppOpti
96
117
  dispose.add(() => log(`[Presentation] dispose ${context.appId}`))
97
118
 
98
119
  const page$$ = context.createStorage('page', { index: 0 })
120
+ const view$$ = context.createStorage('view', { uid: "", originX: 0, originY: 0, width: 0, height: 0 })
99
121
 
100
122
  let lastIndex = -1
101
123
  const syncPage = async (index: number) => {
@@ -159,7 +181,7 @@ export const NetlessAppPresentation: NetlessApp<{}, unknown, PresentationAppOpti
159
181
  originX: -width / 2, originY: -height / 2, width, height,
160
182
  animationMode: 'immediately' as AnimationMode.Immediately
161
183
  })
162
- const maxScale = view.camera.scale * (options.disableCameraTransform ? 1 : (options.maxCameraScale || 3))
184
+ const maxScale = view.camera.scale * (options.disableCameraTransform ? 1 : maxCameraScale)
163
185
  const minScale = view.camera.scale
164
186
  view.setCameraBound({
165
187
  damping: 1,
@@ -167,32 +189,51 @@ export const NetlessAppPresentation: NetlessApp<{}, unknown, PresentationAppOpti
167
189
  minContentMode: () => minScale,
168
190
  centerX: 0, centerY: 0, width, height
169
191
  })
192
+ syncViewFromRemote(true)
170
193
  }
171
194
  }
172
195
 
173
- syncPage(page$$.state.index)
174
- dispose.add(page$$.addStateChangedListener(() => syncPage(page$$.state.index)))
196
+ const me = context.getRoom()?.uid || context.getDisplayer().observerId + ''
175
197
 
176
- // Workaround for window-manager also listens to room state and change scene.
177
- // https://github.com/netless-io/window-manager/blob/5e6b79f/src/AppManager.ts#L666
178
- //
179
- // When we close some app with a whiteboard view, the related scenes will be deleted.
180
- // Then whiteboard's room.state.sceneState will automatically navigate to an existing scene.
181
- // Then it goes to "/pdf/123456", which is a folder, so it will open the first scene "/pdf/123456/0".
182
- //
183
- // But we actually does not use that scene, our scenes' names are ["1", "2", ...].
184
- // So if we found this situation, force sync page once.
185
- dispose.add(context.emitter.on('roomStateChange', (state) => {
186
- if (state.sceneState?.scenePath.endsWith('/0')) {
187
- // About timing things:
188
- // window-manager's listener runs in sync, but context.emitter is based on Emittery
189
- // which runs callbacks in the next micro task, so this code will run after the "/0" task.
190
- syncPage(page$$.state.index)
198
+ let throttleSyncView = 0
199
+ const syncView = () => {
200
+ if (throttleSyncView > 0) return
201
+ const { width, height } = app.page() || {}
202
+ if (width && height && context.getIsWritable()) {
203
+ const { camera, size } = view
204
+ const fixedW = Math.min(size.width, size.height * width / height)
205
+ const fixedH = Math.min(size.height, size.width * height / width)
206
+ const w = fixedW / camera.scale
207
+ const h = fixedH / camera.scale
208
+ const x = camera.centerX - w / 2
209
+ const y = camera.centerY - h / 2
210
+ throttleSyncView = setTimeout(() => {
211
+ throttleSyncView = 0
212
+ view$$.setState({ uid: me, originX: x, originY: y, width: w, height: h })
213
+ }, 50)
191
214
  }
192
- }))
215
+ }
216
+ dispose.add(() => {
217
+ clearTimeout(throttleSyncView)
218
+ throttleSyncView = 0
219
+ })
220
+
221
+ const syncViewFromRemote = (force = false, animate = false) => {
222
+ const { uid, originX, originY, width, height } = view$$.state
223
+ if ((force || uid !== me) && width > 0 && height > 0) {
224
+ view.moveCameraToContain({
225
+ originX, originY, width, height,
226
+ animationMode: (animate ? 'continuous' : 'immediately') as AnimationMode
227
+ })
228
+ }
229
+ }
230
+
231
+ syncPage(page$$.state.index)
232
+ dispose.add(page$$.addStateChangedListener(() => syncPage(page$$.state.index)))
233
+ dispose.add(view$$.addStateChangedListener(() => syncViewFromRemote(false, true)))
193
234
 
194
235
  const box = context.getBox()
195
- const app = dispose.add(createPresentation(box, pages, jumpPage, page$$))
236
+ const app = dispose.add(createPresentation(box, pages, jumpPage, page$$, options.thumbnail))
196
237
  app.contentDOM.dataset.appPresentationVersion = __VERSION__
197
238
  app.scaleDocsToFit = scaleDocsToFit
198
239
  app.log = log
@@ -207,6 +248,26 @@ export const NetlessAppPresentation: NetlessApp<{}, unknown, PresentationAppOpti
207
248
  return () => view.callbacks.off('onSizeUpdated', scaleDocsToFit)
208
249
  })
209
250
 
251
+ // Init viewport if provided `viewport`.
252
+ if (options.viewport && context.isAddApp && app.page()) {
253
+ const page = app.page()!
254
+ const viewport = typeof options.viewport === 'function' ? options.viewport(page) : options.viewport
255
+ const fix = (i: number, x: number) => i == 0 ? i : i <= 1 ? i * x : i
256
+ view$$.setState({
257
+ uid: me,
258
+ originX: fix(viewport.x, page.width) - page.width / 2,
259
+ originY: fix(viewport.y, page.height) - page.height / 2,
260
+ width: fix(viewport.width, page.width),
261
+ height: fix(viewport.height, page.height),
262
+ })
263
+ }
264
+
265
+ dispose.make(() => {
266
+ view.callbacks.on('onCameraUpdatedByDevice', syncView)
267
+ return () => view.callbacks.off('onCameraUpdatedByDevice', syncView)
268
+ })
269
+ syncViewFromRemote(true)
270
+
210
271
  dispose.add(context.emitter.on("writableChange", (isWritable: boolean): void => {
211
272
  app.setReadonly(!isWritable)
212
273
  }))
@@ -366,15 +427,16 @@ class AppPresentation extends Presentation {
366
427
  }
367
428
  }
368
429
 
369
- const createPresentation = (
430
+ function createPresentation(
370
431
  box: ReadonlyTeleBox,
371
432
  pages: PresentationPage[],
372
433
  jumpPage: (index: number) => void,
373
434
  page$$: Storage<{ index: number }>,
374
- ) => {
435
+ thumbnail?: (src: string) => string,
436
+ ): AppPresentation {
375
437
  box.mountStyles(styles)
376
438
 
377
- const app = new AppPresentation({ pages, readonly: box.readonly, jumpPage })
439
+ const app = new AppPresentation({ pages, readonly: box.readonly, jumpPage, thumbnail })
378
440
  app.box = box
379
441
  box.mountContent(app.contentDOM)
380
442
  box.mountFooter(app.footerDOM)
@@ -397,6 +459,10 @@ export interface InstallOptions {
397
459
  * @example "DocsViewer"
398
460
  */
399
461
  as?: string
462
+ /**
463
+ * Options to customize the local app (not synced to others).
464
+ */
465
+ appOptions?: PresentationAppOptions
400
466
  }
401
467
 
402
468
  /**
@@ -410,5 +476,5 @@ export const install = (register: RegisterFn, options: InstallOptions = {}): Pro
410
476
  if (options.as) {
411
477
  app = Object.assign({}, app, { kind: options.as })
412
478
  }
413
- return register({ kind: app.kind, src: app })
479
+ return register({ kind: app.kind, src: app, appOptions: options.appOptions })
414
480
  }
@@ -16,6 +16,7 @@ export interface PresentationPage {
16
16
  export interface PresentationConfig {
17
17
  readonly pages: PresentationPage[]
18
18
  readonly readonly?: boolean
19
+ readonly thumbnail?: (src: string) => string
19
20
  }
20
21
 
21
22
  /**
@@ -76,6 +77,7 @@ export class Presentation implements IDisposable<void> {
76
77
  constructor(config: PresentationConfig) {
77
78
  this.pages = config.pages
78
79
  this.preload = new Preload(this.pages)
80
+ if (config.thumbnail) this.thumbnail = config.thumbnail
79
81
  this.dispose.add(this.preload)
80
82
  this.readonly = config.readonly ?? false
81
83
  this.dom = document.createElement('div')
@@ -120,7 +122,7 @@ export class Presentation implements IDisposable<void> {
120
122
  const c_previewPageName = this.c('preview-page-name')
121
123
  for (let index = 0; index < this.pages.length; ++index) {
122
124
  const page = this.pages[index]
123
- const previewSRC = page.thumbnail || this.x_oss_process(page.src)
125
+ const previewSRC = page.thumbnail || this.thumbnail(page.src)
124
126
 
125
127
  const previewPage = document.createElement('a')
126
128
  previewPage.className = `${c_previewPage} ${this.c(`preview-page-${index}`)}`
@@ -294,17 +296,8 @@ export class Presentation implements IDisposable<void> {
294
296
  this.preload.touch(this.pageIndex)
295
297
  }
296
298
 
297
- private c(className: string): string {
298
- return `${this.namespace}-${className}`
299
- }
300
-
301
- private isEditable(el: EventTarget | null): boolean {
302
- if (!el) return false
303
- const { tagName } = el as HTMLElement
304
- return tagName == 'INPUT' || tagName == 'TEXTAREA' || tagName == 'SELECT'
305
- }
306
-
307
- private x_oss_process(src: string): string {
299
+ /** Returns a modified URL that points to the thumbnail image. */
300
+ thumbnail(src: string): string {
308
301
  try {
309
302
  const url = new URL(src)
310
303
  url.searchParams.set('x-oss-process', 'image/resize,l_50')
@@ -314,4 +307,14 @@ export class Presentation implements IDisposable<void> {
314
307
  return src
315
308
  }
316
309
  }
310
+
311
+ private c(className: string): string {
312
+ return `${this.namespace}-${className}`
313
+ }
314
+
315
+ private isEditable(el: EventTarget | null): boolean {
316
+ if (!el) return false
317
+ const { tagName } = el as HTMLElement
318
+ return tagName == 'INPUT' || tagName == 'TEXTAREA' || tagName == 'SELECT'
319
+ }
317
320
  }