@netless/fastboard-core 0.3.6 → 1.0.0-canary.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.
@@ -0,0 +1,631 @@
1
+ import type { Disposer, Readable, StartStopNotifier, Writable } from "./store";
2
+ import type {
3
+ AddPageParams,
4
+ AnimationMode,
5
+ ApplianceNames,
6
+ Camera,
7
+ CameraState,
8
+ Color,
9
+ ConversionResponse,
10
+ ConvertedFile,
11
+ HotKeys,
12
+ JoinRoomParams,
13
+ MemberState,
14
+ MountParams,
15
+ NetlessApp,
16
+ PublicEvent,
17
+ Room,
18
+ RoomCallbacks,
19
+ RoomPhase,
20
+ SceneDefinition,
21
+ ShapeType,
22
+ SyncedStore,
23
+ WhiteWebSdkConfiguration,
24
+ } from "./typings";
25
+
26
+ import { v4 } from "@lukeed/uuid";
27
+ import { SyncedStorePlugin } from "@netless/synced-store";
28
+ import { BuiltinApps, WindowManager } from "@netless/window-manager";
29
+ import { DefaultHotKeys, WhiteWebSdk } from "white-web-sdk";
30
+ import { addManagerListener, addRoomListener, ensureOfficialPlugins } from "./helpers";
31
+ import { createVal } from "./store";
32
+
33
+ // The inheritance is for simplifying the code, so that we can use
34
+ // `prop = f(this.room)` directly in its child class.
35
+ class FastboardAppStruct<TEventData> {
36
+ // Intensionally not class-field to prevent `__publicField` usage.
37
+ protected declare _disposers: Disposer[];
38
+ protected declare _destroyed: boolean;
39
+ protected _assertNotDestroyed() {
40
+ if (this._destroyed) {
41
+ throw new Error("FastboardApp has been destroyed");
42
+ }
43
+ }
44
+ protected _flushAllDisposers() {
45
+ this._disposers.forEach(disposer => disposer());
46
+ this._disposers = [];
47
+ }
48
+
49
+ constructor(
50
+ readonly sdk: WhiteWebSdk,
51
+ readonly room: Room,
52
+ readonly manager: WindowManager,
53
+ readonly syncedStore: SyncedStore<TEventData>,
54
+ readonly hotKeys: Partial<HotKeys>
55
+ ) {
56
+ this._disposers = [];
57
+ this._destroyed = false;
58
+ }
59
+
60
+ /** @internal */
61
+ protected _val<T>(init: T, start: StartStopNotifier<T>): Readable<T>;
62
+ /** @internal */
63
+ protected _val<T>(init: T, start: StartStopNotifier<T>, setter: (value: T) => void): Writable<T>;
64
+ protected _val<T>(init: T, start: StartStopNotifier<T>, setter?: (value: T) => void) {
65
+ const val = createVal(init, start, setter as (value: T) => void);
66
+ this._disposers.push(val.dispose.bind(val));
67
+ return val;
68
+ }
69
+ }
70
+
71
+ export class FastboardApp<TEventData = any> extends FastboardAppStruct<TEventData> {
72
+ constructor(
73
+ sdk: WhiteWebSdk,
74
+ room: Room,
75
+ manager: WindowManager,
76
+ syncedStore: SyncedStore<TEventData>,
77
+ hotKeys: Partial<HotKeys>
78
+ ) {
79
+ super(sdk, room, manager, syncedStore, hotKeys);
80
+
81
+ // Guard `app.destroy()` so that network errors won't break fastboard.
82
+ this._disposers.push(
83
+ addRoomListener(this.room, "onDisconnectWithError", error => {
84
+ console.warn("FastboardApp was disconnected with error.");
85
+ console.error(error);
86
+ this._destroyed = true;
87
+ this._flushAllDisposers();
88
+ this.manager.destroy();
89
+ })
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Disconnect from whiteboard room.
95
+ */
96
+ async destroy() {
97
+ if (this._destroyed) return;
98
+ this._destroyed = true;
99
+ this._flushAllDisposers();
100
+ this.manager.destroy();
101
+ await this.room.disconnect();
102
+ }
103
+
104
+ /**
105
+ * Render this app to some DOM.
106
+ */
107
+ bindContainer(container: HTMLElement) {
108
+ this._assertNotDestroyed();
109
+ this.manager.bindContainer(container);
110
+ }
111
+
112
+ /**
113
+ * Move window manager's collector to some place.
114
+ */
115
+ bindCollector(container: HTMLElement) {
116
+ this._assertNotDestroyed();
117
+ this.manager.bindCollectorContainer(container);
118
+ }
119
+
120
+ /**
121
+ * Is current room writable?
122
+ */
123
+ readonly writable = this._val(
124
+ this.room.isWritable,
125
+ set => addRoomListener(this.room, "onEnableWriteNowChanged", () => set(this.room.isWritable)),
126
+ writable => this.room.setWritable(writable)
127
+ );
128
+
129
+ /**
130
+ * Is current room online?
131
+ */
132
+ readonly phase = this._val<RoomPhase>(this.room.phase, set =>
133
+ addRoomListener(this.room, "onPhaseChanged", set)
134
+ );
135
+
136
+ /**
137
+ * Current user's state, including 'strokeColor', etc.
138
+ *
139
+ * To change the tool, use `app.setAppliance('pencil')`.
140
+ */
141
+ readonly memberState = this._val<MemberState>(this.room.state.memberState, set =>
142
+ addRoomListener(this.room, "onRoomStateChanged", state => {
143
+ if (state.memberState) set(state.memberState);
144
+ })
145
+ );
146
+
147
+ /**
148
+ * Window manager's windows' state (is it maximized).
149
+ */
150
+ readonly boxState = this._val(this.manager.boxState, set =>
151
+ addManagerListener(this.manager, "boxStateChange", set)
152
+ );
153
+
154
+ /**
155
+ * Window manager's focused (at the top) app's id, like `HelloWorld-1A2b3C4d`.
156
+ */
157
+ readonly focusedApp = this._val(this.manager.focused, set =>
158
+ addManagerListener(this.manager, "focusedChange", set)
159
+ );
160
+
161
+ /**
162
+ * How many times can I call `app.redo()`?
163
+ */
164
+ readonly canRedoSteps = this._val(this.manager.canRedoSteps, set =>
165
+ addManagerListener(this.manager, "canRedoStepsChange", set)
166
+ );
167
+
168
+ /**
169
+ * How many times can I call `app.undo()`?
170
+ */
171
+ readonly canUndoSteps = this._val(this.manager.canUndoSteps, set =>
172
+ addManagerListener(this.manager, "canUndoStepsChange", set)
173
+ );
174
+
175
+ /**
176
+ * The synced camera state across all users.
177
+ */
178
+ readonly baseCamera = this._val(this.manager.baseCamera, set =>
179
+ addManagerListener(this.manager, "baseCameraChange", set)
180
+ );
181
+
182
+ /**
183
+ * The local camera state of main view in window manager.
184
+ *
185
+ * Change the camera position by `app.moveCamera()`.
186
+ */
187
+ readonly camera = this._val<CameraState>(this.manager.cameraState, set =>
188
+ addManagerListener(this.manager, "cameraStateChange", set)
189
+ );
190
+
191
+ /**
192
+ * 0..n-1, current index of pages.
193
+ */
194
+ readonly pageIndex = this._val(
195
+ this.manager.mainViewSceneIndex,
196
+ set => addManagerListener(this.manager, "mainViewSceneIndexChange", set),
197
+ index => this.manager.setMainViewSceneIndex(index)
198
+ );
199
+
200
+ /**
201
+ * How many pages are there in the main view?
202
+ */
203
+ readonly pageLength = this._val(this.manager.mainViewScenesLength, set =>
204
+ addManagerListener(this.manager, "mainViewScenesLengthChange", set)
205
+ );
206
+
207
+ /** @internal */
208
+ private _appsStatus: AppsStatus = {};
209
+
210
+ /**
211
+ * Apps loading status.
212
+ */
213
+ readonly appsStatus = this._val<AppsStatus>({}, set =>
214
+ addManagerListener(this.manager, "loadApp", ({ kind, status, reason }) => {
215
+ this._appsStatus[kind] = { status: load_app_event_to_status(status), reason };
216
+ set(this._appsStatus);
217
+ })
218
+ );
219
+
220
+ /**
221
+ * Undo a step on main view.
222
+ */
223
+ undo() {
224
+ this._assertNotDestroyed();
225
+ this.manager.undo();
226
+ }
227
+
228
+ /**
229
+ * Redo a step on main view.
230
+ */
231
+ redo() {
232
+ this._assertNotDestroyed();
233
+ this.manager.redo();
234
+ }
235
+
236
+ /**
237
+ * Move main view's camera.
238
+ */
239
+ moveCamera(camera: Partial<Camera> & { animationMode?: AnimationMode }) {
240
+ this._assertNotDestroyed();
241
+ this.manager.moveCamera(camera);
242
+ }
243
+
244
+ /**
245
+ * Delete all things on the main view.
246
+ */
247
+ cleanCurrentScene() {
248
+ this._assertNotDestroyed();
249
+ this.manager.cleanCurrentScene();
250
+ }
251
+
252
+ /**
253
+ * Set current tool, like `pencil`.
254
+ */
255
+ setAppliance(appliance: ApplianceNames | `${ApplianceNames}`, shape?: ShapeType | `${ShapeType}`) {
256
+ this._assertNotDestroyed();
257
+ this.manager.mainView.setMemberState({
258
+ currentApplianceName: appliance as ApplianceNames,
259
+ shapeType: shape as ShapeType,
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Set pencil and shape's thickness.
265
+ */
266
+ setStrokeWidth(width: number) {
267
+ this._assertNotDestroyed();
268
+ this.manager.mainView.setMemberState({ strokeWidth: width });
269
+ }
270
+
271
+ /**
272
+ * Set pencil and shape's color.
273
+ */
274
+ setStrokeColor(color: Color) {
275
+ this._assertNotDestroyed();
276
+ this.manager.mainView.setMemberState({ strokeColor: color });
277
+ }
278
+
279
+ /**
280
+ * Set text size. Default is 16.
281
+ */
282
+ setTextSize(size: number) {
283
+ this._assertNotDestroyed();
284
+ this.manager.mainView.setMemberState({ textSize: size });
285
+ }
286
+
287
+ /**
288
+ * Set text color.
289
+ */
290
+ setTextColor(color: Color) {
291
+ this._assertNotDestroyed();
292
+ this.manager.mainView.setMemberState({ textColor: color });
293
+ }
294
+
295
+ /**
296
+ * Toggle dotted line effect on pencil.
297
+ */
298
+ toggleDottedLine(force?: boolean) {
299
+ this._assertNotDestroyed();
300
+ this.manager.mainView.setMemberState({ dottedLine: force ?? !this.memberState.value.dottedLine });
301
+ }
302
+
303
+ /**
304
+ * Goto previous page, returns true if success.
305
+ */
306
+ prevPage() {
307
+ this._assertNotDestroyed();
308
+ return this.manager.prevPage();
309
+ }
310
+
311
+ /**
312
+ * Goto next page, returns true if success.
313
+ */
314
+ nextPage() {
315
+ this._assertNotDestroyed();
316
+ return this.manager.nextPage();
317
+ }
318
+
319
+ /**
320
+ * Add one page to the main whiteboard view.
321
+ * ```js
322
+ * app.addPage({ after: true }) // add one page right after current page
323
+ * app.nextPage() // then, goto that page
324
+ * ```
325
+ */
326
+ addPage(params?: AddPageParams) {
327
+ this._assertNotDestroyed();
328
+ return this.manager.addPage(params);
329
+ }
330
+
331
+ /**
332
+ * Remove one page at given index or current page (by default).
333
+ */
334
+ removePage(index?: number) {
335
+ this._assertNotDestroyed();
336
+ return this.manager.removePage(index);
337
+ }
338
+
339
+ /**
340
+ * Insert an image to the main view.
341
+ */
342
+ async insertImage(url: string, options: InsertImageOptions = {}) {
343
+ this._assertNotDestroyed();
344
+ let { width, height } = options;
345
+ // If user does not provide size, we get the real size through <img> element.
346
+ if (!width || !height) {
347
+ const size = await new Promise<{ width: number; height: number }>(resolve => {
348
+ const image = new Image();
349
+ image.onload = () => resolve(image);
350
+ image.onerror = () => resolve({ width: 0, height: 0 });
351
+ image.src = url;
352
+ });
353
+ width = size.width;
354
+ height = size.height;
355
+ }
356
+ const { divElement } = this.manager.mainView;
357
+ const containerSize = {
358
+ width: divElement?.scrollWidth || innerWidth || 100,
359
+ height: divElement?.scrollHeight || innerHeight || 100,
360
+ };
361
+ // If fetch image failed, maybe there's CORS, fallback to use container size.
362
+ if (!width || !height) {
363
+ width = containerSize.width;
364
+ height = containerSize.height;
365
+ }
366
+ // Get the position, default to the center of whiteboard.
367
+ const { centerX = 0, centerY = 0 } = options;
368
+ // Now we do real insertion.
369
+ const maxWidth = containerSize.width * 0.8;
370
+ const scale = Math.min(maxWidth / width, 1);
371
+ const uuid = v4();
372
+ width *= scale;
373
+ height *= scale;
374
+ const { locked = false, uniformScale } = options;
375
+ this.manager.mainView.insertImage({ uuid, centerX, centerY, width, height, locked, uniformScale });
376
+ this.manager.mainView.completeImageUpload(uuid, url);
377
+ // Move camera to fit image height.
378
+ this.moveCamera({ centerX, centerY, scale: containerSize.height / (height + 32) });
379
+ }
380
+
381
+ /**
382
+ * Insert the Media Player app.
383
+ */
384
+ insertMedia(title: string, src: string) {
385
+ this._assertNotDestroyed();
386
+ return this.manager.addApp({
387
+ kind: BuiltinApps.MediaPlayer,
388
+ options: { title },
389
+ attributes: { src },
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Insert PDF/PPTX from conversion result.
395
+ * @param status https://developer.netless.link/server-en/home/server-conversion#get-query-task-conversion-progress
396
+ */
397
+ insertDocs(filename: string, status: ConversionResponse): Promise<string | undefined>;
398
+
399
+ /**
400
+ * Insert PDF/PPTX from projector conversion result.
401
+ * @param response https://developer.netless.link/server-zh/home/server-projector#get-%E6%9F%A5%E8%AF%A2%E4%BB%BB%E5%8A%A1%E8%BD%AC%E6%8D%A2%E8%BF%9B%E5%BA%A6
402
+ */
403
+ insertDocs(filename: string, response: ProjectorResponse): Promise<string | undefined>;
404
+
405
+ /**
406
+ * Manual way.
407
+ * @example
408
+ * app.insertDocs({
409
+ * fileType: 'pptx',
410
+ * scenePath: `/pptx/${conversion.taskId}`,
411
+ * taskId: conversion.taskId,
412
+ * title: 'Title',
413
+ * })
414
+ */
415
+ insertDocs(params: InsertDocsParams): Promise<string | undefined>;
416
+
417
+ insertDocs(arg1: string | InsertDocsParams, arg2?: ConversionResponse | ProjectorResponse) {
418
+ this._assertNotDestroyed();
419
+ if (typeof arg1 === "object" && "fileType" in arg1) {
420
+ return this._insertDocsImpl(arg1);
421
+ } else if (arg2 && arg2.status !== "Finished") {
422
+ throw new Error("FastboardApp cannot insert a converting doc.");
423
+ } else if (arg2 && "progress" in arg2) {
424
+ const title = arg1;
425
+ const scenePath = `/${arg2.uuid}/${v4()}`;
426
+ const scenes1 = arg2.progress.convertedFileList.map(converted_file_to_scene);
427
+ const { scenes, taskId, url } = make_slide_params(scenes1);
428
+ if (taskId && url) {
429
+ return this._insertDocsImpl({ fileType: "pptx", scenePath, scenes, title, taskId, url });
430
+ } else {
431
+ return this._insertDocsImpl({ fileType: "pdf", scenePath, scenes: scenes1, title });
432
+ }
433
+ } else if (arg2 && "prefix" in arg2) {
434
+ const title = arg1;
435
+ const scenePath = `/${arg2.uuid}/${v4()}`;
436
+ const taskId = arg2.uuid;
437
+ const url = arg2.prefix;
438
+ this._insertDocsImpl({ fileType: "pptx", scenePath, taskId, title, url });
439
+ }
440
+ }
441
+
442
+ /** @internal */
443
+ private _insertDocsImpl({ fileType, scenePath, title, scenes, ...attributes }: InsertDocsParams) {
444
+ this._assertNotDestroyed();
445
+ switch (fileType) {
446
+ case "pdf":
447
+ return this.manager.addApp({
448
+ kind: BuiltinApps.DocsViewer,
449
+ options: { scenePath, title, scenes },
450
+ });
451
+ case "pptx":
452
+ if (scenes && scenes[0].ppt) {
453
+ console.warn("There shouldn't be scenes[].ppt in pptx params.");
454
+ }
455
+ if (!WindowManager.registered.has("Slide")) {
456
+ console.warn("You haven't register @netless/app-slide.");
457
+ }
458
+ return this.manager.addApp({
459
+ kind: "Slide",
460
+ options: { scenePath, title },
461
+ attributes,
462
+ });
463
+ }
464
+ }
465
+ }
466
+
467
+ export interface AppsStatus {
468
+ [kind: string]: {
469
+ status: "idle" | "loading" | "failed";
470
+ /** Exist if status is `failed` */
471
+ reason?: string;
472
+ };
473
+ }
474
+
475
+ export interface InsertImageOptions {
476
+ /** Image width, in pixel */
477
+ width?: number;
478
+ /** Image height, in pixel */
479
+ height?: number;
480
+ /** Image position, in pixel, default as 0 */
481
+ centerX?: number;
482
+ /** Image position, in pixel, default as 0 */
483
+ centerY?: number;
484
+ /** Whether to disable moving, default is false */
485
+ locked?: boolean;
486
+ /** Whether to keep ratio */
487
+ uniformScale?: boolean;
488
+ }
489
+
490
+ /** Params for static docs, they are rendered as many images. */
491
+ export interface InsertDocsStatic {
492
+ readonly fileType: "pdf";
493
+ /** Unique string for binding whiteboard view to the doc. Must start with `/`. */
494
+ readonly scenePath: string;
495
+ /** @example [{ name: '1', ppt: { src: 'url/to/ppt/1.png' } }] */
496
+ readonly scenes: SceneDefinition[];
497
+ /** Window title. */
498
+ readonly title?: string;
499
+ }
500
+
501
+ /** Params for slides, they are rendered in @netless/app-slide with animations. */
502
+ export interface InsertDocsDynamic {
503
+ readonly fileType: "pptx";
504
+ /** Unique string for binding whiteboard view to the doc. Must start with `/`. */
505
+ readonly scenePath: string;
506
+ /** Conversion task id, see https://developer.netless.link/server-en/home/server-conversion#get-query-task-conversion-progress. */
507
+ readonly taskId: string;
508
+ /** Window title. */
509
+ readonly title?: string;
510
+ /** Where the slide resource placed. @default `https://convertcdn.netless.link/dynamicConvert` */
511
+ readonly url?: string;
512
+ /** @example [{ name: '1' }, { name: '2' }, { name: '3' }] */
513
+ readonly scenes?: SceneDefinition[];
514
+ }
515
+
516
+ export type InsertDocsParams = InsertDocsStatic | InsertDocsDynamic;
517
+
518
+ export interface ProjectorResponse {
519
+ uuid: string;
520
+ status: "Waiting" | "Converting" | "Finished" | "Fail";
521
+ /** 0..100 */
522
+ convertedPercentage: number;
523
+ /** https://example.org/path/to/dynamicConvert */
524
+ prefix: string;
525
+ pageCount: number;
526
+ /** {1:"{prefix}/{taskId}/preview/1.png"} */
527
+ previews: Record<number, string>;
528
+ /** {prefix}/{taskId}/jsonOutput/note.json */
529
+ note: string;
530
+ /** 20xxxxx */
531
+ errorCode: `${number}`;
532
+ errorMessage: string;
533
+ }
534
+
535
+ function load_app_event_to_status(status: PublicEvent["loadApp"]["status"]) {
536
+ return status === "start" ? "loading" : status === "failed" ? "failed" : "idle";
537
+ }
538
+
539
+ function converted_file_to_scene(f: ConvertedFile, i: number): SceneDefinition {
540
+ return {
541
+ name: String(i + 1),
542
+ ppt: {
543
+ src: f.conversionFileUrl,
544
+ width: f.width,
545
+ height: f.height,
546
+ previewURL: f.preview,
547
+ },
548
+ };
549
+ }
550
+
551
+ function make_slide_params(scenes: SceneDefinition[]) {
552
+ let taskId = "";
553
+ let url = "";
554
+
555
+ // e.g. "ppt(x)://cdn/prefix/dynamicConvert/{taskId}/1.slide"
556
+ const pptSrcRE = /^pptx?(?<prefix>:\/\/\S+?dynamicConvert)\/(?<taskId>\w+)\//;
557
+
558
+ for (const { ppt } of scenes) {
559
+ if (!ppt || !ppt.src.startsWith("ppt")) {
560
+ continue;
561
+ }
562
+ const match = pptSrcRE.exec(ppt.src);
563
+ if (!match || !match.groups) {
564
+ continue;
565
+ }
566
+ taskId = match.groups.taskId;
567
+ url = "https" + match.groups.prefix;
568
+ break;
569
+ }
570
+
571
+ const emptyScenes = scenes.map(s => ({ name: s.name }));
572
+
573
+ return { scenes: emptyScenes, taskId, url };
574
+ }
575
+
576
+ export interface FastboardOptions {
577
+ sdkConfig: Omit<WhiteWebSdkConfiguration, "useMobXState"> & {
578
+ region: NonNullable<WhiteWebSdkConfiguration["region"]>;
579
+ };
580
+ joinRoom: Omit<JoinRoomParams, "useMultiViews" | "disableNewPencil" | "disableMagixEventDispatchLimit"> & {
581
+ callbacks?: Partial<RoomCallbacks>;
582
+ };
583
+ managerConfig?: Omit<MountParams, "room">;
584
+ netlessApps?: NetlessApp[];
585
+ }
586
+
587
+ export async function createFastboardCore<TEventData = any>({
588
+ sdkConfig,
589
+ joinRoom: { callbacks, ...joinRoomParams },
590
+ managerConfig,
591
+ netlessApps,
592
+ }: FastboardOptions) {
593
+ const sdk = new WhiteWebSdk({ ...sdkConfig, useMobXState: true });
594
+
595
+ const hotKeys = joinRoomParams.hotKeys || {
596
+ ...DefaultHotKeys,
597
+ changeToSelector: "s",
598
+ changeToLaserPointer: "z",
599
+ changeToPencil: "p",
600
+ changeToRectangle: "r",
601
+ changeToEllipse: "c",
602
+ changeToEraser: "e",
603
+ changeToText: "t",
604
+ changeToStraight: "l",
605
+ changeToArrow: "a",
606
+ changeToHand: "h",
607
+ };
608
+
609
+ if (netlessApps) {
610
+ netlessApps.forEach(app => WindowManager.register({ kind: app.kind, src: app }));
611
+ }
612
+
613
+ const room = await sdk.joinRoom(
614
+ {
615
+ floatBar: true,
616
+ disableEraseImage: true,
617
+ hotKeys,
618
+ ...ensureOfficialPlugins(joinRoomParams),
619
+ useMultiViews: true,
620
+ disableNewPencil: false,
621
+ disableMagixEventDispatchLimit: true,
622
+ },
623
+ callbacks
624
+ );
625
+
626
+ const syncedStore = await SyncedStorePlugin.init<TEventData>(room);
627
+
628
+ const manager = await WindowManager.mount({ cursor: true, ...managerConfig, room });
629
+
630
+ return new FastboardApp<TEventData>(sdk, room, manager, syncedStore, hotKeys);
631
+ }