├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .npmignore
├── .prettierignore
├── .prettierrc.json
├── CHANGELOG.md
├── LICENSE.txt
├── README.md
├── README.zh-cn.md
├── __mocks__
└── white-web-sdk.ts
├── docs
├── advanced.md
├── api.md
├── app-context.md
├── basic.md
├── camera.md
├── cn
│ ├── advanced.md
│ ├── api.md
│ ├── app-context.md
│ ├── basic.md
│ ├── camera.md
│ ├── concept.md
│ ├── custom-max-bar.md
│ ├── develop-app.md
│ ├── export-pdf.md
│ ├── migrate.md
│ ├── quickstart.md
│ └── replay.md
├── concept.md
├── custom-max-bar.md
├── develop-app.md
├── export-pdf.md
├── migrate.md
├── quickstart.md
└── replay.md
├── e2e
├── cypress.json
├── cypress
│ └── integration
│ │ ├── boxstate.spec.ts
│ │ ├── common.ts
│ │ ├── cursor.spec.ts
│ │ ├── index.spec.ts
│ │ ├── mainViewScenePath.spec.ts
│ │ ├── readonly.spec.ts
│ │ └── redoUndo.spec.ts
├── package.json
├── tsconfig.json
└── yarn.lock
├── example
├── app
│ ├── board.css
│ ├── board.tsx
│ ├── counter.ts
│ └── helloworld-app.ts
├── apps.ts
├── docs.json
├── h5.html
├── index.css
├── index.html
├── index.tsx
├── package.json
├── pnpm-lock.yaml
├── register.ts
├── shim.d.ts
├── tsconfig.json
└── vite.config.js
├── jest.config.js
├── package.json
├── pnpm-lock.yaml
├── src
├── App
│ ├── AppContext.ts
│ ├── AppPageStateImpl.ts
│ ├── AppProxy.ts
│ ├── MagixEvent
│ │ └── index.ts
│ ├── Storage
│ │ ├── StorageEvent.ts
│ │ ├── index.ts
│ │ ├── typings.ts
│ │ └── utils.ts
│ └── index.ts
├── AppListener.ts
├── AppManager.ts
├── AttributesDelegate.ts
├── BoxEmitter.ts
├── BoxManager.ts
├── BuiltinApps.ts
├── ContainerResizeObserver.ts
├── Cursor
│ ├── Cursor.svelte
│ ├── Cursor.svelte.d.ts
│ ├── Cursor.ts
│ ├── icons.ts
│ ├── icons2.ts
│ └── index.ts
├── Helper.ts
├── InternalEmitter.ts
├── Page
│ ├── PageController.ts
│ └── index.ts
├── PageState.ts
├── ReconnectRefresher.ts
├── RedoUndo.ts
├── Register
│ ├── index.ts
│ ├── loader.ts
│ └── storage.ts
├── Utils
│ ├── AppCreateQueue.ts
│ ├── Common.ts
│ ├── Reactive.ts
│ ├── RoomHacker.ts
│ ├── error.ts
│ └── log.ts
├── View
│ ├── IframeBridge.ts
│ ├── MainView.ts
│ └── ViewManager.ts
├── callback.ts
├── constants.ts
├── image.d.ts
├── image
│ ├── eraser-cursor.png
│ ├── laser-pointer-cursor.svg
│ ├── pencil-cursor.png
│ ├── pencil-eraser-1.svg
│ ├── pencil-eraser-2.svg
│ ├── pencil-eraser-3.svg
│ ├── selector-cursor.png
│ ├── shape-cursor.svg
│ └── text-cursor.svg
├── index.ts
├── shim.d.ts
├── style.css
└── typings.ts
├── test
├── Utils
│ └── AppCreateQueue.test.ts
├── index.test.ts
└── page.test.ts
├── tsconfig.json
└── vite.config.js
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | public/
4 | test/
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config */
2 | const config = {
3 | root: true,
4 | env: {
5 | browser: true,
6 | },
7 | parser: "@typescript-eslint/parser",
8 | plugins: ["svelte3", "@typescript-eslint"],
9 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
10 | rules: {
11 | "@typescript-eslint/consistent-type-imports": ["warn"],
12 | "@typescript-eslint/no-empty-interface": "off",
13 | "@typescript-eslint/no-explicit-any": "off",
14 | "@typescript-eslint/explicit-module-boundary-types": "off",
15 | "@typescript-eslint/no-unused-vars": [
16 | "error",
17 | {
18 | argsIgnorePattern: "^_",
19 | },
20 | ],
21 | "@typescript-eslint/ban-types": [
22 | "error",
23 | {
24 | types: { "{}": false },
25 | extendDefaults: true,
26 | },
27 | ],
28 | },
29 | overrides: [
30 | {
31 | files: ["*.svelte"],
32 | processor: "svelte3/svelte3",
33 | },
34 | ],
35 | settings: {
36 | "svelte3/typescript": true,
37 | },
38 | };
39 |
40 | // eslint-disable-next-line no-undef
41 | module.exports = config;
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .env
4 | .idea
5 | .vscode
6 | e2e/cypress/*
7 | !e2e/cypress/integration/
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | test/
3 | tsconfig.json
4 | vite.config.json
5 | yarn.lock
6 | e2e/
7 | example/
8 | yarn.lock
9 | yarn-error.log
10 | .idea
11 | .eslintignore
12 | .eslintrc.js
13 | .vscode
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | yarn.lock
2 | node_modules/
3 | dist/
4 | test/
5 | tsconfig.json
6 | vite.config.js
7 | .vscode/
8 | **/*.md
9 | pnpm-lock.yaml
10 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 4,
4 | "semi": true,
5 | "singleQuote": false,
6 | "endOfLine": "auto",
7 | "arrowParens": "avoid",
8 | "printWidth": 100,
9 | "overrides": [
10 | {
11 | "files": ["package.json"],
12 | "options": {
13 | "tabWidth": 2
14 | }
15 | }
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 netless
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WindowManager
2 |
3 | [中文](./README.zh-cn.md)
4 |
5 | `WindowManager` is a window management system based on `white-web-sdk` `InvisiblePlugin` implementation.
6 |
7 | This application provides the following.
8 |
9 | 1. provides `NetlessApp` plug-in `API` system
10 | 2. support `APP` to open as a window
11 | 3. support application synchronization in each end
12 | 4. cursor synchronization
13 | 5. view angle following
14 |
15 | ## Content list
16 | - [Install](#Install)
17 | - [QuickStart](#QuickStart)
18 | - [concept](docs/concept.md)
19 | - [references](docs/api.md)
20 | - [migration from whiteboard](docs/migrate.md)
21 | - [replay](docs/replay.md)
22 | - [advanced use](docs/advanced.md)
23 | - [view-follow](docs/advanced.md#view-mode)
24 | - [Develop custom APP](docs/develop-app.md)
25 | - [Export PDF](docs/export-pdf.md)
26 |
27 | ## Install
28 |
29 | pnpm
30 | ```sh
31 | $ pnpm install @netless/window-manager
32 | ```
33 | yarn
34 | ```sh
35 | $ yarn install @netless/window-manager
36 | ```
37 |
38 | ## QuickStart
39 |
40 | ```javascript
41 | import { White-WebSdk } from "white-web-sdk";
42 | import { WindowManager, BuiltinApps } from "@netless/window-manager";
43 | import "@netless/window-manager/dist/style.css";
44 |
45 | const sdk = new WhiteWebSdk({
46 | appIdentifier: "appIdentifier",
47 | useMobXState: true // make sure this option is turned on
48 | });
49 |
50 | sdk.joinRoom({
51 | uuid: "room uuid",
52 | roomToken: "room token",
53 | invisiblePlugins: [WindowManager],
54 | useMultiViews: true, // Multi-window must be enabled with useMultiViews
55 | disableMagixEventDispatchLimit: true, // Make sure this option is turned on
56 | }).then(async room => {
57 | const manager = await WindowManager.mount(
58 | room,
59 | container
60 | // See below for full configuration
61 | );
62 | });
63 | ```
64 |
65 | [mount full parameter](docs/api.md#mount)
66 |
67 | > ``containerSizeRatio`` In order to ensure that the window is displayed at different resolutions, the whiteboard can only be synchronized in the same scale area
68 |
69 |
70 | ## MainView
71 |
72 | `MainView`, the main whiteboard, is the main whiteboard that sits underneath all windows.
73 |
74 | Because of the multiple windows, we abstracted out a main whiteboard, and migrated some of the previous operations on `room` to `mainView` operations
75 |
76 |
77 |
78 | ### `collector`
79 |
80 | > `collector` is the icon when the window is minimized, default size `width: 40px;` `height: 40px;`
81 |
82 |
83 | ### Cursor synchronization
84 |
85 | > The original `cursorAdapter` in `SDK` is not available in multi-window, if you need cursor synchronization, you need to enable `cursor` option in `manager`.
86 |
87 | ```typescript
88 | sdk.joinRoom({
89 | // cursorAdapter: cursorAdapter, the original cursorAdapter in sdk needs to be turned off
90 | userPayload: {
91 | nickName: "cursor name",
92 | avatar: "User avatar link",
93 | },
94 | });
95 |
96 | WindowManager.mount({
97 | cursor: true, // turn on cursor synchronization
98 | });
99 | ```
100 |
101 | ## APP
102 |
103 | Static and dynamic PPTs are inserted into the whiteboard as `App`, and persisted to the whiteboard
104 |
105 | `App` may be created automatically when the page is refreshed, no need to insert it repeatedly
106 |
107 | If the `App` requires a `scenePath`, then a `scenePath` can only be opened at the same time, requiring a unique `App` instance
108 |
109 | ### Add static/dynamic PPT to whiteboard
110 |
111 | ```javascript
112 | const appId = await manager.addApp({
113 | kind: BuiltinApps.DocsViewer,
114 | options: {
115 | scenePath: "/docs-viewer",
116 | title: "docs1", // optional
117 | scenes: [], // SceneDefinition[] Static/Dynamic Scene data
118 | },
119 | });
120 | ```
121 |
122 | ### Add audio and video to the whiteboard
123 |
124 | ```javascript
125 | const appId = await manager.addApp({
126 | kind: BuiltinApps.MediaPlayer,
127 | options: {
128 | title: "test.mp3", // optional
129 | },
130 | attributes: {
131 | src: "xxxx", // audio/video url
132 | },
133 | });
134 | ```
135 |
136 | ### Set follow mode
137 |
138 | Only the broadcast side, i.e. the teacher, needs to set the follow mode, the other side of the main whiteboard will follow the view of the broadcast side
139 |
140 | > Note that `manager`'s `setViewMode` cannot be used at the same time as `room.setViewMode`.
141 |
142 | ```javascript
143 | manager.setViewMode("broadcaster"); // turn on follow mode
144 | manager.setViewMode("freedom"); // turn off follow mode
145 | ```
146 |
147 | Get the current `broadcaster` ID
148 | ```javascript
149 | manager.broadcaster
150 | ```
151 |
152 | ### Set `readonly` for all `app`s
153 |
154 | ```javascript
155 | manager.setReadonly(true); // all windows become readonly
156 | manager.setReadonly(false); // unlock the readonly setting, note that if the current whiteboard isWritable is false, the whiteboard's state is the highest priority
157 | ```
158 |
159 | ### Toggle `mainView` to writable state
160 |
161 | ```javascript
162 | manager.switchMainViewToWriter();
163 | ```
164 |
165 | ### Switch `mainView` `scenePath`
166 |
167 | Switch the `ScenePath` of the main whiteboard and set the main whiteboard to writable state
168 |
169 | ```javascript
170 | manager.setMainViewScenePath(scenePath);
171 | ```
172 |
173 | ### toggle `mainView` `sceneIndex`
174 |
175 | Toggles the `SceneIndex` of the main whiteboard and sets the main whiteboard to writable state
176 |
177 | ```javascript
178 | manager.setMainViewSceneIndex(sceneIndex);
179 | ```
180 |
181 | ### Get the `mainView` `scenePath`
182 |
183 | ```javascript
184 | manager.getMainViewScenePath();
185 | ```
186 |
187 | ### Get `mainView` `sceneIndex`
188 |
189 | ```javascript
190 | manager.getMainViewSceneIndex();
191 | ```
192 |
193 | ### Listen to the `mainView` `mode`
194 |
195 | ```javascript
196 | manager.emitter.on("mainViewModeChange", mode => {
197 | // mode type is ViewVisionMode
198 | });
199 | ```
200 |
201 | ### Listening for window maximization and minimization
202 |
203 | ```javascript
204 | manager.emitter.on("boxStateChange", state => {
205 | if (state === "maximized") {
206 | // maximize
207 | }
208 | if (state === "minimized") {
209 | // minimized
210 | }
211 | if (state === "normal") {
212 | // return to normal
213 | }
214 | });
215 | ```
216 |
217 | ### Listening for `broadcaster` changes
218 | ```javascript
219 | manager.emitter.on("broadcastChange", id => {
220 | // broadcast id changed
221 | })
222 |
223 | ```
224 |
225 | ### Close the `App`
226 |
227 | ```javascript
228 | manager.closeApp(appId);
229 | ```
230 |
231 | ## Manually destroy `WindowManager`
232 |
233 | ```javascript
234 | manager.destroy();
235 | ```
236 |
237 | ## Development process
238 |
239 | ```bash
240 | pnpm install
241 |
242 | pnpm build
243 |
244 | cd example
245 |
246 | pnpm install
247 |
248 | pnpm dev
249 | ```
250 |
--------------------------------------------------------------------------------
/README.zh-cn.md:
--------------------------------------------------------------------------------
1 | # WindowManager
2 |
3 | - 目录
4 | - [概念](docs/concept.md)
5 | - [references](docs/api.md)
6 | - [从白板迁移](docs/migrate.md)
7 | - [回放](docs/replay.md)
8 | - [进阶使用](docs/advanced.md)
9 | - [视角跟随](docs/advanced.md#view-mode)
10 | - [开发自定义 APP](docs/develop-app.md)
11 | - [导出 PDF](docs/export-pdf.md)
12 | - [Changelog](./CHANGELOG.md)
13 | ## MainView
14 |
15 | `MainView` 也就是主白板, 是垫在所有窗口下面的主白板
16 |
17 | 因为多窗口的原因,所以抽象出来一个主白板, 并且把以前部分对 `room` 的操作, 迁移到对 `mainView` 操作
18 |
19 | ### 快速开始
20 |
21 | ```javascript
22 | import { WhiteWebSdk } from "white-web-sdk";
23 | import { WindowManager, BuiltinApps } from "@netless/window-manager";
24 | import "@netless/window-manager/dist/style.css";
25 |
26 | const sdk = new WhiteWebSdk({
27 | appIdentifier: "appIdentifier",
28 | useMobXState: true // 请确保打开这个选项
29 | });
30 |
31 | sdk.joinRoom({
32 | uuid: "room uuid",
33 | roomToken: "room token",
34 | invisiblePlugins: [WindowManager],
35 | useMultiViews: true, // 多窗口必须用开启 useMultiViews
36 | disableMagixEventDispatchLimit: true, // 请确保打开这个选项
37 | }).then(async room => {
38 | const manager = await WindowManager.mount(
39 | room,
40 | container
41 | // 完整配置见下方
42 | );
43 | });
44 | ```
45 |
46 | [mount 完整参数](docs/api.md#mount)
47 |
48 |
49 | > `containerSizeRatio` 为了保证窗口在不同分辨率下显示效果, 白板在相同的比例区域才能进行同步
50 |
51 | > `chessboard` 当挂载的区域不完全符合比例时, 白板会在挂载的 dom 中划分一个符合比例的区域出来, 此时多出来的部分会默认显示为棋盘透明背景
52 |
53 | ### `collector`
54 |
55 | > `collector` 就是窗口最小化时的图标, 默认大小 `width: 40px;` `height: 40px;`
56 |
57 |
58 | ### 光标同步
59 |
60 | > 原本的 `SDK` 中的 `cursorAdapter` 在多窗口中不可用, 如需要光标同步功能需要在 `manager` 中开启 `cursor` 选项
61 |
62 | ```typescript
63 | sdk.joinRoom({
64 | // cursorAdapter: cursorAdapter, 原本开启 sdk 中的 cursorAdapter 需要关闭
65 | userPayload: {
66 | nickName: "光标名称",
67 | avatar: "用户头像链接",
68 | },
69 | });
70 |
71 | WindowManager.mount({
72 | cursor: true, // 开启光标同步
73 | });
74 | ```
75 |
76 | ## APP
77 |
78 | 静态和动态 PPT 是作为 `App` 插入到白板, 并持久化到白板中
79 |
80 | `App` 或会在页面刷新时自动创建出来, 不需要重复插入
81 |
82 | 如果 `App` 需要 `scenePath` 时,那么一个 `scenePath` 只能同时打开一个,要求为 `App` 实例唯一
83 |
84 | ### 添加静态/动态 PPT 到白板上
85 |
86 | ```javascript
87 | const appId = await manager.addApp({
88 | kind: BuiltinApps.DocsViewer,
89 | options: {
90 | scenePath: "/docs-viewer",
91 | title: "docs1", // 可选
92 | scenes: [], // SceneDefinition[] 静态/动态 Scene 数据
93 | },
94 | });
95 | ```
96 |
97 | ### 添加音视频到白板
98 |
99 | ```javascript
100 | const appId = await manager.addApp({
101 | kind: BuiltinApps.MediaPlayer,
102 | options: {
103 | title: "test.mp3", // 可选
104 | },
105 | attributes: {
106 | src: "xxxx", // 音视频 url
107 | },
108 | });
109 | ```
110 |
111 | ### 设置跟随模式
112 |
113 | 只有广播端也就是老师需要设置跟随模式, 其他端的主白板都会跟随广播端的视角
114 |
115 | > 注意 `manager` 的 `setViewMode` 不能和 `room.setViewMode` 同时使用
116 |
117 | ```javascript
118 | manager.setViewMode("broadcaster"); // 开启跟随模式
119 | manager.setViewMode("freedom"); // 关闭跟随模式
120 | ```
121 |
122 | 获取当前的 `broadcaster` ID
123 | ```javascript
124 | manager.broadcaster
125 | ```
126 |
127 | ### 设置所有 `app` 的 `readonly`
128 |
129 | ```javascript
130 | manager.setReadonly(true); // 所有窗口变成 readonly 状态
131 | manager.setReadonly(false); // 解锁设置的 readonly, 注意如果当前白板的 isWritable 为 false 以白板的状态为最高优先级
132 | ```
133 |
134 | ### 切换 `mainView` 为可写状态
135 |
136 | ```javascript
137 | manager.switchMainViewToWriter();
138 | ```
139 |
140 | ### 切换 `mainView` `scenePath`
141 |
142 | 切换主白板的 `ScenePath` 并把主白板设置为可写状态
143 |
144 | ```javascript
145 | manager.setMainViewScenePath(scenePath);
146 | ```
147 |
148 | ### 切换 `mainView` `sceneIndex`
149 |
150 | 切换主白板的 `SceneIndex` 并把主白板设置为可写状态
151 |
152 | ```javascript
153 | manager.setMainViewSceneIndex(sceneIndex);
154 | ```
155 |
156 | ### 获取 `mainView` `scenePath`
157 |
158 | ```javascript
159 | manager.getMainViewScenePath();
160 | ```
161 |
162 | ### 获取 `mainView` `sceneIndex`
163 |
164 | ```javascript
165 | manager.getMainViewSceneIndex();
166 | ```
167 |
168 | ### 监听 `mainView` 的 `mode`
169 |
170 | ```javascript
171 | manager.emitter.on("mainViewModeChange", mode => {
172 | // mode 类型为 ViewVisionMode
173 | });
174 | ```
175 |
176 | ### 监听窗口最大化最小化
177 |
178 | ```javascript
179 | manager.emitter.on("boxStateChange", state => {
180 | if (state === "maximized") {
181 | // 最大化
182 | }
183 | if (state === "minimized") {
184 | // 最小化
185 | }
186 | if (state === "normal") {
187 | // 恢复正常
188 | }
189 | });
190 | ```
191 |
192 | ### 监听 `broadcaster` 变化
193 | ```javascript
194 | manager.emitter.on("broadcastChange", id => {
195 | // broadcast id 进行了改变
196 | })
197 |
198 | ```
199 |
200 | ### 关闭 `App`
201 |
202 | ```javascript
203 | manager.closeApp(appId);
204 | ```
205 |
206 | ## 手动销毁 `WindowManager`
207 |
208 | ```javascript
209 | manager.destroy();
210 | ```
211 |
212 | ## 开发流程
213 |
214 | ```bash
215 | pnpm install
216 |
217 | pnpm build
218 |
219 | cd example
220 |
221 | pnpm install
222 |
223 | pnpm dev
224 | ```
225 |
--------------------------------------------------------------------------------
/__mocks__/white-web-sdk.ts:
--------------------------------------------------------------------------------
1 | import { vi } from "vitest";
2 |
3 | class InvisiblePlugin {
4 | attributes: any = {};
5 | setAttributes(attrs: any) {
6 | this.attributes = { ...this.attributes, ...attrs };
7 | }
8 | }
9 |
10 | const UpdateEventKind = {
11 | Inserted: 0,
12 | Updated: 1,
13 | Removed: 2,
14 | };
15 |
16 | enum ApplianceNames {
17 | selector = "selector",
18 | clicker = "clicker",
19 | laserPointer = "laserPointer",
20 | pencil = "pencil",
21 | rectangle = "rectangle",
22 | ellipse = "ellipse",
23 | shape = "shape",
24 | eraser = "eraser",
25 | text = "text",
26 | straight = "straight",
27 | arrow = "arrow",
28 | hand = "hand",
29 | }
30 |
31 | enum ViewMode {
32 | Freedom = "freedom",
33 | Follower = "follower",
34 | Broadcaster = "broadcaster",
35 | }
36 |
37 | const isPlayer = vi.fn(() => false);
38 |
39 | export { InvisiblePlugin, UpdateEventKind, ApplianceNames, ViewMode, isPlayer };
40 |
--------------------------------------------------------------------------------
/docs/advanced.md:
--------------------------------------------------------------------------------
1 | ## Advanced usage
2 |
3 | - Table of contents
4 | - [Undo Redo](#redo-undo)
5 | - [clean screen](#clean-current-scene)
6 | - [Judging whether to open a certain APP](#has-kind)
7 | - [page controller](#page-control)
8 | - [viewpoint](#view-mode)
9 | - [Insert image to current app](#insert-image-to-app)
10 |
11 |
12 |
Undo redo
13 |
14 | > The following events and properties will automatically switch application objects according to the `focus` window
15 |
16 | #### Get the number of steps that can be undone/redoed
17 |
18 | ```ts
19 | manager.canUndoSteps
20 | manager.canRedoSteps
21 | ```
22 |
23 | #### Monitor changes in the number of steps that can be undone/redoed
24 |
25 | `canRedoStepsChange` and `canUndoStepsChange` will retrigger when switching windows
26 |
27 | ```ts
28 | manager.emitter.on("canUndoStepsChange", (steps: number) => {
29 | // undoable steps update
30 | })
31 | manager.emitter.on("canRedoStepsChange", (steps: number) => {
32 | // Update the number of steps that can be redone
33 | })
34 | ```
35 |
36 | #### Undo/Redo
37 |
38 | ```ts
39 | manager.undo() // undo
40 | manager.redo() // redo
41 | ```
42 |
43 |
44 |
45 | Clear screen
46 |
47 | Because there are multiple whiteboards in multi-window mode, if you want to clear the current `focus` whiteboard, you only need to call
48 |
49 | ```ts
50 | manager.cleanCurrentScene()
51 | ```
52 |
53 | If you only want to clean up the handwriting on the main whiteboard, you need
54 |
55 | ```ts
56 | manager.mainView.cleanCurrentScene()
57 | ```
58 |
59 |
60 |
61 |
62 | Determine whether to open a certain APP
63 |
64 | ```ts
65 | manager.emitter.on("ready", () => { // ready event is triggered after all app creation is complete
66 | const apps = manager.queryAll(); // Get all opened apps
67 | const hasSlide = apps.some(app => app.kind === "Slide"); // Determine whether there is Slide in the opened APP
68 | });
69 | ```
70 |
71 |
72 |
73 | page controller
74 |
75 | `manager` provides a `pageState` to get the current index and the total number of pages
76 |
77 | ```ts
78 | manager.pageState.index // current index
79 | manager.pageState.length // total number of pages
80 |
81 | manager.emitter.on("pageStateChange", state => {
82 | // This event will be triggered when the current index changes and the total number of pages changes
83 | });
84 | ```
85 |
86 | Previous/Next/Add a page
87 |
88 | ```ts
89 | manager.nextPage()
90 | manager.prevPage()
91 | manager.addPage()
92 | ```
93 |
94 |
95 |
96 | View follow
97 |
98 | `ViewMode` in multi-window has `broadcaster` `freedom` two modes
99 |
100 | - `freedom`
101 |
102 | Free mode, users can freely zoom and move the viewing angle
103 |
104 | Even if there is an anchor in the room, the anchor cannot affect the user's perspective
105 |
106 | - `broadcaster`
107 |
108 | Host mode, other people's perspectives will follow me during operation
109 |
110 | At the same time, other people in `broadcaster` mode will also affect my perspective
111 |
112 | When `isWritable` is `false`, it will only follow other `broadcaster` perspectives
113 |
114 |
115 |
116 | Insert an image into the current app
117 |
118 | ```ts
119 | // Determine whether the current is maximized
120 | if (manager.boxState === "maximized") {
121 | // The value of `focused` will vary depending on the currently focused app
122 | const app = manager.queryOne(manager. focused)
123 | // Only apps with a view can insert pictures, apps like video and audio do not have a view
124 | if (app.view) {
125 | var imageInformation = {
126 | uuid: uuid,
127 | centerX: centerX,
128 | centerY: centerY,
129 | width: width,
130 | height: height,
131 | locked: false,
132 | };
133 | app.view.insertImage(imageInformation);
134 | app.view.completeImageUpload(uuid, src);
135 | }
136 | }
137 | ```
138 |
--------------------------------------------------------------------------------
/docs/basic.md:
--------------------------------------------------------------------------------
1 | # Basic Tutorial
2 | `WindowManager` has built-in `DocsViewer` and `MediaPlayer` to play PPT and audio and video
3 |
4 | ## Open dynamic/static PPT
5 | ```typescript
6 | import { BuiltinApps } from "@netless/window-manager";
7 |
8 | const appId = await manager.addApp({
9 | kind: BuiltinApps.DocsViewer,
10 | options: {
11 | scenePath: "/docs-viewer", // define the scenePath where the ppt is located
12 | title: "docs1", // optional
13 | scenes: [], // SceneDefinition[] static/dynamic Scene data
14 | },
15 | });
16 | ```
17 |
18 | ## Open audio and video
19 | ```typescript
20 | import { BuiltinApps } from "@netless/window-manager";
21 |
22 | const appId = await manager.addApp({
23 | kind: BuiltinApps.MediaPlayer,
24 | options: {
25 | title: "test.mp3", // optional
26 | },
27 | attributes: {
28 | src: "xxxx", // audio and video url
29 | },
30 | });
31 | ```
32 |
33 |
34 | ## Query all apps
35 | ```typescript
36 | const apps = manager.queryAll();
37 | ```
38 |
39 | ## Query a single APP
40 | ```typescript
41 | const app = manager.queryOne(appId);
42 | ```
43 |
44 | ## Close App
45 | ```typescript
46 | manager.closeApp(appId);
47 | ```
48 |
49 | ## events
50 |
51 | ### Minimize and maximize the window
52 | ```typescript
53 | manager.emitter.on("boxStateChange", state => {
54 | // maximized | minimized | normal
55 | });
56 | ```
57 |
58 | ### Camera follow mode
59 | ```typescript
60 | manager.emitter.on("broadcastChange", state => {
61 | // state: number | undefined
62 | });
63 | ```
64 |
--------------------------------------------------------------------------------
/docs/camera.md:
--------------------------------------------------------------------------------
1 | # view angle
2 | In multi-window mode, multiple whiteboards can exist at the same time, but in most cases users only need to operate on the main whiteboard, which is `mainView`
3 |
4 |
5 | ## Get `camera` of `mainView`
6 | ```typescript
7 | manager.mainView.camera
8 | ```
9 |
10 | ## Get `size` of `mainView`
11 | ```typescript
12 | manager.mainView.size
13 | ```
14 |
15 | ## Monitor `camera` changes in `mainView`
16 | ```typescript
17 | manager.mainView.callbacks.on("onCameraUpdated", camera => {
18 | // updated camera
19 | })
20 | ```
21 |
22 | ## Monitor the change of `size` of `mainView`
23 | ```typescript
24 | manager.mainView.callbacks.on("onSizeUpdated", camera => {
25 | // updated size
26 | })
27 | ```
28 |
29 | ## Move `camera` via `api`
30 | ```typescript
31 | manager.moveCamera(camera)
32 | ```
33 |
34 | ## Set view bounds
35 | Limit everyone's viewing angle to a rectangle centered at world coordinates (0, 0) with a width of 1024 and a height of 768.
36 | ```typescript
37 | manager.setCameraBound({
38 | centerX: 0,
39 | centerY: 0,
40 | width: 1024,
41 | height: 768,
42 | })
43 | ```
44 |
45 | ## Prohibit/allow movement and scaling of `mainView` `camera`
46 | ```typescript
47 | // Prohibit `camera` from moving and zooming
48 | manager.mainView.disableCameraTransform = true
49 |
50 | // restore `camera` movement, scaling
51 | manager.mainView.disableCameraTransform = false
52 | ```
53 | **Note** that when this property is `true`, only device operations are prohibited. You can still actively adjust the perspective using the moveCamera method.
--------------------------------------------------------------------------------
/docs/cn/advanced.md:
--------------------------------------------------------------------------------
1 | ## 进阶使用
2 |
3 | - 目录
4 | - [撤销重做](#redo-undo)
5 | - [清屏](#clean-current-scene)
6 | - [判断是否打开某种 APP](#has-kind)
7 | - [页面控制器](#page-control)
8 | - [视角](#view-mode)
9 | - [插入图片到当前app](#insert-image-to-app)
10 |
11 |
12 | 撤销重做
13 |
14 | > 以下事件和属性都会根据 `focus` 的窗口来进行自动切换应用对象
15 |
16 | #### 获取可以撤销/重做的步数
17 |
18 | ```ts
19 | manager.canUndoSteps
20 | manager.canRedoSteps
21 | ```
22 |
23 | #### 监听可以撤销/重做的步数的变化
24 |
25 | `canRedoStepsChange` 和 `canUndoStepsChange` 会在切换窗口时重新触发
26 |
27 | ```ts
28 | manager.emitter.on("canUndoStepsChange", (steps: number) => {
29 | // 可以撤销的步数更新
30 | })
31 | manager.emitter.on("canRedoStepsChange", (steps: number) => {
32 | // 可以重做的步数更新
33 | })
34 | ```
35 |
36 | #### 撤销/重做
37 |
38 | ```ts
39 | manager.undo() //撤销
40 | manager.redo() // 重做
41 | ```
42 |
43 |
44 |
45 | 清屏
46 |
47 | 因为在多窗口模式下有多个白板, 如果想要清除当前 `focus` 的白板只需要调用
48 |
49 | ```ts
50 | manager.cleanCurrentScene()
51 | ```
52 |
53 | 只想清理主白板的笔迹则需要
54 |
55 | ```ts
56 | manager.mainView.cleanCurrentScene()
57 | ```
58 |
59 |
60 |
61 |
62 | 判断是否打开某种 APP
63 |
64 | ```ts
65 | manager.emitter.on("ready", () => { // ready 事件在所有 app 创建完成后触发
66 | const apps = manager.queryAll(); // 获取所有已经打开的 App
67 | const hasSlide = apps.some(app => app.kind === "Slide"); // 判断已经打开的 APP 中是否有 Slide
68 | });
69 | ```
70 |
71 |
72 |
73 | 页面控制器
74 |
75 | `manager` 提供了一个 `pageState` 来获取当前的 index 和总页数
76 |
77 | ```ts
78 | manager.pageState.index // 当前的 index
79 | manager.pageState.length // 总页数
80 |
81 | manager.emitter.on("pageStateChange", state => {
82 | // 当前 index 变化和总页数变化会触发此事件
83 | });
84 | ```
85 |
86 | 上一页/下一页/添加一页
87 |
88 | ```ts
89 | manager.nextPage()
90 | manager.prevPage()
91 | manager.addPage()
92 | ```
93 |
94 |
95 |
96 | 视角跟随
97 |
98 | 多窗口中 `ViewMode` 有 `broadcaster` `freedom` 两种模式
99 |
100 | - `freedom`
101 |
102 | 自由模式,用户可以自由放缩、移动视角
103 |
104 | 即便房间里有主播,主播也无法影响用户的视角
105 |
106 | - `broadcaster`
107 |
108 | 主播模式, 操作时其他人的视角都会跟随我
109 |
110 | 同时其他为 `broadcaster` 模式的人也会影响我的视角
111 |
112 | 在 `isWritable` 为 `false` 时只会跟随其他 `broadcaster` 的视角
113 |
114 |
115 |
116 | 插入图片到当前 app
117 |
118 | ```ts
119 | // 判断当前是否为最大化
120 | if (manager.boxState === "maximized") {
121 | // `focused` 的值的会根据当前 focus 的 app 不同而变化
122 | const app = manager.queryOne(manager.focused)
123 | // 有 view 的 app 才可以插入图片, 像是 视频,音频之类的 app 是没有 view 的
124 | if (app.view) {
125 | var imageInformation = {
126 | uuid: uuid,
127 | centerX: centerX,
128 | centerY: centerY,
129 | width: width,
130 | height: height,
131 | locked: false,
132 | };
133 | app.view.insertImage(imageInformation);
134 | app.view.completeImageUpload(uuid, src);
135 | }
136 | }
137 | ```
138 |
--------------------------------------------------------------------------------
/docs/cn/app-context.md:
--------------------------------------------------------------------------------
1 | ## AppContext
2 |
3 | `AppContext` 是插件运行时传入的上下文
4 | 你可以通过此对象操作 APP 的 ui, 获取当前房间的状态, 以及订阅状态的变化
5 |
6 | - [api](#api)
7 | - [view](#view)
8 | - [page](#page)
9 | - [storage](#storage)
10 | - [ui(box)](#box)
11 | - [events](#events)
12 | - [Advanced](#Advanced)
13 |
14 | API
15 |
16 | - **context.appId**
17 |
18 | 插入 `app` 时生成的唯一 ID
19 |
20 | ```ts
21 | const appId = context.appId;
22 | ```
23 |
24 | - **context.isReplay**
25 |
26 | 类型: `boolean`
27 |
28 | 当前是否回放模式
29 |
30 | - **context.getDisplayer()**
31 |
32 | 在默认情况下 `Displayer` 为白板的 `room` 实例
33 |
34 | 回放时则为 `Player` 实例
35 |
36 | ```ts
37 | const displayer = context.getDisplayer();
38 |
39 | assert(displayer, room); // 互动房间
40 | assert(displayer, player); // 回放房间
41 | ```
42 |
43 |
44 | - **context.getIsWritable()**
45 |
46 | 获取当前状态是否可写\
47 | 可以通过监听 `writableChange` 事件获取可写状态的改变
48 |
49 | ```ts
50 | const isWritable = context.getIsWritable();
51 | ```
52 |
53 | - **context.getBox()**
54 |
55 | 获取当前 app 的 box
56 |
57 | ```ts
58 | const box = context.getBox();
59 |
60 | box.$content; // box 的 main element
61 | box.$footer;
62 | ```
63 |
64 | 挂载白板
65 |
66 | 当应用想要一个可以涂画的白板,可以使用以下接口
67 |
68 | - **context.mountView()**
69 |
70 | 挂载白板到指定 dom
71 |
72 | ```ts
73 | context.mountView(element);
74 | ```
75 |
76 | **注意** 在调用 `manager` 的 `addApp` 时必须填写 `scenePath` 才可以使用 `view`
77 | ```ts
78 | manager.addApp({
79 | kind: "xxx",
80 | options: { // 可选配置
81 | scenePath: "/example-path"
82 | }
83 | })
84 | ```
85 |
86 | Page
87 |
88 | 白板有多页的概念, 可以通过以下接口添加,切换,以及删除
89 |
90 | - **context.addPage()**
91 |
92 | 添加一页至 `view`
93 |
94 | ```ts
95 | context.addPage() // 默认在最后添加一页
96 | context.addPage({ after: true }) // 在当前页后添加一页
97 | context.addPage({ scene: { name: "page2" } }) // 传入 page 信息
98 | ```
99 |
100 | - **context.nextPage()**
101 |
102 | 上一页
103 |
104 | ```ts
105 | context.nextPage();
106 | ```
107 |
108 | - **context.prevPage()**
109 |
110 | 下一页
111 |
112 | ```ts
113 | context.prevPage();
114 | ```
115 | - **context.removePage()**
116 |
117 | 删除一页
118 |
119 | ```ts
120 | context.removePage() // 默认删除当前页
121 | context.removePage(1) // 也可以指定 index 删除
122 | ```
123 |
124 | - **context.pageState**
125 |
126 | 获取当前所在的 `index` 和一共有多少页\
127 | 当想要监听 `pageState` 的变化时, 可以监听 `pageStateChange` 事件获取最新的 `pageState`
128 |
129 | ```ts
130 | context.pageState;
131 | // {
132 | // index: number,
133 | // length: number,
134 | // }
135 | ```
136 |
137 | storage
138 |
139 | 存储和同步状态,以及发送事件的一系列集合
140 |
141 | - **context.storage**
142 |
143 | 默认创建的 storage 实例
144 |
145 | ```ts
146 | context.storage
147 | ```
148 |
149 | - **context.createStorage(namespace)**
150 |
151 | 同时你也可以创建多个 `storage` 实例
152 |
153 | 返回: `Storage`
154 |
155 | ```ts
156 | type State = { count: number };
157 | const defaultState = { count: 0 };
158 | const storage = context.createStorage("store1", defaultState);
159 | ```
160 |
161 | - **storage.state**
162 |
163 | 类型: `State`\
164 | 默认值: `defaultState`
165 |
166 | 在所有客户端之间同步的状态,调用 `storage.setState()` 来改变它。
167 |
168 | - **storage.ensureState(partialState)**
169 |
170 | 确保 `storage.state` 包含某些初始值,类似于执行了:
171 |
172 | ```js
173 | // 这段代码不能直接运行,因为 app.state 是只读的
174 | storage.state = { ...partialState, ...storage.state };
175 | ```
176 |
177 | **partialState**
178 |
179 | 类型: `Partial`
180 |
181 | ```js
182 | storage.state; // { a: 1 }
183 | storage.ensureState({ a: 0, b: 0 });
184 | storage.state; // { a: 1, b: 0 }
185 | ```
186 |
187 | - **storage.setState(partialState)**
188 |
189 | 和 React 的 `setState` 类似,更新 `storage.state` 并同步到所有客户端。
190 |
191 | 当设置某个字段为 `undefined` 时,它会被从 `storage.state` 里删除。
192 |
193 | > - 状态同步所需的时间和网络状态与数据大小有关,建议只在 state 里存储必须的数据。
194 |
195 | **partialState**
196 |
197 | 类型: `Partial`
198 |
199 | ```js
200 | storage.state; //=> { count: 0, a: 1 }
201 | storage.setState({ count: storage.state.count + 1, b: 2 });
202 | storage.state; //=> { count: 1, a: 1, b: 2 }
203 | ```
204 |
205 | - **storage.addStateChangedListener(listener)**
206 |
207 | 它在有人调用 `storage.setState()` 后触发 (包含当前 `storage`)
208 |
209 | 返回: `() => void`
210 |
211 | ```js
212 | const disposer = storage.addStateChangedListener(diff => {
213 | console.log("state changed", diff.oldValue, diff.newValue);
214 | disposer(); // remove listener by calling disposer
215 | });
216 | ```
217 |
218 | - **context.dispatchMagixEvent(event, payload)**
219 |
220 | 向其他客户端广播事件消息
221 |
222 | ```js
223 | context.dispatchMagixEvent("click", { data: "data" });
224 | ```
225 |
226 | - **context.addMagixEventListener(event, listener)**
227 |
228 | 当接收来自其他客户端的消息时(当其他客户端调用'context.dispatchMagixEvent()`时), 它会被触发
229 |
230 | 返回: `() => void` a disposer function.
231 |
232 | ```js
233 | const disposer = context.addMagixEventListener("click", ({ payload }) => {
234 | console.log(payload.data);
235 | disposer();
236 | });
237 |
238 | context.dispatchMagixEvent("click", { data: "data" });
239 | ```
240 |
241 | UI (box)
242 |
243 | box 是白板为所有应用默认创建的 UI
244 | 应用所有可以操作的 UI 部分都在 box 范围内
245 |
246 | - **context.getBox()**
247 |
248 | 获取 box
249 | 返回类型: `ReadonlyTeleBox`
250 |
251 | - **box.mountStyles()**
252 |
253 | 挂载样式到 `box`
254 | 参数: `string | HTMLStyleElement`
255 |
256 | ```js
257 | const box = context.getBox();
258 | box.mountStyles(`
259 | .app-span {
260 | color: red;
261 | }
262 | `)
263 | ```
264 |
265 | - **box.mountContent()**
266 |
267 | 挂载元素到 `box`
268 | 参数: `HTMLElement`
269 |
270 | ```js
271 | const box = context.getBox();
272 | const content = document.createElement("div");
273 | box.mountContent(context);
274 | ```
275 |
276 | - **box.mountFooter()**
277 |
278 | 挂载元素到 `box` 的 `footer`
279 | 参数: `HTMLElement`
280 |
281 | ```js
282 | const box = context.getBox();
283 | const footer = document.createElement("div");
284 | box.mountFooter(context);
285 | ```
286 |
287 | events
288 |
289 | - **destroy**
290 |
291 | app 被关闭时发送
292 |
293 | ```ts
294 | context.emitter.on("destroy", () => {
295 | // release your listeners
296 | });
297 | ```
298 |
299 | - **writableChange**
300 |
301 | 白板可写状态切换时触发
302 |
303 | ```ts
304 | context.emitter.on("writableChange", isWritable => {
305 | //
306 | });
307 | ```
308 |
309 | - **focus**
310 |
311 | 当前 app 获得焦点或者失去焦点时触发
312 |
313 | ```ts
314 | context.emitter.on("focus", focus => {
315 | //
316 | });
317 | ```
318 |
319 | - **pageStateChange**
320 |
321 | `PageState`
322 |
323 | ```ts
324 | type PateState {
325 | index: number;
326 | length: number;
327 | }
328 | ```
329 |
330 | 当前页数和总页数变化时触发
331 |
332 | ```ts
333 | context.emitter.on("pageStateChange", pageState => {
334 | // { index: 0, length: 1 }
335 | });
336 | ```
337 | - **roomStageChange**
338 |
339 | 房间的状态变化时触发\
340 | 比如当教具切换时
341 |
342 | ```js
343 | context.emitter.on("roomStageChange", stage => {
344 | if (state.memberState) {
345 | console.log("appliance change to", state.memberState.currentApplianceName);
346 | }
347 | });
348 | ```
349 |
350 | 或者是当前房间人数变化时
351 |
352 | ```js
353 | context.emitter.on("roomStageChange", stage => {
354 | if (state.roomMembers) {
355 | console.log("current room members change", state.roomMembers);
356 | }
357 | });
358 | ```
359 | 详细状态的介绍请参考 https://developer.netless.link/javascript-zh/home/business-state-management
360 |
361 | Advanced
362 |
363 | - **context.getView()**
364 |
365 | 获取 `view` 实例
366 |
367 | ```ts
368 | const view = context.getView();
369 | ```
--------------------------------------------------------------------------------
/docs/cn/basic.md:
--------------------------------------------------------------------------------
1 | # 基础教程
2 | `WindowManager` 内置了 `DocsViewer` 和 `MediaPlayer` 用来播放 PPT 和音视频
3 |
4 | ## 打开动态/静态 PPT
5 | ```typescript
6 | import { BuiltinApps } from "@netless/window-manager";
7 |
8 | const appId = await manager.addApp({
9 | kind: BuiltinApps.DocsViewer,
10 | options: {
11 | scenePath: "/docs-viewer", // 定义 ppt 所在的 scenePath
12 | title: "docs1", // 可选
13 | scenes: [], // SceneDefinition[] 静态/动态 Scene 数据
14 | },
15 | });
16 | ```
17 |
18 | ## 打开音视频
19 | ```typescript
20 | import { BuiltinApps } from "@netless/window-manager";
21 |
22 | const appId = await manager.addApp({
23 | kind: BuiltinApps.MediaPlayer,
24 | options: {
25 | title: "test.mp3", // 可选
26 | },
27 | attributes: {
28 | src: "xxxx", // 音视频 url
29 | },
30 | });
31 | ```
32 |
33 |
34 | ## 查询所有的 App
35 | ```typescript
36 | const apps = manager.queryAll();
37 | ```
38 |
39 | ## 查询单个 APP
40 | ```typescript
41 | const app = manager.queryOne(appId);
42 | ```
43 |
44 | ## 关闭 App
45 | ```typescript
46 | manager.closeApp(appId);
47 | ```
48 |
49 | ## events
50 |
51 | ### 窗口最小化最大化
52 | ```typescript
53 | manager.emitter.on("boxStateChange", state => {
54 | // maximized | minimized | normal
55 | });
56 | ```
57 |
58 | ### 视角跟随模式
59 | ```typescript
60 | manager.emitter.on("broadcastChange", state => {
61 | // state: number | undefined
62 | });
63 | ```
64 |
65 |
--------------------------------------------------------------------------------
/docs/cn/camera.md:
--------------------------------------------------------------------------------
1 | # 视角
2 | 在多窗口模式下, 可以同时存在多块白板,但是大多数情况下用户都只需要对主白板也就是 `mainView` 进行操作
3 |
4 |
5 | ## 获取 `mainView` 的 `camera`
6 | ```typescript
7 | manager.mainView.camera
8 | ```
9 |
10 | ## 获取 `mainView` 的 `size`
11 | ```typescript
12 | manager.mainView.size
13 | ```
14 |
15 | ## 监听 `mainView` 的 `camera` 变化
16 | ```typescript
17 | manager.mainView.callbacks.on("onCameraUpdated", camera => {
18 | // 更新后的 camera
19 | })
20 | ```
21 |
22 | ## 监听 `mainView` 的 `size` 的变化
23 | ```typescript
24 | manager.mainView.callbacks.on("onSizeUpdated", camera => {
25 | // 更新后的 size
26 | })
27 | ```
28 |
29 | ## 通过 `api` 移动 `camera`
30 | ```typescript
31 | manager.moveCamera(camera)
32 | ```
33 |
34 | ## 设置视角边界
35 | 把所有人的视角限制在以世界坐标 (0, 0) 为中心,宽为 1024,高为 768 的矩形之中。
36 | ```typescript
37 | manager.setCameraBound({
38 | centerX: 0,
39 | centerY: 0,
40 | width: 1024,
41 | height: 768,
42 | })
43 | ```
44 |
45 | ## 禁止/允许 `mainView` `camera` 的移动,缩放
46 | ```typescript
47 | // 禁止 `camera` 移动,缩放
48 | manager.mainView.disableCameraTransform = true
49 |
50 | // 恢复 `camera` 移动,缩放
51 | manager.mainView.disableCameraTransform = false
52 | ```
53 | **注意**,该属性为 `true` 时,仅仅禁止设备操作。你依然可以用 `moveCamera` 方法主动调整视角。
54 |
--------------------------------------------------------------------------------
/docs/cn/concept.md:
--------------------------------------------------------------------------------
1 | # 概念
2 |
3 | ## 同步区域
4 |
5 | 在不同分辨率的设备上,想要看到相同的区域和窗口,我们就需要在所有设备保持一个相同的比例。
6 |
7 | 所以 `WindowManager` 有一个 `containerSizeRatio` 的选项来配置白板的宽高比,默认为 `9 / 16`
8 |
9 | 如果外层给到 `WindowManager` 宽高并不是完美适配这个宽高比的话, `WindowManger` 会自动在内部算出一个适配这个比例的最大宽高,然后填充上去,这时在内部就会有一些区域不能操作
10 |
--------------------------------------------------------------------------------
/docs/cn/custom-max-bar.md:
--------------------------------------------------------------------------------
1 | ## 如何自定义最大化 `titleBar`
2 |
3 | 获取并订阅所有的 `box`
4 |
5 | ```js
6 | manager.boxManager.teleboxManager.boxes$.subscribe(boxes => {
7 | // boxes 为所有的窗口,当窗口添加和删除时都会触发
8 | })
9 | ```
10 |
11 | 切换 `box` 的 `focus`
12 | ```js
13 | manager.boxManager.teleBoxManager.focusBox(box)
14 | ```
15 |
16 | 关闭某个 `box`
17 | ```js
18 | manager.boxManager.teleBoxManager.remove(box)
19 | ```
20 |
21 | 切换最大化状态
22 | ```js
23 | manager.boxManager.teleBoxManager.setMaximized(false)
24 | manager.boxManager.teleBoxManager.setMaximized(true)
25 | ```
26 |
27 | 切换最小化状态
28 | ```js
29 | manager.boxManager.teleBoxManager.setMinimized(true)
30 | manager.boxManager.teleBoxManager.setMaximized(false)
31 | ```
--------------------------------------------------------------------------------
/docs/cn/develop-app.md:
--------------------------------------------------------------------------------
1 | # 开发自定义 APP
2 |
3 | - [AppContext](./app-context.md)
4 |
5 | ## official apps https://github.com/netless-io/netless-app
6 |
7 | ## app-with-whiteboard
8 |
9 | 如果需要 app 中挂载白板请参考 [board.tsx](https://github.com/netless-io/window-manager/blob/master/example/app/board.tsx)
10 |
11 |
12 |
13 | ```ts
14 | import type { NetlessApp, AppContext } from "@netless/window-manager";
15 |
16 | const HelloWorld: NetlessApp = {
17 | kind: "HelloWorld",
18 | setup: (context: AppContext) => {
19 | context.mountView(context.getBox().$content); // 可选: 挂载 View 到 box 上
20 | },
21 | };
22 |
23 | WindowManager.register({
24 | kind: HelloWorld.kind,
25 | src: HelloWorld,
26 | });
27 |
28 | manager.addApp({
29 | kind: "HelloWorld",
30 | options: {
31 | scenePath: "/hello-world", // 如果需要在 App 中使用白板则必须声明 scenePath
32 | },
33 | });
34 | ```
35 |
36 | ## Counter
37 |
38 | ```ts
39 | const Counter: NetlessApp<{ count: number }> = {
40 | kind: "Counter",
41 | setup: (context) => {
42 | const storage = context.storage;
43 | storage.ensureState({ count: 0 });
44 |
45 | const box = context.getBox(); // box 为这个应用打开的窗口
46 | const $content = box.$content // 获取窗口的 content
47 |
48 | const countDom = document.createElement("div");
49 | countDom.innerText = storage.state.count.toString();
50 | $content.appendChild(countDom);
51 |
52 | // 监听 state 变化回调
53 | storage.addStateChangedListener(diff => {
54 | if (diff.count) {
55 | // diff 会给出 newValue 和 oldValue
56 | console.log(diff.count.newValue);
57 | console.log(diff.count.oldValue);
58 | countDom.innerText = diff.count.newValue.toString();
59 | }
60 | });
61 |
62 | const incButton = document.createElement("button");
63 | incButton.innerText = "Inc";
64 | const incButtonOnClick = () => {
65 | storage.setState({ count: storage.state.count + 1 });
66 | }
67 | incButton.addEventListener("click", incButtonOnClick);
68 | $content.appendChild(incButton);
69 |
70 | const decButton = document.createElement("button");
71 | decButton.innerText = "Dec";
72 | const decButtonOnClick = () => {
73 | storage.setState({ count: storage.state.count - 1 });
74 | }
75 | decButton.addEventListener("click", decButtonOnClick);
76 | $content.appendChild(decButton);
77 |
78 | // 监听事件
79 | const event1Disposer = context.addMagixEventListener("event1", msg => {
80 | console.log("event1", msg);
81 | });
82 |
83 | // 向打开 app 的其他人发送消息
84 | context.dispatchMagixEvent("event1", { count: 10 });
85 |
86 | // 应用销毁时, 注意清理掉监听器
87 | context.emitter.on("destroy", () => {
88 | incButton.removeEventListener("click", incButtonOnClick);
89 | decButton.removeEventListener("click", decButtonOnClick);
90 | event1Disposer();
91 | });
92 | }
93 | }
94 | ```
--------------------------------------------------------------------------------
/docs/cn/export-pdf.md:
--------------------------------------------------------------------------------
1 | # 导出 PDF
2 |
3 | 此功能需要额外安装 `jspdf` 依赖才能使用
4 |
5 | ```
6 | npm install jspdf@2.5.1
7 | ```
8 |
9 | ### 支持的 app 及版本
10 |
11 | 1. @netless/app-slide `0.2.23` 及以上版本支持保存动态 ppt 板书
12 |
13 | 2. @netless/app-docs-viewer `0.2.10` 及以上版本支持保存 pdf 板书, **注意** app-docs-viewer 中可以展示静态 ppt, pdf, 动态 ppt. 其中只有 pdf 文件支持保存板书
14 |
15 | 对应 @netless/window-manager `0.4.50` 及以上
16 |
17 | 3. white-web-sdk `2.16.37` 及以上
18 |
19 | ### 发起保存板书任务
20 |
21 | 通过 `window.postMessage` 发事件来发起保存板书任务, 注意不要在任务尚未完成之前重复发送该事件.
22 |
23 | ```js
24 | window.postMessage({
25 | type: "@netless/_request_save_pdf_",
26 | appId: /* windowManager.addApp 返回的值, 指定要保存哪个窗口的板书, */
27 | })
28 | ```
29 |
30 | ### 获取任务进度
31 |
32 | 任务进度也通过 message 事件传递, 你需要在发起任务之前监听任务进度事件, 实例代码如下所示.
33 | 其中 data.result 只有在任务成功时候才有值, 任务失败或者任务进行中都为 null.
34 | **如果下载任务失败, 则 progress 为 100 但是 result 为 null.**
35 |
36 | ```js
37 | window.addEventListener("message", evt => {
38 | if (evt.data.type === "@netless/_result_save_pdf_") {
39 | console.log(evt.data);
40 | // data 包含如下属性
41 | // data.type: 固定值 "@netless/_result_save_pdf_"
42 | // data.appId: 指明是哪次下载任务, 与发起保存板书时候传递的 appId 值一致
43 | // data.progress: 下载进度, 0 ~ 100
44 | // data.result: { pdf: ArrayBuffer {}, title: "a.pptx" } 或者 null, 为板书的 pdf 文件内容,
45 | // 仅当下载进度 100 时才有值. 获取到 ArrayBuffer 后需要自行完成下载到本地的逻辑.
46 | }
47 | });
48 | ```
49 |
--------------------------------------------------------------------------------
/docs/cn/migrate.md:
--------------------------------------------------------------------------------
1 |
2 | ### 注意事项
3 |
4 | 多窗口模式必须开启白板的 `useMultiViews` 和 `useMobXState` 选项
5 |
6 | 会造成原本以下 `room` 上的一些方法和 `state` 失效
7 |
8 | `方法`
9 |
10 | - `room.bindHtmlElement()` 用 `WindowManager.mount()` 代替
11 | - `room.scalePptToFit()` 暂无代替,不再推荐调用 `scalePptToFit`
12 | - `room.setScenePath()` 用 `manager.setMainViewScenePath()` 代替
13 | - `room.setSceneIndex()` 用 `manager.setMainViewSceneIndex()` 代替
14 |
15 | > 为了方便使用 `manager` 替换了 `room` 上的一些方法可以直接对 `mainView` 生效
16 |
17 | - `room.disableCameraTransform`
18 | - `room.moveCamera`
19 | - `room.moveCameraToContain`
20 | - `room.convertToPointInWorld`
21 | - `room.setCameraBound`
22 |
23 | `camera`
24 |
25 | - `room.state.cameraState` 用 `manager.mainView.camera` 和 `manager.mainView.size` 代替
26 |
27 | 想要监听主白板 `camera` 的变化, 请使用如下方式代替
28 |
29 | ```javascript
30 | manager.mainView.callbacks.on("onCameraUpdated", camera => {
31 | console.log(camera);
32 | });
33 | ```
34 |
35 | 监听主白板 `size` 变化
36 |
37 | ```javascript
38 | manager.mainView.callbacks.on("onSizeUpdated", size => {
39 | console.log(size);
40 | });
41 | ```
42 |
43 |
44 |
45 | ## `white-web-sdk` 从 `2.15.x` 迁移至 `2.16.x`
46 |
47 | ### `room.setMemberState`
48 |
49 | 此方法在开启多窗口时要等待 `WindowManager` 挂载完成后才可以直接调用。
50 |
51 | 或者使用 `manager.mainView.setMemberState` 代替
52 |
53 |
54 |
55 | ### `room.pptPreviousStep` `room.pptNextStep` 切换上下页
56 |
57 | 因为窗口实现机制的改变, `pptPreviousStep` 和 `pptNextStep` 不再生效
58 |
59 | 如果需要切换主白板的上下页, 请使用 `manager.nextPage` 和 `manager.prevPage`
60 |
61 |
--------------------------------------------------------------------------------
/docs/cn/quickstart.md:
--------------------------------------------------------------------------------
1 | # 快速上手
2 |
3 | ## 安装
4 | 通过 `npm` 或 `yarn` 来安装 `WindowManager`.
5 | ```shell
6 | # npm
7 | $ npm install @netless/window-manager
8 |
9 | # yarn
10 | $ yarn add @netless/window-manager
11 | ```
12 |
13 | 引用
14 | ```typescript
15 | import { WhiteWindowSDK } from "@netless/window-manager";
16 | import "@netless/window-manager/dist/style.css";
17 | ```
18 |
19 | ## 开始使用
20 |
21 | ### 准备容器
22 | 在页面中创建一个用于挂载的容器
23 | ```html
24 |
25 | ```
26 |
27 | ### 初始化 SDK
28 | ```typescript
29 | const sdk = new WhiteWindowSDK({
30 | appIdentifier: "appIdentifier"
31 | })
32 | ```
33 |
34 | ### 加入房间并挂载容器
35 | ```typescript
36 | const manager = await sdk.mount({
37 | joinRoomParams: {
38 | uuid: "room uuid",
39 | roomToken: "room token",
40 | },
41 | mountParams: {
42 | container: document.getElementById("container")
43 | }
44 | })
45 | ```
46 |
47 | ### 卸载
48 | ```typescript
49 | manager.destroy();
50 | ```
51 |
--------------------------------------------------------------------------------
/docs/cn/replay.md:
--------------------------------------------------------------------------------
1 | ## 回放
2 |
3 | > 注意: 多窗口的回放只支持从创建房间开始就是多窗口的房间
4 |
5 | > 如果是一开始作为单窗口模式使用,又转变成多窗口模式使用, 则会造成回放渲染空白
6 |
7 |
8 |
9 |
10 | ```typescript
11 | import { WhiteWebSdk } from "white-web-sdk";
12 | import { WindowManager, BuiltinApps } from "@netless/window-manager";
13 | import "@netless/window-manager/dist/style.css";
14 |
15 | const sdk = new WhiteWebSdk({
16 | appIdentifier: "appIdentifier",
17 | useMobXState: true // 请确保打开这个选项
18 | });
19 |
20 | let manager: WindowManager;
21 |
22 | sdk.replayRoom({
23 | uuid: "room uuid",
24 | roomToken: "room token",
25 | invisiblePlugins: [WindowManager],
26 | useMultiViews: true, // 多窗口必须用开启 useMultiViews
27 | }).then(player => {
28 | player.callbacks.on("onPhaseChanged", async (phase) => {
29 | if (phase === PlayerPhase.Playing) {
30 | if (manager) return;
31 | manager = await WindowManager.mount({
32 | room: player,
33 | container: document.getElementById("container")
34 | });
35 | }
36 | })
37 | });
38 |
39 | player.play(); // WindowManager 只有在播放之后才能挂载
40 | ```
--------------------------------------------------------------------------------
/docs/concept.md:
--------------------------------------------------------------------------------
1 | # Concept
2 |
3 | ## Sync Zone
4 |
5 | On devices with different resolutions, if we want to see the same area and window, we need to maintain the same ratio on all devices.
6 |
7 | So `WindowManager` has a `containerSizeRatio` option to configure the aspect ratio of the whiteboard, the default is `9 / 16`
8 |
9 | If the width and height given to WindowManager by the outer layer do not perfectly fit this aspect ratio, WindowManger will automatically calculate a maximum width and height that fits this ratio internally, and then fill it in. At this time, there will be some internal areas that cannot be operated
10 |
11 |
--------------------------------------------------------------------------------
/docs/custom-max-bar.md:
--------------------------------------------------------------------------------
1 | ## How to customize maximized `titleBar`
2 |
3 | Get and subscribe to all `box`
4 |
5 | ```js
6 | manager.boxManager.teleboxManager.boxes$.subscribe(boxes => {
7 | // boxes are all windows, trigger when windows are added and deleted
8 | })
9 | ```
10 |
11 | Toggle `focus` of `box`
12 | ```js
13 | manager.boxManager.teleBoxManager.focusBox(box)
14 | ```
15 |
16 | close a `box`
17 | ```js
18 | manager.boxManager.teleBoxManager.remove(box)
19 | ```
20 |
21 | Toggle maximized state
22 | ```js
23 | manager.boxManager.teleBoxManager.setMaximized(false)
24 | manager.boxManager.teleBoxManager.setMaximized(true)
25 | ```
26 |
27 | Toggle minimized state
28 | ```js
29 | manager.boxManager.teleBoxManager.setMinimized(true)
30 | manager.boxManager.teleBoxManager.setMaximized(false)
31 | ```
--------------------------------------------------------------------------------
/docs/develop-app.md:
--------------------------------------------------------------------------------
1 | # Develop custom APP
2 |
3 | - [AppContext](./app-context.md)
4 |
5 | ## official apps https://github.com/netless-io/netless-app
6 |
7 | ## app-with-whiteboard
8 |
9 | If you need to mount a whiteboard in the app, please refer to [board.tsx](https://github.com/netless-io/window-manager/blob/master/example/app/board.tsx)
10 |
11 |
12 |
13 | ```ts
14 | import type { NetlessApp, AppContext } from "@netless/window-manager";
15 |
16 | const HelloWorld: NetlessApp = {
17 | kind: "Hello World",
18 | setup: (context: AppContext) => {
19 | context.mountView(context.getBox().$content); // optional: mount the View to the box
20 | },
21 | };
22 |
23 | WindowManager.register({
24 | kind: HelloWorld.kind,
25 | src: HelloWorld,
26 | });
27 |
28 | manager.addApp({
29 | kind: "Hello World",
30 | options: {
31 | scenePath: "/hello-world", // If you need to use the whiteboard in the app, you must declare scenePath
32 | },
33 | });
34 | ```
35 |
36 | ## Counter
37 |
38 | ```ts
39 | const Counter: NetlessApp<{ count: number }> = {
40 | kind: "Counter",
41 | setup: context => {
42 | const storage = context.storage;
43 | storage.ensureState({ count: 0 });
44 |
45 | const box = context.getBox(); // box is the window opened for this application
46 | const $content = box.$content; // Get the content of the window
47 |
48 | const countDom = document.createElement("div");
49 | countDom.innerText = storage.state.count.toString();
50 | $content.appendChild(countDom);
51 |
52 | // Listen for state change callbacks
53 | storage.addStateChangedListener(diff => {
54 | if (diff.count) {
55 | // diff will give newValue and oldValue
56 | console.log(diff.count.newValue);
57 | console.log(diff.count.oldValue);
58 | countDom.innerText = diff.count.newValue.toString();
59 | }
60 | });
61 |
62 | const incButton = document.createElement("button");
63 | incButton.innerText = "Inc";
64 | const incButtonOnClick = () => {
65 | storage.setState({ count: storage.state.count + 1 });
66 | };
67 | incButton.addEventListener("click", incButtonOnClick);
68 | $content.appendChild(incButton);
69 |
70 | const decButton = document.createElement("button");
71 | decButton.innerText = "Dec";
72 | const decButtonOnClick = () => {
73 | storage.setState({ count: storage.state.count - 1 });
74 | };
75 | decButton.addEventListener("click", decButtonOnClick);
76 | $content.appendChild(decButton);
77 |
78 | // listen for events
79 | const event1Disposer = context.addMagixEventListener("event1", msg => {
80 | console.log("event1", msg);
81 | });
82 |
83 | // Send a message to other people who have the app open
84 | context.dispatchMagixEvent("event1", { count: 10 });
85 |
86 | // When the application is destroyed, pay attention to clean up the listener
87 | context.emitter.on("destroy", () => {
88 | incButton.removeEventListener("click", incButtonOnClick);
89 | decButton.removeEventListener("click", decButtonOnClick);
90 | event1Disposer();
91 | });
92 | },
93 | };
94 | ```
95 |
--------------------------------------------------------------------------------
/docs/export-pdf.md:
--------------------------------------------------------------------------------
1 | # Export PDF
2 |
3 | This feature requires additional installation of the `jspdf` dependency to use
4 |
5 | ```
6 | npm install jspdf@2.5.1
7 | ```
8 |
9 | ### Supported apps and versions
10 |
11 | 1. @netless/app-slide `0.2.23` and above support saving dynamic ppt board writing
12 |
13 | 2. @netless/app-docs-viewer `0.2.10` and above support saving pdf board writing, **Note** app-docs-viewer can show static ppt, pdf, dynamic ppt. Only pdf files support saving board writing
14 |
15 | Only pdf files can be saved in the app-docs-viewer, which is compatible with @netless/window-manager `0.4.50` and above.
16 |
17 | 3. white-web-sdk `2.16.37` and above
18 |
19 | ### Initiate a save board writing task
20 |
21 | Launch the save board writing task with a `window.postMessage` event, and be careful not to repeat the event before the task is completed.
22 |
23 | ```js
24 | window.postMessage({
25 | type: "@netless/_request_save_pdf_",
26 | appId: /* windowManager.addApp return value, specify which window to save the board writing, */
27 | })
28 | ```
29 |
30 | ### Get task progress
31 |
32 | Task progress is also passed through the message event, you need to listen to the task progress event before launching the task, the example code is shown below.
33 | The data.result will only have a value if the task succeeds, and will be null if the task fails or is in progress.
34 | **If the download task fails, then progress is 100 but result is null.**
35 |
36 | ```js
37 | window.addEventListener("message", evt => {
38 | if (evt.data.type === "@netless/_result_save_pdf_") {
39 | console.log(evt.data);
40 | // data contains the following properties
41 | // data.type: fixed value "@netless/_result_save_pdf_"
42 | // data.appId: specifies which download task, same as the appId value passed when the board writing was saved
43 | // data.progress: progress of the download, 0 ~ 100
44 | // data.result: { pdf: ArrayBuffer {}, title: "a.pptx" } or null, the contents of the pdf file for the board writing.
45 | // value only when the download progresses to 100. After getting the ArrayBuffer you need to complete the logic of downloading to local.
46 | }
47 | });
48 | ```
49 |
--------------------------------------------------------------------------------
/docs/migrate.md:
--------------------------------------------------------------------------------
1 | ### Precautions
2 |
3 | Multi-window mode must enable whiteboard `useMultiViews` and `useMobXState` options
4 |
5 | It will cause some methods and `state` on the following `room` to fail
6 |
7 | `method`
8 |
9 | - `room.bindHtmlElement()` is replaced by `WindowManager.mount()`
10 | - There is no replacement for `room.scalePptToFit()`, calling `scalePptToFit` is no longer recommended
11 | - `room.setScenePath()` is replaced by `manager.setMainViewScenePath()`
12 | - `room.setSceneIndex()` is replaced by `manager.setMainViewSceneIndex()`
13 |
14 | > In order to use `manager` to replace some methods on `room`, it can directly take effect on `mainView`
15 |
16 | - `room.disableCameraTransform`
17 | - `room.moveCamera`
18 | - `room.moveCameraToContain`
19 | - `room.convertToPointInWorld`
20 | - `room.setCameraBound`
21 |
22 | `camera`
23 |
24 | - `room.state.cameraState` is replaced by `manager.mainView.camera` and `manager.mainView.size`
25 |
26 | If you want to monitor the main whiteboard `camera` changes, please use the following method instead
27 |
28 | ```javascript
29 | manager.mainView.callbacks.on("onCameraUpdated", camera => {
30 | console.log(camera);
31 | });
32 | ```
33 |
34 | Monitor main whiteboard `size` changes
35 |
36 | ```javascript
37 | manager.mainView.callbacks.on("onSizeUpdated", size => {
38 | console.log(size);
39 | });
40 | ```
41 |
42 |
43 |
44 | ## `white-web-sdk` migrated from `2.15.x` to `2.16.x`
45 |
46 | ### `room.setMemberState`
47 |
48 | This method can be called directly after waiting for `WindowManager` to be mounted when multi-window is enabled.
49 |
50 | Or use `manager.mainView.setMemberState` instead
51 |
52 |
53 |
54 | ### `room.pptPreviousStep` `room.pptNextStep` Switch to the next page
55 |
56 | `pptPreviousStep` and `pptNextStep` no longer work due to changes in window implementation
57 |
58 | If you need to switch the top and bottom pages of the main whiteboard, please use `manager.nextPage` and `manager.prevPage`
59 |
--------------------------------------------------------------------------------
/docs/quickstart.md:
--------------------------------------------------------------------------------
1 | # Get started quickly
2 |
3 | ## Install
4 | Install `WindowManager` via `npm` or `yarn`.
5 | ```bash
6 | # npm
7 | $ npm install @netless/window-manager
8 |
9 | # yarn
10 | $ yarn add @netless/window-manager
11 | ```
12 |
13 | Import:
14 | ```typescript
15 | import { WhiteWindowSDK } from "@netless/window-manager";
16 | import "@netless/window-manager/dist/style.css";
17 | ```
18 |
19 | ## Start using
20 |
21 | ### Prepare container
22 | Create a container for mounting in the page
23 | ```html
24 |
25 | ```
26 |
27 | ### Initialize the SDK
28 | ```typescript
29 | const sdk = new WhiteWindowSDK({
30 | appIdentifier: "appIdentifier"
31 | })
32 | ```
33 |
34 | ### Join the room and mount the container
35 | ```typescript
36 | const manager = await sdk.mount({
37 | joinRoomParams: {
38 | uuid: "room uuid",
39 | roomToken: "room token",
40 | },
41 | mountParams: {
42 | container: document.getElementById("container")
43 | }
44 | })
45 | ```
46 |
47 | ### Uninstall
48 | ```typescript
49 | manager.destroy();
50 | ```
51 |
--------------------------------------------------------------------------------
/docs/replay.md:
--------------------------------------------------------------------------------
1 | ## playback
2 |
3 | > Note: Multi-window playback only supports multi-window rooms from the creation of the room
4 |
5 | > If it is used as a single-window mode at the beginning and then converted to a multi-window mode, it will cause blank playback rendering
6 |
7 |
8 |
9 |
10 | ```typescript
11 | import { WhiteWebSdk } from "white-web-sdk";
12 | import { WindowManager, BuiltinApps } from "@netless/window-manager";
13 | import "@netless/window-manager/dist/style.css";
14 |
15 | const sdk = new WhiteWebSdk({
16 | appIdentifier: "appIdentifier",
17 | useMobXState: true // make sure this option is turned on
18 | });
19 |
20 | let manager: WindowManager;
21 |
22 | sdk.replayRoom({
23 | uuid: "room uuid",
24 | roomToken: "room token",
25 | invisiblePlugins: [WindowManager],
26 | useMultiViews: true, // Multi-window must be enabled useMultiViews
27 | }).then(player => {
28 | player.callbacks.on("onPhaseChanged", async (phase) => {
29 | if (phase === PlayerPhase.Playing) {
30 | if (manager) return;
31 | manager = await WindowManager.mount({
32 | room: player,
33 | container: document.getElementById("container")
34 | });
35 | }
36 | })
37 | });
38 |
39 | player.play(); // WindowManager can only be mounted after playing
40 | ```
41 |
--------------------------------------------------------------------------------
/e2e/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "projectId": "window-manager",
3 | "baseUrl": "http://localhost:4000",
4 | "integrationFolder": "cypress/integration",
5 | "testFiles": "**/*.spec.ts",
6 | "video": false
7 | }
8 |
--------------------------------------------------------------------------------
/e2e/cypress/integration/boxstate.spec.ts:
--------------------------------------------------------------------------------
1 | import { TELE_BOX_STATE } from "@netless/telebox-insider";
2 | import type { WindowManager } from "../../../dist";
3 | import { HelloWorldApp } from "../../../example/app/helloworld-app";
4 | import sinon from "sinon";
5 | import "./common";
6 |
7 | describe("boxState", () => {
8 | before(() => {
9 | sinon.restore();
10 | cy.visit("/");
11 | cy.wait(8000);
12 | });
13 |
14 | afterEach(() => {
15 | cy.wait(1000);
16 | });
17 |
18 | after(() => {
19 | cy.window().then(async (window: any) => {
20 | const manager = window.manager as WindowManager;
21 | const apps = manager.queryAll();
22 | if (apps.length > 0) {
23 | apps.forEach(app => {
24 | manager.closeApp(app.id);
25 | });
26 | }
27 | });
28 | });
29 |
30 | it("添加一个 App", () => {
31 | cy.window().then(async (window: any) => {
32 | const manager = window.manager as WindowManager;
33 | const WindowManager = window.WindowManager;
34 | const onFocus = sinon.spy();
35 | const onCreated = sinon.spy();
36 |
37 | WindowManager.register({
38 | kind: "HelloWorld",
39 | src: HelloWorldApp,
40 | addHooks: (emitter: any) => {
41 | emitter.on("focus", () => onFocus());
42 | emitter.on("created", () => onCreated());
43 | },
44 | });
45 |
46 | const apps = manager.queryAll();
47 | if (apps.length === 0) {
48 | cy.wrap(null).then(() => {
49 | return manager.addApp({
50 | kind: "HelloWorld",
51 | options: {
52 | scenePath: "/helloworld1",
53 | },
54 | });
55 | });
56 | cy.wait(100).then(() => {
57 | expect(onCreated.calledOnce).to.be.true;
58 | expect(onFocus.calledOnce).to.be.true;
59 | });
60 | }
61 | });
62 | });
63 |
64 | it("最大化", () => {
65 | cy.window().then(async (window: any) => {
66 | const manager = window.manager;
67 | const apps = manager.queryAll();
68 | const app = apps[0];
69 | expect(manager.boxState).to.be.equal(TELE_BOX_STATE.Normal);
70 | cy.get(`[data-tele-box-i-d=${app.id}] .telebox-titlebar-icon-maximize`).click({
71 | force: true,
72 | });
73 | cy.wait(500).then(() => {
74 | expect(manager.boxState).to.be.equal(TELE_BOX_STATE.Maximized);
75 | });
76 | cy.get(`[data-tele-box-i-d=${app.id}] .telebox-titlebar-icon-maximize`).click({
77 | force: true,
78 | });
79 | cy.wait(500).then(() => {
80 | expect(manager.boxState).to.be.equal(TELE_BOX_STATE.Normal);
81 | });
82 | });
83 | });
84 |
85 | it("最小化", () => {
86 | cy.window().then(async (window: any) => {
87 | const manager = window.manager;
88 | const apps = manager.queryAll();
89 | const app = apps[0];
90 | expect(manager.boxState).to.be.equal(TELE_BOX_STATE.Normal);
91 | cy.get(`[data-tele-box-i-d=${app.id}] .telebox-titlebar-icon-minimize`).click({
92 | force: true,
93 | });
94 | cy.wait(500).then(() => {
95 | expect(manager.boxState).to.be.equal(TELE_BOX_STATE.Minimized);
96 | cy.get(".telebox-collector.telebox-collector-visible").should("have.length", 1);
97 | });
98 | });
99 | });
100 |
101 | it("从最小化恢复 focus topBox", () => {
102 | cy.window().then(async (window: any) => {
103 | const manager = window.manager as WindowManager;
104 | const apps = manager.queryAll();
105 | const app = apps[0];
106 | expect(manager.boxState).to.be.equal(TELE_BOX_STATE.Minimized);
107 | cy.get(`.telebox-collector.telebox-collector-visible`).click({ force: true });
108 | cy.wait(500).then(() => {
109 | expect(manager.boxState).to.be.equal(TELE_BOX_STATE.Normal);
110 | expect(manager.focused).to.be.equal(app.id);
111 | });
112 | });
113 | });
114 | });
115 |
116 | export {};
117 |
--------------------------------------------------------------------------------
/e2e/cypress/integration/common.ts:
--------------------------------------------------------------------------------
1 | const resizeObserverLoopErrRe = /^ResizeObserver loop limit exceeded/;
2 |
3 | Cypress.on("uncaught:exception", err => {
4 | if (resizeObserverLoopErrRe.test(err.message)) {
5 | return false;
6 | }
7 | });
8 |
--------------------------------------------------------------------------------
/e2e/cypress/integration/cursor.spec.ts:
--------------------------------------------------------------------------------
1 | import { RoomPhase } from "white-web-sdk";
2 | import type { Room } from "white-web-sdk";
3 |
4 | describe("光标", () => {
5 | before(() => {
6 | cy.visit("/");
7 | cy.wait(8000);
8 | });
9 |
10 | afterEach(() => {
11 | cy.wait(1000);
12 | });
13 |
14 | it("room members 数据正确", () => {
15 | cy.window().then((window: any) => {
16 | const manager = window.manager;
17 | const room = window.room as Room;
18 | expect(room.phase).to.be.equal(RoomPhase.Connected);
19 | expect(manager).to.be.a("object");
20 | expect(room.isWritable).to.be.true;
21 | cy.get(".netless-whiteboard").should("have.length", 1);
22 | expect(room.state.roomMembers.length).to.be.gte(2);
23 | });
24 | });
25 |
26 | // 光标实现方式修改, 不再有默认的 dom
27 | // it("光标 dom 存在", () => {
28 | // cy.window().then((window: any) => {
29 | // const room = window.room as Room;
30 | // cy.get(".netless-window-manager-cursor-mid").should("have.length", 1);
31 | // expect(room.state.roomMembers.length).to.be.gte(2);
32 | // });
33 | // })
34 | });
35 |
36 | export {};
37 |
--------------------------------------------------------------------------------
/e2e/cypress/integration/index.spec.ts:
--------------------------------------------------------------------------------
1 | import { RoomPhase } from "white-web-sdk";
2 |
3 | describe("正常流程", () => {
4 | before(() => {
5 | cy.visit("/");
6 | cy.wait(8000);
7 | });
8 |
9 | afterEach(() => {
10 | cy.wait(1000);
11 | });
12 |
13 | it("挂载成功", () => {
14 | cy.window().then((window: any) => {
15 | const manager = window.manager;
16 | const room = window.room;
17 | expect(room).to.be.a("object");
18 | expect(room.phase).to.be.equal(RoomPhase.Connected);
19 | expect(manager).to.be.a("object");
20 | });
21 | });
22 |
23 | it("插入一个 APP", () => {
24 | cy.window().then(async (window: any) => {
25 | const manager = window.manager;
26 | const appId = await manager.addApp({
27 | kind: "HelloWorld",
28 | options: {
29 | scenePath: "/helloworld1",
30 | },
31 | });
32 | cy.wait(1000).then(() => {
33 | expect(appId).to.be.string;
34 | cy.get(".telebox-box").should("have.length", 1);
35 | expect(manager.focused).to.be.equal(appId);
36 | const app = manager.queryOne(appId);
37 | expect(app).to.be.a("object");
38 | expect(app.view.focusScenePath).to.be.match(/helloworld1/);
39 | });
40 | });
41 | });
42 |
43 | it("删除所有 APP", () => {
44 | cy.window().then(async (window: any) => {
45 | const manager = window.manager;
46 | const apps = manager.queryAll();
47 | for (const app of apps) {
48 | await app.close();
49 | }
50 | expect(manager.queryAll().length).to.be.equal(0);
51 | cy.get(".telebox-box").should("have.length", 0);
52 | expect(manager.focused).to.be.undefined;
53 | });
54 | });
55 | });
56 |
57 | export {};
58 |
--------------------------------------------------------------------------------
/e2e/cypress/integration/mainViewScenePath.spec.ts:
--------------------------------------------------------------------------------
1 | import type { Room } from "white-web-sdk";
2 | import type { WindowManager } from "../../../dist";
3 | import "./common";
4 |
5 | describe("切换 MainViewScene", () => {
6 | before(() => {
7 | cy.visit("/");
8 | cy.wait(8000);
9 | });
10 |
11 | afterEach(() => {
12 | cy.wait(1000);
13 | });
14 |
15 | it("设置 MainViewSceneIndex", () => {
16 | cy.window().then(async (window: any) => {
17 | const manager = window.manager as WindowManager;
18 | const room = window.room;
19 | expect(room).to.be.a("object");
20 | expect(manager).to.be.a("object");
21 |
22 | cy.wrap(null).then(() => manager.setMainViewSceneIndex(0));
23 |
24 | expect(manager.mainViewSceneIndex).to.be.equal(0);
25 | expect(manager.mainView.focusScenePath).to.be.equal("/init");
26 | });
27 | });
28 |
29 | it("设置 mainViewScenePath 更新 mainViewSceneIndex", () => {
30 | cy.window().then((window: any) => {
31 | const manager = window.manager as WindowManager;
32 | const room = window.room;
33 | expect(room).to.be.a("object");
34 | expect(manager).to.be.a("object");
35 |
36 | cy.wrap(null).then(() => room.putScenes("/", [{}]));
37 | cy.wrap(null).then(() => manager.setMainViewSceneIndex(1));
38 |
39 | cy.wait(1000).then(() => {
40 | expect(manager.mainViewSceneIndex).to.be.equal(1);
41 | });
42 |
43 | cy.wrap(null).then(() => manager.setMainViewScenePath("/"));
44 |
45 | cy.wait(1000).then(() => {
46 | expect(manager.mainViewSceneIndex).to.be.equal(0);
47 | });
48 | });
49 | });
50 |
51 | it("删除 scenes 更新 sceneIndex", () => {
52 | cy.window().then(async (window: any) => {
53 | const manager = window.manager as WindowManager;
54 | const room = window.room as Room;
55 | expect(room).to.be.a("object");
56 | expect(manager).to.be.a("object");
57 | room.putScenes("/", [{}]);
58 |
59 | cy.wrap(null).then(() => manager.setMainViewSceneIndex(1));
60 |
61 | cy.wait(1000).then(() => {
62 | const focusScenePath = manager.mainView.focusScenePath;
63 | if (focusScenePath) {
64 | expect(focusScenePath).to.be.not.equal("/init");
65 | cy.wrap(null).then(() => room.removeScenes(focusScenePath));
66 | cy.wait(1000).then(() => {
67 | expect(manager.mainViewSceneIndex).to.be.equal(0);
68 | expect(manager.mainView.focusScenePath).to.be.equal("/init");
69 | });
70 | } else {
71 | expect(true).to.be.false;
72 | }
73 | });
74 | });
75 | });
76 | });
77 |
78 | export {};
79 |
--------------------------------------------------------------------------------
/e2e/cypress/integration/readonly.spec.ts:
--------------------------------------------------------------------------------
1 | import { RoomPhase } from "white-web-sdk";
2 | import type { Room } from "white-web-sdk";
3 |
4 | describe("只读模式加入房间", () => {
5 | before(() => {
6 | cy.visit("/?isWritable=false");
7 | cy.wait(8000);
8 | });
9 |
10 | afterEach(() => {
11 | cy.wait(1000);
12 | });
13 |
14 | it("挂载成功", () => {
15 | cy.window().then((window: any) => {
16 | const manager = window.manager;
17 | const room = window.room as Room;
18 | expect(room.phase).to.be.equal(RoomPhase.Connected);
19 | expect(manager).to.be.a("object");
20 | expect(room.isWritable).to.be.false;
21 | cy.get(".netless-whiteboard").should("have.length", 1);
22 | });
23 | });
24 | });
25 |
26 | export {};
27 |
--------------------------------------------------------------------------------
/e2e/cypress/integration/redoUndo.spec.ts:
--------------------------------------------------------------------------------
1 | import type { WindowManager } from "../../../dist";
2 | import "./common";
3 |
4 | describe("redo undo 切换", () => {
5 | before(() => {
6 | cy.visit("/");
7 | cy.wait(8000);
8 | });
9 |
10 | afterEach(() => {
11 | cy.wait(1000);
12 | });
13 |
14 | it("获取 redo undo steps", () => {
15 | cy.window().then(async (window: any) => {
16 | const manager = window.manager as WindowManager;
17 | const room = window.room;
18 | expect(room).to.be.a("object");
19 | expect(manager).to.be.a("object");
20 |
21 | expect(manager.canRedoSteps).to.be.equal(0);
22 | expect(manager.canUndoSteps).to.be.equal(0);
23 | });
24 | });
25 | });
26 |
27 | export {};
28 |
--------------------------------------------------------------------------------
/e2e/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "e2e",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "cy:run": "cypress run"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "@types/sinon": "^10.0.8",
14 | "cypress": "^8.7.0",
15 | "sinon": "^12.0.1"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/e2e/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDirs": ["./", "../dist"],
4 | "outDir": "dist",
5 | "strictNullChecks": true,
6 | "strict": true,
7 | "declaration": true,
8 | "noImplicitAny": true,
9 | "module": "ESNext",
10 | "skipLibCheck": true,
11 | "target": "es6",
12 | "jsx": "react",
13 | "forceConsistentCasingInFileNames": false,
14 | "lib": [
15 | "es6",
16 | "dom",
17 | "ES2017"
18 | ],
19 | "allowSyntheticDefaultImports": true,
20 | "experimentalDecorators": true,
21 | "moduleResolution": "node",
22 | "allowJs": true,
23 | "types": ["cypress"],
24 | "noUnusedParameters": true,
25 | "noImplicitOverride": true,
26 | },
27 | "include": [
28 | "../node_modules/@types/**/index.d.ts", "cypress/integration",
29 | "../dist"
30 | ],
31 | }
--------------------------------------------------------------------------------
/example/app/board.css:
--------------------------------------------------------------------------------
1 | .board-footer {
2 | width: 100%;
3 | height: 100%;
4 | display: flex;
5 | }
6 |
--------------------------------------------------------------------------------
/example/app/board.tsx:
--------------------------------------------------------------------------------
1 | import type { NetlessApp } from "../../dist";
2 | import React, { useEffect, useState } from "react";
3 | import ReactDom from "react-dom";
4 | import type { AppContext } from "../../dist";
5 | import "./board.css";
6 |
7 | export const Board: NetlessApp = {
8 | kind: "Board",
9 | setup: context => {
10 | // 获取 app 的 box
11 | const box = context.getBox();
12 |
13 | // 挂载白板的 view 到 box 到 content
14 | context.mountView(box.$content);
15 |
16 | // 挂载自定义的 footer 到 box 的 footer 上
17 | mount(box.$footer, context);
18 |
19 | setTimeout(() => {
20 | context.dispatchAppEvent("board", 42);
21 | context.dispatchAppEvent("board2");
22 | }, 1000);
23 | },
24 | };
25 |
26 | const BoardFooter = ({ context }: { context: AppContext }) => {
27 | const [pageState, setPageState] = useState({ index: 0, length: 0 });
28 |
29 | const nextPage = () => context.nextPage();
30 |
31 | const prevPage = () => context.prevPage();
32 |
33 | const addPage = () => context.addPage();
34 |
35 | const removePage = () => context.removePage(1);
36 |
37 | const removeLastPage = () => context.removePage(context.pageState.length - 1);
38 |
39 | useEffect(() => {
40 | setPageState(context.pageState);
41 | return context.emitter.on("pageStateChange", pageState => {
42 | console.log("pageStateChange", pageState);
43 | setPageState(pageState);
44 | });
45 | }, []);
46 |
47 | return (
48 |
49 | 上一页
50 | 下一页
51 | 添加页
52 | 删除一页
53 | 删除最后一页
54 | {pageState.index + 1}/{pageState.length}
55 |
56 | );
57 | };
58 |
59 | export const mount = (dom: HTMLElement, context: AppContext) => {
60 | ReactDom.render( , dom);
61 | };
62 |
--------------------------------------------------------------------------------
/example/app/counter.ts:
--------------------------------------------------------------------------------
1 | import type { NetlessApp } from "../../dist";
2 | import { WindowManager } from "../../dist";
3 |
4 | export const Counter: NetlessApp<{ count: number }> = {
5 | kind: "Counter",
6 | setup: context => {
7 | const storage = context.storage;
8 | // 初始化值,只会在相应的 key 不存在 storage.state 的时候设置值
9 | storage.ensureState({ count: 0 });
10 |
11 | const box = context.getBox(); // box 为这个应用打开的窗口
12 | const $content = box.$content; // 获取窗口的 content
13 |
14 | const countDom = document.createElement("div");
15 | countDom.innerText = storage.state.count.toString();
16 | $content.appendChild(countDom);
17 |
18 | // 监听 state 的修改, 自己和其他人的修改都会触发这个回调
19 | storage.addStateChangedListener(diff => {
20 | if (diff.count) {
21 | countDom.innerText = diff.count.newValue.toString();
22 | }
23 | });
24 |
25 | const incButton = document.createElement("button");
26 | incButton.innerText = "Inc";
27 | const incButtonOnClick = () => {
28 | // 直接设值合并到 state,类似 React.setState
29 | storage.setState({ count: storage.state.count + 1 });
30 | };
31 | incButton.addEventListener("click", incButtonOnClick);
32 | $content.appendChild(incButton);
33 |
34 | const decButton = document.createElement("button");
35 | decButton.innerText = "Dec";
36 | const decButtonOnClick = () => {
37 | storage.setState({ count: storage.state.count - 1 });
38 | };
39 | decButton.addEventListener("click", decButtonOnClick);
40 |
41 | $content.appendChild(decButton);
42 |
43 | // 监听事件
44 | const event1Disposer = context.addMagixEventListener("event1", msg => {
45 | console.log("event1", msg);
46 | });
47 |
48 | // 向打开 app 的其他人发送消息
49 | context.dispatchMagixEvent("event1", { count: 10 });
50 |
51 | // 应用销毁时, 注意清理掉监听器
52 | context.emitter.on("destroy", () => {
53 | incButton.removeEventListener("click", incButtonOnClick);
54 | decButton.removeEventListener("click", decButtonOnClick);
55 | event1Disposer();
56 | });
57 |
58 | return storage;
59 | },
60 | };
61 |
62 | WindowManager.register({
63 | kind: "Counter",
64 | src: Counter,
65 | });
66 |
--------------------------------------------------------------------------------
/example/app/helloworld-app.ts:
--------------------------------------------------------------------------------
1 | import type { AppContext } from "../../dist";
2 | import { WindowManager } from "../../dist";
3 |
4 | interface HelloWorldAttributes {
5 | a?: number;
6 | b?: { c: number };
7 | }
8 | interface HelloWorldMagixEventPayloads {
9 | event1: {
10 | count: number;
11 | };
12 | event2: {
13 | disabled: boolean;
14 | };
15 | }
16 |
17 | export const HelloWorldApp = async () => {
18 | console.log("start loading HelloWorld...");
19 | // await new Promise(resolve => setTimeout(resolve, 2000))
20 | console.log("HelloWorld Loaded");
21 | return {
22 | setup: (context: AppContext) => {
23 | // const state = context.createStorage<>("HelloWorldApp", { a: 1 });
24 | // context.storage.onStateChanged.addListener(diff => {
25 | // if (diff.a) {
26 | // console.log("diff", diff.a.newValue, diff.a.oldValue);
27 | // }
28 | // });
29 | // const c = { c: 3 };
30 | // if (context.getIsWritable()) {
31 | // context.storage.setState({ a: 2, b: c });
32 | // context.storage.setState({ a: 2, b: c });
33 | // }
34 | console.log("HelloWorldApp pageState", context.pageState);
35 | console.log("helloworld options", context.getAppOptions());
36 |
37 | context.emitter.on("pageStateChange", pageState => {
38 | console.log("HelloWorldApp pageState change", pageState);
39 | });
40 | // context.addMagixEventListener("event1", message => {
41 | // console.log("MagixEvent", message);
42 | // });
43 | // context.dispatchMagixEvent("event1", { count: 1 });
44 | context.mountView(context.getBox().$content);
45 | context.emitter.on("destroy", () => console.log("[HelloWorld]: destroy"));
46 |
47 | return "Hello World Result";
48 | },
49 | };
50 | };
51 |
52 | WindowManager.register({
53 | kind: "HelloWorld",
54 | src: HelloWorldApp as any,
55 | appOptions: () => "AppOptions",
56 | addHooks: emitter => {
57 | emitter.on("created", result => {
58 | console.log("HelloWordResult", result);
59 | });
60 | emitter.on("focus", result => {
61 | console.log("HelloWorld focus", result);
62 | });
63 | },
64 | });
65 |
--------------------------------------------------------------------------------
/example/apps.ts:
--------------------------------------------------------------------------------
1 | import type { WindowManager } from "../dist";
2 | import { BuiltinApps } from "../dist";
3 | import * as docs from "./docs.json";
4 |
5 | export const createHelloWorld = (manager: WindowManager) => {
6 | manager.addApp({
7 | kind: "HelloWorld",
8 | options: {
9 | scenePath: "/helloworld1",
10 | },
11 | });
12 | };
13 |
14 | export const createCounter = async (manager: WindowManager) => {
15 | manager.addApp({
16 | kind: "Counter",
17 | });
18 | };
19 |
20 | export const createStatic = (manager: WindowManager) => {
21 | return manager.addApp({
22 | kind: BuiltinApps.DocsViewer,
23 | options: {
24 | scenePath: "/test5",
25 | title: "ppt1",
26 | scenes: docs.staticDocs,
27 | },
28 | });
29 | };
30 | export const createDynamic = (manager: WindowManager) => {
31 | return manager.addApp({
32 | kind: BuiltinApps.DocsViewer,
33 | options: {
34 | scenePath: "/ppt3",
35 | title: "ppt3",
36 | scenes: docs.dynamicDocs,
37 | },
38 | });
39 | };
40 | export const createVideo = (manager: WindowManager) => {
41 | manager.addApp({
42 | kind: BuiltinApps.MediaPlayer,
43 | attributes: {
44 | src: "https://developer-assets.netless.link/Zelda.mp4",
45 | },
46 | });
47 | };
48 |
49 | export const createSlide = (manager: WindowManager) => {
50 | manager.addApp({
51 | kind: "Slide",
52 | options: {
53 | scenePath: `/ppt/9340e8e067bc11ec8f582b1b98453394`, // [1]
54 | title: "a.pptx",
55 | },
56 | attributes: {
57 | taskId: "9340e8e067bc11ec8f582b1b98453394", // [2]
58 | url: "https://convertcdn.netless.link/dynamicConvert", // [3]
59 | },
60 | });
61 | };
62 |
63 | export const createBoard = (manager: WindowManager) => {
64 | return manager.addApp({
65 | kind: "Board",
66 | options: {
67 | scenePath: "/board1",
68 | },
69 | });
70 | };
71 |
72 | export const createIframe = (manager: WindowManager) => {
73 | return manager.getIframeBridge().insert({
74 | width: 400,
75 | height: 300,
76 | url: "/h5.html",
77 | displaySceneDir: "/",
78 | });
79 | };
80 |
--------------------------------------------------------------------------------
/example/docs.json:
--------------------------------------------------------------------------------
1 | {
2 | "staticDocs": [
3 | {
4 | "name": "1",
5 | "ppt": {
6 | "height": 1010,
7 | "src": "https://convertcdn.netless.link/staticConvert/18140800fe8a11eb8cb787b1c376634e/1.png",
8 | "width": 714
9 | }
10 | },
11 | {
12 | "name": "2",
13 | "ppt": {
14 | "height": 1010,
15 | "src": "https://convertcdn.netless.link/staticConvert/18140800fe8a11eb8cb787b1c376634e/2.png",
16 | "width": 714
17 | }
18 | }
19 | ],
20 | "dynamicDocs": [
21 | {
22 | "name": "1",
23 | "ppt": {
24 | "height": 720,
25 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/1.png",
26 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/1.slide",
27 | "width": 1280
28 | }
29 | },
30 | {
31 | "name": "2",
32 | "ppt": {
33 | "height": 720,
34 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/2.png",
35 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/2.slide",
36 | "width": 1280
37 | }
38 | },
39 | {
40 | "name": "3",
41 | "ppt": {
42 | "height": 720,
43 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/3.png",
44 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/3.slide",
45 | "width": 1280
46 | }
47 | },
48 | {
49 | "name": "4",
50 | "ppt": {
51 | "height": 720,
52 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/4.png",
53 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/4.slide",
54 | "width": 1280
55 | }
56 | },
57 | {
58 | "name": "5",
59 | "ppt": {
60 | "height": 720,
61 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/5.png",
62 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/5.slide",
63 | "width": 1280
64 | }
65 | },
66 | {
67 | "name": "6",
68 | "ppt": {
69 | "height": 720,
70 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/6.png",
71 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/6.slide",
72 | "width": 1280
73 | }
74 | },
75 | {
76 | "name": "7",
77 | "ppt": {
78 | "height": 720,
79 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/7.png",
80 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/7.slide",
81 | "width": 1280
82 | }
83 | },
84 | {
85 | "name": "8",
86 | "ppt": {
87 | "height": 720,
88 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/8.png",
89 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/8.slide",
90 | "width": 1280
91 | }
92 | },
93 | {
94 | "name": "9",
95 | "ppt": {
96 | "height": 720,
97 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/9.png",
98 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/9.slide",
99 | "width": 1280
100 | }
101 | },
102 | {
103 | "name": "10",
104 | "ppt": {
105 | "height": 720,
106 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/10.png",
107 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/10.slide",
108 | "width": 1280
109 | }
110 | },
111 | {
112 | "name": "11",
113 | "ppt": {
114 | "height": 720,
115 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/11.png",
116 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/11.slide",
117 | "width": 1280
118 | }
119 | },
120 | {
121 | "name": "12",
122 | "ppt": {
123 | "height": 720,
124 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/12.png",
125 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/12.slide",
126 | "width": 1280
127 | }
128 | },
129 | {
130 | "name": "13",
131 | "ppt": {
132 | "height": 720,
133 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/13.png",
134 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/13.slide",
135 | "width": 1280
136 | }
137 | },
138 | {
139 | "name": "14",
140 | "ppt": {
141 | "height": 720,
142 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/14.png",
143 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/14.slide",
144 | "width": 1280
145 | }
146 | },
147 | {
148 | "name": "15",
149 | "ppt": {
150 | "height": 720,
151 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/15.png",
152 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/15.slide",
153 | "width": 1280
154 | }
155 | },
156 | {
157 | "name": "16",
158 | "ppt": {
159 | "height": 720,
160 | "previewURL": "https://convertcdn.netless.link/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/preview/16.png",
161 | "src": "pptx://cover.herewhite.com/dynamicConvert/6a212c90fa5311ea8b9c074232aaccd4/16.slide",
162 | "width": 1280
163 | }
164 | }
165 | ]
166 | }
167 |
--------------------------------------------------------------------------------
/example/h5.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | H5 TEST
7 |
8 |
9 |
47 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/example/index.css:
--------------------------------------------------------------------------------
1 | .app {
2 | display: flex;
3 | width: 100vw;
4 | height: 100vh;
5 | padding: 16px 16px;
6 | overflow: hidden;
7 | box-sizing: border-box;
8 | }
9 |
10 | .side {
11 | flex-shrink: 0;
12 | padding: 16px;
13 | margin-right: 16px;
14 | text-align: center;
15 | user-select: none;
16 | }
17 |
18 | .side-button {
19 | display: block;
20 | margin: 1em 0;
21 | }
22 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Document
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "test",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "vite --host"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "@netless/app-slide": "^0.2.24",
14 | "@netless/video-js-plugin": "^0.3.7",
15 | "normalize.css": "^8.0.1",
16 | "p-queue": "^7.1.0",
17 | "react": "^17.0.2",
18 | "react-dom": "^17.0.2",
19 | "video.js": "^7.14.3",
20 | "white-web-sdk": "^2.16.43"
21 | },
22 | "devDependencies": {
23 | "jspdf": "^2.5.1",
24 | "vite": "^2.7.13"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/example/register.ts:
--------------------------------------------------------------------------------
1 | import { WindowManager } from "../dist";
2 | import "./app/helloworld-app";
3 | import "./app/counter";
4 | import { Board } from "./app/board";
5 |
6 | WindowManager.register({
7 | kind: "Slide",
8 | appOptions: {
9 | // turn on to show debug controller
10 | debug: false,
11 | },
12 | src: (async () => {
13 | const app = await import("@netless/app-slide");
14 | return app.default ?? app;
15 | }) as any,
16 | });
17 |
18 | WindowManager.register({
19 | kind: "Monaco",
20 | src: "https://netless-app.oss-cn-hangzhou.aliyuncs.com/@netless/app-monaco/0.1.12/dist/main.iife.js",
21 | });
22 |
23 | WindowManager.register({
24 | kind: "GeoGebra",
25 | src: "https://netless-app.oss-cn-hangzhou.aliyuncs.com/@netless/app-geogebra/0.0.4/dist/main.iife.js",
26 | appOptions: {
27 | HTML5Codebase: "https://flat-storage-cn-hz.whiteboard.agora.io/GeoGebra/HTML5/5.0/web3d",
28 | },
29 | });
30 |
31 | WindowManager.register({
32 | kind: "Countdown",
33 | src: "https://netless-app.oss-cn-hangzhou.aliyuncs.com/@netless/app-countdown/0.0.2/dist/main.iife.js",
34 | });
35 |
36 | WindowManager.register({
37 | kind: "Board",
38 | src: Board,
39 | });
40 |
--------------------------------------------------------------------------------
/example/shim.d.ts:
--------------------------------------------------------------------------------
1 | declare interface ImportMetaEnv {
2 | VITE_APPID: string;
3 | VITE_ROOM_UUID: string;
4 | VITE_ROOM_TOKEN: string;
5 | }
6 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "ESNext",
4 | "skipLibCheck": true,
5 | "target": "es6",
6 | "jsx": "react",
7 | "moduleResolution": "node",
8 | "allowJs": false,
9 | "types": ["vite/client"],
10 | "resolveJsonModule": true,
11 | "esModuleInterop": true,
12 | },
13 | "exclude": [
14 | "*.js"
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/example/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 |
3 | export default defineConfig({
4 | server: {
5 | port: 4000
6 | }
7 | });
8 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 |
3 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
4 | module.exports = {
5 | preset: "ts-jest",
6 | testEnvironment: "jsdom",
7 | globals: {
8 | "ts-jest": {
9 | tsconfig: "./test/tsconfig.json",
10 | },
11 | },
12 | testMatch: ["/test/*.ts", "**/test/**/*.test.ts", "test/**.test.ts"],
13 | setupFiles: ["jest-canvas-mock", "jest-fetch-mock"],
14 | transform: {
15 | "^.+\\.svelte$": [
16 | "svelte-jester",
17 | {
18 | preprocess: true,
19 | },
20 | ],
21 | ".+\\.(css|svg|styl|less|sass|scss|png|jpg|ttf|woff|woff2|inline)$": "jest-transform-stub",
22 | },
23 | moduleNameMapper: {
24 | "^.+.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2|inline)$": "jest-transform-stub",
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@netless/window-manager",
3 | "version": "1.0.3",
4 | "description": "Multi-window mode for Netless Whiteboard",
5 | "author": "l1shen (https://github.com/l1shen)",
6 | "license": "MIT",
7 | "repository": "netless-io/window-manager",
8 | "main": "dist/index.js",
9 | "module": "dist/index.mjs",
10 | "types": "dist/index.d.ts",
11 | "files": [
12 | "dist",
13 | "docs",
14 | "src"
15 | ],
16 | "scripts": {
17 | "build": "vite build && npm run type-gen",
18 | "lint": "eslint --ext .ts,.tsx,.svelte . && prettier --check .",
19 | "predev": "npm run type-gen",
20 | "prettier": "prettier --write .",
21 | "test": "vitest",
22 | "type-gen": "dts src/index.ts -o dist/index.d.ts"
23 | },
24 | "peerDependencies": {
25 | "jspdf": "2.5.1",
26 | "white-web-sdk": "^2.16.52"
27 | },
28 | "peerDependenciesMeta": {
29 | "jspdf": {
30 | "optional": true
31 | }
32 | },
33 | "dependencies": {
34 | "@juggle/resize-observer": "^3.3.1",
35 | "@netless/telebox-insider": "0.2.31",
36 | "emittery": "^0.9.2",
37 | "lodash": "^4.17.21",
38 | "p-retry": "^4.6.1",
39 | "uuid": "^7.0.3",
40 | "video.js": ">=7"
41 | },
42 | "devDependencies": {
43 | "@hyrious/dts": "^0.2.2",
44 | "@netless/app-docs-viewer": "^0.2.18",
45 | "@netless/app-media-player": "0.1.4",
46 | "@rollup/plugin-commonjs": "^20.0.0",
47 | "@rollup/plugin-node-resolve": "^13.0.4",
48 | "@rollup/plugin-url": "^6.1.0",
49 | "@sveltejs/vite-plugin-svelte": "^1.0.0-next.22",
50 | "@tsconfig/svelte": "^2.0.1",
51 | "@types/debug": "^4.1.7",
52 | "@types/lodash": "^4.14.182",
53 | "@types/lodash-es": "^4.17.4",
54 | "@types/uuid": "^8.3.1",
55 | "@typescript-eslint/eslint-plugin": "^4.30.0",
56 | "@typescript-eslint/parser": "^4.30.0",
57 | "@vitest/ui": "^0.14.1",
58 | "cypress": "^8.7.0",
59 | "dotenv": "^10.0.0",
60 | "eslint": "^7.32.0",
61 | "eslint-config-prettier": "^8.3.0",
62 | "eslint-plugin-svelte3": "^3.2.0",
63 | "jsdom": "^19.0.0",
64 | "jspdf": "^2.5.1",
65 | "less": "^4.1.1",
66 | "prettier": "^2.3.2",
67 | "prettier-plugin-svelte": "^2.4.0",
68 | "rollup-plugin-analyzer": "^4.0.0",
69 | "rollup-plugin-styles": "^3.14.1",
70 | "side-effect-manager": "0.1.5",
71 | "svelte": "^3.42.4",
72 | "typescript": "^4.5.5",
73 | "vite": "^2.9.9",
74 | "vitest": "^0.14.1",
75 | "white-web-sdk": "2.16.52"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/App/AppPageStateImpl.ts:
--------------------------------------------------------------------------------
1 | import type { Displayer, ScenesCallbacksNode, View } from "white-web-sdk";
2 | import type { PageState } from "../Page";
3 |
4 | export type AppPageStateParams = {
5 | displayer: Displayer;
6 | scenePath: string | undefined;
7 | view: View | undefined;
8 | notifyPageStateChange: () => void;
9 | };
10 |
11 | export class AppPageStateImpl {
12 | private sceneNode: ScenesCallbacksNode | null = null;
13 |
14 | constructor(private params: AppPageStateParams) {
15 | const { displayer, scenePath } = this.params;
16 | if (scenePath) {
17 | this.sceneNode = displayer.createScenesCallback(scenePath, {
18 | onAddScene: this.onSceneChange,
19 | onRemoveScene: this.onSceneChange,
20 | });
21 | }
22 | }
23 |
24 | private onSceneChange = (node: ScenesCallbacksNode) => {
25 | this.sceneNode = node;
26 | this.params.notifyPageStateChange();
27 | };
28 |
29 | public getFullPath(index: number) {
30 | const scenes = this.sceneNode?.scenes;
31 | if (this.params.scenePath && scenes) {
32 | const name = scenes[index];
33 | if (name) {
34 | return `${this.params.scenePath}/${name}`;
35 | }
36 | }
37 | }
38 |
39 | public toObject(): PageState {
40 | return {
41 | index: this.params.view?.focusSceneIndex || 0,
42 | length: this.sceneNode?.scenes.length || 0,
43 | };
44 | }
45 |
46 | public destroy() {
47 | this.sceneNode?.dispose();
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/App/MagixEvent/index.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | MagixEventListenerOptions as WhiteMagixListenerOptions,
3 | Event as WhiteEvent,
4 | EventPhase as WhiteEventPhase,
5 | Scope as WhiteScope,
6 | } from "white-web-sdk";
7 |
8 | export interface MagixEventListenerOptions extends WhiteMagixListenerOptions {
9 | /**
10 | * Rapid emitted callbacks will be slowed down to this interval (in ms).
11 | */
12 | fireInterval?: number;
13 | /**
14 | * If `true`, sent events will reach self-listeners after committed to server.
15 | * Otherwise the events will reach self-listeners immediately.
16 | */
17 | fireSelfEventAfterCommit?: boolean;
18 | }
19 |
20 | export interface MagixEventMessage<
21 | TPayloads = any,
22 | TEvent extends MagixEventTypes = MagixEventTypes
23 | > extends Omit {
24 | /** Event name */
25 | event: TEvent;
26 | /** Event Payload */
27 | payload: TPayloads[TEvent];
28 | /** Whiteboard ID of the client who dispatched the event. It will be AdminObserverId for system events. */
29 | authorId: number;
30 | scope: `${WhiteScope}`;
31 | phase: `${WhiteEventPhase}`;
32 | }
33 |
34 | export type MagixEventTypes = Extract;
35 |
36 | export type MagixEventPayload<
37 | TPayloads = any,
38 | TEvent extends MagixEventTypes = MagixEventTypes
39 | > = TPayloads[TEvent];
40 |
41 | export type MagixEventDispatcher = <
42 | TEvent extends MagixEventTypes = MagixEventTypes
43 | >(
44 | event: TEvent,
45 | payload: TPayloads[TEvent]
46 | ) => void;
47 |
48 | export type MagixEventHandler<
49 | TPayloads = any,
50 | TEvent extends MagixEventTypes = MagixEventTypes
51 | > = (message: MagixEventMessage) => void;
52 |
53 | export type MagixEventListenerDisposer = () => void;
54 |
55 | export type MagixEventAddListener = <
56 | TEvent extends MagixEventTypes = MagixEventTypes
57 | >(
58 | event: TEvent,
59 | handler: MagixEventHandler,
60 | options?: MagixEventListenerOptions | undefined
61 | ) => MagixEventListenerDisposer;
62 |
63 | export type MagixEventRemoveListener = <
64 | TEvent extends MagixEventTypes = MagixEventTypes
65 | >(
66 | event: TEvent,
67 | handler?: MagixEventHandler
68 | ) => void;
69 |
--------------------------------------------------------------------------------
/src/App/Storage/StorageEvent.ts:
--------------------------------------------------------------------------------
1 | export type StorageEventListener = (event: T) => void;
2 |
3 | export class StorageEvent {
4 | listeners = new Set>();
5 |
6 | get length(): number {
7 | return this.listeners.size;
8 | }
9 |
10 | dispatch(message: TMessage): void {
11 | this.listeners.forEach(callback => callback(message));
12 | }
13 |
14 | addListener(listener: StorageEventListener): void {
15 | this.listeners.add(listener);
16 | }
17 |
18 | removeListener(listener: StorageEventListener): void {
19 | this.listeners.delete(listener);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/App/Storage/typings.ts:
--------------------------------------------------------------------------------
1 | import type { StorageEventListener } from "./StorageEvent";
2 |
3 | export type RefValue = { k: string; v: TValue; __isRef: true };
4 |
5 | export type ExtractRawValue = TValue extends RefValue ? TRefValue : TValue;
6 |
7 | export type AutoRefValue = RefValue>;
8 |
9 | export type MaybeRefValue = TValue | AutoRefValue;
10 |
11 | export type DiffOne = { oldValue?: T; newValue?: T };
12 |
13 | export type Diff = { [K in keyof T]?: DiffOne };
14 |
15 | export type StorageOnSetStatePayload = {
16 | [K in keyof TState]?: MaybeRefValue;
17 | };
18 |
19 | export type StorageStateChangedEvent = Diff;
20 |
21 | export type StorageStateChangedListener = StorageEventListener<
22 | StorageStateChangedEvent
23 | >;
24 |
25 | export type StorageStateChangedListenerDisposer = () => void;
26 |
--------------------------------------------------------------------------------
/src/App/Storage/utils.ts:
--------------------------------------------------------------------------------
1 | import { has } from "lodash";
2 | import { genUID } from "side-effect-manager";
3 | import type { AutoRefValue, ExtractRawValue, RefValue } from "./typings";
4 |
5 | export const plainObjectKeys = Object.keys as (o: T) => Array>;
6 |
7 | export function isRef(e: unknown): e is RefValue {
8 | return Boolean(has(e, "__isRef"));
9 | }
10 |
11 | export function makeRef(v: TValue): RefValue {
12 | return { k: genUID(), v, __isRef: true };
13 | }
14 |
15 | export function makeAutoRef(v: TValue): AutoRefValue {
16 | return isRef>(v) ? v : makeRef(v as ExtractRawValue);
17 | }
18 |
--------------------------------------------------------------------------------
/src/App/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./AppProxy";
2 | export * from "./AppContext";
3 |
--------------------------------------------------------------------------------
/src/AppListener.ts:
--------------------------------------------------------------------------------
1 | import { callbacks } from "./callback";
2 | import { internalEmitter } from "./InternalEmitter";
3 | import { Events, MagixEventName } from "./constants";
4 | import { isEqual, omit } from "lodash";
5 | import { setViewFocusScenePath } from "./Utils/Common";
6 | import type { AnimationMode, Camera, Event } from "white-web-sdk";
7 | import type { AppManager } from "./AppManager";
8 | import type { TeleBoxState } from "@netless/telebox-insider";
9 |
10 | type SetAppFocusIndex = {
11 | type: "main" | "app";
12 | appID?: string;
13 | index: number;
14 | };
15 |
16 | export class AppListeners {
17 | private displayer = this.manager.displayer;
18 |
19 | constructor(private manager: AppManager) {}
20 |
21 | private get boxManager() {
22 | return this.manager.boxManager;
23 | }
24 |
25 | public addListeners() {
26 | this.displayer.addMagixEventListener(MagixEventName, this.mainMagixEventListener);
27 | }
28 |
29 | public removeListeners() {
30 | this.displayer.removeMagixEventListener(MagixEventName, this.mainMagixEventListener);
31 | }
32 |
33 | private mainMagixEventListener = (event: Event) => {
34 | if (event.authorId !== this.displayer.observerId) {
35 | const data = event.payload;
36 | switch (data.eventName) {
37 | case Events.AppMove: {
38 | this.appMoveHandler(data.payload);
39 | break;
40 | }
41 | case Events.AppResize: {
42 | this.appResizeHandler(data.payload);
43 | break;
44 | }
45 | case Events.AppBoxStateChange: {
46 | this.boxStateChangeHandler(data.payload);
47 | break;
48 | }
49 | case Events.SetMainViewScenePath: {
50 | this.setMainViewScenePathHandler(data.payload);
51 | break;
52 | }
53 | case Events.MoveCamera: {
54 | this.moveCameraHandler(data.payload);
55 | break;
56 | }
57 | case Events.MoveCameraToContain: {
58 | this.moveCameraToContainHandler(data.payload);
59 | break;
60 | }
61 | case Events.CursorMove: {
62 | this.cursorMoveHandler(data.payload);
63 | break;
64 | }
65 | case Events.RootDirRemoved: {
66 | this.rootDirRemovedHandler();
67 | break;
68 | }
69 | case Events.Refresh: {
70 | this.refreshHandler();
71 | break;
72 | }
73 | case Events.InitMainViewCamera: {
74 | this.initMainViewCameraHandler();
75 | break;
76 | }
77 | case Events.SetAppFocusIndex: {
78 | this.setAppFocusViewIndexHandler(data.payload);
79 | break;
80 | }
81 | default:
82 | break;
83 | }
84 | }
85 | };
86 |
87 | private appMoveHandler = (payload: any) => {
88 | this.boxManager?.moveBox(payload);
89 | };
90 |
91 | private appResizeHandler = (payload: any) => {
92 | this.boxManager?.resizeBox(Object.assign(payload, { skipUpdate: true }));
93 | this.manager.room?.refreshViewSize();
94 | };
95 |
96 | private boxStateChangeHandler = (state: TeleBoxState) => {
97 | callbacks.emit("boxStateChange", state);
98 | };
99 |
100 | private setMainViewScenePathHandler = ({ nextScenePath }: { nextScenePath: string }) => {
101 | setViewFocusScenePath(this.manager.mainView, nextScenePath);
102 | callbacks.emit("mainViewScenePathChange", nextScenePath);
103 | };
104 |
105 | private moveCameraHandler = (
106 | payload: Camera & { animationMode?: AnimationMode | undefined }
107 | ) => {
108 | if (isEqual(omit(payload, ["animationMode"]), { ...this.manager.mainView.camera })) return;
109 | this.manager.mainView.moveCamera(payload);
110 | };
111 |
112 | private moveCameraToContainHandler = (payload: any) => {
113 | this.manager.mainView.moveCameraToContain(payload);
114 | };
115 |
116 | private cursorMoveHandler = (payload: any) => {
117 | internalEmitter.emit("cursorMove", payload);
118 | };
119 |
120 | private rootDirRemovedHandler = () => {
121 | this.manager.createRootDirScenesCallback();
122 | this.manager.mainViewProxy.rebind();
123 | internalEmitter.emit("rootDirRemoved");
124 | };
125 |
126 | private refreshHandler = () => {
127 | this.manager.windowManger._refresh();
128 | };
129 |
130 | private initMainViewCameraHandler = () => {
131 | this.manager.mainViewProxy.addCameraReaction();
132 | };
133 |
134 | private setAppFocusViewIndexHandler = (payload: SetAppFocusIndex) => {
135 | if (payload.type === "main") {
136 | this.manager.setSceneIndexWithoutSync(payload.index);
137 | } else if (payload.type === "app" && payload.appID) {
138 | const app = this.manager.appProxies.get(payload.appID);
139 | if (app) {
140 | app.setSceneIndexWithoutSync(payload.index);
141 | }
142 | }
143 | };
144 | }
145 |
--------------------------------------------------------------------------------
/src/AttributesDelegate.ts:
--------------------------------------------------------------------------------
1 | import { AppAttributes } from "./constants";
2 | import { get, isObject, pick } from "lodash";
3 | import { setViewFocusScenePath } from "./Utils/Common";
4 | import type { AddAppParams, AppSyncAttributes } from "./index";
5 | import type { Camera, Size, View } from "white-web-sdk";
6 | import type { Cursor } from "./Cursor/Cursor";
7 |
8 | export enum Fields {
9 | Apps = "apps",
10 | Focus = "focus",
11 | State = "state",
12 | BoxState = "boxState",
13 | MainViewCamera = "mainViewCamera",
14 | MainViewSize = "mainViewSize",
15 | Broadcaster = "broadcaster",
16 | Cursors = "cursors",
17 | Position = "position",
18 | CursorState = "cursorState",
19 | FullPath = "fullPath",
20 | Registered = "registered",
21 | IframeBridge = "iframeBridge",
22 | }
23 |
24 | export type Apps = {
25 | [key: string]: AppSyncAttributes;
26 | };
27 |
28 | export type Position = {
29 | x: number;
30 | y: number;
31 | type: PositionType;
32 | id?: string;
33 | };
34 |
35 | export type PositionType = "main" | "app";
36 |
37 | export type StoreContext = {
38 | getAttributes: () => any;
39 | safeUpdateAttributes: (keys: string[], value: any) => void;
40 | safeSetAttributes: (attributes: any) => void;
41 | };
42 |
43 | export type ICamera = Camera & { id: string };
44 |
45 | export type ISize = Size & { id: string };
46 |
47 | export class AttributesDelegate {
48 | constructor(private context: StoreContext) {}
49 |
50 | public setContext(context: StoreContext) {
51 | this.context = context;
52 | }
53 |
54 | public get attributes() {
55 | return this.context.getAttributes();
56 | }
57 |
58 | public apps(): Apps {
59 | return get(this.attributes, [Fields.Apps]);
60 | }
61 |
62 | public get focus(): string | undefined {
63 | return get(this.attributes, [Fields.Focus]);
64 | }
65 |
66 | public getAppAttributes(id: string): AppSyncAttributes {
67 | return get(this.apps(), [id]);
68 | }
69 |
70 | public getAppState(id: string) {
71 | return get(this.apps(), [id, Fields.State]);
72 | }
73 |
74 | public getMaximized() {
75 | return get(this.attributes, ["maximized"]);
76 | }
77 |
78 | public getMinimized() {
79 | return get(this.attributes, ["minimized"]);
80 | }
81 |
82 | public setupAppAttributes(params: AddAppParams, id: string, isDynamicPPT: boolean) {
83 | const attributes = this.attributes;
84 | if (!attributes.apps) {
85 | this.context.safeSetAttributes({ apps: {} });
86 | }
87 | const attrNames = ["scenePath", "title"];
88 | if (!isDynamicPPT) {
89 | attrNames.push("scenes");
90 | }
91 | const options = pick(params.options, attrNames);
92 | const attrs: AppSyncAttributes = { kind: params.kind, options, isDynamicPPT };
93 | if (typeof params.src === "string") {
94 | attrs.src = params.src;
95 | }
96 | attrs.createdAt = Date.now();
97 | this.context.safeUpdateAttributes([Fields.Apps, id], attrs);
98 | this.context.safeUpdateAttributes([Fields.Apps, id, Fields.State], {
99 | [AppAttributes.Size]: {},
100 | [AppAttributes.Position]: {},
101 | [AppAttributes.SceneIndex]: 0,
102 | });
103 | }
104 |
105 | public updateAppState(appId: string, stateName: AppAttributes, state: any) {
106 | if (get(this.attributes, [Fields.Apps, appId, Fields.State])) {
107 | this.context.safeUpdateAttributes([Fields.Apps, appId, Fields.State, stateName], state);
108 | }
109 | }
110 |
111 | public cleanAppAttributes(id: string) {
112 | this.context.safeUpdateAttributes([Fields.Apps, id], undefined);
113 | this.context.safeSetAttributes({ [id]: undefined });
114 | const focus = this.attributes[Fields.Focus];
115 | if (focus === id) {
116 | this.cleanFocus();
117 | }
118 | }
119 |
120 | public cleanFocus() {
121 | this.context.safeSetAttributes({ [Fields.Focus]: undefined });
122 | }
123 |
124 | public getAppSceneIndex(id: string) {
125 | return this.getAppState(id)?.[AppAttributes.SceneIndex];
126 | }
127 |
128 | public getAppScenePath(id: string) {
129 | return this.getAppAttributes(id)?.options?.scenePath;
130 | }
131 |
132 | public getMainViewScenePath(): string | undefined {
133 | return this.attributes["_mainScenePath"];
134 | }
135 |
136 | public getMainViewSceneIndex() {
137 | return this.attributes["_mainSceneIndex"];
138 | }
139 |
140 | public getBoxState() {
141 | return this.attributes[Fields.BoxState];
142 | }
143 |
144 | public setMainViewScenePath(scenePath: string) {
145 | this.context.safeSetAttributes({ _mainScenePath: scenePath });
146 | }
147 |
148 | public setMainViewSceneIndex(index: number) {
149 | this.context.safeSetAttributes({ _mainSceneIndex: index });
150 | }
151 |
152 | public getMainViewCamera(): MainViewCamera {
153 | return get(this.attributes, [Fields.MainViewCamera]);
154 | }
155 |
156 | public getMainViewSize(): MainViewSize {
157 | return get(this.attributes, [Fields.MainViewSize]);
158 | }
159 |
160 | public setMainViewCamera(camera: ICamera) {
161 | this.context.safeSetAttributes({ [Fields.MainViewCamera]: { ...camera } });
162 | }
163 |
164 | public setMainViewSize(size: ISize) {
165 | if (size.width === 0 || size.height === 0) return;
166 | this.context.safeSetAttributes({ [Fields.MainViewSize]: { ...size } });
167 | }
168 |
169 | public setMainViewCameraAndSize(camera: ICamera, size: ISize) {
170 | if (size.width === 0 || size.height === 0) return;
171 | this.context.safeSetAttributes({
172 | [Fields.MainViewCamera]: { ...camera },
173 | [Fields.MainViewSize]: { ...size },
174 | });
175 | }
176 |
177 | public setAppFocus = (appId: string, focus: boolean) => {
178 | if (focus) {
179 | this.context.safeSetAttributes({ [Fields.Focus]: appId });
180 | } else {
181 | this.context.safeSetAttributes({ [Fields.Focus]: undefined });
182 | }
183 | };
184 |
185 | public updateCursor(uid: string, position: Position) {
186 | if (!get(this.attributes, [Fields.Cursors])) {
187 | this.context.safeUpdateAttributes([Fields.Cursors], {});
188 | }
189 | if (!get(this.attributes, [Fields.Cursors, uid])) {
190 | this.context.safeUpdateAttributes([Fields.Cursors, uid], {});
191 | }
192 | this.context.safeUpdateAttributes([Fields.Cursors, uid, Fields.Position], position);
193 | }
194 |
195 | public updateCursorState(uid: string, cursorState: string | undefined) {
196 | if (!get(this.attributes, [Fields.Cursors, uid])) {
197 | this.context.safeUpdateAttributes([Fields.Cursors, uid], {});
198 | }
199 | this.context.safeUpdateAttributes([Fields.Cursors, uid, Fields.CursorState], cursorState);
200 | }
201 |
202 | public getCursorState(uid: string) {
203 | return get(this.attributes, [Fields.Cursors, uid, Fields.CursorState]);
204 | }
205 |
206 | public cleanCursor(uid: string) {
207 | this.context.safeUpdateAttributes([Fields.Cursors, uid], undefined);
208 | }
209 |
210 | // TODO 状态中保存一个 SceneName 优化性能
211 | public setMainViewFocusPath(mainView: View) {
212 | const scenePath = this.getMainViewScenePath();
213 | if (scenePath) {
214 | setViewFocusScenePath(mainView, scenePath);
215 | }
216 | }
217 |
218 | public getIframeBridge() {
219 | return get(this.attributes, [Fields.IframeBridge]);
220 | }
221 |
222 | public setIframeBridge(data: any) {
223 | if (isObject(data)) {
224 | const oldState = this.getIframeBridge();
225 | for (const key in data) {
226 | const value = (data as any)[key];
227 | if (oldState[key] !== value) {
228 | this.context.safeUpdateAttributes([Fields.IframeBridge, key], value);
229 | }
230 | }
231 | }
232 | }
233 | }
234 |
235 | export type MainViewSize = {
236 | id: string;
237 | width: number;
238 | height: number;
239 | };
240 |
241 | export type MainViewCamera = {
242 | id: string;
243 | centerX: number;
244 | centerY: number;
245 | scale: number;
246 | };
247 |
248 | export type Cursors = {
249 | [key: string]: Cursor;
250 | };
251 |
252 | export const store = new AttributesDelegate({
253 | getAttributes: () => {
254 | throw new Error("getAttributes not implemented");
255 | },
256 | safeSetAttributes: () => {
257 | throw new Error("safeSetAttributes not implemented");
258 | },
259 | safeUpdateAttributes: () => {
260 | throw new Error("safeUpdateAttributes not implemented");
261 | },
262 | });
263 |
--------------------------------------------------------------------------------
/src/BoxEmitter.ts:
--------------------------------------------------------------------------------
1 | import type { TELE_BOX_STATE } from "@netless/telebox-insider";
2 | import Emittery from "emittery";
3 |
4 | export type BoxMovePayload = { appId: string; x: number; y: number };
5 | export type BoxFocusPayload = { appId: string };
6 | export type BoxResizePayload = {
7 | appId: string;
8 | width: number;
9 | height: number;
10 | x?: number;
11 | y?: number;
12 | };
13 | export type BoxClosePayload = { appId: string; error?: Error };
14 | export type BoxStateChangePayload = { appId: string; state: TELE_BOX_STATE };
15 |
16 | export type BoxEvent = {
17 | move: BoxMovePayload;
18 | focus: BoxFocusPayload;
19 | resize: BoxResizePayload;
20 | close: BoxClosePayload;
21 | boxStateChange: BoxStateChangePayload;
22 | };
23 |
24 | export type BoxEmitterType = Emittery;
25 | export const boxEmitter: BoxEmitterType = new Emittery();
26 |
--------------------------------------------------------------------------------
/src/BuiltinApps.ts:
--------------------------------------------------------------------------------
1 | import AppDocsViewer from "@netless/app-docs-viewer";
2 | import AppMediaPlayer, { setOptions } from "@netless/app-media-player";
3 | import { WindowManager } from "./index";
4 |
5 | export const setupBuiltin = () => {
6 | if (WindowManager.debug) {
7 | setOptions({ verbose: true });
8 | }
9 |
10 | WindowManager.register({
11 | kind: AppDocsViewer.kind,
12 | src: AppDocsViewer,
13 | });
14 | WindowManager.register({
15 | kind: AppMediaPlayer.kind,
16 | src: AppMediaPlayer as any,
17 | });
18 | };
19 |
20 | export const BuiltinApps = {
21 | DocsViewer: AppDocsViewer.kind as string,
22 | MediaPlayer: AppMediaPlayer.kind as string,
23 | };
24 |
--------------------------------------------------------------------------------
/src/ContainerResizeObserver.ts:
--------------------------------------------------------------------------------
1 | import { ResizeObserver as ResizeObserverPolyfill } from "@juggle/resize-observer";
2 | import { isFunction } from "lodash";
3 | import { WindowManager } from "./index";
4 | import type { EmitterType } from "./InternalEmitter";
5 | import type { UnsubscribeFn } from "emittery";
6 |
7 | const ResizeObserver = window.ResizeObserver || ResizeObserverPolyfill;
8 |
9 | export class ContainerResizeObserver {
10 | private containerResizeObserver?: ResizeObserver;
11 | private disposer?: UnsubscribeFn;
12 |
13 | constructor(private emitter: EmitterType) {}
14 |
15 | public static create(
16 | container: HTMLElement,
17 | sizer: HTMLElement,
18 | wrapper: HTMLDivElement,
19 | emitter: EmitterType
20 | ) {
21 | const containerResizeObserver = new ContainerResizeObserver(emitter);
22 | containerResizeObserver.observePlaygroundSize(container, sizer, wrapper);
23 | return containerResizeObserver;
24 | }
25 |
26 | public observePlaygroundSize(
27 | container: HTMLElement,
28 | sizer: HTMLElement,
29 | wrapper: HTMLDivElement
30 | ) {
31 | this.updateSizer(container.getBoundingClientRect(), sizer, wrapper);
32 |
33 | this.containerResizeObserver = new ResizeObserver(entries => {
34 | const containerRect = entries[0]?.contentRect;
35 | if (containerRect) {
36 | this.updateSizer(containerRect, sizer, wrapper);
37 | this.emitter.emit("playgroundSizeChange", containerRect);
38 | }
39 | });
40 |
41 | this.disposer = this.emitter.on("containerSizeRatioUpdate", () => {
42 | const containerRect = container.getBoundingClientRect();
43 | this.updateSizer(containerRect, sizer, wrapper);
44 | this.emitter.emit("playgroundSizeChange", containerRect);
45 | });
46 |
47 | this.containerResizeObserver.observe(container);
48 | }
49 |
50 | public updateSizer(
51 | { width, height }: DOMRectReadOnly,
52 | sizer: HTMLElement,
53 | wrapper: HTMLDivElement
54 | ) {
55 | if (width && height) {
56 | if (height / width > WindowManager.containerSizeRatio) {
57 | height = width * WindowManager.containerSizeRatio;
58 | sizer.classList.toggle("netless-window-manager-sizer-horizontal", true);
59 | } else {
60 | width = height / WindowManager.containerSizeRatio;
61 | sizer.classList.toggle("netless-window-manager-sizer-horizontal", false);
62 | }
63 | wrapper.style.width = `${width}px`;
64 | wrapper.style.height = `${height}px`;
65 | }
66 | }
67 |
68 | public disconnect() {
69 | this.containerResizeObserver?.disconnect();
70 | if (isFunction(this.disposer)) {
71 | this.disposer();
72 | this.disposer = undefined;
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Cursor/Cursor.svelte:
--------------------------------------------------------------------------------
1 |
43 |
44 |
49 | {#if !isLaserPointer}
50 |
51 |
55 | {#if hasAvatar}
56 |
62 | {/if}
63 |
{cursorName}
64 | {#if hasTagName}
65 |
66 | {tagName}
67 |
68 | {/if}
69 |
70 |
71 | {/if}
72 |
73 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/src/Cursor/Cursor.svelte.d.ts:
--------------------------------------------------------------------------------
1 | import { SvelteComponentTyped } from "svelte";
2 |
3 | declare class Cursor extends SvelteComponentTyped<{
4 | readonly cursorName: string;
5 | readonly tagName?: string;
6 | readonly backgroundColor: string;
7 | readonly appliance: string;
8 | readonly x: number;
9 | readonly y: number;
10 | readonly src?: string;
11 | readonly visible: boolean;
12 | readonly avatar: string;
13 | readonly theme: string;
14 | readonly color: string;
15 | readonly cursorTagBackgroundColor: string;
16 | readonly opacity: number;
17 | readonly pencilEraserSize?: number;
18 | readonly custom?: boolean;
19 | }> {}
20 |
21 | export default Cursor;
22 |
--------------------------------------------------------------------------------
/src/Cursor/Cursor.ts:
--------------------------------------------------------------------------------
1 | import type { RoomMember } from "white-web-sdk";
2 | import type { CursorOptions } from "../index";
3 | import type { AppManager } from "../AppManager";
4 | import type { Position } from "../AttributesDelegate";
5 | import type { CursorManager } from "./index";
6 |
7 | import { omit } from "lodash";
8 | import { ApplianceNames } from "white-web-sdk";
9 |
10 | import { findMemberByUid } from "../Helper";
11 | import App from "./Cursor.svelte";
12 | import { remoteIcon } from "./icons2";
13 |
14 | export type Payload = {
15 | [key: string]: any;
16 | };
17 |
18 | export class Cursor {
19 | private member?: RoomMember;
20 | private timer?: number;
21 | private component?: App;
22 | private style: CursorOptions["style"] & string = "default";
23 |
24 | constructor(
25 | private manager: AppManager,
26 | private memberId: string,
27 | private cursorManager: CursorManager,
28 | private wrapper?: HTMLElement
29 | ) {
30 | this.updateMember();
31 | this.createCursor();
32 | this.autoHidden();
33 | this.setStyle(cursorManager.style);
34 | }
35 |
36 | public move = (position: Position) => {
37 | if (position.type === "main") {
38 | const rect = this.cursorManager.wrapperRect;
39 | if (this.component && rect) {
40 | this.autoHidden();
41 | this.moveCursor(position, rect, this.manager.mainView);
42 | }
43 | } else {
44 | const focusView = this.cursorManager.focusView;
45 | const viewRect = focusView?.divElement?.getBoundingClientRect();
46 | const viewCamera = focusView?.camera;
47 | if (focusView && viewRect && viewCamera && this.component) {
48 | this.autoHidden();
49 | this.moveCursor(position, viewRect, focusView);
50 | }
51 | }
52 | };
53 |
54 | public setStyle = (style: typeof this.style) => {
55 | this.style = style;
56 | if (this.component) {
57 | this.component.$set({
58 | src: this.getIcon(),
59 | custom: this.isCustomIcon(),
60 | });
61 | }
62 | };
63 |
64 | public leave = () => {
65 | this.hide();
66 | };
67 |
68 | private moveCursor(cursor: Position, rect: DOMRect, view: any) {
69 | const { x, y, type } = cursor;
70 | const point = view?.screen.convertPointToScreen(x, y);
71 | if (point) {
72 | let translateX = point.x - 2;
73 | let translateY = point.y - 18;
74 | if (this.isCustomIcon()) {
75 | translateX -= 11;
76 | translateY += 4;
77 | }
78 | if (type === "app") {
79 | const wrapperRect = this.cursorManager.wrapperRect;
80 | if (wrapperRect) {
81 | translateX = translateX + rect.x - wrapperRect.x;
82 | translateY = translateY + rect.y - wrapperRect.y;
83 | }
84 | }
85 | if (point.x < 0 || point.x > rect.width || point.y < 0 || point.y > rect.height) {
86 | this.component?.$set({ visible: false, x: translateX, y: translateY });
87 | } else {
88 | this.component?.$set({ visible: true, x: translateX, y: translateY });
89 | }
90 | }
91 | }
92 |
93 | public get memberApplianceName() {
94 | return this.member?.memberState?.currentApplianceName;
95 | }
96 |
97 | public get memberColor() {
98 | const rgb = this.member?.memberState?.strokeColor.join(",");
99 | return `rgb(${rgb})`;
100 | }
101 |
102 | public get memberColorHex(): string {
103 | const [r, g, b] = this.member?.memberState?.strokeColor || [236, 52, 85];
104 | return ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
105 | }
106 |
107 | private get payload(): Payload | undefined {
108 | return this.member?.payload;
109 | }
110 |
111 | public get memberCursorName() {
112 | return this.payload?.nickName || this.payload?.cursorName || this.memberId;
113 | }
114 |
115 | private get memberTheme() {
116 | if (this.payload?.theme) {
117 | return "netless-window-manager-cursor-inner-mellow";
118 | } else {
119 | return "netless-window-manager-cursor-inner";
120 | }
121 | }
122 |
123 | private get memberCursorTextColor() {
124 | return this.payload?.cursorTextColor || "#FFFFFF";
125 | }
126 |
127 | private get memberCursorTagBackgroundColor() {
128 | return this.payload?.cursorTagBackgroundColor || this.memberColor;
129 | }
130 |
131 | private get memberAvatar() {
132 | return this.payload?.avatar;
133 | }
134 |
135 | private get memberOpacity() {
136 | if (!this.memberCursorName && !this.memberAvatar) {
137 | return 0;
138 | } else {
139 | return 1;
140 | }
141 | }
142 |
143 | private get memberTagName(): string | undefined {
144 | return this.payload?.cursorTagName;
145 | }
146 |
147 | private autoHidden() {
148 | if (this.timer) {
149 | clearTimeout(this.timer);
150 | }
151 | this.timer = window.setTimeout(() => {
152 | this.hide();
153 | }, 1000 * 10); // 10 秒钟自动隐藏
154 | }
155 |
156 | private async createCursor() {
157 | if (this.member && this.wrapper) {
158 | this.component = new App({
159 | target: this.wrapper,
160 | props: this.initProps(),
161 | });
162 | }
163 | }
164 |
165 | private initProps() {
166 | return {
167 | uid: this.memberId,
168 | x: 0,
169 | y: 0,
170 | appliance: this.memberApplianceName as string,
171 | avatar: this.memberAvatar,
172 | src: this.getIcon(),
173 | custom: this.isCustomIcon(),
174 | visible: false,
175 | backgroundColor: this.memberColor,
176 | cursorName: this.memberCursorName,
177 | theme: this.memberTheme,
178 | color: this.memberCursorTextColor,
179 | cursorTagBackgroundColor: this.memberCursorTagBackgroundColor,
180 | opacity: this.memberOpacity,
181 | tagName: this.memberTagName,
182 | pencilEraserSize: this.member?.memberState.pencilEraserSize,
183 | };
184 | }
185 |
186 | private getIcon(): string | undefined {
187 | if (!this.member) return;
188 |
189 | const { memberApplianceName, memberColorHex } = this;
190 | const { userApplianceIcons, applianceIcons } = this.cursorManager;
191 |
192 | let iconsKey: string | undefined = this.memberApplianceName;
193 | if (iconsKey === ApplianceNames.pencilEraser) {
194 | iconsKey = `${iconsKey}${this.member?.memberState.pencilEraserSize || 1}`;
195 | }
196 |
197 | const userApplianceSrc = iconsKey && userApplianceIcons[iconsKey];
198 | if (userApplianceSrc) return userApplianceSrc;
199 |
200 | if (this.style === "custom" && memberApplianceName) {
201 | const customApplianceSrc = remoteIcon(memberApplianceName, memberColorHex);
202 | if (customApplianceSrc) return customApplianceSrc;
203 | }
204 |
205 | const applianceSrc = applianceIcons[iconsKey || ApplianceNames.shape];
206 | return applianceSrc || applianceIcons[ApplianceNames.shape];
207 | }
208 |
209 | private isCustomIcon(): boolean {
210 | if (!this.member) return false;
211 |
212 | const { memberApplianceName, memberColorHex } = this;
213 | const { userApplianceIcons } = this.cursorManager;
214 |
215 | let iconsKey: string | undefined = this.memberApplianceName;
216 | if (iconsKey === ApplianceNames.pencilEraser) {
217 | iconsKey = `${iconsKey}${this.member?.memberState.pencilEraserSize || 1}`;
218 | }
219 |
220 | const userApplianceSrc = iconsKey && userApplianceIcons[iconsKey];
221 | if (userApplianceSrc) return false;
222 |
223 | if (this.style === "custom" && memberApplianceName) {
224 | const customApplianceSrc = remoteIcon(memberApplianceName, memberColorHex);
225 | if (customApplianceSrc) return true;
226 | }
227 |
228 | return false;
229 | }
230 |
231 | public updateMember() {
232 | this.member = findMemberByUid(this.manager.room, this.memberId);
233 | this.updateComponent();
234 | return this.member;
235 | }
236 |
237 | private updateComponent() {
238 | this.component?.$set(omit(this.initProps(), ["x", "y"]));
239 | }
240 |
241 | public destroy() {
242 | if (this.component) {
243 | this.component.$destroy();
244 | }
245 | this.cursorManager.cursorInstances.delete(this.memberId);
246 | if (this.timer) {
247 | clearTimeout(this.timer);
248 | }
249 | }
250 |
251 | public hide() {
252 | if (this.component) {
253 | this.component.$set({ visible: false });
254 | this.destroy();
255 | }
256 | }
257 | }
258 |
--------------------------------------------------------------------------------
/src/Cursor/icons.ts:
--------------------------------------------------------------------------------
1 | import { ApplianceNames } from "white-web-sdk";
2 | import pencil from "../image/pencil-cursor.png";
3 | import selector from "../image/selector-cursor.png";
4 | import eraser from "../image/eraser-cursor.png";
5 | import shape from "../image/shape-cursor.svg";
6 | import text from "../image/text-cursor.svg";
7 | import laser from "../image/laser-pointer-cursor.svg";
8 | import pencilEraser1 from "../image/pencil-eraser-1.svg";
9 | import pencilEraser2 from "../image/pencil-eraser-2.svg";
10 | import pencilEraser3 from "../image/pencil-eraser-3.svg";
11 |
12 | export const ApplianceMap: {
13 | [key: string]: string;
14 | } = {
15 | [ApplianceNames.pencil]: pencil,
16 | [ApplianceNames.selector]: selector,
17 | [ApplianceNames.eraser]: eraser,
18 | [ApplianceNames.shape]: shape,
19 | [ApplianceNames.text]: text,
20 | [ApplianceNames.laserPointer]: laser,
21 | ["pencilEraser1"]: pencilEraser1,
22 | ["pencilEraser2"]: pencilEraser2,
23 | ["pencilEraser3"]: pencilEraser3,
24 | };
25 |
--------------------------------------------------------------------------------
/src/Cursor/icons2.ts:
--------------------------------------------------------------------------------
1 | import type { MemberState } from "white-web-sdk";
2 | import { ApplianceNames } from "white-web-sdk";
3 |
4 | type Color = string;
5 |
6 | const staticCircle = `data:image/svg+xml,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Ccircle cx='12' cy='12' r='2.5' stroke='%23000' stroke-linejoin='square'/%3E%3Ccircle cx='12' cy='12' r='3.5' stroke='%23FFF'/%3E%3C/g%3E%3C/svg%3E`;
7 |
8 | function circleUrl(color: Color): string {
9 | return `data:image/svg+xml,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Ccircle cx='12' cy='12' r='2.5' stroke='%23${color}' stroke-linejoin='square'/%3E%3Ccircle cx='12' cy='12' r='3.5' stroke='%23${color}'/%3E%3C/g%3E%3C/svg%3E`;
10 | }
11 |
12 | function crossUrl(color: Color): string {
13 | return `data:image/svg+xml,%3Csvg width='24' height='24' xmlns='http://www.w3.org/2000/svg' fill='none'%3E%3Cpath d='M5 12H19' stroke='%23${color}' stroke-linejoin='round'/%3E%3Cpath d='M12 5V19' stroke='%23${color}' stroke-linejoin='round'/%3E%3C/svg%3E`;
14 | }
15 |
16 | function cssCursor(url: string): string {
17 | return `url("${url}") 12 12, auto`;
18 | }
19 |
20 | function makeStyleContent(config: { [cursor: string]: string }): string {
21 | let result = "";
22 | for (const cursor in config) {
23 | result += `.netless-whiteboard.${cursor} {cursor: ${config[cursor]}}\n`;
24 | }
25 | return result;
26 | }
27 |
28 | const $style = document.createElement("style");
29 |
30 | export function enableLocal(memberState: MemberState): () => void {
31 | const [r, g, b] = memberState.strokeColor;
32 | const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
33 | $style.textContent = makeStyleContent({
34 | "cursor-pencil": cssCursor(circleUrl(hex)),
35 | "cursor-eraser": cssCursor(staticCircle),
36 | "cursor-rectangle": cssCursor(crossUrl(hex)),
37 | "cursor-ellipse": cssCursor(crossUrl(hex)),
38 | "cursor-straight": cssCursor(crossUrl(hex)),
39 | "cursor-arrow": cssCursor(crossUrl(hex)),
40 | "cursor-shape": cssCursor(crossUrl(hex)),
41 | });
42 | document.head.appendChild($style);
43 |
44 | return () => {
45 | if ($style.parentNode == null) return;
46 | document.head.removeChild($style);
47 | };
48 | }
49 |
50 | const shapeAppliances: Set = new Set([
51 | ApplianceNames.rectangle,
52 | ApplianceNames.ellipse,
53 | ApplianceNames.straight,
54 | ApplianceNames.arrow,
55 | ApplianceNames.shape,
56 | ]);
57 |
58 | export function remoteIcon(applianceName: ApplianceNames, hex: string): string | undefined {
59 | if (applianceName === ApplianceNames.pencil) {
60 | return circleUrl(hex);
61 | } else if (applianceName === ApplianceNames.eraser) {
62 | return staticCircle;
63 | } else if (shapeAppliances.has(applianceName)) {
64 | return crossUrl(hex);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Helper.ts:
--------------------------------------------------------------------------------
1 | import pRetry from "p-retry";
2 | import type { Room, RoomMember } from "white-web-sdk";
3 | import { WhiteVersion } from "white-web-sdk";
4 | import { REQUIRE_VERSION } from "./constants";
5 | import { WindowManager } from "./index";
6 | import { getVersionNumber } from "./Utils/Common";
7 | import { WhiteWebSDKInvalidError } from "./Utils/error";
8 | import { log } from "./Utils/log";
9 |
10 | export const setupWrapper = (
11 | root: HTMLElement
12 | ): {
13 | playground: HTMLDivElement;
14 | wrapper: HTMLDivElement;
15 | sizer: HTMLDivElement;
16 | mainViewElement: HTMLDivElement;
17 | } => {
18 | const playground = document.createElement("div");
19 | playground.className = "netless-window-manager-playground";
20 |
21 | const sizer = document.createElement("div");
22 | sizer.className = "netless-window-manager-sizer";
23 |
24 | const wrapper = document.createElement("div");
25 | wrapper.className = "netless-window-manager-wrapper";
26 |
27 | const mainViewElement = document.createElement("div");
28 | mainViewElement.className = "netless-window-manager-main-view";
29 |
30 | playground.appendChild(sizer);
31 | sizer.appendChild(wrapper);
32 | wrapper.appendChild(mainViewElement);
33 | root.appendChild(playground);
34 | WindowManager.wrapper = wrapper;
35 |
36 | return { playground, wrapper, sizer, mainViewElement };
37 | };
38 |
39 | export const checkVersion = () => {
40 | const version = getVersionNumber(WhiteVersion);
41 | if (version < getVersionNumber(REQUIRE_VERSION)) {
42 | throw new WhiteWebSDKInvalidError(REQUIRE_VERSION);
43 | }
44 | };
45 |
46 | export const findMemberByUid = (room: Room | undefined, uid: string) => {
47 | const roomMembers = room?.state.roomMembers || [];
48 | let maxMemberId = -1; // 第一个进入房间的用户 memberId 是 0
49 | let result: RoomMember | undefined = undefined;
50 | for (const member of roomMembers) {
51 | if (member.payload?.uid === uid && maxMemberId < member.memberId) {
52 | maxMemberId = member.memberId;
53 | result = member;
54 | }
55 | }
56 | return result;
57 | };
58 |
59 | export const createInvisiblePlugin = async (room: Room): Promise => {
60 | let manager = room.getInvisiblePlugin(WindowManager.kind) as WindowManager;
61 | if (manager) return manager;
62 |
63 | let resolve!: (manager: WindowManager) => void;
64 | const promise = new Promise(r => {
65 | // @ts-expect-error Set private property.
66 | WindowManager._resolve = resolve = r;
67 | });
68 |
69 | let wasReadonly = false;
70 | const canOperate = isRoomTokenWritable(room);
71 | if (!room.isWritable && canOperate) {
72 | wasReadonly = true;
73 | await pRetry(
74 | async count => {
75 | log(`switching to writable (x${count})`);
76 | await room.setWritable(true);
77 | },
78 | { retries: 10, maxTimeout: 5000 }
79 | );
80 | }
81 | if (room.isWritable) {
82 | log("creating InvisiblePlugin...");
83 | room.createInvisiblePlugin(WindowManager, {}).catch(console.warn);
84 | } else {
85 | if (canOperate) console.warn("[WindowManager]: failed to switch to writable");
86 | console.warn("[WindowManager]: waiting for others to create the plugin...");
87 | }
88 |
89 | const timeout = setTimeout(() => {
90 | console.warn("[WindowManager]: no one called createInvisiblePlugin() after 20 seconds");
91 | }, 20_000);
92 |
93 | const abort = setTimeout(() => {
94 | throw new Error("[WindowManager]: no one called createInvisiblePlugin() after 60 seconds");
95 | }, 60_000);
96 |
97 | const interval = setInterval(() => {
98 | manager = room.getInvisiblePlugin(WindowManager.kind) as WindowManager;
99 | if (manager) {
100 | clearTimeout(abort);
101 | clearTimeout(timeout);
102 | clearInterval(interval);
103 | resolve(manager);
104 | if (wasReadonly && room.isWritable) {
105 | setTimeout(() => room.setWritable(false).catch(console.warn), 500);
106 | }
107 | }
108 | }, 200);
109 |
110 | return promise;
111 | };
112 |
113 | const isRoomTokenWritable = (room: Room) => {
114 | try {
115 | const str = atob(room.roomToken.slice("NETLESSROOM_".length));
116 | const index = str.indexOf("&role=");
117 | const role = +str[index + "&role=".length];
118 | return role < 2;
119 | } catch (error) {
120 | console.error(error);
121 | return false;
122 | }
123 | };
124 |
--------------------------------------------------------------------------------
/src/InternalEmitter.ts:
--------------------------------------------------------------------------------
1 | import Emittery from "emittery";
2 | import type { AppInitState, CursorMovePayload } from "./index";
3 |
4 | export type RemoveSceneParams = {
5 | scenePath: string;
6 | index?: number;
7 | };
8 |
9 | export type EmitterEvent = {
10 | onCreated: undefined;
11 | InitReplay: AppInitState;
12 | error: Error;
13 | seekStart: undefined;
14 | seek: number;
15 | mainViewMounted: undefined;
16 | observerIdChange: number;
17 | boxStateChange: string;
18 | playgroundSizeChange: DOMRect;
19 | startReconnect: undefined;
20 | onReconnected: undefined;
21 | removeScenes: RemoveSceneParams;
22 | cursorMove: CursorMovePayload;
23 | updateManagerRect: undefined;
24 | focusedChange: { focused: string | undefined; prev: string | undefined };
25 | rootDirRemoved: undefined; // 根目录整个被删除
26 | rootDirSceneRemoved: string; // 根目录下的场景被删除
27 | setReadonly: boolean;
28 | changePageState: undefined;
29 | writableChange: boolean;
30 | containerSizeRatioUpdate: number;
31 | };
32 |
33 | export type EmitterType = Emittery;
34 | export const internalEmitter: EmitterType = new Emittery();
35 |
--------------------------------------------------------------------------------
/src/Page/PageController.ts:
--------------------------------------------------------------------------------
1 | import type { SceneDefinition } from "white-web-sdk";
2 |
3 | export type AddPageParams = {
4 | after?: boolean;
5 | scene?: SceneDefinition;
6 | };
7 |
8 | export type PageState = {
9 | index: number;
10 | length: number;
11 | };
12 |
13 | export interface PageController {
14 | nextPage: () => Promise;
15 | prevPage: () => Promise;
16 | jumpPage: (index: number) => Promise;
17 | addPage: (params?: AddPageParams) => Promise;
18 | removePage: (index: number) => Promise;
19 | pageState: PageState;
20 | }
21 |
22 | export interface PageRemoveService {
23 | removeSceneByIndex: (index: number) => Promise;
24 | setSceneIndexWithoutSync: (index: number) => void;
25 | }
26 |
--------------------------------------------------------------------------------
/src/Page/index.ts:
--------------------------------------------------------------------------------
1 | import type { PageState } from "./PageController";
2 |
3 | export * from "./PageController";
4 |
5 | export const calculateNextIndex = (index: number, pageState: PageState) => {
6 | let nextIndex = 0;
7 | const maxIndex = pageState.length - 1;
8 | if (index === pageState.index) {
9 | if (index === maxIndex) {
10 | nextIndex = index - 1;
11 | } else {
12 | nextIndex = pageState.index + 1;
13 | }
14 | } else {
15 | nextIndex = pageState.index;
16 | }
17 | return nextIndex;
18 | };
19 |
--------------------------------------------------------------------------------
/src/PageState.ts:
--------------------------------------------------------------------------------
1 | import type { AppManager } from "./AppManager";
2 | import type { PageState } from "./Page";
3 |
4 | import { internalEmitter } from "./InternalEmitter";
5 | import { callbacks } from "./callback";
6 |
7 | export class PageStateImpl {
8 | constructor(private manager: AppManager) {
9 | internalEmitter.on("changePageState", () => {
10 | callbacks.emit("pageStateChange", this.toObject());
11 | });
12 | }
13 |
14 | public get index(): number {
15 | return this.manager.store.getMainViewSceneIndex() || 0;
16 | }
17 |
18 | public get length(): number {
19 | return this.manager.mainViewScenesLength || 0;
20 | }
21 |
22 | public toObject(): PageState {
23 | const index = this.index >= this.length ? this.length - 1 : this.index;
24 | return {
25 | index,
26 | length: this.length,
27 | };
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/ReconnectRefresher.ts:
--------------------------------------------------------------------------------
1 | import { debounce, isFunction } from "lodash";
2 | import { log } from "./Utils/log";
3 | import { RoomPhase } from "white-web-sdk";
4 | import type { Room } from "white-web-sdk";
5 | import type { EmitterType } from "./InternalEmitter";
6 | import { EnsureReconnectEvent } from "./constants";
7 | import { wait } from "./Utils/Common";
8 |
9 | export type ReconnectRefresherContext = {
10 | emitter: EmitterType;
11 | };
12 |
13 | // 白板重连之后会刷新所有的对象,导致 listener 失效, 所以这里在重连之后重新对所有对象进行监听
14 | export class ReconnectRefresher {
15 | private phase?: RoomPhase;
16 | private room: Room | undefined;
17 | private reactors: Map = new Map();
18 | private disposers: Map = new Map();
19 |
20 | constructor(private ctx: ReconnectRefresherContext) {}
21 |
22 | public setRoom(room: Room | undefined) {
23 | this.room = room;
24 | this.phase = room?.phase;
25 | if (room) {
26 | room.callbacks.off("onPhaseChanged", this.onPhaseChanged);
27 | room.callbacks.on("onPhaseChanged", this.onPhaseChanged);
28 | // 重连成功之后向服务发送一次消息, 确认当前的状态是最新的
29 | room.addMagixEventListener(
30 | EnsureReconnectEvent,
31 | payload => {
32 | if (payload.authorId === room.observerId) {
33 | this.onReconnected();
34 | }
35 | },
36 | { fireSelfEventAfterCommit: true }
37 | );
38 | }
39 | }
40 |
41 | public setContext(ctx: ReconnectRefresherContext) {
42 | this.ctx = ctx;
43 | }
44 |
45 | private onPhaseChanged = async (phase: RoomPhase) => {
46 | if (phase === RoomPhase.Reconnecting) {
47 | this.ctx.emitter.emit("startReconnect");
48 | }
49 | if (phase === RoomPhase.Connected && this.phase === RoomPhase.Reconnecting) {
50 | if (this.room?.isWritable) {
51 | this.room?.dispatchMagixEvent(EnsureReconnectEvent, {});
52 | } else {
53 | await wait(500);
54 | this.onReconnected();
55 | }
56 | }
57 | this.phase = phase;
58 | };
59 |
60 | private onReconnected = debounce(() => {
61 | this._onReconnected();
62 | }, 1000);
63 |
64 | private _onReconnected = () => {
65 | log("onReconnected refresh reactors");
66 | this.releaseDisposers();
67 | this.reactors.forEach((func, id) => {
68 | if (isFunction(func)) {
69 | this.disposers.set(id, func());
70 | }
71 | });
72 | this.ctx.emitter.emit("onReconnected");
73 | };
74 |
75 | private releaseDisposers() {
76 | this.disposers.forEach(disposer => {
77 | if (isFunction(disposer)) {
78 | disposer();
79 | }
80 | });
81 | this.disposers.clear();
82 | }
83 |
84 | public refresh() {
85 | this._onReconnected();
86 | }
87 |
88 | public add(id: string, func: any) {
89 | const disposer = this.disposers.get(id);
90 | if (disposer && isFunction(disposer)) {
91 | disposer();
92 | }
93 | if (isFunction(func)) {
94 | this.reactors.set(id, func);
95 | this.disposers.set(id, func());
96 | }
97 | }
98 |
99 | public remove(id: string) {
100 | if (this.reactors.has(id)) {
101 | this.reactors.delete(id);
102 | }
103 | const disposer = this.disposers.get(id);
104 | if (disposer) {
105 | if (isFunction(disposer)) {
106 | disposer();
107 | }
108 | this.disposers.delete(id);
109 | }
110 | }
111 |
112 | public hasReactor(id: string) {
113 | return this.reactors.has(id);
114 | }
115 |
116 | public destroy() {
117 | this.room?.callbacks.off("onPhaseChanged", this.onPhaseChanged);
118 | this.room?.removeMagixEventListener(EnsureReconnectEvent, this.onReconnected);
119 | this.releaseDisposers();
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/RedoUndo.ts:
--------------------------------------------------------------------------------
1 | import { callbacks } from "./callback";
2 | import { internalEmitter } from "./InternalEmitter";
3 | import type { View } from "white-web-sdk";
4 | import type { AppProxy } from "./App";
5 |
6 | export type RedoUndoContext = {
7 | mainView: () => View;
8 | focus: () => string | undefined;
9 | getAppProxy: (id: string) => AppProxy | undefined;
10 | };
11 |
12 | export class RedoUndo {
13 | constructor(private context: RedoUndoContext) {
14 | internalEmitter.on("focusedChange", changed => {
15 | this.disposePrevFocusViewRedoUndoListeners(changed.prev);
16 | setTimeout(() => {
17 | this.addRedoUndoListeners(changed.focused);
18 | }, 0);
19 | });
20 | internalEmitter.on("rootDirRemoved", () => {
21 | this.disposePrevFocusViewRedoUndoListeners(context.focus());
22 | this.addRedoUndoListeners(context.focus());
23 | });
24 | this.addRedoUndoListeners(context.focus());
25 | }
26 |
27 | private addRedoUndoListeners = (focused: string | undefined) => {
28 | if (focused === undefined) {
29 | this.addViewCallbacks(
30 | this.context.mainView(),
31 | this.onCanRedoStepsUpdate,
32 | this.onCanUndoStepsUpdate
33 | );
34 | } else {
35 | const focusApp = this.context.getAppProxy(focused);
36 | if (focusApp && focusApp.view) {
37 | this.addViewCallbacks(
38 | focusApp.view,
39 | this.onCanRedoStepsUpdate,
40 | this.onCanUndoStepsUpdate
41 | );
42 | }
43 | }
44 | };
45 |
46 | private addViewCallbacks = (
47 | view: View,
48 | redoListener: (steps: number) => void,
49 | undoListener: (steps: number) => void
50 | ) => {
51 | redoListener(view.canRedoSteps);
52 | undoListener(view.canUndoSteps);
53 | view.callbacks.on("onCanRedoStepsUpdate", redoListener);
54 | view.callbacks.on("onCanUndoStepsUpdate", undoListener);
55 | };
56 |
57 | private disposeViewCallbacks = (view: View) => {
58 | view.callbacks.off("onCanRedoStepsUpdate", this.onCanRedoStepsUpdate);
59 | view.callbacks.off("onCanUndoStepsUpdate", this.onCanUndoStepsUpdate);
60 | };
61 |
62 | private onCanRedoStepsUpdate = (steps: number) => {
63 | callbacks.emit("canRedoStepsChange", steps);
64 | };
65 |
66 | private onCanUndoStepsUpdate = (steps: number) => {
67 | callbacks.emit("canUndoStepsChange", steps);
68 | };
69 |
70 | private disposePrevFocusViewRedoUndoListeners = (prevFocused: string | undefined) => {
71 | let view: View | undefined = undefined;
72 | if (prevFocused === undefined) {
73 | view = this.context.mainView();
74 | } else {
75 | const appProxy = this.context.getAppProxy(prevFocused);
76 | if (appProxy && appProxy.view) {
77 | view = appProxy.view;
78 | }
79 | }
80 | if (view) {
81 | this.disposeViewCallbacks(view);
82 | }
83 | };
84 |
85 | public destroy() {
86 | this.disposePrevFocusViewRedoUndoListeners(this.context.focus());
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/Register/index.ts:
--------------------------------------------------------------------------------
1 | import Emittery from "emittery";
2 | import { loadApp } from "./loader";
3 | import type { NetlessApp, RegisterEvents, RegisterParams } from "../typings";
4 |
5 | export type LoadAppEvent = {
6 | kind: string;
7 | status: "start" | "success" | "failed";
8 | reason?: string;
9 | };
10 |
11 | export type SyncRegisterAppPayload = { kind: string; src: string; name: string | undefined };
12 | export type SyncRegisterApp = (payload: SyncRegisterAppPayload) => void;
13 |
14 | class AppRegister {
15 | public kindEmitters: Map> = new Map();
16 | public registered: Map = new Map();
17 | public appClassesCache: Map> = new Map();
18 | public appClasses: Map Promise> = new Map();
19 |
20 | private syncRegisterApp: SyncRegisterApp | null = null;
21 |
22 | public setSyncRegisterApp(fn: SyncRegisterApp) {
23 | this.syncRegisterApp = fn;
24 | }
25 |
26 | public onSyncRegisterAppChange = (payload: SyncRegisterAppPayload) => {
27 | this.register({ kind: payload.kind, src: payload.src });
28 | };
29 |
30 | public async register(params: RegisterParams): Promise {
31 | this.appClassesCache.delete(params.kind);
32 | this.registered.set(params.kind, params);
33 |
34 | const paramSrc = params.src;
35 | let downloadApp: () => Promise;
36 |
37 | if (typeof paramSrc === "string") {
38 | downloadApp = async () => {
39 | const result = (await loadApp(paramSrc, params.kind, params.name)) as any;
40 | if (result.__esModule) {
41 | return result.default;
42 | }
43 | return result;
44 | };
45 | if (this.syncRegisterApp) {
46 | this.syncRegisterApp({ kind: params.kind, src: paramSrc, name: params.name });
47 | }
48 | }
49 | if (typeof paramSrc === "function") {
50 | downloadApp = async () => {
51 | let appClass = (await paramSrc()) as any;
52 | if (appClass) {
53 | if (appClass.__esModule || appClass.default) {
54 | appClass = appClass.default;
55 | }
56 | return appClass;
57 | } else {
58 | throw new Error(`[WindowManager]: load remote script failed, ${paramSrc}`);
59 | }
60 | };
61 | }
62 | if (typeof paramSrc === "object") {
63 | downloadApp = async () => paramSrc;
64 | }
65 | this.appClasses.set(params.kind, async () => {
66 | let app = this.appClassesCache.get(params.kind);
67 | if (!app) {
68 | app = downloadApp();
69 | this.appClassesCache.set(params.kind, app);
70 | }
71 | return app;
72 | });
73 |
74 | if (params.addHooks) {
75 | const emitter = this.createKindEmitter(params.kind);
76 | if (emitter) {
77 | params.addHooks(emitter);
78 | }
79 | }
80 | }
81 |
82 | public unregister(kind: string) {
83 | this.appClasses.delete(kind);
84 | this.appClassesCache.delete(kind);
85 | this.registered.delete(kind);
86 | const kindEmitter = this.kindEmitters.get(kind);
87 | if (kindEmitter) {
88 | kindEmitter.clearListeners();
89 | this.kindEmitters.delete(kind);
90 | }
91 | }
92 |
93 | public async notifyApp(
94 | kind: string,
95 | event: T,
96 | payload: RegisterEvents[T]
97 | ) {
98 | const emitter = this.kindEmitters.get(kind);
99 | await emitter?.emit(event, payload);
100 | }
101 |
102 | private createKindEmitter(kind: string) {
103 | if (!this.kindEmitters.has(kind)) {
104 | const emitter = new Emittery();
105 | this.kindEmitters.set(kind, emitter);
106 | }
107 | return this.kindEmitters.get(kind);
108 | }
109 | }
110 |
111 | export const appRegister = new AppRegister();
112 |
--------------------------------------------------------------------------------
/src/Register/loader.ts:
--------------------------------------------------------------------------------
1 | import { callbacks } from "../callback";
2 | import { getItem, setItem } from "./storage";
3 | import type { NetlessApp } from "../typings";
4 |
5 | const Prefix = "NetlessApp";
6 |
7 | const TIMEOUT = 10000; // 下载 script 10 秒超时
8 |
9 | export const getScript = async (url: string): Promise => {
10 | const item = await getItem(url);
11 | if (item) {
12 | return item.sourceCode;
13 | } else {
14 | const result = await fetchWithTimeout(url, { timeout: TIMEOUT });
15 | const text = await result.text();
16 | await setItem(url, text);
17 | return text;
18 | }
19 | };
20 |
21 | export const executeScript = (text: string, appName: string): NetlessApp => {
22 | let result = Function(text + `\n;return ${appName}`)();
23 | if (typeof result === "undefined") {
24 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
25 | // @ts-ignore
26 | result = window[appName];
27 | }
28 | return result;
29 | };
30 |
31 | export const loadApp = async (url: string, key: string, name?: string): Promise => {
32 | const appName = name || Prefix + key;
33 | callbacks.emit("loadApp", { kind: key, status: "start" });
34 |
35 | let text: string;
36 | try {
37 | text = await getScript(url);
38 | if (!text || text.length === 0) {
39 | callbacks.emit("loadApp", { kind: key, status: "failed", reason: "script is empty." });
40 | throw new Error("[WindowManager]: script is empty.");
41 | }
42 | } catch (error) {
43 | callbacks.emit("loadApp", { kind: key, status: "failed", reason: error.message });
44 | throw error;
45 | }
46 | return getResult(text, appName, key);
47 | };
48 |
49 | const getResult = (text: string, appName: string, key: string): NetlessApp => {
50 | try {
51 | const result = executeScript(text, appName);
52 | callbacks.emit("loadApp", { kind: key, status: "success" });
53 | return result;
54 | } catch (error: any) {
55 | if (error.message.includes("Can only have one anonymous define call per script file")) {
56 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
57 | // @ts-ignore
58 | const define = window.define;
59 | if ("function" == typeof define && define.amd) {
60 | delete define.amd;
61 | }
62 | const result = executeScript(text, appName);
63 | callbacks.emit("loadApp", { kind: key, status: "success" });
64 | return result;
65 | }
66 | callbacks.emit("loadApp", { kind: key, status: "failed", reason: error.message });
67 | throw error;
68 | }
69 | };
70 |
71 | async function fetchWithTimeout(resource: string, options: RequestInit & { timeout: number }) {
72 | const { timeout = 10000 } = options;
73 |
74 | const controller = new AbortController();
75 | const id = setTimeout(() => controller.abort(), timeout);
76 |
77 | const response = await fetch(resource, {
78 | ...options,
79 | signal: controller.signal,
80 | headers: {
81 | "content-type": "text/plain",
82 | },
83 | });
84 | clearTimeout(id);
85 |
86 | return response;
87 | }
88 |
--------------------------------------------------------------------------------
/src/Register/storage.ts:
--------------------------------------------------------------------------------
1 | const DatabaseName = "__WindowManagerAppCache";
2 |
3 | let db: IDBDatabase;
4 | let store: IDBObjectStore;
5 |
6 | export type Item = {
7 | kind: string;
8 | sourceCode: string;
9 | };
10 |
11 | export const initDb = async () => {
12 | db = await createDb();
13 | };
14 |
15 | export const setItem = (key: string, val: any) => {
16 | if (!db) return;
17 | return addRecord(db, { kind: key, sourceCode: val });
18 | };
19 |
20 | export const getItem = async (key: string): Promise- => {
21 | if (!db) return null;
22 | return await query(db, key);
23 | };
24 |
25 | export const removeItem = (key: string) => {
26 | if (!db) return;
27 | return deleteRecord(db, key);
28 | };
29 |
30 | function createDb(): Promise
{
31 | return new Promise((resolve, reject) => {
32 | const request = indexedDB.open(DatabaseName, 2);
33 | request.onerror = e => {
34 | reject(e);
35 | };
36 |
37 | request.onupgradeneeded = (event: any) => {
38 | const db = event.target.result as IDBDatabase;
39 | if (!db.objectStoreNames.contains("apps")) {
40 | store = db.createObjectStore("apps", { keyPath: "kind" });
41 | store.createIndex("kind", "kind", { unique: true });
42 | }
43 | };
44 |
45 | request.onsuccess = () => {
46 | const db = request.result;
47 | resolve(db);
48 | };
49 | });
50 | }
51 |
52 | function query(db: IDBDatabase, val: string): Promise {
53 | return new Promise((resolve, reject) => {
54 | const index = db.transaction(["apps"]).objectStore("apps").index("kind");
55 | const request = index.get(val);
56 | request.onerror = e => reject(e);
57 | request.onsuccess = () => {
58 | if (request.result) {
59 | resolve(request.result);
60 | } else {
61 | resolve(null);
62 | }
63 | };
64 | });
65 | }
66 |
67 | function addRecord(db: IDBDatabase, payload: any): Promise {
68 | return new Promise((resolve, reject) => {
69 | const request = db.transaction(["apps"], "readwrite").objectStore("apps").add(payload);
70 | request.onsuccess = () => resolve();
71 | request.onerror = () => reject();
72 | });
73 | }
74 |
75 | function deleteRecord(db: IDBDatabase, key: string): Promise {
76 | return new Promise((resolve, reject) => {
77 | const request = db.transaction(["apps"], "readwrite").objectStore("apps").delete(key);
78 | request.onsuccess = () => resolve();
79 | request.onerror = () => reject();
80 | });
81 | }
82 |
--------------------------------------------------------------------------------
/src/Utils/AppCreateQueue.ts:
--------------------------------------------------------------------------------
1 | import { callbacks } from "../callback";
2 | import { SETUP_APP_DELAY } from "../constants";
3 |
4 | export type Invoker = () => Promise;
5 |
6 | export class AppCreateQueue {
7 | private list: Invoker[] = [];
8 | private currentInvoker: Invoker | undefined;
9 | private timer: number | undefined;
10 | public isEmit = false;
11 |
12 | private initInterval() {
13 | return setInterval(() => {
14 | this.invoke();
15 | }, 50);
16 | }
17 |
18 | public push(item: Invoker) {
19 | this.list.push(item);
20 | this.invoke();
21 | if (this.timer === undefined && this.list.length > 0) {
22 | this.timer = this.initInterval();
23 | }
24 | }
25 |
26 | public invoke() {
27 | if (this.list.length === 0) {
28 | return;
29 | }
30 | if (this.currentInvoker !== undefined) {
31 | return;
32 | }
33 |
34 | const item = this.list.shift();
35 | if (item) {
36 | this.currentInvoker = item;
37 | item()
38 | .then(() => {
39 | this.invoked();
40 | })
41 | .catch(error => {
42 | console.error(`[WindowManager]: create app error: ${error.message}`);
43 | this.invoked();
44 | });
45 | }
46 | }
47 |
48 | private invoked = () => {
49 | this.currentInvoker = undefined;
50 | if (this.list.length === 0) {
51 | this.clear();
52 | this.emitReady();
53 | }
54 | };
55 |
56 | private clear = () => {
57 | clearInterval(this.timer);
58 | this.timer = undefined;
59 | };
60 |
61 | public emitReady() {
62 | if (!this.isEmit) {
63 | setTimeout(() => {
64 | callbacks.emit("ready");
65 | }, SETUP_APP_DELAY);
66 | }
67 | this.isEmit = true;
68 | }
69 |
70 | public empty() {
71 | this.list = [];
72 | this.clear();
73 | }
74 |
75 | public destroy() {
76 | if (this.timer) {
77 | this.clear();
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/Utils/Common.ts:
--------------------------------------------------------------------------------
1 | import { appRegister } from "../Register";
2 | import { debounce } from "lodash";
3 | import { internalEmitter } from "../InternalEmitter";
4 | import { ROOT_DIR } from "../constants";
5 | import { ScenePathType } from "white-web-sdk";
6 | import { v4 } from "uuid";
7 | import type { PublicEvent } from "../callback";
8 | import type { Displayer, ViewVisionMode, Room, View, SceneDefinition } from "white-web-sdk";
9 | import type Emittery from "emittery";
10 |
11 | export const genAppId = async (kind: string) => {
12 | const impl = await appRegister.appClasses.get(kind)?.();
13 | if (impl && impl.config?.singleton) {
14 | return kind;
15 | }
16 | return `${kind}-${v4().replace("-", "").slice(0, 8)}`;
17 | };
18 |
19 | export const setViewFocusScenePath = (view: View, focusScenePath: string) => {
20 | if (view.focusScenePath !== focusScenePath) {
21 | view.focusScenePath = focusScenePath;
22 | return view;
23 | }
24 | };
25 |
26 | export const setViewSceneIndex = (view: View, index: number) => {
27 | if (view.focusSceneIndex !== index) {
28 | view.focusSceneIndex = index;
29 | return view;
30 | }
31 | };
32 |
33 | export const setScenePath = (room: Room | undefined, scenePath: string) => {
34 | if (room && room.isWritable) {
35 | if (room.state.sceneState.scenePath !== scenePath) {
36 | const nextScenePath = scenePath === "/" ? "" : scenePath;
37 | room.setScenePath(nextScenePath);
38 | }
39 | }
40 | };
41 |
42 | export const getScenePath = (
43 | room: Room | undefined,
44 | dir: string | undefined,
45 | index: number
46 | ): string | undefined => {
47 | if (room && dir) {
48 | const scenes = entireScenes(room);
49 | const scene = scenes[dir]?.[index];
50 | if (scene) {
51 | return `${dir}/${scene.name}`;
52 | }
53 | }
54 | };
55 |
56 | export const removeScenes = (room: Room | undefined, scenePath: string, index?: number) => {
57 | if (room) {
58 | const type = room.scenePathType(scenePath);
59 | if (type !== ScenePathType.None) {
60 | (room.removeScenes as any)(scenePath, index);
61 | }
62 | }
63 | };
64 |
65 | export const setViewMode = (view: View, mode: ViewVisionMode) => {
66 | if (!(view as any).didRelease && view.mode !== mode) {
67 | view.mode = mode;
68 | }
69 | };
70 |
71 | export const emitError = (error: Error) => {
72 | if (internalEmitter.listenerCount("error") > 0) {
73 | internalEmitter.emit("error", error);
74 | } else {
75 | console.log("[WindowManager]:", error);
76 | }
77 | };
78 |
79 | export const addEmitterOnceListener = (event: any, listener: any) => {
80 | internalEmitter.once(event).then(listener);
81 | };
82 |
83 | export const notifyMainViewModeChange = debounce(
84 | (callbacks: Emittery, mode: ViewVisionMode) => {
85 | callbacks.emit("mainViewModeChange", mode);
86 | },
87 | 200
88 | );
89 |
90 | export const makeValidScenePath = (displayer: Displayer, scenePath: string, index = 0) => {
91 | const scenes = entireScenes(displayer)[scenePath];
92 | if (!scenes) return;
93 | const scene = scenes[index];
94 | if (!scene) return;
95 | const firstSceneName = scene.name;
96 | if (scenePath === ROOT_DIR) {
97 | return `/${firstSceneName}`;
98 | } else {
99 | return `${scenePath}/${firstSceneName}`;
100 | }
101 | };
102 |
103 | export const entireScenes = (displayer: Displayer) => {
104 | return displayer.entireScenes();
105 | };
106 |
107 | export const putScenes = (
108 | room: Room | undefined,
109 | path: string,
110 | scenes: SceneDefinition[],
111 | index?: number
112 | ) => {
113 | for (let i = 0; i < scenes.length; ++i) {
114 | if (scenes[i].name?.includes("/")) {
115 | throw new Error("scenes name can not have '/'");
116 | }
117 | }
118 | return room?.putScenes(path, scenes, index);
119 | };
120 |
121 | export const isValidScenePath = (scenePath: string) => {
122 | return scenePath.startsWith("/");
123 | };
124 |
125 | export const parseSceneDir = (scenePath: string) => {
126 | const sceneList = scenePath.split("/");
127 | sceneList.pop();
128 | let sceneDir = sceneList.join("/");
129 | // "/page1" 的 dir 为 "/"
130 | if (sceneDir === "") {
131 | sceneDir = "/";
132 | }
133 | return sceneDir;
134 | };
135 |
136 | export const ensureValidScenePath = (scenePath: string) => {
137 | if (scenePath.endsWith("/")) {
138 | return scenePath.slice(0, -1);
139 | } else {
140 | return scenePath;
141 | }
142 | };
143 |
144 | export const getVersionNumber = (version: string) => {
145 | const versionString = version
146 | .split(".")
147 | .map(s => s.padStart(2, "0"))
148 | .join("");
149 | return parseInt(versionString);
150 | };
151 |
152 | export const wait = (time: number) => new Promise(resolve => setTimeout(resolve, time));
153 |
154 | // rootDirPage: /page1 || / page2
155 | // notRootDirPage: /dir1/page1 || /dir1/page2
156 | export const isRootDirPage = (scenePath: string) => {
157 | const delimiterCount = scenePath.split("").reduce((prev, cur) => {
158 | if (cur === ROOT_DIR) {
159 | prev += 1;
160 | }
161 | return prev;
162 | }, 0);
163 | return delimiterCount === 1;
164 | };
165 |
--------------------------------------------------------------------------------
/src/Utils/Reactive.ts:
--------------------------------------------------------------------------------
1 | import { listenUpdated, unlistenUpdated, reaction, UpdateEventKind } from "white-web-sdk";
2 | import type { AkkoObjectUpdatedProperty, AkkoObjectUpdatedListener } from "white-web-sdk";
3 | import { isObject } from "lodash";
4 |
5 | // 兼容 13 和 14 版本 SDK
6 | export const onObjectByEvent = (event: UpdateEventKind) => {
7 | return (object: any, func: () => void) => {
8 | if (object === undefined) return;
9 | if (listenUpdated) {
10 | const listener = (events: readonly AkkoObjectUpdatedProperty[]) => {
11 | const kinds = events.map(e => e.kind);
12 | if (kinds.includes(event)) {
13 | func();
14 | }
15 | };
16 | listenUpdated(object, listener);
17 | func();
18 | return () => unlistenUpdated(object, listener);
19 | } else {
20 | return reaction(
21 | () => object,
22 | () => {
23 | func();
24 | },
25 | {
26 | fireImmediately: true,
27 | }
28 | );
29 | }
30 | };
31 | };
32 |
33 | export const safeListenPropsUpdated = (
34 | getProps: () => T,
35 | callback: AkkoObjectUpdatedListener,
36 | onDestroyed?: (props: unknown) => void
37 | ) => {
38 | let disposeListenUpdated: (() => void) | null = null;
39 | const disposeReaction = reaction(
40 | getProps,
41 | () => {
42 | if (disposeListenUpdated) {
43 | disposeListenUpdated();
44 | disposeListenUpdated = null;
45 | }
46 | const props = getProps();
47 | if (isObject(props)) {
48 | disposeListenUpdated = () => unlistenUpdated(props, callback);
49 | listenUpdated(props, callback);
50 | } else {
51 | onDestroyed?.(props);
52 | }
53 | },
54 | { fireImmediately: true }
55 | );
56 |
57 | return () => {
58 | disposeListenUpdated?.();
59 | disposeReaction();
60 | };
61 | };
62 |
63 | export const onObjectRemoved = onObjectByEvent(UpdateEventKind.Removed);
64 | export const onObjectInserted = onObjectByEvent(UpdateEventKind.Inserted);
65 |
--------------------------------------------------------------------------------
/src/Utils/RoomHacker.ts:
--------------------------------------------------------------------------------
1 | import { internalEmitter } from "../InternalEmitter";
2 | import { isPlayer } from "white-web-sdk";
3 | import type { WindowManager } from "../index";
4 | import type { Camera, Room, Player, PlayerSeekingResult } from "white-web-sdk";
5 | import { ROOT_DIR } from "../constants";
6 |
7 | // 修改多窗口状态下一些失效的方法实现到 manager 的 mainview 上, 降低迁移成本
8 | export const replaceRoomFunction = (room: Room | Player, manager: WindowManager) => {
9 | if (isPlayer(room)) {
10 | const player = room as unknown as Player;
11 | delegateSeekToProgressTime(player);
12 | } else {
13 | room = room as unknown as Room;
14 | const descriptor = Object.getOwnPropertyDescriptor(room, "disableCameraTransform");
15 | if (descriptor) return;
16 | Object.defineProperty(room, "disableCameraTransform", {
17 | get() {
18 | return manager.mainView.disableCameraTransform;
19 | },
20 | set(disable: boolean) {
21 | manager.mainView.disableCameraTransform = disable;
22 | },
23 | });
24 |
25 | Object.defineProperty(room, "canUndoSteps", {
26 | get() {
27 | return manager.canUndoSteps;
28 | },
29 | });
30 |
31 | Object.defineProperty(room, "canRedoSteps", {
32 | get() {
33 | return manager.canRedoSteps;
34 | },
35 | });
36 |
37 | room.moveCamera = (camera: Camera) => manager.moveCamera(camera);
38 | room.moveCameraToContain = (...args) => manager.moveCameraToContain(...args);
39 | room.convertToPointInWorld = (...args) => manager.mainView.convertToPointInWorld(...args);
40 | room.setCameraBound = (...args) => manager.mainView.setCameraBound(...args);
41 | room.scenePreview = (...args) => manager.mainView.scenePreview(...args);
42 | room.fillSceneSnapshot = (...args) => manager.mainView.fillSceneSnapshot(...args);
43 | room.generateScreenshot = (...args) => manager.mainView.generateScreenshot(...args);
44 | room.setMemberState = (...args) => manager.mainView.setMemberState(...args);
45 | room.redo = () => manager.redo();
46 | room.undo = () => manager.undo();
47 | room.cleanCurrentScene = () => manager.cleanCurrentScene();
48 | room.delete = () => manager.delete();
49 | room.copy = () => manager.copy();
50 | room.paste = () => manager.paste();
51 | room.duplicate = () => manager.duplicate();
52 | room.insertImage = (...args) => manager.insertImage(...args);
53 | room.completeImageUpload = (...args) => manager.completeImageUpload(...args);
54 | room.insertText = (...args) => manager.insertText(...args);
55 | room.lockImage = (...args) => manager.lockImage(...args);
56 | room.lockImages = (...args) => manager.lockImages(...args);
57 |
58 | delegateRemoveScenes(room, manager);
59 | }
60 | };
61 |
62 | const delegateRemoveScenes = (room: Room, manager: WindowManager) => {
63 | const originRemoveScenes = room.removeScenes;
64 | room.removeScenes = (scenePath: string, index?: number) => {
65 | if (scenePath === ROOT_DIR) {
66 | manager.appManager?.updateRootDirRemoving(true);
67 | }
68 | const result = originRemoveScenes.call(room, scenePath);
69 | internalEmitter.emit("removeScenes", { scenePath, index });
70 | return result;
71 | };
72 | };
73 |
74 | const delegateSeekToProgressTime = (player: Player) => {
75 | const originSeek = player.seekToProgressTime;
76 | // eslint-disable-next-line no-inner-declarations
77 | async function newSeek(time: number): Promise {
78 | // seek 时需要先关闭所有的 app 防止内部使用的 mobx 出现错误
79 | await internalEmitter.emit("seekStart");
80 | const seekResult = await originSeek.call(player, time);
81 | internalEmitter.emit("seek", time);
82 | return seekResult;
83 | }
84 | player.seekToProgressTime = newSeek;
85 | };
86 |
--------------------------------------------------------------------------------
/src/Utils/error.ts:
--------------------------------------------------------------------------------
1 | export class AppCreateError extends Error {
2 | override message = "[WindowManager]: app duplicate exists and cannot be created again";
3 | }
4 |
5 | export class AppNotRegisterError extends Error {
6 | constructor(kind: string) {
7 | super(`[WindowManager]: app ${kind} need register or provide src`);
8 | }
9 | }
10 |
11 | export class AppManagerNotInitError extends Error {
12 | override message = "[WindowManager]: AppManager must be initialized";
13 | }
14 |
15 | export class WhiteWebSDKInvalidError extends Error {
16 | constructor(version: string) {
17 | super(`[WindowManager]: white-web-sdk version must large than ${version}`);
18 | }
19 | }
20 |
21 | export class ParamsInvalidError extends Error {
22 | override message = "[WindowManager]: kind must be a valid string";
23 | }
24 |
25 | export class BoxNotCreatedError extends Error {
26 | override message = "[WindowManager]: box need created";
27 | }
28 |
29 | export class InvalidScenePath extends Error {
30 | override message = `[WindowManager]: ScenePath should start with "/"`;
31 | }
32 |
33 | export class BoxManagerNotFoundError extends Error {
34 | override message = "[WindowManager]: boxManager not found";
35 | }
36 |
37 | export class BindContainerRoomPhaseInvalidError extends Error {
38 | override message = "[WindowManager]: room phase only Connected can be bindContainer";
39 | }
40 |
--------------------------------------------------------------------------------
/src/Utils/log.ts:
--------------------------------------------------------------------------------
1 | import { WindowManager } from "../index";
2 |
3 | export const log = (...args: any[]): void => {
4 | if (WindowManager.debug) {
5 | console.log(`[WindowManager]:`, ...args);
6 | }
7 | };
8 |
--------------------------------------------------------------------------------
/src/View/ViewManager.ts:
--------------------------------------------------------------------------------
1 | import type { View, Displayer } from "white-web-sdk";
2 |
3 | export class ViewManager {
4 | public views: Map = new Map();
5 |
6 | constructor(private displayer: Displayer) {}
7 |
8 | public createView(id: string): View {
9 | const view = createView(this.displayer);
10 | this.views.set(id, view);
11 | return view;
12 | }
13 |
14 | public getView(id: string): View | undefined {
15 | return this.views.get(id);
16 | }
17 |
18 | public destroyView(id: string): void {
19 | const view = this.views.get(id);
20 | if (view) {
21 | try {
22 | view.release();
23 | } catch {
24 | // ignore
25 | }
26 | this.views.delete(id);
27 | }
28 | }
29 |
30 | public setViewScenePath(id: string, scenePath: string): void {
31 | const view = this.views.get(id);
32 | if (view) {
33 | view.focusScenePath = scenePath;
34 | }
35 | }
36 |
37 | public destroy() {
38 | this.views.forEach(view => {
39 | try {
40 | view.release();
41 | } catch {
42 | // ignore
43 | }
44 | });
45 | this.views.clear();
46 | }
47 | }
48 |
49 | export const createView = (displayer: Displayer): View => {
50 | const view = displayer.views.createView();
51 | setDefaultCameraBound(view);
52 | return view;
53 | };
54 |
55 | export const setDefaultCameraBound = (view: View) => {
56 | view.setCameraBound({
57 | maxContentMode: () => 10,
58 | minContentMode: () => 0.1,
59 | });
60 | };
61 |
--------------------------------------------------------------------------------
/src/callback.ts:
--------------------------------------------------------------------------------
1 | import Emittery from "emittery";
2 | import type { TeleBoxColorScheme, TELE_BOX_STATE } from "@netless/telebox-insider";
3 | import type { CameraState, SceneState, View, ViewVisionMode } from "white-web-sdk";
4 | import type { LoadAppEvent } from "./Register";
5 | import type { PageState } from "./Page";
6 | import type {
7 | BoxClosePayload,
8 | BoxFocusPayload,
9 | BoxMovePayload,
10 | BoxResizePayload,
11 | BoxStateChangePayload,
12 | } from "./BoxEmitter";
13 | import type { AppPayload } from "./typings";
14 |
15 | export type PublicEvent = {
16 | mainViewModeChange: ViewVisionMode;
17 | boxStateChange: `${TELE_BOX_STATE}`;
18 | darkModeChange: boolean;
19 | prefersColorSchemeChange: TeleBoxColorScheme;
20 | cameraStateChange: CameraState;
21 | mainViewScenePathChange: string;
22 | mainViewSceneIndexChange: number;
23 | focusedChange: string | undefined;
24 | mainViewScenesLengthChange: number;
25 | canRedoStepsChange: number;
26 | canUndoStepsChange: number;
27 | loadApp: LoadAppEvent;
28 | ready: undefined; // 所有 APP 创建完毕时触发
29 | sceneStateChange: SceneState;
30 | pageStateChange: PageState;
31 | fullscreenChange: boolean;
32 | appsChange: string[]; // APP 列表变化时触发
33 | onBoxMove: BoxMovePayload;
34 | onBoxResize: BoxResizePayload;
35 | onBoxFocus: BoxFocusPayload;
36 | onBoxClose: BoxClosePayload;
37 | onBoxStateChange: BoxStateChangePayload;
38 | onMainViewMounted: View;
39 | onMainViewRebind: View;
40 | onAppViewMounted: AppPayload;
41 | onAppSetup: string;
42 | onAppScenePathChange: AppPayload;
43 | };
44 |
45 | export type CallbacksType = Emittery;
46 | export const callbacks: CallbacksType = new Emittery();
47 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export enum Events {
2 | AppMove = "AppMove",
3 | AppFocus = "AppFocus",
4 | AppResize = "AppResize",
5 | AppBoxStateChange = "AppBoxStateChange",
6 | GetAttributes = "GetAttributes",
7 | UpdateWindowManagerWrapper = "UpdateWindowManagerWrapper",
8 | InitReplay = "InitReplay",
9 | WindowCreated = "WindowCreated",
10 | SetMainViewScenePath = "SetMainViewScenePath",
11 | SetMainViewSceneIndex = "SetMainViewSceneIndex",
12 | SetAppFocusIndex = "SetAppFocusIndex",
13 | SwitchViewsToFreedom = "SwitchViewsToFreedom",
14 | MoveCamera = "MoveCamera",
15 | MoveCameraToContain = "MoveCameraToContain",
16 | CursorMove = "CursorMove",
17 | RootDirRemoved = "RootDirRemoved",
18 | Refresh = "Refresh",
19 | InitMainViewCamera = "InitMainViewCamera",
20 | }
21 |
22 | export const MagixEventName = "__WindowManger";
23 | export const EnsureReconnectEvent = "__WindowMangerEnsureReconnected__";
24 |
25 | export enum AppAttributes {
26 | Size = "size",
27 | Position = "position",
28 | SceneIndex = "SceneIndex",
29 | ZIndex = "zIndex",
30 | }
31 |
32 | export enum AppEvents {
33 | setBoxSize = "setBoxSize",
34 | setBoxMinSize = "setBoxMinSize",
35 | destroy = "destroy",
36 | }
37 |
38 | export enum AppStatus {
39 | StartCreate = "StartCreate",
40 | }
41 |
42 | export enum CursorState {
43 | Leave = "leave",
44 | Normal = "normal",
45 | }
46 |
47 | export const REQUIRE_VERSION = "2.16.1";
48 |
49 | export const MIN_WIDTH = 340 / 720;
50 | export const MIN_HEIGHT = 340 / 720;
51 |
52 | export const SET_SCENEPATH_DELAY = 100; // 设置 scenePath 的延迟事件
53 |
54 | export const DEFAULT_CONTAINER_RATIO = 9 / 16;
55 |
56 | export const ROOT_DIR = "/";
57 | export const INIT_DIR = "/init";
58 |
59 | export const SETUP_APP_DELAY = 50;
60 |
--------------------------------------------------------------------------------
/src/image.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.svg" {
2 | const content: string;
3 | export default content;
4 | }
5 |
6 | declare module "*.jpg" {
7 | const content: string;
8 | export default content;
9 | }
10 |
11 | declare module "*.png" {
12 | const content: string;
13 | export default content;
14 | }
15 |
16 | declare module "*.gif" {
17 | const content: string;
18 | export default content;
19 | }
20 |
--------------------------------------------------------------------------------
/src/image/eraser-cursor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netless-io/window-manager/c2177799c62084b2a2c6fa69805a203dc1409ac5/src/image/eraser-cursor.png
--------------------------------------------------------------------------------
/src/image/laser-pointer-cursor.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 编组 2
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/image/pencil-cursor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netless-io/window-manager/c2177799c62084b2a2c6fa69805a203dc1409ac5/src/image/pencil-cursor.png
--------------------------------------------------------------------------------
/src/image/pencil-eraser-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/image/pencil-eraser-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/image/pencil-eraser-3.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/image/selector-cursor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netless-io/window-manager/c2177799c62084b2a2c6fa69805a203dc1409ac5/src/image/selector-cursor.png
--------------------------------------------------------------------------------
/src/image/shape-cursor.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | shape-cursor
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/image/text-cursor.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | text-cursor
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/shim.d.ts:
--------------------------------------------------------------------------------
1 | import type { SvelteComponent } from "svelte";
2 |
3 | declare module "*.svelte" {
4 | const app: SvelteComponent;
5 | export default app;
6 | }
7 |
8 | declare global {
9 | const __APP_VERSION__: string;
10 | const __APP_DEPENDENCIES__: Record;
11 | }
12 |
--------------------------------------------------------------------------------
/src/style.css:
--------------------------------------------------------------------------------
1 | .netless-window-manager-playground {
2 | width: 100%;
3 | height: 100%;
4 | position: relative;
5 | z-index: 1;
6 | overflow: hidden;
7 | user-select: none;
8 | }
9 |
10 | .netless-window-manager-sizer {
11 | position: absolute;
12 | top: 0;
13 | left: 0;
14 | width: 100%;
15 | height: 100%;
16 | z-index: 1;
17 | overflow: hidden;
18 | display: flex;
19 | }
20 |
21 | .netless-window-manager-sizer-horizontal {
22 | flex-direction: column;
23 | }
24 |
25 | .netless-window-manager-sizer::before,
26 | .netless-window-manager-sizer::after {
27 | flex: 1;
28 | content: "";
29 | display: block;
30 | }
31 |
32 | .netless-window-manager-chess-sizer::before,
33 | .netless-window-manager-chess-sizer::after {
34 | background-image: linear-gradient(45deg, #b0b0b0 25%, transparent 25%),
35 | linear-gradient(-45deg, #b0b0b0 25%, transparent 25%),
36 | linear-gradient(45deg, transparent 75%, #b0b0b0 75%),
37 | linear-gradient(-45deg, transparent 75%, #b0b0b0 75%);
38 | background-color: #fff;
39 | background-size: 20px 20px;
40 | background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
41 | }
42 |
43 | .netless-window-manager-wrapper {
44 | position: relative;
45 | z-index: 1;
46 | width: 100%;
47 | height: 100%;
48 | overflow: hidden;
49 | }
50 |
51 | .netless-window-manager-main-view {
52 | width: 100%;
53 | height: 100%;
54 | }
55 |
56 | .netless-window-manager-cursor-pencil-image {
57 | width: 26px;
58 | height: 26px;
59 | }
60 |
61 | .netless-window-manager-cursor-eraser-image {
62 | width: 26px;
63 | height: 26px;
64 | }
65 |
66 | .netless-window-manager-cursor-selector-image {
67 | width: 24px;
68 | height: 24px;
69 | }
70 |
71 | .netless-window-manager-cursor-selector-avatar {
72 | border-radius: 50%;
73 | border-style: solid;
74 | border-width: 2px;
75 | border-color: white;
76 | margin-bottom: 2px;
77 | }
78 |
79 | .netless-window-manager-cursor-selector-avatar img {
80 | width: 12px;
81 | }
82 |
83 | .netless-window-manager-cursor-inner {
84 | border-radius: 4px;
85 | display: flex;
86 | align-items: center;
87 | justify-content: center;
88 | flex-direction: row;
89 | padding-left: 4px;
90 | padding-right: 4px;
91 | font-size: 12px;
92 | }
93 |
94 | .netless-window-manager-cursor-inner-mellow {
95 | height: 32px;
96 | border-radius: 16px;
97 | display: flex;
98 | align-items: center;
99 | justify-content: center;
100 | flex-direction: row;
101 | padding-left: 16px;
102 | padding-right: 16px;
103 | }
104 |
105 | .netless-window-manager-cursor-tag-name {
106 | font-size: 12px;
107 | margin-left: 4px;
108 | padding: 2px 8px;
109 | border-radius: 4px;
110 | }
111 |
112 | .netless-window-manager-cursor-mid {
113 | display: flex;
114 | flex-direction: column;
115 | align-items: center;
116 | justify-content: center;
117 | position: absolute;
118 | width: 26px;
119 | height: 26px;
120 | z-index: 2147483647;
121 | left: 0;
122 | top: 0;
123 | will-change: transform;
124 | transition: transform 0.12s;
125 | transform-origin: 0 0;
126 | user-select: none;
127 | pointer-events: none;
128 | }
129 |
130 | .netless-window-manager-cursor-pencil-offset {
131 | margin-left: -20px;
132 | }
133 |
134 | .netless-window-manager-cursor-selector-offset {
135 | margin-left: -22px;
136 | margin-top: 56px;
137 | }
138 |
139 | .netless-window-manager-cursor-text-offset {
140 | margin-left: -30px;
141 | margin-top: 18px;
142 | }
143 |
144 | .netless-window-manager-cursor-shape-offset {
145 | display: flex;
146 | flex-direction: column;
147 | align-items: center;
148 | justify-content: center;
149 | position: absolute;
150 | width: 180px;
151 | height: 64px;
152 | margin-left: -30px;
153 | margin-top: 12px;
154 | }
155 |
156 | .netless-window-manager-cursor-laserPointer-image {
157 | margin-left: -22px;
158 | margin-top: 3px;
159 | }
160 |
161 | .netless-window-manager-cursor-pencilEraser-image {
162 | margin-left: -22px;
163 | margin-top: 3px;
164 | }
165 |
166 | .netless-window-manager-laserPointer-pencilEraser-offset {
167 | margin-left: -18px;
168 | }
169 |
170 | .netless-window-manager-pencilEraser-3-offset {
171 | margin-top: -14px;
172 | margin-left: -6px;
173 | }
174 |
175 | .netless-window-manager-cursor-name {
176 | width: 100%;
177 | height: 48px;
178 | display: flex;
179 | align-items: center;
180 | justify-content: center;
181 | position: absolute;
182 | top: -40px;
183 | }
184 |
185 | .cursor-image-wrapper {
186 | display: flex;
187 | justify-content: center;
188 | }
189 |
190 | .telebox-collector {
191 | position: absolute;
192 | right: 10px;
193 | bottom: 15px;
194 | }
195 |
196 | .netless-iframe-brdige-hidden {
197 | display: none;
198 | }
199 |
200 | .netless-window-manager-fullscreen .telebox-titlebar,
201 | .netless-window-manager-fullscreen .telebox-max-titlebar-maximized,
202 | .netless-window-manager-fullscreen .netless-app-slide-footer,
203 | .netless-window-manager-fullscreen .telebox-footer-wrap,
204 | .netless-window-manager-fullscreen .telebox-titlebar-wrap {
205 | display: none;
206 | }
207 |
--------------------------------------------------------------------------------
/src/typings.ts:
--------------------------------------------------------------------------------
1 | import type Emittery from "emittery";
2 | import type {
3 | AnimationMode,
4 | ApplianceNames,
5 | Displayer,
6 | DisplayerState,
7 | Player,
8 | Room,
9 | SceneDefinition,
10 | SceneState,
11 | View,
12 | } from "white-web-sdk";
13 | import type { AppContext } from "./App";
14 | import type { ReadonlyTeleBox, TeleBoxRect } from "@netless/telebox-insider";
15 | import type { PageState } from "./Page";
16 |
17 | export interface NetlessApp<
18 | Attributes extends {} = any,
19 | MagixEventPayloads = any,
20 | AppOptions = any,
21 | SetupResult = any
22 | > {
23 | kind: string;
24 | config?: {
25 | /** Box width relative to whiteboard. 0~1. Default 0.5. */
26 | width?: number;
27 | /** Box height relative to whiteboard. 0~1. Default 0.5. */
28 | height?: number;
29 |
30 | /** Minimum box width relative to whiteboard. 0~1. Default 340 / 720. */
31 | minwidth?: number;
32 | /** Minimum box height relative to whiteboard. 0~1. Default 340 / 720. */
33 | minheight?: number;
34 |
35 | /** App only single instance. */
36 | singleton?: boolean;
37 | };
38 | setup: (context: AppContext) => SetupResult;
39 | }
40 |
41 | export type AppEmitterEvent = {
42 | /**
43 | * before plugin destroyed
44 | */
45 | destroy: { error?: Error };
46 | attributesUpdate: T | undefined;
47 | /**
48 | * room isWritable change or box blur
49 | */
50 | writableChange: boolean;
51 | sceneStateChange: SceneState;
52 | setBoxSize: { width: number; height: number };
53 | setBoxMinSize: { minwidth: number; minheight: number };
54 | setBoxTitle: { title: string };
55 | containerRectUpdate: TeleBoxRect;
56 | roomStateChange: Partial;
57 | focus: boolean;
58 | reconnected: void;
59 | seek: number;
60 | pageStateChange: PageState;
61 | };
62 |
63 | export type RegisterEventData = {
64 | appId: string;
65 | };
66 |
67 | export type RegisterEvents = {
68 | created: RegisterEventData & { result: SetupResult };
69 | destroy: RegisterEventData;
70 | focus: RegisterEventData;
71 | };
72 |
73 | export type RegisterParams = {
74 | kind: string;
75 | src:
76 | | NetlessApp
77 | | string
78 | | (() => Promise>)
79 | | (() => Promise<{ default: NetlessApp }>);
80 | appOptions?: AppOptions | (() => AppOptions);
81 | addHooks?: (emitter: Emittery>) => void;
82 | /** dynamic load app package name */
83 | name?: string;
84 | };
85 |
86 | export type AppListenerKeys = keyof AppEmitterEvent;
87 |
88 | export type ApplianceIcons = Partial>;
89 |
90 | export type { AppContext } from "./App/AppContext";
91 | export type { ReadonlyTeleBox, TeleBoxRect };
92 | export type { SceneState, SceneDefinition, View, AnimationMode, Displayer, Room, Player };
93 | export type { Storage, StorageStateChangedEvent, StorageStateChangedListener } from "./App/Storage";
94 | export * from "./Page";
95 | export * from "./Utils/error";
96 |
97 | export type AppPayload = {
98 | appId: string;
99 | view: View;
100 | };
101 |
--------------------------------------------------------------------------------
/test/Utils/AppCreateQueue.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, vi, it, expect, afterEach } from "vitest";
2 | import { AppCreateQueue } from "../../src/Utils/AppCreateQueue";
3 | import { wait } from "../../src/Utils/Common";
4 | import { callbacks } from "../../src/callback";
5 |
6 | describe("AppCreateQueue", () => {
7 |
8 | vi.mock("white-web-sdk");
9 | vi.mock("../../src/callback", () => {
10 | return { callbacks: { emit: vi.fn() } };
11 | });
12 |
13 | afterEach(() => {
14 | vi.resetAllMocks();
15 | });
16 |
17 | it("push should invoke and emit ready", async () => {
18 | const queue = new AppCreateQueue();
19 | const fn = vi.fn().mockResolvedValue(undefined);
20 | const fn2 = vi.fn().mockResolvedValue(undefined);
21 | queue.push(fn);
22 | queue.push(fn2);
23 |
24 | expect(fn).toBeCalled();
25 | await wait(50);
26 | expect(fn2).toBeCalled();
27 | await wait(50);
28 | expect(callbacks.emit).toBeCalledWith("ready");
29 | });
30 |
31 | it("empty should clear queue", async () => {
32 | const queue = new AppCreateQueue();
33 |
34 | const fn = vi.fn().mockResolvedValue(undefined);
35 | const fn2 = vi.fn().mockResolvedValue(undefined);
36 | queue.push(fn);
37 | queue.push(fn2);
38 |
39 | expect(fn).toBeCalled();
40 | queue.empty();
41 | await wait(50);
42 | expect(fn2).not.toBeCalled();
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/test/index.test.ts:
--------------------------------------------------------------------------------
1 | import { Displayer, WindowManager } from "../src";
2 | import { describe, it, vi, expect, beforeAll } from "vitest";
3 |
4 | describe("WindowManager", () => {
5 | beforeAll(() => {
6 | vi.mock("white-web-sdk");
7 | });
8 |
9 | const displayer = {} as Displayer;
10 |
11 | it("constructor", async () => {
12 | const invisiblePluginContext = { kind: "WindowManager", displayer };
13 | const wm = new WindowManager(invisiblePluginContext);
14 | expect(wm).toBeDefined();
15 | expect(wm.attributes).toBeDefined();
16 | expect(wm.setAttributes).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/test/page.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, vi, describe, it } from "vitest";
2 | import { calculateNextIndex } from "../src";
3 |
4 |
5 | describe("calculateNextIndex", () => {
6 |
7 | vi.mock("white-web-sdk");
8 |
9 | it("delete first page next index should be plus 1", () => {
10 | const nextIndex = calculateNextIndex(0, { index: 0, length: 2 });
11 | expect(nextIndex).toBe(1);
12 | });
13 |
14 | it("delete last page next index should be minus 1", () => {
15 | const nextIndex2 = calculateNextIndex(1, { index: 1, length: 2 });
16 | expect(nextIndex2).toBe(0);
17 | });
18 |
19 | it("delete page not equal current index, next index should equal current index", () => {
20 | const nextIndex3 = calculateNextIndex(1, { index: 2, length: 3 });
21 | expect(nextIndex3).toBe(2);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDir": "./src",
4 | "outDir": "dist",
5 | "strictNullChecks": true,
6 | "strict": true,
7 | "declaration": true,
8 | "noImplicitAny": true,
9 | "module": "ESNext",
10 | "skipLibCheck": true,
11 | "useDefineForClassFields": false,
12 | "target": "es6",
13 | "jsx": "react",
14 | "forceConsistentCasingInFileNames": false,
15 | "lib": ["es6", "dom", "ES2017"],
16 | "allowSyntheticDefaultImports": true,
17 | "experimentalDecorators": true,
18 | "moduleResolution": "node",
19 | "allowJs": true,
20 | "types": ["svelte"],
21 | "useUnknownInCatchVariables": false,
22 | "noUnusedParameters": true,
23 | "noImplicitOverride": true,
24 | "isolatedModules": true,
25 | "resolveJsonModule": true,
26 | "esModuleInterop": true
27 | },
28 | "include": ["src", "./src/shim.d.ts", "../node_modules/@types/**/index.d.ts"]
29 | }
30 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import { defineConfig } from 'vitest/config'
3 | import { svelte } from "@sveltejs/vite-plugin-svelte";
4 | import { dependencies, peerDependencies, version, devDependencies } from "./package.json"
5 | import { omit } from "lodash";
6 |
7 | export default defineConfig(({ mode }) => {
8 | const isProd = mode === "production";
9 |
10 | return {
11 | test: {
12 | environment: "jsdom",
13 | deps: {
14 | inline: [
15 | "@juggle/resize-observer"
16 | ]
17 | }
18 | },
19 | define: {
20 | __APP_VERSION__: JSON.stringify(version),
21 | __APP_DEPENDENCIES__: JSON.stringify({
22 | dependencies, peerDependencies, devDependencies
23 | }),
24 | },
25 | plugins: [
26 | svelte({
27 | emitCss: false,
28 | experimental: {
29 | useVitePreprocess: true,
30 | },
31 | })
32 | ],
33 | build: {
34 | lib: {
35 | // eslint-disable-next-line no-undef
36 | entry: path.resolve(__dirname, "src/index.ts"),
37 | formats: ["es", "umd", "cjs"],
38 | name: "WindowManager",
39 | fileName: (moduleType) => {
40 | return moduleType === "es" ? "index.mjs" : "index.js";
41 | }
42 | },
43 | outDir: "dist",
44 | sourcemap: true,
45 | rollupOptions: {
46 | external: Object.keys({
47 | ...omit(dependencies, ["@netless/telebox-insider"]),
48 | ...peerDependencies,
49 | }),
50 | },
51 | minify: isProd,
52 | },
53 | };
54 | });
55 |
--------------------------------------------------------------------------------