├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── example
├── index.html
├── package.json
├── pnpm-lock.yaml
├── src
│ └── main.ts
├── tsconfig.json
└── vite.config.ts
├── package.json
├── pnpm-lock.yaml
├── src
├── awareness.ts
├── ephemeral.ts
├── index.ts
├── sync.ts
├── undo.ts
└── utils.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | # vscode
3 | .vscode
4 |
5 | # Intellij
6 | *.iml
7 | .idea
8 |
9 | # npm
10 | node_modules
11 |
12 | # Don't include the compiled main.js file in the repo.
13 | # They should be uploaded to GitHub releases instead.
14 | main.js
15 | styles.css
16 |
17 | # Exclude sourcemaps
18 | *.map
19 |
20 | # obsidian
21 | data.json
22 |
23 | # Exclude macOS Finder (System Explorer) View States
24 | .DS_Store
25 |
26 | .env*
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Loro
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 | # Codemirror Binding for Loro
2 |
3 | - Sync document state with Loro
4 | - Sync cursors with Loro's Awareness and
5 | [Cursor](https://loro.dev/docs/tutorial/cursor)
6 | - Undo/Redo in collaborative editing
7 |
8 | ## Usage
9 |
10 | ```ts
11 | import { EditorState } from "@codemirror/state";
12 | import { EditorView } from "@codemirror/view";
13 | import { LoroExtensions } from "loro-codemirror";
14 | import { EphemeralStore, LoroDoc, UndoManager } from "loro-crdt";
15 |
16 | const doc = new LoroDoc();
17 | const ephemeral = new EphemeralStore();
18 | const undoManager = new UndoManager(doc, {});
19 |
20 | new EditorView({
21 | state: EditorState.create({
22 | extensions: [
23 | // ... other extensions
24 | LoroExtensions(
25 | doc,
26 | // optional LoroEphemeralPlugin
27 | {
28 | ephemeral,
29 | user: { name: "Bob", colorClassName: "user1" },
30 | },
31 | // optional LoroUndoPlugin
32 | undoManager,
33 | ),
34 | ],
35 | }),
36 | parent: document.querySelector("#editor")!,
37 | });
38 | ```
39 |
40 | You can find the example
41 | [here](https://github.com/loro-dev/loro-codemirror/tree/main/example).
42 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Loro CodeMirror Example
7 |
112 |
113 |
114 |
117 |
118 |
119 |
120 | Editor 1
121 | User 1
124 |
125 |
126 |
127 |
128 |
129 | Editor 2
130 | User 2
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "loro-codemirror-example",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@codemirror/commands": "^6.3.3",
13 | "@codemirror/lang-javascript": "^6.2.2",
14 | "@codemirror/lang-markdown": "^6.3.2",
15 | "@codemirror/state": "^6.5.1",
16 | "@codemirror/view": "^6.36.2",
17 | "codemirror": "^6.0.1",
18 | "loro-codemirror": "link:..",
19 | "loro-crdt": "^1.5.5"
20 | },
21 | "devDependencies": {
22 | "typescript": "^5.7.3",
23 | "vite": "^6.1.1",
24 | "vite-plugin-top-level-await": "^1.5.0",
25 | "vite-plugin-wasm": "^3.4.1"
26 | }
27 | }
--------------------------------------------------------------------------------
/example/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | dependencies:
11 | '@codemirror/commands':
12 | specifier: ^6.3.3
13 | version: 6.8.0
14 | '@codemirror/lang-javascript':
15 | specifier: ^6.2.2
16 | version: 6.2.2
17 | '@codemirror/lang-markdown':
18 | specifier: ^6.3.2
19 | version: 6.3.2
20 | '@codemirror/state':
21 | specifier: ^6.5.1
22 | version: 6.5.1
23 | '@codemirror/view':
24 | specifier: ^6.36.2
25 | version: 6.36.2
26 | codemirror:
27 | specifier: ^6.0.1
28 | version: 6.0.1
29 | loro-codemirror:
30 | specifier: link:..
31 | version: link:..
32 | loro-crdt:
33 | specifier: ^1.5.5
34 | version: 1.5.5
35 | devDependencies:
36 | typescript:
37 | specifier: ^5.7.3
38 | version: 5.7.3
39 | vite:
40 | specifier: ^6.1.1
41 | version: 6.1.1
42 | vite-plugin-top-level-await:
43 | specifier: ^1.5.0
44 | version: 1.5.0(rollup@4.31.0)(vite@6.1.1)
45 | vite-plugin-wasm:
46 | specifier: ^3.4.1
47 | version: 3.4.1(vite@6.1.1)
48 |
49 | packages:
50 |
51 | '@codemirror/autocomplete@6.18.4':
52 | resolution: {integrity: sha512-sFAphGQIqyQZfP2ZBsSHV7xQvo9Py0rV0dW7W3IMRdS+zDuNb2l3no78CvUaWKGfzFjI4FTrLdUSj86IGb2hRA==}
53 |
54 | '@codemirror/commands@6.8.0':
55 | resolution: {integrity: sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==}
56 |
57 | '@codemirror/lang-css@6.3.1':
58 | resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
59 |
60 | '@codemirror/lang-html@6.4.9':
61 | resolution: {integrity: sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==}
62 |
63 | '@codemirror/lang-javascript@6.2.2':
64 | resolution: {integrity: sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==}
65 |
66 | '@codemirror/lang-markdown@6.3.2':
67 | resolution: {integrity: sha512-c/5MYinGbFxYl4itE9q/rgN/sMTjOr8XL5OWnC+EaRMLfCbVUmmubTJfdgpfcSS2SCaT7b+Q+xi3l6CgoE+BsA==}
68 |
69 | '@codemirror/language@6.10.8':
70 | resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==}
71 |
72 | '@codemirror/lint@6.8.4':
73 | resolution: {integrity: sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==}
74 |
75 | '@codemirror/search@6.5.8':
76 | resolution: {integrity: sha512-PoWtZvo7c1XFeZWmmyaOp2G0XVbOnm+fJzvghqGAktBW3cufwJUWvSCcNG0ppXiBEM05mZu6RhMtXPv2hpllig==}
77 |
78 | '@codemirror/state@6.5.1':
79 | resolution: {integrity: sha512-3rA9lcwciEB47ZevqvD8qgbzhM9qMb8vCcQCNmDfVRPQG4JT9mSb0Jg8H7YjKGGQcFnLN323fj9jdnG59Kx6bg==}
80 |
81 | '@codemirror/view@6.36.2':
82 | resolution: {integrity: sha512-DZ6ONbs8qdJK0fdN7AB82CgI6tYXf4HWk1wSVa0+9bhVznCuuvhQtX8bFBoy3dv8rZSQqUd8GvhVAcielcidrA==}
83 |
84 | '@esbuild/aix-ppc64@0.24.2':
85 | resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
86 | engines: {node: '>=18'}
87 | cpu: [ppc64]
88 | os: [aix]
89 |
90 | '@esbuild/android-arm64@0.24.2':
91 | resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
92 | engines: {node: '>=18'}
93 | cpu: [arm64]
94 | os: [android]
95 |
96 | '@esbuild/android-arm@0.24.2':
97 | resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
98 | engines: {node: '>=18'}
99 | cpu: [arm]
100 | os: [android]
101 |
102 | '@esbuild/android-x64@0.24.2':
103 | resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
104 | engines: {node: '>=18'}
105 | cpu: [x64]
106 | os: [android]
107 |
108 | '@esbuild/darwin-arm64@0.24.2':
109 | resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
110 | engines: {node: '>=18'}
111 | cpu: [arm64]
112 | os: [darwin]
113 |
114 | '@esbuild/darwin-x64@0.24.2':
115 | resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
116 | engines: {node: '>=18'}
117 | cpu: [x64]
118 | os: [darwin]
119 |
120 | '@esbuild/freebsd-arm64@0.24.2':
121 | resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
122 | engines: {node: '>=18'}
123 | cpu: [arm64]
124 | os: [freebsd]
125 |
126 | '@esbuild/freebsd-x64@0.24.2':
127 | resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
128 | engines: {node: '>=18'}
129 | cpu: [x64]
130 | os: [freebsd]
131 |
132 | '@esbuild/linux-arm64@0.24.2':
133 | resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
134 | engines: {node: '>=18'}
135 | cpu: [arm64]
136 | os: [linux]
137 |
138 | '@esbuild/linux-arm@0.24.2':
139 | resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
140 | engines: {node: '>=18'}
141 | cpu: [arm]
142 | os: [linux]
143 |
144 | '@esbuild/linux-ia32@0.24.2':
145 | resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
146 | engines: {node: '>=18'}
147 | cpu: [ia32]
148 | os: [linux]
149 |
150 | '@esbuild/linux-loong64@0.24.2':
151 | resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
152 | engines: {node: '>=18'}
153 | cpu: [loong64]
154 | os: [linux]
155 |
156 | '@esbuild/linux-mips64el@0.24.2':
157 | resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
158 | engines: {node: '>=18'}
159 | cpu: [mips64el]
160 | os: [linux]
161 |
162 | '@esbuild/linux-ppc64@0.24.2':
163 | resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
164 | engines: {node: '>=18'}
165 | cpu: [ppc64]
166 | os: [linux]
167 |
168 | '@esbuild/linux-riscv64@0.24.2':
169 | resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
170 | engines: {node: '>=18'}
171 | cpu: [riscv64]
172 | os: [linux]
173 |
174 | '@esbuild/linux-s390x@0.24.2':
175 | resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
176 | engines: {node: '>=18'}
177 | cpu: [s390x]
178 | os: [linux]
179 |
180 | '@esbuild/linux-x64@0.24.2':
181 | resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
182 | engines: {node: '>=18'}
183 | cpu: [x64]
184 | os: [linux]
185 |
186 | '@esbuild/netbsd-arm64@0.24.2':
187 | resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
188 | engines: {node: '>=18'}
189 | cpu: [arm64]
190 | os: [netbsd]
191 |
192 | '@esbuild/netbsd-x64@0.24.2':
193 | resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
194 | engines: {node: '>=18'}
195 | cpu: [x64]
196 | os: [netbsd]
197 |
198 | '@esbuild/openbsd-arm64@0.24.2':
199 | resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
200 | engines: {node: '>=18'}
201 | cpu: [arm64]
202 | os: [openbsd]
203 |
204 | '@esbuild/openbsd-x64@0.24.2':
205 | resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
206 | engines: {node: '>=18'}
207 | cpu: [x64]
208 | os: [openbsd]
209 |
210 | '@esbuild/sunos-x64@0.24.2':
211 | resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
212 | engines: {node: '>=18'}
213 | cpu: [x64]
214 | os: [sunos]
215 |
216 | '@esbuild/win32-arm64@0.24.2':
217 | resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
218 | engines: {node: '>=18'}
219 | cpu: [arm64]
220 | os: [win32]
221 |
222 | '@esbuild/win32-ia32@0.24.2':
223 | resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
224 | engines: {node: '>=18'}
225 | cpu: [ia32]
226 | os: [win32]
227 |
228 | '@esbuild/win32-x64@0.24.2':
229 | resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
230 | engines: {node: '>=18'}
231 | cpu: [x64]
232 | os: [win32]
233 |
234 | '@lezer/common@1.2.3':
235 | resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
236 |
237 | '@lezer/css@1.1.9':
238 | resolution: {integrity: sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==}
239 |
240 | '@lezer/highlight@1.2.1':
241 | resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
242 |
243 | '@lezer/html@1.3.10':
244 | resolution: {integrity: sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==}
245 |
246 | '@lezer/javascript@1.4.21':
247 | resolution: {integrity: sha512-lL+1fcuxWYPURMM/oFZLEDm0XuLN128QPV+VuGtKpeaOGdcl9F2LYC3nh1S9LkPqx9M0mndZFdXCipNAZpzIkQ==}
248 |
249 | '@lezer/lr@1.4.2':
250 | resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==}
251 |
252 | '@lezer/markdown@1.4.0':
253 | resolution: {integrity: sha512-mk4MYeq6ZQdxgsgRAe0G7kqPRV6Desajfa14TcHoGGXIqqj1/2ARN31VFpmrXDgvXiGBWpA7RXtv0he+UdTkGw==}
254 |
255 | '@marijn/find-cluster-break@1.0.2':
256 | resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
257 |
258 | '@rollup/plugin-virtual@3.0.2':
259 | resolution: {integrity: sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==}
260 | engines: {node: '>=14.0.0'}
261 | peerDependencies:
262 | rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
263 | peerDependenciesMeta:
264 | rollup:
265 | optional: true
266 |
267 | '@rollup/rollup-android-arm-eabi@4.31.0':
268 | resolution: {integrity: sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==}
269 | cpu: [arm]
270 | os: [android]
271 |
272 | '@rollup/rollup-android-arm64@4.31.0':
273 | resolution: {integrity: sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==}
274 | cpu: [arm64]
275 | os: [android]
276 |
277 | '@rollup/rollup-darwin-arm64@4.31.0':
278 | resolution: {integrity: sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==}
279 | cpu: [arm64]
280 | os: [darwin]
281 |
282 | '@rollup/rollup-darwin-x64@4.31.0':
283 | resolution: {integrity: sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==}
284 | cpu: [x64]
285 | os: [darwin]
286 |
287 | '@rollup/rollup-freebsd-arm64@4.31.0':
288 | resolution: {integrity: sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==}
289 | cpu: [arm64]
290 | os: [freebsd]
291 |
292 | '@rollup/rollup-freebsd-x64@4.31.0':
293 | resolution: {integrity: sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==}
294 | cpu: [x64]
295 | os: [freebsd]
296 |
297 | '@rollup/rollup-linux-arm-gnueabihf@4.31.0':
298 | resolution: {integrity: sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==}
299 | cpu: [arm]
300 | os: [linux]
301 |
302 | '@rollup/rollup-linux-arm-musleabihf@4.31.0':
303 | resolution: {integrity: sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==}
304 | cpu: [arm]
305 | os: [linux]
306 |
307 | '@rollup/rollup-linux-arm64-gnu@4.31.0':
308 | resolution: {integrity: sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==}
309 | cpu: [arm64]
310 | os: [linux]
311 |
312 | '@rollup/rollup-linux-arm64-musl@4.31.0':
313 | resolution: {integrity: sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==}
314 | cpu: [arm64]
315 | os: [linux]
316 |
317 | '@rollup/rollup-linux-loongarch64-gnu@4.31.0':
318 | resolution: {integrity: sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==}
319 | cpu: [loong64]
320 | os: [linux]
321 |
322 | '@rollup/rollup-linux-powerpc64le-gnu@4.31.0':
323 | resolution: {integrity: sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==}
324 | cpu: [ppc64]
325 | os: [linux]
326 |
327 | '@rollup/rollup-linux-riscv64-gnu@4.31.0':
328 | resolution: {integrity: sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==}
329 | cpu: [riscv64]
330 | os: [linux]
331 |
332 | '@rollup/rollup-linux-s390x-gnu@4.31.0':
333 | resolution: {integrity: sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==}
334 | cpu: [s390x]
335 | os: [linux]
336 |
337 | '@rollup/rollup-linux-x64-gnu@4.31.0':
338 | resolution: {integrity: sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==}
339 | cpu: [x64]
340 | os: [linux]
341 |
342 | '@rollup/rollup-linux-x64-musl@4.31.0':
343 | resolution: {integrity: sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==}
344 | cpu: [x64]
345 | os: [linux]
346 |
347 | '@rollup/rollup-win32-arm64-msvc@4.31.0':
348 | resolution: {integrity: sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==}
349 | cpu: [arm64]
350 | os: [win32]
351 |
352 | '@rollup/rollup-win32-ia32-msvc@4.31.0':
353 | resolution: {integrity: sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==}
354 | cpu: [ia32]
355 | os: [win32]
356 |
357 | '@rollup/rollup-win32-x64-msvc@4.31.0':
358 | resolution: {integrity: sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==}
359 | cpu: [x64]
360 | os: [win32]
361 |
362 | '@swc/core-darwin-arm64@1.10.18':
363 | resolution: {integrity: sha512-FdGqzAIKVQJu8ROlnHElP59XAUsUzCFSNsou+tY/9ba+lhu8R9v0OI5wXiPErrKGZpQFMmx/BPqqhx3X4SuGNg==}
364 | engines: {node: '>=10'}
365 | cpu: [arm64]
366 | os: [darwin]
367 |
368 | '@swc/core-darwin-x64@1.10.18':
369 | resolution: {integrity: sha512-RZ73gZRituL/ZVLgrW6BYnQ5g8tuStG4cLUiPGJsUZpUm0ullSH6lHFvZTCBNFTfpQChG6eEhi2IdG6DwFp1lw==}
370 | engines: {node: '>=10'}
371 | cpu: [x64]
372 | os: [darwin]
373 |
374 | '@swc/core-linux-arm-gnueabihf@1.10.18':
375 | resolution: {integrity: sha512-8iJqI3EkxJuuq21UHoen1VS+QlS23RvynRuk95K+Q2HBjygetztCGGEc+Xelx9a0uPkDaaAtFvds4JMDqb9SAA==}
376 | engines: {node: '>=10'}
377 | cpu: [arm]
378 | os: [linux]
379 |
380 | '@swc/core-linux-arm64-gnu@1.10.18':
381 | resolution: {integrity: sha512-8f1kSktWzMB6PG+r8lOlCfXz5E8Qhsmfwonn77T/OfjvGwQaWrcoASh2cdjpk3dydbf8jsKGPQE1lSc7GyjXRQ==}
382 | engines: {node: '>=10'}
383 | cpu: [arm64]
384 | os: [linux]
385 |
386 | '@swc/core-linux-arm64-musl@1.10.18':
387 | resolution: {integrity: sha512-4rv+E4VLdgQw6zjbTAauCAEExxChvxMpBUMCiZweTNPKbJJ2dY6BX2WGJ1ea8+RcgqR/Xysj3AFbOz1LBz6dGA==}
388 | engines: {node: '>=10'}
389 | cpu: [arm64]
390 | os: [linux]
391 |
392 | '@swc/core-linux-x64-gnu@1.10.18':
393 | resolution: {integrity: sha512-vTNmyRBVP+sZca+vtwygYPGTNudTU6Gl6XhaZZ7cEUTBr8xvSTgEmYXoK/2uzyXpaTUI4Bmtp1x81cGN0mMoLQ==}
394 | engines: {node: '>=10'}
395 | cpu: [x64]
396 | os: [linux]
397 |
398 | '@swc/core-linux-x64-musl@1.10.18':
399 | resolution: {integrity: sha512-1TZPReKhFCeX776XaT6wegknfg+g3zODve+r4oslFHI+g7cInfWlxoGNDS3niPKyuafgCdOjme2g3OF+zzxfsQ==}
400 | engines: {node: '>=10'}
401 | cpu: [x64]
402 | os: [linux]
403 |
404 | '@swc/core-win32-arm64-msvc@1.10.18':
405 | resolution: {integrity: sha512-o/2CsaWSN3bkzVQ6DA+BiFKSVEYvhWGA1h+wnL2zWmIDs2Knag54sOEXZkCaf8YQyZesGeXJtPEy9hh/vjJgkA==}
406 | engines: {node: '>=10'}
407 | cpu: [arm64]
408 | os: [win32]
409 |
410 | '@swc/core-win32-ia32-msvc@1.10.18':
411 | resolution: {integrity: sha512-eTPASeJtk4mJDfWiYEiOC6OYUi/N7meHbNHcU8e+aKABonhXrIo/FmnTE8vsUtC6+jakT1TQBdiQ8fzJ1kJVwA==}
412 | engines: {node: '>=10'}
413 | cpu: [ia32]
414 | os: [win32]
415 |
416 | '@swc/core-win32-x64-msvc@1.10.18':
417 | resolution: {integrity: sha512-1Dud8CDBnc34wkBOboFBQud9YlV1bcIQtKSg7zC8LtwR3h+XAaCayZPkpGmmAlCv1DLQPvkF+s0JcaVC9mfffQ==}
418 | engines: {node: '>=10'}
419 | cpu: [x64]
420 | os: [win32]
421 |
422 | '@swc/core@1.10.18':
423 | resolution: {integrity: sha512-IUWKD6uQYGRy8w2X9EZrtYg1O3SCijlHbCXzMaHQYc1X7yjijQh4H3IVL9ssZZyVp2ZDfQZu4bD5DWxxvpyjvg==}
424 | engines: {node: '>=10'}
425 | peerDependencies:
426 | '@swc/helpers': '*'
427 | peerDependenciesMeta:
428 | '@swc/helpers':
429 | optional: true
430 |
431 | '@swc/counter@0.1.3':
432 | resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
433 |
434 | '@swc/types@0.1.17':
435 | resolution: {integrity: sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==}
436 |
437 | '@types/estree@1.0.6':
438 | resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
439 |
440 | codemirror@6.0.1:
441 | resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==}
442 |
443 | crelt@1.0.6:
444 | resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
445 |
446 | esbuild@0.24.2:
447 | resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
448 | engines: {node: '>=18'}
449 | hasBin: true
450 |
451 | fsevents@2.3.3:
452 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
453 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
454 | os: [darwin]
455 |
456 | loro-crdt@1.5.5:
457 | resolution: {integrity: sha512-XflGSRTgbFFEpYOc5J2WTVFg5mkEK7Cap+tNFTcIvSAxXQfsLGI8ai+y2PeYPVVbmHSV8NtLwId0QB2aR1kEWQ==}
458 |
459 | nanoid@3.3.8:
460 | resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
461 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
462 | hasBin: true
463 |
464 | picocolors@1.1.1:
465 | resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
466 |
467 | postcss@8.5.3:
468 | resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
469 | engines: {node: ^10 || ^12 || >=14}
470 |
471 | rollup@4.31.0:
472 | resolution: {integrity: sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==}
473 | engines: {node: '>=18.0.0', npm: '>=8.0.0'}
474 | hasBin: true
475 |
476 | source-map-js@1.2.1:
477 | resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
478 | engines: {node: '>=0.10.0'}
479 |
480 | style-mod@4.1.2:
481 | resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
482 |
483 | typescript@5.7.3:
484 | resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
485 | engines: {node: '>=14.17'}
486 | hasBin: true
487 |
488 | uuid@10.0.0:
489 | resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
490 | hasBin: true
491 |
492 | vite-plugin-top-level-await@1.5.0:
493 | resolution: {integrity: sha512-r/DtuvHrSqUVk23XpG2cl8gjt1aATMG5cjExXL1BUTcSNab6CzkcPua9BPEc9fuTP5UpwClCxUe3+dNGL0yrgQ==}
494 | peerDependencies:
495 | vite: '>=2.8'
496 |
497 | vite-plugin-wasm@3.4.1:
498 | resolution: {integrity: sha512-ja3nSo2UCkVeitltJGkS3pfQHAanHv/DqGatdI39ja6McgABlpsZ5hVgl6wuR8Qx5etY3T5qgDQhOWzc5RReZA==}
499 | peerDependencies:
500 | vite: ^2 || ^3 || ^4 || ^5 || ^6
501 |
502 | vite@6.1.1:
503 | resolution: {integrity: sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==}
504 | engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
505 | hasBin: true
506 | peerDependencies:
507 | '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
508 | jiti: '>=1.21.0'
509 | less: '*'
510 | lightningcss: ^1.21.0
511 | sass: '*'
512 | sass-embedded: '*'
513 | stylus: '*'
514 | sugarss: '*'
515 | terser: ^5.16.0
516 | tsx: ^4.8.1
517 | yaml: ^2.4.2
518 | peerDependenciesMeta:
519 | '@types/node':
520 | optional: true
521 | jiti:
522 | optional: true
523 | less:
524 | optional: true
525 | lightningcss:
526 | optional: true
527 | sass:
528 | optional: true
529 | sass-embedded:
530 | optional: true
531 | stylus:
532 | optional: true
533 | sugarss:
534 | optional: true
535 | terser:
536 | optional: true
537 | tsx:
538 | optional: true
539 | yaml:
540 | optional: true
541 |
542 | w3c-keyname@2.2.8:
543 | resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
544 |
545 | snapshots:
546 |
547 | '@codemirror/autocomplete@6.18.4':
548 | dependencies:
549 | '@codemirror/language': 6.10.8
550 | '@codemirror/state': 6.5.1
551 | '@codemirror/view': 6.36.2
552 | '@lezer/common': 1.2.3
553 |
554 | '@codemirror/commands@6.8.0':
555 | dependencies:
556 | '@codemirror/language': 6.10.8
557 | '@codemirror/state': 6.5.1
558 | '@codemirror/view': 6.36.2
559 | '@lezer/common': 1.2.3
560 |
561 | '@codemirror/lang-css@6.3.1':
562 | dependencies:
563 | '@codemirror/autocomplete': 6.18.4
564 | '@codemirror/language': 6.10.8
565 | '@codemirror/state': 6.5.1
566 | '@lezer/common': 1.2.3
567 | '@lezer/css': 1.1.9
568 |
569 | '@codemirror/lang-html@6.4.9':
570 | dependencies:
571 | '@codemirror/autocomplete': 6.18.4
572 | '@codemirror/lang-css': 6.3.1
573 | '@codemirror/lang-javascript': 6.2.2
574 | '@codemirror/language': 6.10.8
575 | '@codemirror/state': 6.5.1
576 | '@codemirror/view': 6.36.2
577 | '@lezer/common': 1.2.3
578 | '@lezer/css': 1.1.9
579 | '@lezer/html': 1.3.10
580 |
581 | '@codemirror/lang-javascript@6.2.2':
582 | dependencies:
583 | '@codemirror/autocomplete': 6.18.4
584 | '@codemirror/language': 6.10.8
585 | '@codemirror/lint': 6.8.4
586 | '@codemirror/state': 6.5.1
587 | '@codemirror/view': 6.36.2
588 | '@lezer/common': 1.2.3
589 | '@lezer/javascript': 1.4.21
590 |
591 | '@codemirror/lang-markdown@6.3.2':
592 | dependencies:
593 | '@codemirror/autocomplete': 6.18.4
594 | '@codemirror/lang-html': 6.4.9
595 | '@codemirror/language': 6.10.8
596 | '@codemirror/state': 6.5.1
597 | '@codemirror/view': 6.36.2
598 | '@lezer/common': 1.2.3
599 | '@lezer/markdown': 1.4.0
600 |
601 | '@codemirror/language@6.10.8':
602 | dependencies:
603 | '@codemirror/state': 6.5.1
604 | '@codemirror/view': 6.36.2
605 | '@lezer/common': 1.2.3
606 | '@lezer/highlight': 1.2.1
607 | '@lezer/lr': 1.4.2
608 | style-mod: 4.1.2
609 |
610 | '@codemirror/lint@6.8.4':
611 | dependencies:
612 | '@codemirror/state': 6.5.1
613 | '@codemirror/view': 6.36.2
614 | crelt: 1.0.6
615 |
616 | '@codemirror/search@6.5.8':
617 | dependencies:
618 | '@codemirror/state': 6.5.1
619 | '@codemirror/view': 6.36.2
620 | crelt: 1.0.6
621 |
622 | '@codemirror/state@6.5.1':
623 | dependencies:
624 | '@marijn/find-cluster-break': 1.0.2
625 |
626 | '@codemirror/view@6.36.2':
627 | dependencies:
628 | '@codemirror/state': 6.5.1
629 | style-mod: 4.1.2
630 | w3c-keyname: 2.2.8
631 |
632 | '@esbuild/aix-ppc64@0.24.2':
633 | optional: true
634 |
635 | '@esbuild/android-arm64@0.24.2':
636 | optional: true
637 |
638 | '@esbuild/android-arm@0.24.2':
639 | optional: true
640 |
641 | '@esbuild/android-x64@0.24.2':
642 | optional: true
643 |
644 | '@esbuild/darwin-arm64@0.24.2':
645 | optional: true
646 |
647 | '@esbuild/darwin-x64@0.24.2':
648 | optional: true
649 |
650 | '@esbuild/freebsd-arm64@0.24.2':
651 | optional: true
652 |
653 | '@esbuild/freebsd-x64@0.24.2':
654 | optional: true
655 |
656 | '@esbuild/linux-arm64@0.24.2':
657 | optional: true
658 |
659 | '@esbuild/linux-arm@0.24.2':
660 | optional: true
661 |
662 | '@esbuild/linux-ia32@0.24.2':
663 | optional: true
664 |
665 | '@esbuild/linux-loong64@0.24.2':
666 | optional: true
667 |
668 | '@esbuild/linux-mips64el@0.24.2':
669 | optional: true
670 |
671 | '@esbuild/linux-ppc64@0.24.2':
672 | optional: true
673 |
674 | '@esbuild/linux-riscv64@0.24.2':
675 | optional: true
676 |
677 | '@esbuild/linux-s390x@0.24.2':
678 | optional: true
679 |
680 | '@esbuild/linux-x64@0.24.2':
681 | optional: true
682 |
683 | '@esbuild/netbsd-arm64@0.24.2':
684 | optional: true
685 |
686 | '@esbuild/netbsd-x64@0.24.2':
687 | optional: true
688 |
689 | '@esbuild/openbsd-arm64@0.24.2':
690 | optional: true
691 |
692 | '@esbuild/openbsd-x64@0.24.2':
693 | optional: true
694 |
695 | '@esbuild/sunos-x64@0.24.2':
696 | optional: true
697 |
698 | '@esbuild/win32-arm64@0.24.2':
699 | optional: true
700 |
701 | '@esbuild/win32-ia32@0.24.2':
702 | optional: true
703 |
704 | '@esbuild/win32-x64@0.24.2':
705 | optional: true
706 |
707 | '@lezer/common@1.2.3': {}
708 |
709 | '@lezer/css@1.1.9':
710 | dependencies:
711 | '@lezer/common': 1.2.3
712 | '@lezer/highlight': 1.2.1
713 | '@lezer/lr': 1.4.2
714 |
715 | '@lezer/highlight@1.2.1':
716 | dependencies:
717 | '@lezer/common': 1.2.3
718 |
719 | '@lezer/html@1.3.10':
720 | dependencies:
721 | '@lezer/common': 1.2.3
722 | '@lezer/highlight': 1.2.1
723 | '@lezer/lr': 1.4.2
724 |
725 | '@lezer/javascript@1.4.21':
726 | dependencies:
727 | '@lezer/common': 1.2.3
728 | '@lezer/highlight': 1.2.1
729 | '@lezer/lr': 1.4.2
730 |
731 | '@lezer/lr@1.4.2':
732 | dependencies:
733 | '@lezer/common': 1.2.3
734 |
735 | '@lezer/markdown@1.4.0':
736 | dependencies:
737 | '@lezer/common': 1.2.3
738 | '@lezer/highlight': 1.2.1
739 |
740 | '@marijn/find-cluster-break@1.0.2': {}
741 |
742 | '@rollup/plugin-virtual@3.0.2(rollup@4.31.0)':
743 | optionalDependencies:
744 | rollup: 4.31.0
745 |
746 | '@rollup/rollup-android-arm-eabi@4.31.0':
747 | optional: true
748 |
749 | '@rollup/rollup-android-arm64@4.31.0':
750 | optional: true
751 |
752 | '@rollup/rollup-darwin-arm64@4.31.0':
753 | optional: true
754 |
755 | '@rollup/rollup-darwin-x64@4.31.0':
756 | optional: true
757 |
758 | '@rollup/rollup-freebsd-arm64@4.31.0':
759 | optional: true
760 |
761 | '@rollup/rollup-freebsd-x64@4.31.0':
762 | optional: true
763 |
764 | '@rollup/rollup-linux-arm-gnueabihf@4.31.0':
765 | optional: true
766 |
767 | '@rollup/rollup-linux-arm-musleabihf@4.31.0':
768 | optional: true
769 |
770 | '@rollup/rollup-linux-arm64-gnu@4.31.0':
771 | optional: true
772 |
773 | '@rollup/rollup-linux-arm64-musl@4.31.0':
774 | optional: true
775 |
776 | '@rollup/rollup-linux-loongarch64-gnu@4.31.0':
777 | optional: true
778 |
779 | '@rollup/rollup-linux-powerpc64le-gnu@4.31.0':
780 | optional: true
781 |
782 | '@rollup/rollup-linux-riscv64-gnu@4.31.0':
783 | optional: true
784 |
785 | '@rollup/rollup-linux-s390x-gnu@4.31.0':
786 | optional: true
787 |
788 | '@rollup/rollup-linux-x64-gnu@4.31.0':
789 | optional: true
790 |
791 | '@rollup/rollup-linux-x64-musl@4.31.0':
792 | optional: true
793 |
794 | '@rollup/rollup-win32-arm64-msvc@4.31.0':
795 | optional: true
796 |
797 | '@rollup/rollup-win32-ia32-msvc@4.31.0':
798 | optional: true
799 |
800 | '@rollup/rollup-win32-x64-msvc@4.31.0':
801 | optional: true
802 |
803 | '@swc/core-darwin-arm64@1.10.18':
804 | optional: true
805 |
806 | '@swc/core-darwin-x64@1.10.18':
807 | optional: true
808 |
809 | '@swc/core-linux-arm-gnueabihf@1.10.18':
810 | optional: true
811 |
812 | '@swc/core-linux-arm64-gnu@1.10.18':
813 | optional: true
814 |
815 | '@swc/core-linux-arm64-musl@1.10.18':
816 | optional: true
817 |
818 | '@swc/core-linux-x64-gnu@1.10.18':
819 | optional: true
820 |
821 | '@swc/core-linux-x64-musl@1.10.18':
822 | optional: true
823 |
824 | '@swc/core-win32-arm64-msvc@1.10.18':
825 | optional: true
826 |
827 | '@swc/core-win32-ia32-msvc@1.10.18':
828 | optional: true
829 |
830 | '@swc/core-win32-x64-msvc@1.10.18':
831 | optional: true
832 |
833 | '@swc/core@1.10.18':
834 | dependencies:
835 | '@swc/counter': 0.1.3
836 | '@swc/types': 0.1.17
837 | optionalDependencies:
838 | '@swc/core-darwin-arm64': 1.10.18
839 | '@swc/core-darwin-x64': 1.10.18
840 | '@swc/core-linux-arm-gnueabihf': 1.10.18
841 | '@swc/core-linux-arm64-gnu': 1.10.18
842 | '@swc/core-linux-arm64-musl': 1.10.18
843 | '@swc/core-linux-x64-gnu': 1.10.18
844 | '@swc/core-linux-x64-musl': 1.10.18
845 | '@swc/core-win32-arm64-msvc': 1.10.18
846 | '@swc/core-win32-ia32-msvc': 1.10.18
847 | '@swc/core-win32-x64-msvc': 1.10.18
848 |
849 | '@swc/counter@0.1.3': {}
850 |
851 | '@swc/types@0.1.17':
852 | dependencies:
853 | '@swc/counter': 0.1.3
854 |
855 | '@types/estree@1.0.6': {}
856 |
857 | codemirror@6.0.1:
858 | dependencies:
859 | '@codemirror/autocomplete': 6.18.4
860 | '@codemirror/commands': 6.8.0
861 | '@codemirror/language': 6.10.8
862 | '@codemirror/lint': 6.8.4
863 | '@codemirror/search': 6.5.8
864 | '@codemirror/state': 6.5.1
865 | '@codemirror/view': 6.36.2
866 |
867 | crelt@1.0.6: {}
868 |
869 | esbuild@0.24.2:
870 | optionalDependencies:
871 | '@esbuild/aix-ppc64': 0.24.2
872 | '@esbuild/android-arm': 0.24.2
873 | '@esbuild/android-arm64': 0.24.2
874 | '@esbuild/android-x64': 0.24.2
875 | '@esbuild/darwin-arm64': 0.24.2
876 | '@esbuild/darwin-x64': 0.24.2
877 | '@esbuild/freebsd-arm64': 0.24.2
878 | '@esbuild/freebsd-x64': 0.24.2
879 | '@esbuild/linux-arm': 0.24.2
880 | '@esbuild/linux-arm64': 0.24.2
881 | '@esbuild/linux-ia32': 0.24.2
882 | '@esbuild/linux-loong64': 0.24.2
883 | '@esbuild/linux-mips64el': 0.24.2
884 | '@esbuild/linux-ppc64': 0.24.2
885 | '@esbuild/linux-riscv64': 0.24.2
886 | '@esbuild/linux-s390x': 0.24.2
887 | '@esbuild/linux-x64': 0.24.2
888 | '@esbuild/netbsd-arm64': 0.24.2
889 | '@esbuild/netbsd-x64': 0.24.2
890 | '@esbuild/openbsd-arm64': 0.24.2
891 | '@esbuild/openbsd-x64': 0.24.2
892 | '@esbuild/sunos-x64': 0.24.2
893 | '@esbuild/win32-arm64': 0.24.2
894 | '@esbuild/win32-ia32': 0.24.2
895 | '@esbuild/win32-x64': 0.24.2
896 |
897 | fsevents@2.3.3:
898 | optional: true
899 |
900 | loro-crdt@1.5.5: {}
901 |
902 | nanoid@3.3.8: {}
903 |
904 | picocolors@1.1.1: {}
905 |
906 | postcss@8.5.3:
907 | dependencies:
908 | nanoid: 3.3.8
909 | picocolors: 1.1.1
910 | source-map-js: 1.2.1
911 |
912 | rollup@4.31.0:
913 | dependencies:
914 | '@types/estree': 1.0.6
915 | optionalDependencies:
916 | '@rollup/rollup-android-arm-eabi': 4.31.0
917 | '@rollup/rollup-android-arm64': 4.31.0
918 | '@rollup/rollup-darwin-arm64': 4.31.0
919 | '@rollup/rollup-darwin-x64': 4.31.0
920 | '@rollup/rollup-freebsd-arm64': 4.31.0
921 | '@rollup/rollup-freebsd-x64': 4.31.0
922 | '@rollup/rollup-linux-arm-gnueabihf': 4.31.0
923 | '@rollup/rollup-linux-arm-musleabihf': 4.31.0
924 | '@rollup/rollup-linux-arm64-gnu': 4.31.0
925 | '@rollup/rollup-linux-arm64-musl': 4.31.0
926 | '@rollup/rollup-linux-loongarch64-gnu': 4.31.0
927 | '@rollup/rollup-linux-powerpc64le-gnu': 4.31.0
928 | '@rollup/rollup-linux-riscv64-gnu': 4.31.0
929 | '@rollup/rollup-linux-s390x-gnu': 4.31.0
930 | '@rollup/rollup-linux-x64-gnu': 4.31.0
931 | '@rollup/rollup-linux-x64-musl': 4.31.0
932 | '@rollup/rollup-win32-arm64-msvc': 4.31.0
933 | '@rollup/rollup-win32-ia32-msvc': 4.31.0
934 | '@rollup/rollup-win32-x64-msvc': 4.31.0
935 | fsevents: 2.3.3
936 |
937 | source-map-js@1.2.1: {}
938 |
939 | style-mod@4.1.2: {}
940 |
941 | typescript@5.7.3: {}
942 |
943 | uuid@10.0.0: {}
944 |
945 | vite-plugin-top-level-await@1.5.0(rollup@4.31.0)(vite@6.1.1):
946 | dependencies:
947 | '@rollup/plugin-virtual': 3.0.2(rollup@4.31.0)
948 | '@swc/core': 1.10.18
949 | uuid: 10.0.0
950 | vite: 6.1.1
951 | transitivePeerDependencies:
952 | - '@swc/helpers'
953 | - rollup
954 |
955 | vite-plugin-wasm@3.4.1(vite@6.1.1):
956 | dependencies:
957 | vite: 6.1.1
958 |
959 | vite@6.1.1:
960 | dependencies:
961 | esbuild: 0.24.2
962 | postcss: 8.5.3
963 | rollup: 4.31.0
964 | optionalDependencies:
965 | fsevents: 2.3.3
966 |
967 | w3c-keyname@2.2.8: {}
968 |
--------------------------------------------------------------------------------
/example/src/main.ts:
--------------------------------------------------------------------------------
1 | import { EditorState } from "@codemirror/state";
2 | import { EditorView } from "@codemirror/view";
3 | import {
4 | getTextFromDoc,
5 | LoroEphemeralPlugin,
6 | LoroExtensions,
7 | LoroSyncPlugin,
8 | LoroUndoPlugin,
9 | } from "loro-codemirror";
10 | import { EphemeralStore, LoroDoc, UndoManager } from "loro-crdt";
11 | import { basicSetup } from "codemirror";
12 | import { javascript } from "@codemirror/lang-javascript";
13 |
14 | // Create a Loro document
15 | const doc1 = new LoroDoc();
16 | const ephemeral1: EphemeralStore = new EphemeralStore();
17 | const undoManager1 = new UndoManager(doc1, {});
18 | const doc2 = new LoroDoc();
19 | const ephemeral2: EphemeralStore = new EphemeralStore();
20 | const undoManager2 = new UndoManager(doc2, {});
21 |
22 | doc1.subscribeLocalUpdates((update) => {
23 | doc2.import(update);
24 | });
25 | // Initialize the document
26 | getTextFromDoc(doc1).insert(0, "hello");
27 | doc1.commit();
28 | doc2.subscribeLocalUpdates((update) => {
29 | doc1.import(update);
30 | });
31 |
32 | // @ts-ignore
33 | const _sub1 = ephemeral1.subscribeLocalUpdates((update) => {
34 | ephemeral2.apply(update);
35 | });
36 |
37 | // @ts-ignore
38 | const _sub2 = ephemeral2.subscribeLocalUpdates((update) => {
39 | ephemeral1.apply(update);
40 | });
41 |
42 | // Create the first editor
43 | new EditorView({
44 | state: EditorState.create({
45 | extensions: [
46 | EditorView.theme({
47 | "&": { height: "100%", fontSize: "18px" },
48 | }),
49 | basicSetup,
50 | javascript({ typescript: true }),
51 | LoroExtensions(
52 | doc1,
53 | {
54 | user: { name: "User 1", colorClassName: "user1" },
55 | ephemeral: ephemeral1
56 | },
57 | undoManager1,
58 | ),
59 | ],
60 | }),
61 | parent: document.querySelector("#editor1")!,
62 | });
63 |
64 | // Create the second editor
65 | new EditorView({
66 | state: EditorState.create({
67 | extensions: [
68 | EditorView.theme({
69 | "&": { height: "100%", fontSize: "18px" },
70 | }),
71 | basicSetup,
72 | javascript({ typescript: true }),
73 | LoroSyncPlugin(doc2),
74 | LoroEphemeralPlugin(doc2, ephemeral2, {
75 | name: "User 2",
76 | colorClassName: "user2",
77 | }),
78 | LoroUndoPlugin(doc2, undoManager2),
79 | ],
80 | }),
81 | parent: document.querySelector("#editor2")!,
82 | });
83 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true
21 | },
22 | "include": ["src"]
23 | }
24 |
--------------------------------------------------------------------------------
/example/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import wasm from "vite-plugin-wasm";
3 | import topLevelAwait from "vite-plugin-top-level-await";
4 |
5 | export default defineConfig({
6 | plugins: [wasm(), topLevelAwait()],
7 | optimizeDeps: {
8 | exclude: ["loro-codemirror"],
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "loro-codemirror",
3 | "version": "0.2.0",
4 | "description": "A CodeMirror plugin for loro",
5 | "type": "module",
6 | "main": "dist/index.js",
7 | "types": "dist/index.d.ts",
8 | "exports": {
9 | ".": {
10 | "types": "./dist/index.d.ts",
11 | "default": "./dist/index.js"
12 | }
13 | },
14 | "scripts": {
15 | "build": "tsc",
16 | "dev": "tsc --watch",
17 | "typecheck": "tsc --noEmit"
18 | },
19 | "keywords": [
20 | "loro",
21 | "codemirror"
22 | ],
23 | "author": "leon7hao",
24 | "license": "MIT",
25 | "homepage": "https://github.com/loro-dev/loro-codemirror",
26 | "repository": {
27 | "type": "git",
28 | "url": "https://github.com/loro-dev/loro-codemirror.git"
29 | },
30 | "peerDependencies": {
31 | "@codemirror/state": "^6.0.0",
32 | "@codemirror/view": "^6.7.0",
33 | "loro-crdt": "^1.5.5"
34 | },
35 | "devDependencies": {
36 | "typescript": "^5.7.3"
37 | },
38 | "packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af"
39 | }
--------------------------------------------------------------------------------
/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | dependencies:
11 | '@codemirror/state':
12 | specifier: ^6.0.0
13 | version: 6.5.1
14 | '@codemirror/view':
15 | specifier: ^6.7.0
16 | version: 6.36.2
17 | loro-crdt:
18 | specifier: ^1.5.5
19 | version: 1.5.5
20 | devDependencies:
21 | typescript:
22 | specifier: ^5.7.3
23 | version: 5.7.3
24 |
25 | packages:
26 |
27 | '@codemirror/state@6.5.1':
28 | resolution: {integrity: sha512-3rA9lcwciEB47ZevqvD8qgbzhM9qMb8vCcQCNmDfVRPQG4JT9mSb0Jg8H7YjKGGQcFnLN323fj9jdnG59Kx6bg==}
29 |
30 | '@codemirror/view@6.36.2':
31 | resolution: {integrity: sha512-DZ6ONbs8qdJK0fdN7AB82CgI6tYXf4HWk1wSVa0+9bhVznCuuvhQtX8bFBoy3dv8rZSQqUd8GvhVAcielcidrA==}
32 |
33 | '@marijn/find-cluster-break@1.0.2':
34 | resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
35 |
36 | loro-crdt@1.5.5:
37 | resolution: {integrity: sha512-XflGSRTgbFFEpYOc5J2WTVFg5mkEK7Cap+tNFTcIvSAxXQfsLGI8ai+y2PeYPVVbmHSV8NtLwId0QB2aR1kEWQ==}
38 |
39 | style-mod@4.1.2:
40 | resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
41 |
42 | typescript@5.7.3:
43 | resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
44 | engines: {node: '>=14.17'}
45 | hasBin: true
46 |
47 | w3c-keyname@2.2.8:
48 | resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
49 |
50 | snapshots:
51 |
52 | '@codemirror/state@6.5.1':
53 | dependencies:
54 | '@marijn/find-cluster-break': 1.0.2
55 |
56 | '@codemirror/view@6.36.2':
57 | dependencies:
58 | '@codemirror/state': 6.5.1
59 | style-mod: 4.1.2
60 | w3c-keyname: 2.2.8
61 |
62 | '@marijn/find-cluster-break@1.0.2': {}
63 |
64 | loro-crdt@1.5.5: {}
65 |
66 | style-mod@4.1.2: {}
67 |
68 | typescript@5.7.3: {}
69 |
70 | w3c-keyname@2.2.8: {}
71 |
--------------------------------------------------------------------------------
/src/awareness.ts:
--------------------------------------------------------------------------------
1 | import {
2 | EditorView,
3 | type PluginValue,
4 | ViewUpdate,
5 | layer,
6 | type LayerMarker,
7 | type Rect,
8 | Direction,
9 | RectangleMarker,
10 | } from "@codemirror/view";
11 | import {
12 | Awareness,
13 | type AwarenessListener,
14 | Cursor,
15 | LoroDoc,
16 | LoroText,
17 | type PeerID,
18 | type Subscription,
19 | type Value,
20 | } from "loro-crdt";
21 | import {
22 | Annotation,
23 | EditorSelection,
24 | type Extension,
25 | SelectionRange,
26 | StateEffect,
27 | StateField,
28 | } from "@codemirror/state";
29 |
30 | export const loroCursorTheme = EditorView.baseTheme({
31 | ".loro-cursor": {
32 | position: "absolute",
33 | width: "2px",
34 | display: "inline-block",
35 | height: "1.2em",
36 | },
37 | ".loro-cursor::before": {
38 | position: "absolute",
39 | top: "1.3em",
40 | left: "0",
41 | content: "var(--name)",
42 | padding: "2px 6px",
43 | fontSize: "12px",
44 | borderRadius: "3px",
45 | whiteSpace: "nowrap",
46 | userSelect: "none",
47 | opacity: "0.7",
48 | },
49 | ".loro-selection": {
50 | opacity: "0.5",
51 | },
52 | });
53 | export type CursorState = { anchor: Uint8Array; head?: Uint8Array };
54 |
55 | export type AwarenessState =
56 | | {
57 | type: "update";
58 | uid: string;
59 | cursor: CursorState;
60 | user?: UserState;
61 | }
62 | | {
63 | type: "delete";
64 | uid: string;
65 | };
66 |
67 | export type UserState = {
68 | name: string;
69 | colorClassName: string;
70 | }
71 |
72 | export type CursorEffect =
73 | | {
74 | type: "update";
75 | peer: string;
76 | cursor: { anchor: number; head?: number };
77 | user?: UserState;
78 | }
79 | | {
80 | type: "delete";
81 | peer: string;
82 | }
83 | | {
84 | type: "checkout";
85 | checkout: boolean;
86 | };
87 |
88 | // We should use layer https://github.com/codemirror/dev/issues/989
89 | export const remoteAwarenessAnnotation = Annotation.define();
90 | export const remoteAwarenessEffect = StateEffect.define();
91 | export const remoteAwarenessStateField = StateField.define<{
92 | remoteCursors: Map;
93 | isCheckout: boolean;
94 | }>({
95 | create() {
96 | return { remoteCursors: new Map(), isCheckout: false };
97 | },
98 | update(value, tr) {
99 | for (const effect of tr.effects) {
100 | if (effect.is(remoteAwarenessEffect)) {
101 | switch (effect.value.type) {
102 | case "update":
103 | const { peer: uid, user, cursor } = effect.value;
104 | value.remoteCursors.set(uid, {
105 | cursor,
106 | user,
107 | });
108 | break;
109 | case "delete":
110 | value.remoteCursors.delete(effect.value.peer);
111 | break;
112 | case "checkout":
113 | value.isCheckout = effect.value.checkout;
114 | }
115 | }
116 | }
117 | return value;
118 | },
119 | });
120 |
121 | const isRemoteCursorUpdate = (update: ViewUpdate): boolean => {
122 | const effect = update.transactions
123 | .flatMap((transaction) => transaction.effects)
124 | .filter((effect) => effect.is(remoteAwarenessEffect));
125 | return update.docChanged || update.viewportChanged || effect.length > 0;
126 | };
127 |
128 | export const createCursorLayer = (): Extension => {
129 | return layer({
130 | above: true,
131 | class: "loro-cursor-layer",
132 | update: isRemoteCursorUpdate,
133 | markers: (view) => {
134 | const { remoteCursors: remoteStates, isCheckout } =
135 | view.state.field(remoteAwarenessStateField);
136 | if (isCheckout) {
137 | return [];
138 | }
139 | return Array.from(remoteStates.values()).flatMap((state) => {
140 | const selectionRange = EditorSelection.cursor(
141 | state.cursor.anchor
142 | );
143 | return RemoteCursorMarker.createCursor(
144 | view,
145 | selectionRange,
146 | state.user?.name || "unknown",
147 | state.user?.colorClassName || ""
148 | );
149 | });
150 | },
151 | });
152 | };
153 |
154 | export const createSelectionLayer = (): Extension =>
155 | layer({
156 | above: false,
157 | class: "loro-selection-layer",
158 | update: isRemoteCursorUpdate,
159 | markers: (view) => {
160 | const { remoteCursors: remoteStates, isCheckout } =
161 | view.state.field(remoteAwarenessStateField);
162 | if (isCheckout) {
163 | return [];
164 | }
165 | return Array.from(remoteStates.entries())
166 | .filter(
167 | ([_, state]) =>
168 | state.cursor.head !== undefined &&
169 | state.cursor.anchor !== state.cursor.head
170 | )
171 | .flatMap(([_, state]) => {
172 | const selectionRange = EditorSelection.range(
173 | state.cursor.anchor,
174 | state.cursor.head!
175 | );
176 | const markers = RectangleMarker.forRange(
177 | view,
178 | `loro-selection ${state.user?.colorClassName || ""}`,
179 | selectionRange
180 | );
181 | return markers;
182 | });
183 | },
184 | });
185 |
186 | /**
187 | * Renders a blinking cursor to indicate the cursor of another user.
188 | */
189 | export class RemoteCursorMarker implements LayerMarker {
190 | constructor(
191 | private left: number,
192 | private top: number,
193 | private height: number,
194 | private name: string,
195 | private colorClassName: string
196 | ) { }
197 |
198 | draw(): HTMLElement {
199 | const elt = document.createElement("div");
200 | this.adjust(elt);
201 | return elt;
202 | }
203 |
204 | update(elt: HTMLElement): boolean {
205 | this.adjust(elt);
206 | return true;
207 | }
208 |
209 | adjust(element: HTMLElement) {
210 | element.style.left = `${this.left}px`;
211 | element.style.top = `${this.top}px`;
212 | element.style.height = `${this.height}px`;
213 | element.className = `loro-cursor ${this.colorClassName}`;
214 | element.style.setProperty("--name", `"${this.name}"`);
215 | }
216 |
217 | eq(other: RemoteCursorMarker): boolean {
218 | return (
219 | this.left === other.left &&
220 | this.top === other.top &&
221 | this.height === other.height &&
222 | this.name === other.name
223 | );
224 | }
225 |
226 | public static createCursor(
227 | view: EditorView,
228 | position: SelectionRange,
229 | displayName: string,
230 | colorClassName: string
231 | ): RemoteCursorMarker[] {
232 | const absolutePosition = this.calculateAbsoluteCursorPosition(
233 | position,
234 | view
235 | );
236 | if (!absolutePosition) {
237 | return [];
238 | }
239 | const rect = view.scrollDOM.getBoundingClientRect();
240 | const left =
241 | view.textDirection == Direction.LTR
242 | ? rect.left
243 | : rect.right - view.scrollDOM.clientWidth;
244 | const baseLeft = left - view.scrollDOM.scrollLeft;
245 | const baseTop = rect.top - view.scrollDOM.scrollTop;
246 | return [
247 | new RemoteCursorMarker(
248 | absolutePosition.left - baseLeft,
249 | absolutePosition.top - baseTop,
250 | absolutePosition.bottom - absolutePosition.top,
251 | displayName,
252 | colorClassName
253 | ),
254 | ];
255 | }
256 |
257 | private static calculateAbsoluteCursorPosition(
258 | position: SelectionRange,
259 | view: EditorView
260 | ): Rect | null {
261 | const cappedPositionHead = Math.max(
262 | 0,
263 | Math.min(view.state.doc.length, position.anchor)
264 | );
265 | return view.coordsAtPos(cappedPositionHead, position.assoc || 1);
266 | }
267 | }
268 |
269 | const parseAwarenessUpdate = (
270 | doc: LoroDoc,
271 | awareness: Awareness,
272 | arg: {
273 | updated: PeerID[];
274 | added: PeerID[];
275 | removed: PeerID[];
276 | }
277 | ): StateEffect[] => {
278 | const effects = [];
279 | const { updated, added, removed } = arg;
280 | for (const update of updated.concat(added)) {
281 | const effect = getEffects(doc, awareness, update);
282 | if (effect) {
283 | effects.push(effect);
284 | }
285 | }
286 | return effects;
287 | };
288 |
289 | const getEffects = (
290 | doc: LoroDoc,
291 | awareness: Awareness,
292 | peer: PeerID
293 | ): StateEffect | undefined => {
294 | const states = awareness.getAllStates();
295 | const state = states[peer];
296 | if (!state) {
297 | return;
298 | }
299 | if (peer === doc.peerIdStr) {
300 | return;
301 | }
302 |
303 | if (state.type === "delete") {
304 | return remoteAwarenessEffect.of({
305 | type: "delete",
306 | peer: state.uid,
307 | });
308 | }
309 |
310 | const anchor = Cursor.decode(state.cursor.anchor);
311 | const anchorPos = doc.getCursorPos(anchor).offset;
312 | let headPos = anchorPos;
313 | if (state.cursor.head) {
314 | // range
315 | const head = Cursor.decode(state.cursor.head);
316 | headPos = doc.getCursorPos(head).offset;
317 | }
318 | return remoteAwarenessEffect.of({
319 | type: "update",
320 | peer: state.uid,
321 | cursor: { anchor: anchorPos, head: headPos },
322 | user: state.user,
323 | });
324 | };
325 |
326 | export interface CursorPosition {
327 | cursor: { anchor: number; head?: number };
328 | user?: UserState;
329 | }
330 |
331 | /**
332 | * @deprecated Use EphemeralPlugin instead
333 | */
334 | export class AwarenessPlugin implements PluginValue {
335 | sub: Subscription;
336 |
337 | constructor(
338 | public view: EditorView,
339 | public doc: LoroDoc,
340 | public user: UserState,
341 | public awareness: Awareness,
342 | private getUserId: (() => string) | undefined,
343 | private getTextFromDoc: (doc: LoroDoc) => LoroText
344 | ) {
345 | this.sub = this.doc.subscribe((e) => {
346 | if (e.by === "local") {
347 | // update remote cursor position
348 | const effects = [];
349 | for (const peer of this.awareness.peers()) {
350 | const effect = getEffects(this.doc!, this.awareness, peer);
351 | if (effect) {
352 | effects.push(effect);
353 | }
354 | }
355 | this.view.dispatch({
356 | effects,
357 | });
358 | } else if (e.by === "checkout") {
359 | // TODO: better way
360 | this.view.dispatch({
361 | effects: [
362 | remoteAwarenessEffect.of({
363 | type: "checkout",
364 | checkout: this.doc.isDetached(),
365 | }),
366 | ],
367 | });
368 | }
369 | });
370 | }
371 |
372 | update(update: ViewUpdate): void {
373 | if (
374 | !update.selectionSet &&
375 | !update.focusChanged &&
376 | !update.docChanged
377 | ) {
378 | return;
379 | }
380 | const selection = update.state.selection.main;
381 | if (this.view.hasFocus && !this.doc.isDetached()) {
382 | const cursorState = getCursorState(
383 | this.doc,
384 | selection.anchor,
385 | selection.head,
386 | this.getTextFromDoc
387 | );
388 | this.awareness.setLocalState({
389 | type: "update",
390 | uid: this.getUserId ? this.getUserId() : this.doc.peerIdStr,
391 | cursor: cursorState,
392 | user: this.user,
393 | });
394 | } else {
395 | // when checkout or blur
396 | this.awareness.setLocalState({
397 | type: "delete",
398 | uid: this.getUserId ? this.getUserId() : this.doc.peerIdStr,
399 | });
400 | }
401 | }
402 |
403 | destroy(): void {
404 | this.sub?.();
405 | this.awareness.setLocalState({
406 | type: "delete",
407 | uid: this.getUserId ? this.getUserId() : this.doc.peerIdStr,
408 | });
409 | }
410 | }
411 | export class RemoteAwarenessPlugin implements PluginValue {
412 | _awarenessListener?: AwarenessListener;
413 | constructor(
414 | public view: EditorView,
415 | public doc: LoroDoc,
416 | public awareness: Awareness
417 | ) {
418 | const listener: AwarenessListener = async (arg, origin) => {
419 | if (origin === "local") return;
420 | this.view.dispatch({
421 | effects: parseAwarenessUpdate(this.doc, this.awareness, arg),
422 | });
423 | };
424 | this._awarenessListener = listener;
425 | this.awareness.addListener(listener);
426 | }
427 |
428 | destroy(): void {
429 | if (this._awarenessListener)
430 | this.awareness.removeListener(this._awarenessListener);
431 | }
432 | }
433 |
434 | export const getCursorState = (
435 | doc: LoroDoc,
436 | anchor: number,
437 | head: number | undefined,
438 | getTextFromDoc: (doc: LoroDoc) => LoroText
439 | ) => {
440 | if (anchor === head) {
441 | head = undefined;
442 | }
443 | const anchorCursor = getTextFromDoc(doc).getCursor(anchor)?.encode();
444 |
445 | if (!anchorCursor) {
446 | throw new Error("cursor head not found");
447 | }
448 | let headCursor = undefined;
449 | if (head !== undefined) {
450 | headCursor = getTextFromDoc(doc).getCursor(head)?.encode();
451 | }
452 |
453 | return {
454 | anchor: anchorCursor,
455 | head: headCursor,
456 | };
457 | };
458 |
--------------------------------------------------------------------------------
/src/ephemeral.ts:
--------------------------------------------------------------------------------
1 | import { layer, RectangleMarker, type EditorView, type PluginValue, type ViewUpdate } from "@codemirror/view";
2 | import { Cursor, EphemeralStore, LoroDoc, LoroText, type Subscription } from "loro-crdt";
3 | import { getCursorState, type UserState, type CursorState, remoteAwarenessEffect, type CursorPosition, RemoteCursorMarker } from "./awareness.ts";
4 | import { EditorSelection, StateEffect, StateField, type Extension } from "@codemirror/state";
5 |
6 | export const ephemeralEffect = StateEffect.define();
7 | export const ephemeralStateField = StateField.define<{
8 | remoteCursors: Map;
9 | remoteUsers: Map;
10 | isCheckout: boolean;
11 | }>({
12 | create() {
13 | return { remoteCursors: new Map(), remoteUsers: new Map(), isCheckout: false };
14 | },
15 | update(value, tr) {
16 | for (const effect of tr.effects) {
17 | if (effect.is(ephemeralEffect)) {
18 | switch (effect.value.type) {
19 | case "delete":
20 | value.remoteCursors.delete(effect.value.peer);
21 | break;
22 | case "cursor":
23 | const { peer, cursor } = effect.value;
24 | value.remoteCursors.set(peer, cursor);
25 | break;
26 | case "user":
27 | const { peer: uid, user } = effect.value;
28 | value.remoteUsers.set(uid, user);
29 | break;
30 | case "checkout":
31 | value.isCheckout = effect.value.checkout;
32 | }
33 | }
34 | }
35 | return value;
36 | },
37 | });
38 |
39 | type EphemeralEffect = {
40 | type: "delete";
41 | peer: string;
42 | } | {
43 | type: "cursor";
44 | peer: string;
45 | cursor: { anchor: number; head?: number };
46 | } | {
47 | type: "user";
48 | peer: string;
49 | user?: UserState;
50 | } | {
51 | type: "checkout";
52 | checkout: boolean;
53 | }
54 |
55 | const getCursorEffect = (
56 | doc: LoroDoc,
57 | peer: string,
58 | state: CursorState,
59 | ): StateEffect | undefined => {
60 | const anchor = Cursor.decode(state.anchor);
61 | const anchorPos = doc.getCursorPos(anchor).offset;
62 | let headPos = anchorPos;
63 | if (state.head) {
64 | // range
65 | const head = Cursor.decode(state.head);
66 | headPos = doc.getCursorPos(head).offset;
67 | }
68 | return ephemeralEffect.of({
69 | type: "cursor",
70 | peer,
71 | cursor: { anchor: anchorPos, head: headPos },
72 | });
73 | }
74 |
75 | export type EphemeralState = {
76 | [key: `${string}-cm-cursor`]: CursorState;
77 | [key: `${string}-cm-user`]: UserState | undefined;
78 | };
79 |
80 | const isRemoteCursorUpdate = (update: ViewUpdate): boolean => {
81 | const effect = update.transactions
82 | .flatMap((transaction) => transaction.effects)
83 | .filter((effect) => effect.is(ephemeralEffect));
84 | return update.docChanged || update.viewportChanged || effect.length > 0;
85 | };
86 |
87 | export const createCursorLayer = (): Extension => {
88 | return layer({
89 | above: true,
90 | class: "loro-cursor-layer",
91 | update: isRemoteCursorUpdate,
92 | markers: (view) => {
93 | const { remoteCursors, remoteUsers, isCheckout } =
94 | view.state.field(ephemeralStateField);
95 | if (isCheckout) {
96 | return [];
97 | }
98 | return Array.from(remoteCursors.entries()).flatMap(([peer, state]) => {
99 | const selectionRange = EditorSelection.cursor(
100 | state.anchor
101 | );
102 | const user = remoteUsers.get(peer);
103 | return RemoteCursorMarker.createCursor(
104 | view,
105 | selectionRange,
106 | user?.name || "unknown",
107 | user?.colorClassName || ""
108 | );
109 | });
110 | },
111 | });
112 | };
113 |
114 | export const createSelectionLayer = (): Extension =>
115 | layer({
116 | above: false,
117 | class: "loro-selection-layer",
118 | update: isRemoteCursorUpdate,
119 | markers: (view) => {
120 | const { remoteCursors, remoteUsers, isCheckout } =
121 | view.state.field(ephemeralStateField);
122 | if (isCheckout) {
123 | return [];
124 | }
125 | return Array.from(remoteCursors.entries())
126 | .filter(
127 | ([_, state]) =>
128 | state.head !== undefined &&
129 | state.anchor !== state.head
130 | )
131 | .flatMap(([peer, state]) => {
132 | const user = remoteUsers.get(peer);
133 | const selectionRange = EditorSelection.range(
134 | state.anchor,
135 | state.head!
136 | );
137 | const markers = RectangleMarker.forRange(
138 | view,
139 | `loro-selection ${user?.colorClassName || ""}`,
140 | selectionRange
141 | );
142 | return markers;
143 | });
144 | },
145 | });
146 |
147 | export class EphemeralPlugin implements PluginValue {
148 | sub: Subscription;
149 | ephemeralSub: Subscription;
150 | initUser: boolean = false;
151 |
152 | constructor(
153 | public view: EditorView,
154 | public doc: LoroDoc,
155 | public user: UserState,
156 | public ephemeralStore: EphemeralStore,
157 | private getTextFromDoc: (doc: LoroDoc) => LoroText
158 | ) {
159 | this.sub = this.doc.subscribe((e) => {
160 | if (e.by === "local") {
161 | // update remote cursor position
162 | const { remoteCursors: remoteStates, isCheckout } =
163 | view.state.field(ephemeralStateField);
164 | if (isCheckout) return;
165 | const effects = [];
166 | for (const peer of remoteStates.keys()) {
167 | if (peer === this.doc.peerIdStr) {
168 | continue;
169 | }
170 | const state = this.ephemeralStore.get(`${peer}-cm-cursor`);
171 | if (state) {
172 | const effect = getCursorEffect(this.doc, peer, state);
173 | if (effect) {
174 | effects.push(effect);
175 | }
176 | } else {
177 | effects.push(ephemeralEffect.of({
178 | type: "delete",
179 | peer,
180 | }));
181 | }
182 | }
183 | this.view.dispatch({
184 | effects,
185 | });
186 | } else if (e.by === "checkout") {
187 | // TODO: better way
188 | this.view.dispatch({
189 | effects: [
190 | remoteAwarenessEffect.of({
191 | type: "checkout",
192 | checkout: this.doc.isDetached(),
193 | }),
194 | ],
195 | });
196 | }
197 | });
198 |
199 | this.ephemeralSub = this.ephemeralStore.subscribe((e) => {
200 | if (e.by === "local") return;
201 | const effects = [];
202 | for (const key of e.added.concat(e.updated)) {
203 | const peer = key.split("-")[0];
204 | if (key.endsWith(`-cm-cursor`)) {
205 | const state = this.ephemeralStore.get(key as keyof EphemeralState)! as CursorState;
206 | const effect = getCursorEffect(this.doc, peer, state);
207 | if (effect) {
208 | effects.push(effect);
209 | }
210 | }
211 | if (key.endsWith(`-cm-user`)) {
212 | const user = this.ephemeralStore.get(key as keyof EphemeralState)! as UserState;
213 | effects.push(ephemeralEffect.of({
214 | type: "user",
215 | peer,
216 | user
217 | }));
218 | }
219 | }
220 |
221 | for (const key of e.removed) {
222 | const peer = key.split("-")[0];
223 | if (key.endsWith(`-cm-cursor`)) {
224 | effects.push(ephemeralEffect.of({
225 | type: "delete",
226 | peer,
227 | }));
228 | }
229 | }
230 |
231 | this.view.dispatch({
232 | effects
233 | })
234 | })
235 | }
236 |
237 | update(update: ViewUpdate): void {
238 | if (
239 | !update.selectionSet &&
240 | !update.focusChanged &&
241 | !update.docChanged
242 | ) {
243 | return;
244 | }
245 | const selection = update.state.selection.main;
246 | if (this.view.hasFocus && !this.doc.isDetached()) {
247 | const cursorState = getCursorState(
248 | this.doc,
249 | selection.anchor,
250 | selection.head,
251 | this.getTextFromDoc
252 | );
253 | this.ephemeralStore.set(`${this.doc.peerIdStr}-cm-cursor`, cursorState);
254 | if (!this.initUser) {
255 | this.ephemeralStore.set(`${this.doc.peerIdStr}-cm-user`, this.user);
256 | this.initUser = true;
257 | }
258 | } else {
259 | // when checkout or blur
260 | this.ephemeralStore.delete(`${this.doc.peerIdStr}-cm-cursor`);
261 | }
262 | }
263 |
264 | destroy(): void {
265 | this.sub?.();
266 | this.ephemeralSub?.();
267 | this.ephemeralStore.delete(`${this.doc.peerIdStr}-cm-cursor`);
268 | this.ephemeralStore.delete(`${this.doc.peerIdStr}-cm-user`);
269 | }
270 | }
271 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { type Extension, Prec } from "@codemirror/state";
2 | import { Awareness, EphemeralStore, LoroDoc, LoroText, UndoManager } from "loro-crdt";
3 | import {
4 | createCursorLayer as createAwarenessCursorLayer,
5 | createSelectionLayer as createAwarenessSelectionLayer,
6 | AwarenessPlugin,
7 | remoteAwarenessStateField,
8 | RemoteAwarenessPlugin,
9 | type UserState,
10 | type AwarenessState,
11 | loroCursorTheme,
12 | } from "./awareness.ts";
13 | import { LoroSyncPluginValue } from "./sync.ts";
14 | import { keymap, ViewPlugin } from "@codemirror/view";
15 | import { undoKeyMap, undoManagerStateField, UndoPluginValue } from "./undo.ts";
16 | import { defaultGetTextFromDoc } from "./utils.ts";
17 | import { createCursorLayer, createSelectionLayer, EphemeralPlugin, ephemeralStateField, type EphemeralState } from "./ephemeral.ts";
18 |
19 |
20 | export { undo, redo } from "./undo.ts";
21 |
22 | export { defaultGetTextFromDoc as getTextFromDoc };
23 |
24 | /**
25 | * It is used to sync the document with the remote users.
26 | *
27 | * @param doc - LoroDoc instance
28 | * @returns Extension
29 | */
30 | export const LoroSyncPlugin = (
31 | doc: LoroDoc,
32 | getTextFromDoc?: (doc: LoroDoc) => LoroText
33 | ): Extension => {
34 | return ViewPlugin.define(
35 | (view) =>
36 | new LoroSyncPluginValue(
37 | view,
38 | doc,
39 | getTextFromDoc ?? defaultGetTextFromDoc
40 | )
41 | );
42 | };
43 |
44 | /**
45 | * @deprecated Use LoroEphemeralPlugin instead
46 | * LoroAwarenessPlugin is a plugin that adds awareness to the editor.
47 | * It is used to sync the cursor position and selection of the editor with the remote users.
48 | *
49 | * @param doc - LoroDoc instance
50 | * @param awareness - Awareness instance
51 | * @param user - User info
52 | * @param getUserId - Function to get the user id. If not provided, the doc's peerId will be used.
53 | * @returns Extension[]
54 | */
55 | export const LoroAwarenessPlugin = (
56 | doc: LoroDoc,
57 | awareness: Awareness,
58 | user: UserState,
59 | getUserId?: () => string,
60 | getTextFromDoc?: (doc: LoroDoc) => LoroText
61 | ): Extension[] => {
62 | return [
63 | remoteAwarenessStateField,
64 | createAwarenessCursorLayer(),
65 | createAwarenessSelectionLayer(),
66 | ViewPlugin.define(
67 | (view) =>
68 | new AwarenessPlugin(
69 | view,
70 | doc,
71 | user,
72 | awareness as Awareness,
73 | getUserId,
74 | getTextFromDoc ?? defaultGetTextFromDoc
75 | )
76 | ),
77 | ViewPlugin.define(
78 | (view) =>
79 | new RemoteAwarenessPlugin(
80 | view,
81 | doc,
82 | awareness as Awareness
83 | )
84 | ),
85 | loroCursorTheme,
86 | ];
87 | };
88 |
89 |
90 | /**
91 | * LoroEphemeralPlugin is a plugin that adds ephemeral to the editor.
92 | * It is used to sync the cursor position and selection of the editor with the remote users.
93 | *
94 | * @param doc - LoroDoc instance
95 | * @param ephemeral - EphemeralStore instance
96 | * @param user - User info
97 | * @param getTextFromDoc - Function to get the text from the doc. If not provided, the defaultGetTextFromDoc will be used.
98 | * @returns Extension[]
99 | */
100 | export const LoroEphemeralPlugin = (
101 | doc: LoroDoc,
102 | ephemeral: EphemeralStore,
103 | user: UserState,
104 | getTextFromDoc?: (doc: LoroDoc) => LoroText
105 | ): Extension[] => {
106 | return [
107 | ephemeralStateField,
108 | createCursorLayer(),
109 | createSelectionLayer(),
110 | ViewPlugin.define(
111 | (view) =>
112 | new EphemeralPlugin(
113 | view,
114 | doc,
115 | user,
116 | ephemeral as EphemeralStore,
117 | getTextFromDoc ?? defaultGetTextFromDoc
118 | )
119 | ),
120 | loroCursorTheme,
121 | ];
122 | };
123 |
124 | /**
125 | * LoroUndoPlugin is a plugin that adds undo/redo to the editor.
126 | *
127 | * @param doc - LoroDoc instance
128 | * @param undoManager - UndoManager instance
129 | * @returns Extension[]
130 | */
131 | export const LoroUndoPlugin = (
132 | doc: LoroDoc,
133 | undoManager: UndoManager,
134 | getTextFromDoc?: (doc: LoroDoc) => LoroText
135 | ): Extension[] => {
136 | getTextFromDoc = getTextFromDoc ?? defaultGetTextFromDoc;
137 | return [
138 | undoManagerStateField.init(() => undoManager),
139 | Prec.high(keymap.of([...undoKeyMap])),
140 | ViewPlugin.define(
141 | (view) =>
142 | new UndoPluginValue(view, doc, undoManager, getTextFromDoc)
143 | ),
144 | ];
145 | };
146 |
147 | export function LoroExtensions(
148 | doc: LoroDoc,
149 | ephemeral?: {
150 | user: UserState;
151 | ephemeral: EphemeralStore;
152 | },
153 | undoManager?: UndoManager,
154 | getTextFromDoc?: (doc: LoroDoc) => LoroText
155 | ): Extension {
156 | getTextFromDoc = getTextFromDoc ?? defaultGetTextFromDoc;
157 |
158 | let extension = [
159 | ViewPlugin.define(
160 | (view) => new LoroSyncPluginValue(view, doc, getTextFromDoc)
161 | ).extension,
162 | ];
163 | if (undoManager) {
164 | extension = extension.concat([
165 | undoManagerStateField.init(() => undoManager),
166 | Prec.high(keymap.of([...undoKeyMap])),
167 | ViewPlugin.define(
168 | (view) =>
169 | new UndoPluginValue(view, doc, undoManager, getTextFromDoc)
170 | ).extension,
171 | ]);
172 | }
173 | if (ephemeral) {
174 | extension = extension.concat([
175 | ephemeralStateField,
176 | createCursorLayer(),
177 | createSelectionLayer(),
178 | ViewPlugin.define(
179 | (view) =>
180 | new EphemeralPlugin(
181 | view,
182 | doc,
183 | ephemeral.user,
184 | ephemeral.ephemeral as EphemeralStore,
185 | getTextFromDoc
186 | )
187 | ),
188 | loroCursorTheme,
189 | ]);
190 | }
191 |
192 | return extension;
193 | }
194 |
--------------------------------------------------------------------------------
/src/sync.ts:
--------------------------------------------------------------------------------
1 | import { Annotation, type ChangeSpec } from "@codemirror/state";
2 | import { EditorView, type PluginValue, ViewUpdate } from "@codemirror/view";
3 | import {
4 | LoroDoc,
5 | type LoroEventBatch,
6 | LoroText,
7 | type Subscription,
8 | } from "loro-crdt";
9 |
10 | export const loroSyncAnnotation = Annotation.define();
11 |
12 | export class LoroSyncPluginValue implements PluginValue {
13 | sub?: Subscription;
14 | private isInitDispatch = false;
15 | constructor(
16 | private view: EditorView,
17 | private doc: LoroDoc,
18 | private getTextFromDoc: (doc: LoroDoc) => LoroText
19 | ) {
20 | this.sub = doc.subscribe(this.onRemoteUpdate);
21 | Promise.resolve().then(() => {
22 | this.isInitDispatch = true;
23 | const currentText = this.view.state.doc.toString();
24 | const text = this.getTextFromDoc(this.doc);
25 | if (currentText === text.toString()) {
26 | return;
27 | }
28 | view.dispatch({
29 | changes: [
30 | {
31 | from: 0,
32 | to: this.view.state.doc.length,
33 | insert: text.toString(),
34 | },
35 | ],
36 | });
37 | });
38 | }
39 |
40 | onRemoteUpdate = (e: LoroEventBatch) => {
41 | if (e.by === "local") {
42 | return;
43 | }
44 | if (e.by === "checkout") {
45 | // TODO: better handle checkout
46 | this.view.dispatch({
47 | changes: [
48 | {
49 | from: 0,
50 | to: this.view.state.doc.length,
51 | insert: this.getTextFromDoc(this.doc).toString(),
52 | },
53 | ],
54 | annotations: [loroSyncAnnotation.of(this)],
55 | });
56 | return;
57 | }
58 | if (e.by === "import") {
59 | let changes: ChangeSpec[] = [];
60 | let pos = 0;
61 | for (let { diff, target } of e.events) {
62 | const text = this.getTextFromDoc(this.doc);
63 | // Skip if the event is not a text event
64 | if (diff.type !== "text") return;
65 | // Skip if the event is not for the current document
66 | if (target !== text.id) return;
67 | const textDiff = diff.diff;
68 | for (const delta of textDiff) {
69 | if (delta.insert) {
70 | changes.push({
71 | from: pos,
72 | to: pos,
73 | insert: delta.insert,
74 | });
75 | } else if (delta.delete) {
76 | changes.push({
77 | from: pos,
78 | to: pos + delta.delete,
79 | });
80 | pos += delta.delete;
81 | } else if (delta.retain != null) {
82 | pos += delta.retain;
83 | }
84 | }
85 | this.view.dispatch({
86 | changes,
87 | annotations: [loroSyncAnnotation.of(this)],
88 | });
89 | }
90 | }
91 | };
92 |
93 | update(update: ViewUpdate): void {
94 | if (this.isInitDispatch) {
95 | this.isInitDispatch = false;
96 | return;
97 | }
98 |
99 | if (
100 | !update.docChanged ||
101 | (update.transactions.length > 0 &&
102 | (update.transactions[0].annotation(loroSyncAnnotation) ===
103 | this ||
104 | update.transactions[0].annotation(loroSyncAnnotation) ===
105 | "undo"))
106 | ) {
107 | return;
108 | }
109 | let adj = 0;
110 | update.changes.iterChanges((fromA, toA, fromB, toB, insert) => {
111 | const insertText = insert.sliceString(0, insert.length, "\n");
112 | if (fromA !== toA) {
113 | this.getTextFromDoc(this.doc).delete(fromA + adj, toA - fromA);
114 | }
115 | if (insertText.length > 0) {
116 | this.getTextFromDoc(this.doc).insert(fromA + adj, insertText);
117 | }
118 | adj += insertText.length - (toA - fromA);
119 | });
120 | this.doc.commit();
121 | }
122 |
123 | destroy(): void {
124 | this.sub?.();
125 | this.sub = undefined;
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/undo.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type ChangeSpec,
3 | EditorSelection,
4 | StateEffect,
5 | StateField,
6 | } from "@codemirror/state";
7 | import { EditorView, type PluginValue, ViewUpdate } from "@codemirror/view";
8 | import {
9 | Cursor,
10 | LoroDoc,
11 | LoroText,
12 | type Subscription,
13 | UndoManager,
14 | } from "loro-crdt";
15 | import { loroSyncAnnotation } from "./sync.ts";
16 |
17 | export const undoEffect = StateEffect.define();
18 | export const redoEffect = StateEffect.define();
19 | export const undoManagerStateField = StateField.define(
20 | {
21 | create(state) {
22 | return undefined;
23 | },
24 |
25 | update(value, transaction) {
26 | for (const effect of transaction.effects) {
27 | if (effect.is(undoEffect)) {
28 | if (value && value.canUndo()) {
29 | value.undo();
30 | }
31 | } else if (effect.is(redoEffect)) {
32 | if (value && value.canRedo()) {
33 | value.redo();
34 | }
35 | }
36 | }
37 | return value;
38 | },
39 | }
40 | );
41 |
42 | export class UndoPluginValue implements PluginValue {
43 | sub?: Subscription;
44 | lastSelection: {
45 | anchor: Cursor | undefined;
46 | head: Cursor | undefined;
47 | } = {
48 | anchor: undefined,
49 | head: undefined,
50 | };
51 | constructor(
52 | public view: EditorView,
53 | public doc: LoroDoc,
54 | private undoManager: UndoManager,
55 | private getTextFromDoc: (doc: LoroDoc) => LoroText
56 | ) {
57 | this.sub = doc.subscribe((e) => {
58 | if (e.origin !== "undo") return;
59 |
60 | let changes: ChangeSpec[] = [];
61 | let pos = 0;
62 | for (let { diff, target } of e.events) {
63 | const text = this.getTextFromDoc(this.doc);
64 | // Skip if the event is not a text event
65 | if (diff.type !== "text") return;
66 | // Skip if the event is not for the current document
67 | if (target !== text.id) return;
68 | const textDiff = diff.diff;
69 | for (const delta of textDiff) {
70 | if (delta.insert) {
71 | changes.push({
72 | from: pos,
73 | to: pos,
74 | insert: delta.insert,
75 | });
76 | } else if (delta.delete) {
77 | changes.push({
78 | from: pos,
79 | to: pos + delta.delete,
80 | });
81 | pos += delta.delete;
82 | } else if (delta.retain != null) {
83 | pos += delta.retain;
84 | }
85 | }
86 | this.view.dispatch({
87 | changes,
88 | annotations: [loroSyncAnnotation.of("undo")],
89 | });
90 | }
91 | });
92 |
93 | this.undoManager.setOnPop((isUndo, value, counterRange) => {
94 | const anchor = value.cursors[0] ?? undefined;
95 | const head = value.cursors[1] ?? undefined;
96 | if (!anchor) return;
97 |
98 | setTimeout(() => {
99 | const anchorPos = this.doc!.getCursorPos(anchor).offset;
100 | const headPos = head
101 | ? this.doc!.getCursorPos(head).offset
102 | : anchorPos;
103 | const selection = EditorSelection.single(anchorPos, headPos);
104 | this.view.dispatch({
105 | selection,
106 | effects: [EditorView.scrollIntoView(selection.ranges[0])],
107 | });
108 | }, 0);
109 | });
110 |
111 | this.undoManager.setOnPush((isUndo, counterRange) => {
112 | const cursors = [];
113 | let selection = this.lastSelection;
114 | if (!isUndo) {
115 | const stateSelection = this.view.state.selection.main;
116 | selection.anchor = this.getTextFromDoc(this.doc).getCursor(
117 | stateSelection.anchor
118 | );
119 | selection.head = this.getTextFromDoc(this.doc).getCursor(
120 | stateSelection.head
121 | );
122 | }
123 | if (selection.anchor) {
124 | cursors.push(selection.anchor);
125 | }
126 | if (selection.head) {
127 | cursors.push(selection.head);
128 | }
129 | return {
130 | value: null,
131 | cursors,
132 | };
133 | });
134 | }
135 |
136 | update(update: ViewUpdate): void {
137 | if (update.selectionSet) {
138 | this.lastSelection = {
139 | anchor: this.getTextFromDoc(this.doc).getCursor(
140 | update.state.selection.main.anchor
141 | ),
142 | head: this.getTextFromDoc(this.doc).getCursor(
143 | update.state.selection.main.head
144 | ),
145 | };
146 | }
147 | }
148 |
149 | destroy(): void {
150 | this.sub?.();
151 | this.sub = undefined;
152 | }
153 | }
154 |
155 | export const undo = (view: EditorView): boolean => {
156 | view.dispatch({
157 | effects: [undoEffect.of(null)],
158 | });
159 | return true;
160 | };
161 |
162 | export const redo = (view: EditorView): boolean => {
163 | view.dispatch({
164 | effects: [redoEffect.of(null)],
165 | });
166 | return true;
167 | };
168 |
169 | export const undoKeyMap = [
170 | {
171 | key: "Mod-z",
172 | run: undo,
173 | preventDefault: true,
174 | },
175 | {
176 | key: "Mod-y",
177 | mac: "Mod-Shift-z",
178 | run: redo,
179 | preventDefault: true,
180 | },
181 | {
182 | key: "Mod-Shift-z",
183 | run: redo,
184 | preventDefault: true,
185 | },
186 | ];
187 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { LoroDoc, LoroText } from "loro-crdt";
2 |
3 | /**
4 | * Get the text from the document
5 | */
6 | export const defaultGetTextFromDoc = (doc: LoroDoc): LoroText => {
7 | return doc.getText("codemirror");
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2019",
4 | "module": "Node16",
5 | "lib": ["es2019", "dom"],
6 | "declaration": true,
7 | "outDir": "dist",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "moduleResolution": "node16",
12 | "verbatimModuleSyntax": true,
13 | "isolatedModules": true,
14 | "rewriteRelativeImportExtensions": true
15 | },
16 | "include": ["src/**/*"],
17 | "exclude": ["node_modules", "dist"]
18 | }
19 |
--------------------------------------------------------------------------------