├── .gitignore ├── test.html ├── test ├── index.js ├── test.node.js └── y-monaco.test.js ├── LICENSE ├── webpack.config.js ├── demo ├── index.html └── monaco-demo.js ├── rollup.config.js ├── package.json ├── README.md ├── tsconfig.json └── src └── y-monaco.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Testing y-monaco 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 2 | import * as monaco from './y-monaco.test.js' 3 | 4 | import { runTests } from 'lib0/testing' 5 | import { isBrowser, isNode } from 'lib0/environment' 6 | import * as log from 'lib0/logging' 7 | 8 | if (isBrowser) { 9 | log.createVConsole(document.body) 10 | } 11 | runTests({ 12 | monaco 13 | }).then(success => { 14 | /* istanbul ignore next */ 15 | if (isNode) { 16 | process.exit(success ? 0 : 1) 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /test/test.node.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { JSDOM } from 'jsdom' 4 | import { fileURLToPath } from 'url' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | const documentContent = fs.readFileSync(path.join(__dirname, '../test.html')) 8 | const { window } = new JSDOM(documentContent) 9 | 10 | window.matchMedia = () => ({ matches: false, addEventListener: () => {} }) 11 | 12 | // @ts-ignore 13 | global.self = global 14 | // @ts-ignore 15 | global.navigator = { userAgent: 'Node' } 16 | // @ts-ignore 17 | global.window = window 18 | // @ts-ignore 19 | global.document = window.document 20 | // @ts-ignore 21 | global.innerHeight = 0 22 | // @ts-ignore 23 | global.UIEvent = window.UIEvent 24 | // @ts-ignore 25 | document.getSelection = () => ({ }) 26 | // @ts-ignore 27 | document.queryCommandSupported = () => false 28 | 29 | import('../dist/test.js').then(() => { 30 | console.log('all done!') 31 | }) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Kevin Jahns . 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 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileURLToPath } from 'url' 3 | 4 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 5 | 6 | export default { 7 | mode: 'development', 8 | devtool: 'source-map', 9 | entry: { 10 | monaco: './demo/monaco-demo.js', 11 | 'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js', 12 | 'json.worker': 'monaco-editor/esm/vs/language/json/json.worker', 13 | 'css.worker': 'monaco-editor/esm/vs/language/css/css.worker', 14 | 'html.worker': 'monaco-editor/esm/vs/language/html/html.worker', 15 | 'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker' 16 | }, 17 | resolve: { 18 | alias: { 19 | 'y-monaco': path.resolve(__dirname, 'src/y-monaco.js') 20 | } 21 | }, 22 | devServer: { 23 | compress: true, 24 | static: { 25 | directory: path.join(__dirname, 'demo') 26 | } 27 | }, 28 | output: { 29 | globalObject: 'self', 30 | filename: '[name].bundle.js', 31 | path: path.resolve(__dirname, 'dist'), 32 | publicPath: '/dist/' 33 | }, 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.css$/, 38 | use: ['style-loader', 'css-loader'] 39 | }, 40 | { 41 | test: /\.ttf$/, 42 | use: ['file-loader'] 43 | } 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Yjs Monaco Example 6 | 32 | 33 | 34 | 35 |

36 |

This is a demo of the YjsMonaco binding: y-monaco.

37 |

The content of this editor is shared with every client that visits this domain.

38 |
39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve' 2 | // import commonjs from '@rollup/plugin-commonjs' 3 | import postcss from 'rollup-plugin-postcss' 4 | import fs from 'fs' 5 | import path from 'path' 6 | 7 | const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, './package.json'), { encoding: 'utf8' })) 8 | const dependencies = Object.keys(pkg.peerDependencies).concat(Object.keys(pkg.dependencies)) 9 | 10 | console.log(dependencies) 11 | 12 | const aliases = { 13 | resolveId (importee) { 14 | if (importee === 'yjs') { 15 | return `${process.cwd()}/node_modules/yjs/src/index.js` 16 | } 17 | return null 18 | } 19 | } 20 | 21 | export default [{ 22 | input: './src/y-monaco.js', 23 | output: [{ 24 | name: 'Y', 25 | file: 'dist/y-monaco.cjs', 26 | format: 'cjs', 27 | sourcemap: true 28 | }], 29 | external: id => dependencies.some(dep => dep === id) || /^lib0\//.test(id) 30 | }, { 31 | input: './test/index.js', 32 | output: { 33 | name: 'test', 34 | file: 'dist/test.js', 35 | format: 'esm', 36 | sourcemap: true, 37 | inlineDynamicImports: true 38 | }, 39 | external: id => /^(lib0|isomorphic\.js)\//.test(id) && id[0] !== '.' && id[0] !== '/', 40 | plugins: [ 41 | // debugResolve, 42 | aliases, 43 | nodeResolve(), 44 | postcss({ 45 | plugins: [], 46 | extract: true 47 | }) 48 | ] 49 | } /* { 50 | input: './test/index.js', 51 | output: { 52 | name: 'test', 53 | file: 'dist/test.js', 54 | format: 'esm', 55 | sourcemap: true, 56 | inlineDynamicImports: true 57 | }, 58 | plugins: [ 59 | // debugResolve, 60 | nodeResolve({ 61 | mainFields: ['module', 'browser', 'main'] 62 | }), 63 | commonjs(), 64 | postcss({ 65 | plugins: [], 66 | extract: true 67 | }) 68 | ] 69 | } */] 70 | -------------------------------------------------------------------------------- /demo/monaco-demo.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import * as Y from 'yjs' 4 | import { WebsocketProvider } from 'y-websocket' 5 | // @ts-ignore 6 | import { MonacoBinding } from 'y-monaco' 7 | import * as monaco from 'monaco-editor' 8 | 9 | self.MonacoEnvironment = { 10 | getWorkerUrl: function (moduleId, label) { 11 | if (label === 'json') { 12 | return '/dist/json.worker.bundle.js' 13 | } 14 | if (label === 'css' || label === 'scss' || label === 'less') { 15 | return '/dist/css.worker.bundle.js' 16 | } 17 | if (label === 'html' || label === 'handlebars' || label === 'razor') { 18 | return '/dist/html.worker.bundle.js' 19 | } 20 | if (label === 'typescript' || label === 'javascript') { 21 | return '/dist/ts.worker.bundle.js' 22 | } 23 | return '/dist/editor.worker.bundle.js' 24 | } 25 | } 26 | 27 | window.addEventListener('load', () => { 28 | const ydoc = new Y.Doc() 29 | const provider = new WebsocketProvider('wss://demos.yjs.dev', 'monaco', ydoc) 30 | const type = ydoc.getText('monaco') 31 | 32 | const editor = monaco.editor.create(/** @type {HTMLElement} */ (document.getElementById('monaco-editor')), { 33 | value: '', 34 | language: 'javascript', 35 | theme: 'vs-dark' 36 | }) 37 | const monacoBinding = new MonacoBinding(type, /** @type {monaco.editor.ITextModel} */ (editor.getModel()), new Set([editor]), provider.awareness) 38 | 39 | const connectBtn = /** @type {HTMLElement} */ (document.getElementById('y-connect-btn')) 40 | connectBtn.addEventListener('click', () => { 41 | if (provider.shouldConnect) { 42 | provider.disconnect() 43 | connectBtn.textContent = 'Connect' 44 | } else { 45 | provider.connect() 46 | connectBtn.textContent = 'Disconnect' 47 | } 48 | }) 49 | 50 | // @ts-ignore 51 | window.example = { provider, ydoc, type, monacoBinding } 52 | }) 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "y-monaco", 3 | "version": "0.1.6", 4 | "description": "Monaco editor bindings for Yjs", 5 | "main": "./src/y-monaco.js", 6 | "types": "./dist/src/y-monaco.d.ts", 7 | "type": "module", 8 | "sideEffects": false, 9 | "scripts": { 10 | "start": "npm run demo", 11 | "demo": "webpack serve", 12 | "dist": "npm run clean && rollup -c && tsc", 13 | "test": "rollup -c && node test/test.node.js", 14 | "lint": "standard", 15 | "watch": "rollup -wc", 16 | "debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'", 17 | "preversion": "npm run lint && npm run test && npm run dist", 18 | "clean": "rm -rf dist demo/dist" 19 | }, 20 | "files": [ 21 | "dist/*", 22 | "src/*" 23 | ], 24 | "exports": { 25 | ".": { 26 | "types": "./dist/src/y-monaco.d.ts", 27 | "import": "./src/y-monaco.js", 28 | "require": "./dist/y-monaco.cjs" 29 | }, 30 | "./package.json": "./package.json" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/y-js/y-monaco.git" 35 | }, 36 | "keywords": [ 37 | "Yjs", 38 | "Monaco" 39 | ], 40 | "author": "Kevin Jahns ", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/y-js/y-monaco/issues" 44 | }, 45 | "homepage": "https://github.com/y-js/y-monaco#readme", 46 | "dependencies": { 47 | "lib0": "^0.2.43" 48 | }, 49 | "peerDependencies": { 50 | "monaco-editor": ">=0.20.0", 51 | "yjs": "^13.3.1" 52 | }, 53 | "devDependencies": { 54 | "@rollup/plugin-commonjs": "^21.0.1", 55 | "@rollup/plugin-node-resolve": "^13.1.3", 56 | "@types/node": "^12.20.41", 57 | "concurrently": "^5.3.0", 58 | "css-loader": "^6.7.1", 59 | "file-loader": "^6.2.0", 60 | "jsdom": "^15.2.1", 61 | "live-server": "^1.2.1", 62 | "monaco-editor": "^0.50.0", 63 | "rollup": "^2.63.0", 64 | "rollup-plugin-postcss": "^4.0.2", 65 | "standard": "^14.3.4", 66 | "style-loader": "^1.3.0", 67 | "typescript": "^4.7.4", 68 | "webpack": "^5.74.0", 69 | "webpack-cli": "^4.10.0", 70 | "webpack-dev-server": "^4.9.3", 71 | "y-protocols": "^1.0.5", 72 | "y-websocket": "^1.3.18", 73 | "yjs": "^13.5.23" 74 | }, 75 | "engines": { 76 | "npm": ">=6.0.0", 77 | "node": ">=12.0.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # y-monaco 2 | > [Monaco](https://microsoft.github.io/monaco-editor/index.html) Editor Binding for [Yjs](https://github.com/y-js/yjs) - [Demo](https://demos.yjs.dev/monaco/monaco.html) 3 | 4 | This binding maps a Y.Text to the Monaco editor (the editor that power VS Code). 5 | 6 | ### Features 7 | 8 | * Shared Cursors 9 | 10 | ### Example 11 | 12 | ```js 13 | import * as Y from 'yjs' 14 | import { WebsocketProvider } from 'y-websocket' 15 | import { MonacoBinding } from 'y-monaco' 16 | import * as monaco from 'monaco-editor' 17 | 18 | const ydocument = new Y.Doc() 19 | const provider = new WebsocketProvider(`${location.protocol === 'http:' ? 'ws:' : 'wss:'}//localhost:1234`, 'monaco', ydocument) 20 | const type = ydocument.getText('monaco') 21 | 22 | const editor = monaco.editor.create(document.getElementById('monaco-editor'), { 23 | value: '', // MonacoBinding overwrites this value with the content of type 24 | language: "javascript" 25 | }) 26 | 27 | // Bind Yjs to the editor model 28 | const monacoBinding = new MonacoBinding(type, editor.getModel(), new Set([editor]), provider.awareness) 29 | ``` 30 | 31 | Also look [here](https://github.com/y-js/yjs-demos/tree/master/monaco) for a working example. 32 | 33 | ## API 34 | 35 | ```js 36 | import { MonacoBinding } from 'y-monaco' 37 | 38 | const binding = new MonacoBinding(type, editor.getModel(), new Set([editor]), provider.awareness) 39 | ``` 40 | 41 | ### Class:MonacoBinding 42 | 43 |
44 | constructor(Y.Text, monaco.editor.ITextModel, [Set<monaco.editor.IStandaloneCodeEditor>, [Awareness]]) 45 |
If the editor(s) are specified, MonacoBinding adjusts selections when remote changes happen. Awareness is an implementation of the awareness protocol of y-protocols/awareness. If Awareness is specified, MonacoBinding renders remote selections.
46 | destroy() 47 |
Unregister all event listeners. This is automatically called when the model is disposed.
48 |
49 | 50 | ## Styling 51 | 52 | You can use the following CSS classes to style remote cursor selections: 53 | 54 | - `yRemoteSelection` 55 | - `yRemoteSelectionHead` 56 | 57 | See [demo/index.html](demo/index.html) for example styles. Additionally, you can enable per-user styling (e.g.: different colors per user). The recommended approach for this is to listen to `awareness.on("update", () => ...));` and inject custom styles for every available clientId. You can use the following classnames for this: 58 | 59 | - `yRemoteSelection-${clientId}` 60 | - `yRemoteSelectionHead-${clientId` 61 | 62 | (where `${clientId}` is the Yjs clientId of the specific user). 63 | ### License 64 | 65 | [The MIT License](./LICENSE) © Kevin Jahns 66 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2018", 5 | "lib": ["es2018", "dom"], /* Specify library files to be included in the compilation. */ 6 | "skipLibCheck": true, 7 | "allowJs": true, /* Allow javascript files to be compiled. */ 8 | "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./dist/yjs.js", /* Concatenate and emit output to single file. */ 14 | "outDir": "./dist", /* Redirect output structure to the directory. */ 15 | "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true, /* Enable all strict type-checking options. */ 25 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | "emitDeclarationOnly": true, 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | "paths": { 43 | "yjs": ["./src/index.js"] 44 | }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | // "maxNodeModuleJsDepth": 0, 62 | // "types": ["./src/utils/typedefs.js"] 63 | }, 64 | "include": ["./src/**/*.js", "./tests/**/*.js"] 65 | } 66 | -------------------------------------------------------------------------------- /test/y-monaco.test.js: -------------------------------------------------------------------------------- 1 | 2 | import * as t from 'lib0/testing' 3 | import * as prng from 'lib0/prng' 4 | import * as math from 'lib0/math' 5 | import * as Y from 'yjs' 6 | import { applyRandomTests } from 'yjs/tests/testHelper.js' 7 | import { MonacoBinding } from '../src/y-monaco.js' 8 | import * as monaco from 'monaco-editor' 9 | 10 | /** 11 | * @typedef {Object} EditorSetup 12 | * @property {monaco.editor.ITextModel} model 13 | * @property {Y.Text} type 14 | * @property {MonacoBinding} binding 15 | */ 16 | 17 | /** 18 | * @param {Y.Doc} y 19 | * @return {EditorSetup} 20 | */ 21 | const createMonacoEditor = y => { 22 | const model = monaco.editor.createModel('') 23 | const type = y.getText('monaco') 24 | const binding = new MonacoBinding(type, model) 25 | return { 26 | model, 27 | binding, 28 | type 29 | } 30 | } 31 | 32 | const createTestSetup = () => { 33 | const doc = new Y.Doc() 34 | return { 35 | s1: createMonacoEditor(doc), 36 | s2: createMonacoEditor(doc) 37 | } 38 | } 39 | 40 | /** 41 | * @param {t.TestCase} tc 42 | */ 43 | export const testMonacoInsert = tc => { 44 | const { s1, s2 } = createTestSetup() 45 | const pos = s1.model.getPositionAt(0) 46 | const range = new monaco.Selection(pos.lineNumber, pos.column, pos.lineNumber, pos.column) 47 | s1.model.pushEditOperations([], [{ range, text: 'some content' }], () => null) 48 | t.assert(s1.model.getValue() === 'some content') 49 | t.assert(s1.type.toString() === 'some content') 50 | t.assert(s2.model.getValue() === 'some content') 51 | } 52 | 53 | /** 54 | * @param {t.TestCase} tc 55 | */ 56 | export const testMonacoConcurrentInsert = tc => { 57 | const { s1, s2 } = createTestSetup() 58 | const pos = s1.model.getPositionAt(0) 59 | const range = new monaco.Selection(pos.lineNumber, pos.column, pos.lineNumber, pos.column) 60 | s1.model.pushEditOperations([], [{ range, text: 'A' }], () => null) 61 | s2.model.pushEditOperations([], [{ range, text: 'B' }], () => null) 62 | t.assert(s1.model.getValue().length === 2) 63 | t.assert(s1.type.toString() === s1.model.getValue()) 64 | t.assert(s2.model.getValue() === s1.model.getValue()) 65 | } 66 | 67 | /** 68 | * @param {t.TestCase} tc 69 | */ 70 | export const testMonacoManyEditOps = tc => { 71 | const { s1, s2 } = createTestSetup() 72 | const pos = s1.model.getPositionAt(0) 73 | const range = new monaco.Selection(pos.lineNumber, pos.column, pos.lineNumber, pos.column) 74 | s1.model.pushEditOperations([], [{ range, text: 'A' }, { range, text: 'bc' }], () => null) 75 | s2.model.pushEditOperations([], [{ range, text: 'B' }], () => null) 76 | t.assert(s1.model.getValue().length === 4) 77 | t.assert(s1.type.toString() === s1.model.getValue()) 78 | t.assert(s2.model.getValue() === s1.model.getValue()) 79 | } 80 | 81 | /** 82 | * @param {monaco.editor.ITextModel} model 83 | * @param {number} from 84 | * @param {number} to 85 | */ 86 | const createRangeFromIndex = (model, from, to) => { 87 | const fromPos = model.getPositionAt(from) 88 | const toPos = model.getPositionAt(to) 89 | return new monaco.Selection(fromPos.lineNumber, fromPos.column, toPos.lineNumber, toPos.column) 90 | } 91 | 92 | /** 93 | * @param {t.TestCase} tc 94 | */ 95 | export const testMonacoManyEdits = tc => { 96 | const { s1, s2 } = createTestSetup() 97 | const range = createRangeFromIndex(s1.model, 0, 0) 98 | s1.model.pushEditOperations([], [{ range, text: 'A' }, { range, text: 'b' }], () => null) 99 | s2.model.pushEditOperations([], [{ range, text: 'B123456789' }], () => null) 100 | s2.model.pushEditOperations([], [{ range: createRangeFromIndex(s2.model, 1, 3), text: 'Z' }, { range: createRangeFromIndex(s2.model, 4, 5), text: 'K' }], () => null) 101 | t.assert(s1.type.toString() === s1.model.getValue()) 102 | t.assert(s2.model.getValue() === s1.model.getValue()) 103 | } 104 | 105 | /** 106 | * @param {t.TestCase} tc 107 | */ 108 | export const testMonacoManyYEdits = tc => { 109 | const { s1, s2 } = createTestSetup() 110 | s1.type.insert(0, 'abcde') 111 | s1.binding.doc.transact(() => { 112 | s1.type.insert(1, '1') 113 | s1.type.insert(3, '3') 114 | }) 115 | t.assert(s1.type.toString() === s1.model.getValue()) 116 | t.assert(s2.model.getValue() === s1.model.getValue()) 117 | t.assert(s1.model.getValue() === 'a1b3cde') 118 | } 119 | 120 | let charCounter = 0 121 | const monacoChanges = [ 122 | /** 123 | * @param {Y.Doc} y 124 | * @param {prng.PRNG} gen 125 | * @param {EditorSetup} setup 126 | */ 127 | (y, gen, setup) => { // insert text 128 | const val = setup.model.getValue() 129 | const insertPos = prng.int32(gen, 0, val.length) 130 | const text = charCounter++ + prng.word(gen) 131 | setup.model.pushEditOperations([], [{ range: createRangeFromIndex(setup.model, insertPos, insertPos), text }], () => null) 132 | }, 133 | /** 134 | * @param {Y.Doc} y 135 | * @param {prng.PRNG} gen 136 | * @param {EditorSetup} setup 137 | */ 138 | (y, gen, setup) => { // delete text 139 | const val = setup.model.getValue() 140 | const insertPos = prng.int32(gen, 0, val.length) 141 | const overwrite = math.min(prng.int32(gen, 0, val.length - insertPos), 2) 142 | setup.model.pushEditOperations([], [{ range: createRangeFromIndex(setup.model, insertPos, insertPos + overwrite), text: '' }], () => null) 143 | }, 144 | /** 145 | * @param {Y.Doc} y 146 | * @param {prng.PRNG} gen 147 | * @param {EditorSetup} setup 148 | */ 149 | (y, gen, setup) => { // replace text 150 | const val = setup.model.getValue() 151 | const insertPos = prng.int32(gen, 0, val.length) 152 | const overwrite = math.min(prng.int32(gen, 0, val.length - insertPos), 2) 153 | const text = charCounter++ + prng.word(gen) 154 | setup.model.pushEditOperations([], [{ range: createRangeFromIndex(setup.model, insertPos, insertPos + overwrite), text }], () => null) 155 | }, 156 | /** 157 | * @param {Y.Doc} y 158 | * @param {prng.PRNG} gen 159 | * @param {EditorSetup} setup 160 | */ 161 | (y, gen, setup) => { // insert newline 162 | const val = setup.model.getValue() 163 | const insertPos = prng.int32(gen, 0, val.length) 164 | const text = charCounter++ + '\n' 165 | setup.model.pushEditOperations([], [{ range: createRangeFromIndex(setup.model, insertPos, insertPos), text }], () => null) 166 | } 167 | ] 168 | 169 | /** 170 | * @param {any} result 171 | */ 172 | const checkResult = result => { 173 | for (let i = 1; i < result.testObjects.length; i++) { 174 | const setup1 = result.testObjects[i - 1] 175 | const setup2 = result.testObjects[i] 176 | t.assert(setup1.type.toString() === setup2.type.toString()) 177 | const v1 = setup1.model.getValue() 178 | const v2 = setup2.model.getValue() 179 | t.assert(v1 === v2) 180 | } 181 | } 182 | 183 | /** 184 | * @param {t.TestCase} tc 185 | */ 186 | export const testRepeatGenerateMonacoChanges3 = tc => { 187 | checkResult(applyRandomTests(tc, monacoChanges, 3, createMonacoEditor)) 188 | } 189 | 190 | /** 191 | * @param {t.TestCase} tc 192 | */ 193 | export const testRepeatGenerateMonacoChanges30 = tc => { 194 | checkResult(applyRandomTests(tc, monacoChanges, 30, createMonacoEditor)) 195 | } 196 | 197 | /** 198 | * @param {t.TestCase} tc 199 | */ 200 | export const testRepeatGenerateMonacoChanges40 = tc => { 201 | checkResult(applyRandomTests(tc, monacoChanges, 40, createMonacoEditor)) 202 | } 203 | 204 | /** 205 | * @param {t.TestCase} tc 206 | */ 207 | export const testRepeatGenerateMonacoChanges70 = tc => { 208 | checkResult(applyRandomTests(tc, monacoChanges, 70, createMonacoEditor)) 209 | } 210 | 211 | /** 212 | * @param {t.TestCase} tc 213 | */ 214 | export const testRepeatGenerateMonacoChanges100 = tc => { 215 | checkResult(applyRandomTests(tc, monacoChanges, 100, createMonacoEditor)) 216 | } 217 | 218 | /** 219 | * @param {t.TestCase} tc 220 | */ 221 | export const testRepeatGenerateMonacoChanges300 = tc => { 222 | checkResult(applyRandomTests(tc, monacoChanges, 300, createMonacoEditor)) 223 | } 224 | -------------------------------------------------------------------------------- /src/y-monaco.js: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs' 2 | import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' 3 | import * as error from 'lib0/error' 4 | import { createMutex } from 'lib0/mutex' 5 | import { Awareness } from 'y-protocols/awareness' // eslint-disable-line 6 | 7 | class RelativeSelection { 8 | /** 9 | * @param {Y.RelativePosition} start 10 | * @param {Y.RelativePosition} end 11 | * @param {monaco.SelectionDirection} direction 12 | */ 13 | constructor (start, end, direction) { 14 | this.start = start 15 | this.end = end 16 | this.direction = direction 17 | } 18 | } 19 | 20 | /** 21 | * @param {monaco.editor.IStandaloneCodeEditor} editor 22 | * @param {monaco.editor.ITextModel} monacoModel 23 | * @param {Y.Text} type 24 | */ 25 | const createRelativeSelection = (editor, monacoModel, type) => { 26 | const sel = editor.getSelection() 27 | if (sel !== null) { 28 | const startPos = sel.getStartPosition() 29 | const endPos = sel.getEndPosition() 30 | const start = Y.createRelativePositionFromTypeIndex(type, monacoModel.getOffsetAt(startPos)) 31 | const end = Y.createRelativePositionFromTypeIndex(type, monacoModel.getOffsetAt(endPos)) 32 | return new RelativeSelection(start, end, sel.getDirection()) 33 | } 34 | return null 35 | } 36 | 37 | /** 38 | * @param {monaco.editor.IEditor} editor 39 | * @param {Y.Text} type 40 | * @param {RelativeSelection} relSel 41 | * @param {Y.Doc} doc 42 | * @return {null|monaco.Selection} 43 | */ 44 | const createMonacoSelectionFromRelativeSelection = (editor, type, relSel, doc) => { 45 | const start = Y.createAbsolutePositionFromRelativePosition(relSel.start, doc) 46 | const end = Y.createAbsolutePositionFromRelativePosition(relSel.end, doc) 47 | if (start !== null && end !== null && start.type === type && end.type === type) { 48 | const model = /** @type {monaco.editor.ITextModel} */ (editor.getModel()) 49 | const startPos = model.getPositionAt(start.index) 50 | const endPos = model.getPositionAt(end.index) 51 | return monaco.Selection.createWithDirection(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column, relSel.direction) 52 | } 53 | return null 54 | } 55 | 56 | export class MonacoBinding { 57 | /** 58 | * @param {Y.Text} ytext 59 | * @param {monaco.editor.ITextModel} monacoModel 60 | * @param {Set} [editors] 61 | * @param {Awareness?} [awareness] 62 | */ 63 | constructor (ytext, monacoModel, editors = new Set(), awareness = null) { 64 | this.doc = /** @type {Y.Doc} */ (ytext.doc) 65 | this.ytext = ytext 66 | this.monacoModel = monacoModel 67 | this.editors = editors 68 | this.mux = createMutex() 69 | /** 70 | * @type {Map} 71 | */ 72 | this._savedSelections = new Map() 73 | this._beforeTransaction = () => { 74 | this.mux(() => { 75 | this._savedSelections = new Map() 76 | editors.forEach(editor => { 77 | if (editor.getModel() === monacoModel) { 78 | const rsel = createRelativeSelection(editor, monacoModel, ytext) 79 | if (rsel !== null) { 80 | this._savedSelections.set(editor, rsel) 81 | } 82 | } 83 | }) 84 | }) 85 | } 86 | this.doc.on('beforeAllTransactions', this._beforeTransaction) 87 | this._decorations = new Map() 88 | this._rerenderDecorations = () => { 89 | editors.forEach(editor => { 90 | if (awareness && editor.getModel() === monacoModel) { 91 | // render decorations 92 | const currentDecorations = this._decorations.get(editor) || [] 93 | /** 94 | * @type {Array} 95 | */ 96 | const newDecorations = [] 97 | awareness.getStates().forEach((state, clientID) => { 98 | if (clientID !== this.doc.clientID && state.selection != null && state.selection.anchor != null && state.selection.head != null) { 99 | const anchorAbs = Y.createAbsolutePositionFromRelativePosition(state.selection.anchor, this.doc) 100 | const headAbs = Y.createAbsolutePositionFromRelativePosition(state.selection.head, this.doc) 101 | if (anchorAbs !== null && headAbs !== null && anchorAbs.type === ytext && headAbs.type === ytext) { 102 | let start, end, afterContentClassName, beforeContentClassName 103 | if (anchorAbs.index < headAbs.index) { 104 | start = monacoModel.getPositionAt(anchorAbs.index) 105 | end = monacoModel.getPositionAt(headAbs.index) 106 | afterContentClassName = 'yRemoteSelectionHead yRemoteSelectionHead-' + clientID 107 | beforeContentClassName = null 108 | } else { 109 | start = monacoModel.getPositionAt(headAbs.index) 110 | end = monacoModel.getPositionAt(anchorAbs.index) 111 | afterContentClassName = null 112 | beforeContentClassName = 'yRemoteSelectionHead yRemoteSelectionHead-' + clientID 113 | } 114 | newDecorations.push({ 115 | range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), 116 | options: { 117 | className: 'yRemoteSelection yRemoteSelection-' + clientID, 118 | afterContentClassName, 119 | beforeContentClassName 120 | } 121 | }) 122 | } 123 | } 124 | }) 125 | this._decorations.set(editor, editor.deltaDecorations(currentDecorations, newDecorations)) 126 | } else { 127 | // ignore decorations 128 | this._decorations.delete(editor) 129 | } 130 | }) 131 | } 132 | /** 133 | * @param {Y.YTextEvent} event 134 | */ 135 | this._ytextObserver = event => { 136 | this.mux(() => { 137 | let index = 0 138 | event.delta.forEach(op => { 139 | if (op.retain !== undefined) { 140 | index += op.retain 141 | } else if (op.insert !== undefined) { 142 | const pos = monacoModel.getPositionAt(index) 143 | const range = new monaco.Selection(pos.lineNumber, pos.column, pos.lineNumber, pos.column) 144 | const insert = /** @type {string} */ (op.insert) 145 | monacoModel.applyEdits([{ range, text: insert }]) 146 | index += insert.length 147 | } else if (op.delete !== undefined) { 148 | const pos = monacoModel.getPositionAt(index) 149 | const endPos = monacoModel.getPositionAt(index + op.delete) 150 | const range = new monaco.Selection(pos.lineNumber, pos.column, endPos.lineNumber, endPos.column) 151 | monacoModel.applyEdits([{ range, text: '' }]) 152 | } else { 153 | throw error.unexpectedCase() 154 | } 155 | }) 156 | this._savedSelections.forEach((rsel, editor) => { 157 | const sel = createMonacoSelectionFromRelativeSelection(editor, ytext, rsel, this.doc) 158 | if (sel !== null) { 159 | editor.setSelection(sel) 160 | } 161 | }) 162 | }) 163 | this._rerenderDecorations() 164 | } 165 | ytext.observe(this._ytextObserver) 166 | { 167 | const ytextValue = ytext.toString() 168 | if (monacoModel.getValue() !== ytextValue) { 169 | monacoModel.setValue(ytextValue) 170 | } 171 | } 172 | this._monacoChangeHandler = monacoModel.onDidChangeContent(event => { 173 | // apply changes from right to left 174 | this.mux(() => { 175 | this.doc.transact(() => { 176 | event.changes.sort((change1, change2) => change2.rangeOffset - change1.rangeOffset).forEach(change => { 177 | ytext.delete(change.rangeOffset, change.rangeLength) 178 | ytext.insert(change.rangeOffset, change.text) 179 | }) 180 | }, this) 181 | }) 182 | }) 183 | this._monacoDisposeHandler = monacoModel.onWillDispose(() => { 184 | this.destroy() 185 | }) 186 | if (awareness) { 187 | editors.forEach(editor => { 188 | editor.onDidChangeCursorSelection(() => { 189 | if (editor.getModel() === monacoModel) { 190 | const sel = editor.getSelection() 191 | if (sel === null) { 192 | return 193 | } 194 | let anchor = monacoModel.getOffsetAt(sel.getStartPosition()) 195 | let head = monacoModel.getOffsetAt(sel.getEndPosition()) 196 | if (sel.getDirection() === monaco.SelectionDirection.RTL) { 197 | const tmp = anchor 198 | anchor = head 199 | head = tmp 200 | } 201 | awareness.setLocalStateField('selection', { 202 | anchor: Y.createRelativePositionFromTypeIndex(ytext, anchor), 203 | head: Y.createRelativePositionFromTypeIndex(ytext, head) 204 | }) 205 | } 206 | }) 207 | awareness.on('change', this._rerenderDecorations) 208 | }) 209 | this.awareness = awareness 210 | } 211 | } 212 | 213 | destroy () { 214 | this._monacoChangeHandler.dispose() 215 | this._monacoDisposeHandler.dispose() 216 | this.ytext.unobserve(this._ytextObserver) 217 | this.doc.off('beforeAllTransactions', this._beforeTransaction) 218 | if (this.awareness) { 219 | this.awareness.off('change', this._rerenderDecorations) 220 | } 221 | } 222 | } 223 | --------------------------------------------------------------------------------