@netless/app-slide 0.0.17 → 0.0.21

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,14 @@
1
+ import type { Slide, SLIDE_EVENTS, SyncEvent } from "@netless/slide";
2
+ export declare type SlideState = Slide["slideState"];
3
+ export interface Attributes {
4
+ /** convert task id */
5
+ taskId: string;
6
+ /** base url of converted resources */
7
+ url: string;
8
+ /** internal state of slide, do not change */
9
+ state: SlideState | null;
10
+ }
11
+ export declare type MagixPayload = {
12
+ type: typeof SLIDE_EVENTS.syncDispatch;
13
+ payload: SyncEvent;
14
+ };
@@ -3,3 +3,4 @@ export declare function flattenEvent(ev: MouseEvent | TouchEvent): MouseEvent |
3
3
  export declare function preventEvent(ev: Event): void;
4
4
  export declare function isObj(obj: unknown): boolean;
5
5
  export declare function wait(ms: number): Promise<void>;
6
+ export declare function deepClone<T>(obj: T): T;
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "@netless/app-slide",
3
- "version": "0.0.17",
3
+ "version": "0.0.21",
4
4
  "main": "dist/main.cjs.js",
5
5
  "module": "dist/main.es.js",
6
6
  "types": "dist/index.d.ts",
7
+ "files": [
8
+ "src",
9
+ "dist",
10
+ "README-zh.md"
11
+ ],
7
12
  "devDependencies": {
8
13
  "@netless/app-shared": "^0.1.1",
9
- "@netless/slide": "^0.1.18",
14
+ "@netless/slide": "^0.1.25",
10
15
  "side-effect-manager": "^0.1.3",
11
16
  "vanilla-lazyload": "^17.5.0"
12
17
  },
@@ -5,6 +5,7 @@ import { playSVG } from "./icons/play";
5
5
  import { pauseSVG } from "./icons/pause";
6
6
 
7
7
  import type { ReadonlyTeleBox } from "@netless/window-manager";
8
+ import type { ILazyLoadInstance } from "vanilla-lazyload";
8
9
  import LazyLoad from "vanilla-lazyload";
9
10
  import { SideEffectManager } from "side-effect-manager";
10
11
 
@@ -127,11 +128,21 @@ export class DocsViewer {
127
128
  return this.$content;
128
129
  }
129
130
 
