├── .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 |
10 | 44 | 45 | 46 |
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 | avatar 62 | {/if} 63 | {cursorName} 64 | {#if hasTagName} 65 | 66 | {tagName} 67 | 68 | {/if} 69 |
70 |
71 | {/if} 72 |
73 | {appliance} 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 | --------------------------------------------------------------------------------