├── .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 | Disconnect
35 |
36 | This is a demo of the Yjs ⇔ Monaco 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 |
--------------------------------------------------------------------------------