131
+ private previewLazyLoad?: ILazyLoadInstance;
132
+
130
133
  protected renderPreview(): HTMLElement {
131
134
  if (!this.$preview) {
132
135
  const $preview = document.createElement("div");
133
136
  $preview.className = this.wrapClassName("preview") + " tele-fancy-scrollbar";
134
137
  this.$preview = $preview;
138
+ this.sideEffect.add(() => {
139
+ this.previewLazyLoad = new LazyLoad({
140
+ container: this.$preview,
141
+ elements_selector: `.${this.wrapClassName("preview-page>img")}`,
142
+ });
143
+ return () => this.previewLazyLoad?.destroy();
144
+ });
145
+
135
146
  this.refreshPreview();
136
147
 
137
148
  this.sideEffect.addEventListener($preview, "click", ev => {
@@ -189,13 +200,7 @@ export class DocsViewer {
189
200
  $preview.appendChild($page);
190
201
  });
191
202
 
192
- this.sideEffect.add(() => {
193
- const previewLazyLoad = new LazyLoad({
194
- container: this.$preview,
195
- elements_selector: `.${this.wrapClassName("preview-page>img")}`,
196
- });
197
- return () => previewLazyLoad.destroy();
198
- }, "preview-lazyload");
203
+ this.previewLazyLoad?.update();
199
204
  }
200
205
 
201
206
  protected renderPreviewMask(): HTMLElement {
@@ -0,0 +1,39 @@
1
+ import type { AppContext, Room } from "@netless/window-manager";
2
+ import type { ScenePathType } from "white-web-sdk";
3
+ import type { Slide } from "@netless/slide";
4
+ import type { DocsViewerPage } from "../DocsViewer";
5
+ import type { Attributes } from "../typings";
6
+
7
+ export function createDocsViewerPages(slide: Slide): DocsViewerPage[] {
8
+ const { width, height, slideCount, slideState } = slide;
9
+ const { taskId, url } = slideState;
10
+ const pages: DocsViewerPage[] = [];
11
+ for (let i = 1; i <= slideCount; ++i) {
12
+ pages.push({ width, height, thumbnail: `${url}/${taskId}/preview/${i}.png`, src: "ppt" });
13
+ }
14
+ return pages;
15
+ }
16
+
17
+ // because `renderSlide()` is slow, but switching between scenes in whiteboard is fast
18
+ // so here we make scenes sync with slide page
19
+ export function syncSceneWithSlide(
20
+ room: Room,
21
+ context: AppContext<Attributes>,
22
+ slide: Slide,
23
+ baseScenePath: string
24
+ ) {
25
+ const page = slide.slideState.currentSlideIndex;
26
+ if (!(page > 0) || !context.getIsWritable()) return;
27
+
28
+ const scenePath = [baseScenePath, page].join("/");
29
+
30
+ if (room.scenePathType(scenePath) !== ("page" as ScenePathType.Page)) {
31
+ room.removeScenes(baseScenePath);
32
+ const count = slide.slideCount;
33
+ const scenes: { name: string }[] = [];
34
+ for (let i = 1; i <= count; ++i) scenes.push({ name: `${i}` });
35
+ room.putScenes(baseScenePath, scenes);
36
+ }
37
+
38
+ context.setScenePath(scenePath);
39
+ }
@@ -0,0 +1,257 @@
1
+ // this controller does these things:
2
+ // 1. map slide events to ui
3
+ // 2. make sure to init correctly
4
+ // - the one with (context.isAddApp === true) should call renderSlide(1)
5
+ // - others wait for first sync event and restore from sync state
6
+ // - if none of above happen, force doing renderSlide(1) after timeout
7
+ // 3. send/receive slide sync events
8
+ // 4. automatically re-create scenes to sync strokes, a view must be existing
9
+ // 5. pages information are loaded dynamically by the slide package
10
+
11
+ import type { Event as MagixEvent } from "white-web-sdk";
12
+ import type { AppContext, Player, Room } from "@netless/window-manager";
13
+ import type { SyncEvent } from "@netless/slide";
14
+ import type { Attributes, MagixPayload, SlideState } from "../typings";
15
+
16
+ import { SideEffectManager } from "side-effect-manager";
17
+ import { Slide, SLIDE_EVENTS } from "@netless/slide";
18
+ import { clamp, deepClone, isObj } from "../utils/helpers";
19
+ export { syncSceneWithSlide, createDocsViewerPages } from "./helpers";
20
+
21
+ export const DefaultUrl = "https://convertcdn.netless.link/dynamicConvert";
22
+ export const MaxPollCount = 40; // 500ms * 40 times = 20s
23
+ export const EmptyAttributes: Attributes = {
24
+ taskId: "",
25
+ url: "",
26
+ state: null,
27
+ };
28
+
29
+ export interface SlideControllerOptions {
30
+ context: AppContext<Attributes>;
31
+ anchor: HTMLDivElement;
32
+ onPageChanged: (page: number) => void;
33
+ onTransitionStart: () => void;
34
+ onTransitionEnd: () => void;
35
+ onError: (args: { error: Error }) => void;
36
+ }
37
+
38
+ export class SlideController {
39
+ public readonly context: SlideControllerOptions["context"];
40
+ public readonly slide: Slide;
41
+ public readonly debug: boolean;
42
+
43
+ private readonly channel: string;
44
+ private readonly room?: Room;
45
+ private readonly player?: Player;
46
+ private readonly sideEffect = new SideEffectManager();
47
+
48
+ private readonly onPageChanged: SlideControllerOptions["onPageChanged"];
49
+ private readonly onTransitionStart: SlideControllerOptions["onTransitionStart"];
50
+ private readonly onTransitionEnd: SlideControllerOptions["onTransitionEnd"];
51
+ private readonly onError: SlideControllerOptions["onError"];
52
+
53
+ public constructor({
54
+ context,
55
+ anchor,
56
+ onPageChanged,
57
+ onTransitionStart,
58
+ onTransitionEnd,
59
+ onError,
60
+ }: SlideControllerOptions) {
61
+ this.onPageChanged = onPageChanged;
62
+ this.onTransitionStart = onTransitionStart;
63
+ this.onTransitionEnd = onTransitionEnd;
64
+ this.onError = onError;
65
+ this.context = context;
66
+ this.channel = `channel-${context.appId}`;
67
+ this.room = context.getRoom();
68
+ this.player = this.room ? undefined : (context.getDisplayer() as Player);
69
+ this.debug = import.meta.env.DEV || !!context.getAppOptions()?.debug;
70
+ this.slide = this.createSlide(anchor);
71
+ this.initialize();
72
+ }
73
+
74
+ public jumpToPage(page: number) {
75
+ if (this.ready) {
76
+ page = clamp(page, 1, this.pageCount);
77
+ this.slide.renderSlide(page);
78
+ }
79
+ }
80
+
81
+ private initialize() {
82
+ this.registerEventListeners();
83
+ this.kickStart();
84
+ }
85
+
86
+ private kickStart() {
87
+ const { context, slide } = this;
88
+ const { taskId, url, state } = { ...EmptyAttributes, ...this.context.getAttributes() };
89
+ slide.setResource(taskId, url || DefaultUrl);
90
+ if (state) {
91
+ // if we already have state, try restore from it
92
+ if (this.debug) {
93
+ console.log("[Slide] init with state", deepClone(state));
94
+ }
95
+ slide.setSlideState(deepClone(state));
96
+ } else if (context.isAddApp) {
97
+ // otherwise, maybe this slide is just added, let the adder kick start first render
98
+ if (this.debug) {
99
+ console.log("[Slide] init by renderSlide", 1);
100
+ }
101
+ slide.renderSlide(1);
102
+ }
103
+ // there's still some risk that the adder is left and no first render
104
+ // so anyway, we start polling the slide's "ready state"
105
+ // if in the next 20 seconds the slide is not ready, start render first page
106
+ this.pollReadyState();
107
+ }
108
+
109
+ private registerEventListeners() {
110
+ const { context, slide } = this;
111
+
112
+ this.sideEffect.add(() => {
113
+ const displayer = context.getDisplayer();
114
+ displayer.addMagixEventListener(this.channel, this.magixEventListener, {
115
+ fireSelfEventAfterCommit: true,
116
+ });
117
+ // here we do not pass second param -- the listener
118
+ // because channel name is already unique, just make it simpler
119
+ return () => displayer.removeMagixEventListener(this.channel);
120
+ });
121
+
122
+ slide.on(SLIDE_EVENTS.slideChange, this.onPageChanged);
123
+ slide.on(SLIDE_EVENTS.renderStart, this.onTransitionStart);
124
+ slide.on(SLIDE_EVENTS.renderEnd, this.onTransitionEnd);
125
+ slide.on(SLIDE_EVENTS.mainSeqStepStart, this.onTransitionStart);
126
+ slide.on(SLIDE_EVENTS.mainSeqStepEnd, this.onTransitionEnd);
127
+ slide.on(SLIDE_EVENTS.renderError, this.onError);
128
+ slide.on(SLIDE_EVENTS.stateChange, this.onStateChange);
129
+ slide.on(SLIDE_EVENTS.syncDispatch, this.onSyncDispatch);
130
+ }
131
+
132
+ private onSyncDispatch = (event: SyncEvent) => {
133
+ if (this.context.getIsWritable() && this.room) {
134
+ const payload: MagixPayload = {
135
+ type: SLIDE_EVENTS.syncDispatch,
136
+ payload: event,
137
+ };
138
+ if (this.debug) {
139
+ console.log("[Slide] dispatch", event);
140
+ }
141
+ this.room.dispatchMagixEvent(this.channel, payload);
142
+ }
143
+ };
144
+
145
+ private magixEventListener = (ev: MagixEvent) => {
146
+ if (ev.event === this.channel && isObj(ev.payload)) {
147
+ const { type, payload } = ev.payload as MagixPayload;
148
+ if (type === SLIDE_EVENTS.syncDispatch) {
149
+ this.syncStateOnce();
150
+ if (this.debug) {
151
+ console.log("[Slide] receive", payload);
152
+ }
153
+ this.slide.emit(SLIDE_EVENTS.syncReceive, payload);
154
+ }
155
+ }
156
+ };
157
+
158
+ private syncStateOnceFlag = true;
159
+ private syncStateOnce() {
160
+ // sync state before the first event, so that they can be in the correct order
161
+ if (this.syncStateOnceFlag) {
162
+ const { state } = { ...EmptyAttributes, ...this.context.getAttributes() };
163
+ if (state) {
164
+ if (this.debug) {
165
+ console.log("[Slide] sync with state (once)", deepClone(state));
166
+ }
167
+ this.slide.setSlideState(deepClone(state));
168
+ this.syncStateOnceFlag = false;
169
+ }
170
+ }
171
+ }
172
+
173
+ private onStateChange = (state: SlideState) => {
174
+ if (this.context.getIsWritable()) {
175
+ this.context.updateAttributes(["state"], state);
176
+ }
177
+ };
178
+
179
+ private resolveReady!: () => void;
180
+ public readonly readyPromise = new Promise<void>(resolve => {
181
+ this.resolveReady = resolve;
182
+ });
183
+
184
+ private pollCount = 0;
185
+ private pollReadyState = () => {
186
+ if (this.ready) {
187
+ this.resolveReady();
188
+ } else if (this.pollCount < MaxPollCount) {
189
+ this.pollCount++;
190
+ setTimeout(this.pollReadyState, 500);
191
+ } else {
192
+ this.pollCount = 0;
193
+ if (this.debug) {
194
+ console.log("[Slide] renderSlide (retry after timeout)", 1);
195
+ }
196
+ this.slide.renderSlide(1);
197
+ }
198
+ };
199
+
200
+ public get ready() {
201
+ return this.slide.slideCount > 0;
202
+ }
203
+
204
+ public get pageCount() {
205
+ return this.slide.slideCount;
206
+ }
207
+
208
+ public get page() {
209
+ return this.slide.slideState.currentSlideIndex;
210
+ }
211
+
212
+ private createSlide(anchor: HTMLDivElement) {
213
+ const slide = new Slide({
214
+ anchor,
215
+ interactive: true,
216
+ mode: "interactive",
217
+ resize: true,
218
+ controller: this.debug,
219
+ renderOptions: {
220
+ minFPS: 25,
221
+ maxFPS: 30,
222
+ autoFPS: true,
223
+ autoResolution: true,
224
+ resolution: this.context.getAppOptions()?.resolution,
225
+ },
226
+ timestamp: this.timestamp,
227
+ });
228
+ if (this.debug) {
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ (window as any).slide = slide;
231
+ }
232
+ return slide;
233
+ }
234
+
235
+ private destroyed = false;
236
+
237
+ public destroy() {
238
+ this.sideEffect.flushAll();
239
+ if (!this.destroyed) {
240
+ if (this.debug) {
241
+ console.log("[Slide] destroy (once)");
242
+ }
243
+ this.slide.destroy();
244
+ this.destroyed = true;
245
+ }
246
+ }
247
+
248
+ public timestamp = () => {
249
+ if (this.room && this.room.calibrationTimestamp) {
250
+ return this.room.calibrationTimestamp;
251
+ } else if (this.player) {
252
+ return this.player.beginTimestamp + this.player.progressTime;
253
+ } else {
254
+ return Date.now();
255
+ }
256
+ };
257
+ }
@@ -1,18 +1,20 @@
1
- import type { ApplianceNames } from "white-web-sdk";
2
1
  import type { ReadonlyTeleBox, AnimationMode, View } from "@netless/window-manager";
3
- import type { Slide } from "@netless/slide";
4
- import type { SlideController } from "../utils/slide";
2
+ import type { SlideController, SlideControllerOptions } from "../SlideController";
5
3
 
6
4
  import { SideEffectManager } from "side-effect-manager";
7
- import { createDocsViewerPages } from "../utils/slide";
5
+ import { createDocsViewerPages } from "../SlideController";
8
6
  import { DocsViewer } from "../DocsViewer";
9
7
 
10
- const ClickThroughAppliances = new Set(["clicker", "selector"]);
8
+ export const ClickThroughAppliances = new Set(["clicker", "selector"]);
9
+
10
+ export type MountSlideOptions = Omit<SlideControllerOptions, "context" | "onPageChanged"> & {
11
+ onReady: () => void;
12
+ };
11
13
 
12
14
  export interface SlideDocsViewerConfig {
13
15
  box: ReadonlyTeleBox;
14
16
  view: View;
15
- mountSlideController: (anchor: HTMLDivElement) => Promise<SlideController>;
17
+ mountSlideController: (options: MountSlideOptions) => SlideController;
16
18
  mountWhiteboard: (dom: HTMLDivElement) => void;
17
19
  }
18
20
 
@@ -38,6 +40,14 @@ export class SlideDocsViewer {
38
40
  onPlay: this.onPlay,
39
41
  });
40
42
 
43
+ this.sideEffect.add(() => {
44
+ const handler = (readonly: boolean): void => {
45
+ this.setReadonly(readonly);
46
+ };
47
+ box.events.on("readonly", handler);
48
+ return () => box.events.off("readonly", handler);
49
+ });
50
+
41
51
  this.render();
42
52
  }
43
53
 
@@ -87,11 +97,16 @@ export class SlideDocsViewer {
87
97
  return this.$whiteboardView;
88
98
  }
89
99
 
90
- public async mount() {
100
+ public mount() {
91
101
  this.viewer.mount();
92
- this.slideController = await this.mountSlideController(this.$slide);
93
- this.viewer.pages = createDocsViewerPages(this.slideController.slide);
94
- this.viewer.setPageIndex(this.getPageIndex(this.slideController.slide));
102
+
103
+ this.slideController = this.mountSlideController({
104
+ anchor: this.$slide,
105
+ onTransitionStart: this.viewer.setPlaying,
106
+ onTransitionEnd: this.viewer.setPaused,
107
+ onReady: this.refreshPages,
108
+ onError: this.onError,
109
+ });
95
110
 
96
111
  this.scaleDocsToFit();
97
112
  this.sideEffect.add(() => {
@@ -102,8 +117,21 @@ export class SlideDocsViewer {
102
117
  return this;
103
118
  }
104
119
 
105
- protected getPageIndex(slide: Slide) {
106
- return (slide.slideState.currentSlideIndex || 1) - 1;
120
+ protected onError = ({ error }: { error: Error }) => {
121
+ this.viewer.setPaused();
122
+ console.warn("[Slide] render error", error);
123
+ };
124
+
125
+ protected refreshPages = () => {
126
+ if (this.slideController) {
127
+ this.viewer.pages = createDocsViewerPages(this.slideController.slide);
128
+ this.viewer.setPageIndex(this.getPageIndex(this.slideController.page));
129
+ this.scaleDocsToFit();
130
+ }
131
+ };
132
+
133
+ protected getPageIndex(page: number) {
134
+ return (page > 0 ? page : 1) - 1;
107
135
  }
108
136
 
109
137
  public unmount() {
@@ -125,7 +153,7 @@ export class SlideDocsViewer {
125
153
  this.viewer.destroy();
126
154
  }
127
155
 
128
- public toggleClickThrough(tool?: ApplianceNames) {
156
+ public toggleClickThrough(tool?: string) {
129
157
  this.$whiteboardView.style.pointerEvents =
130
158
  !tool || ClickThroughAppliances.has(tool) ? "none" : "auto";
131
159
  }