├── .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 |
115 |

Loro CodeMirror Plugin

116 |
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 | --------------------------------------------------------------------------------