├── .gitignore ├── test.html ├── tests ├── index.js ├── test.node.js └── y-codemirror.test.js ├── tsconfig.json ├── LICENSE ├── demo ├── codemirror.js └── index.html ├── rollup.config.js ├── package.json ├── README.md └── src └── y-codemirror.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Testing y-codemirror 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | 2 | import * as codemirror from './y-codemirror.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 | codemirror 13 | }).then(success => { 14 | /* istanbul ignore next */ 15 | if (isNode) { 16 | process.exit(success ? 0 : 1) 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /tests/test.node.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | import fs from 'fs' 4 | import jsdom from 'jsdom' 5 | 6 | const documentContent = fs.readFileSync('test.html') 7 | const { window } = new jsdom.JSDOM(documentContent) 8 | 9 | global.window = window 10 | global.document = window.document 11 | global.innerHeight = 0 12 | global.navigator = {} 13 | document.getSelection = () => ({ }) 14 | 15 | document.createRange = () => ({ 16 | setStart () {}, 17 | setEnd () {}, 18 | getClientRects () { 19 | return { 20 | left: 0, 21 | top: 0, 22 | right: 0, 23 | bottom: 0 24 | } 25 | }, 26 | getBoundingClientRect () { 27 | return { 28 | left: 0, 29 | top: 0, 30 | right: 0, 31 | bottom: 0 32 | } 33 | } 34 | }) 35 | 36 | import('./index.js').then(() => { 37 | console.log('') 38 | }) 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["es2018", "dom"], /* Specify library files to be included in the compilation. */ 5 | "allowJs": true, /* Allow javascript files to be compiled. */ 6 | "checkJs": true, /* Report errors in .js files. */ 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "rootDir": "./", 10 | "emitDeclarationOnly": true, 11 | "strict": false, 12 | "noImplicitAny": false, 13 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 14 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 15 | "paths": { 16 | "y-codemirror": ["./src/y-codemirror.js"] 17 | }, 18 | "maxNodeModuleJsDepth": 0, 19 | "allowSyntheticDefaultImports": true 20 | }, 21 | "include": ["./src/y-codemirror.js", "./demo/codemirror.js"] 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/codemirror.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import * as Y from 'yjs' 4 | import { CodemirrorBinding } from 'y-codemirror' 5 | import { WebrtcProvider } from 'y-webrtc' 6 | import CodeMirror from 'codemirror' 7 | import 'codemirror/mode/javascript/javascript.js' 8 | import 'codemirror/lib/codemirror.css' 9 | 10 | window.addEventListener('load', () => { 11 | const ydoc = new Y.Doc() 12 | const provider = new WebrtcProvider('codemirror-demo-room-x', ydoc) 13 | const yText = ydoc.getText('codemirror') 14 | const yUndoManager = new Y.UndoManager(yText, { 15 | // Add all origins that you want to track. The editor binding adds itself automatically. 16 | trackedOrigins: new Set([]) 17 | }) 18 | 19 | const editorContainer = document.createElement('div') 20 | editorContainer.setAttribute('id', 'editor') 21 | document.body.insertBefore(editorContainer, null) 22 | const editor = CodeMirror(editorContainer, { 23 | mode: 'javascript', 24 | lineNumbers: true 25 | }) 26 | 27 | const binding = new CodemirrorBinding(yText, editor, provider.awareness, { 28 | yUndoManager 29 | }) 30 | 31 | const connectBtn = 32 | /** @type {HTMLElement} */ (document.getElementById('y-connect-btn')) 33 | connectBtn.addEventListener('click', () => { 34 | if (provider.shouldConnect) { 35 | provider.disconnect() 36 | connectBtn.textContent = 'Connect' 37 | } else { 38 | provider.connect() 39 | connectBtn.textContent = 'Disconnect' 40 | } 41 | }) 42 | 43 | // @ts-ignore 44 | window.example = { provider, ydoc, yText, binding, yUndoManager } 45 | }) 46 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Yjs CodeMirror Example 6 | 7 | 42 | 43 | 44 | 45 |

46 |

47 | This is a demo of the Yjs ⇔ 48 | CodeMirror binding: 49 | y-codemirror. 50 |

51 |

52 | The content of this editor is shared with every client that visits this 53 | domain. 54 |

55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import cssbundle from 'rollup-plugin-css-bundle' 4 | 5 | const debugResolve = { 6 | resolveId (importee) { 7 | if (importee === 'y-codemirror') { 8 | return `${process.cwd()}/src/y-codemirror.js` 9 | } 10 | if (importee === 'yjs') { 11 | return `${process.cwd()}/node_modules/yjs/tests/testHelper.js` 12 | } 13 | if (importee === 'yjs/tests/testHelper.js') { 14 | return `${process.cwd()}/node_modules/yjs/tests/testHelper.js` 15 | } 16 | return null 17 | } 18 | } 19 | 20 | export default [{ 21 | input: './src/y-codemirror.js', 22 | external: (id) => /^(lib0|yjs|y-protocols|simple-peer)/.test(id), 23 | output: [{ 24 | name: 'y-codemirror', 25 | file: 'dist/y-codemirror.cjs', 26 | format: 'cjs', 27 | sourcemap: true 28 | }] 29 | }, { 30 | input: './demo/codemirror.js', 31 | output: { 32 | name: 'test', 33 | file: 'dist/demo.js', 34 | format: 'iife', 35 | sourcemap: true 36 | }, 37 | plugins: [ 38 | debugResolve, 39 | cssbundle(), 40 | nodeResolve({ 41 | mainFields: ['module', 'browser', 'main'] 42 | }), 43 | commonjs() 44 | ] 45 | }, { 46 | input: './tests/index.js', 47 | output: { 48 | name: 'test', 49 | file: 'dist/test.js', 50 | format: 'iife', 51 | sourcemap: true 52 | }, 53 | plugins: [ 54 | debugResolve, 55 | nodeResolve({ 56 | mainFields: ['module', 'browser', 'main'] 57 | }), 58 | commonjs() 59 | ] 60 | }, { 61 | input: './tests/test.node.js', 62 | output: { 63 | name: 'test', 64 | dir: 'dist', 65 | format: 'es', 66 | sourcemap: true 67 | }, 68 | external: (id) => 69 | /^(lib0|fs|codemirror|jsdom)/.test(id), 70 | plugins: [ 71 | debugResolve, 72 | nodeResolve({ 73 | mainFields: ['module', 'main'] 74 | }), 75 | commonjs() 76 | ] 77 | }] 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "y-codemirror", 3 | "version": "3.0.1", 4 | "type": "module", 5 | "description": "CodeMirror binding for Yjs", 6 | "main": "./dist/y-codemirror.cjs", 7 | "types": "./dist/src/y-codemirror.d.ts", 8 | "module": "./src/y-codemirror.js", 9 | "sideEffects": false, 10 | "funding": { 11 | "type": "GitHub Sponsors ❤", 12 | "url": "https://github.com/sponsors/dmonad" 13 | }, 14 | "exports": { 15 | ".": { 16 | "import": "./src/y-codemirror.js", 17 | "require": "./dist/y-codemirror.cjs" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "files": [ 22 | "dist/*", 23 | "src/*" 24 | ], 25 | "scripts": { 26 | "clean": "rm -rf dist", 27 | "dist": "npm run clean && rollup -c && tsc", 28 | "watch": "rollup -wc", 29 | "test": "npm run dist && node dist/test.node.js", 30 | "test-extensive": "npm run dist && node dist/test.node.js --production --repetition-time 10000", 31 | "lint": "standard && tsc", 32 | "preversion": "npm run lint && npm run test-extensive && npm run dist", 33 | "debug": "concurrently 'http-server -c-1 -o test.html' 'npm run watch'", 34 | "start": "concurrently 'http-server -c-1 -o demo/index.html' 'npm run watch'" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/yjs/y-codemirror.git" 39 | }, 40 | "keywords": [ 41 | "Yjs" 42 | ], 43 | "author": "Kevin Jahns ", 44 | "license": "MIT", 45 | "bugs": { 46 | "url": "https://github.com/yjs/y-codemirror/issues" 47 | }, 48 | "homepage": "https://github.com/yjs/y-codemirror#readme", 49 | "peerDependencies": { 50 | "codemirror": "^5.52.2", 51 | "yjs": "^13.5.17" 52 | }, 53 | "dependencies": { 54 | "lib0": "^0.2.43" 55 | }, 56 | "devDependencies": { 57 | "@rollup/plugin-commonjs": "^17.1.0", 58 | "@rollup/plugin-node-resolve": "^11.2.1", 59 | "@types/codemirror": "0.0.91", 60 | "codemirror": "^5.64.0", 61 | "concurrently": "^5.3.0", 62 | "http-server": "^0.12.3", 63 | "jsdom": "^16.7.0", 64 | "rollup": "^2.60.1", 65 | "rollup-plugin-css-bundle": "^1.0.4", 66 | "standard": "^14.3.4", 67 | "typescript": "^4.5.2", 68 | "y-webrtc": "^10.2.2", 69 | "yjs": "^13.5.17" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # y-codemirror 2 | 3 | > [CodeMirror](https://codemirror.net/5/) Binding for [Yjs](https://github.com/yjs/yjs) - [Demo](https://demos.yjs.dev/codemirror/codemirror.html) 4 | 5 | This binding binds a [Y.Text](https://github.com/yjs/yjs#Shared-Types) to a CodeMirror editor. 6 | 7 | > For [CodeMirror 6](https://codemirror.net)+, go to [yjs/y-codemirror.next](https://github.com/yjs/y-codemirror.next) 8 | 9 | ## Features 10 | 11 | * Sync CodeMirror editor 12 | * Shared Cursors 13 | * Shared Undo / Redo (each client has its own undo-/redo-history) 14 | * Successfully recovers when concurrents edit result in an invalid document schema 15 | 16 | ![CodeMirror Yjs Demo](https://user-images.githubusercontent.com/5553757/79250004-5ed1ac80-7e7e-11ea-81b8-9f833e2d8e66.gif) 17 | 18 | ### Example 19 | 20 | ```js 21 | import * as Y from 'yjs' 22 | import { CodemirrorBinding } from 'y-codemirror' 23 | import { WebrtcProvider } from 'y-webrtc' 24 | import CodeMirror from 'codemirror' 25 | 26 | const ydoc = new Y.Doc() 27 | const provider = new WebrtcProvider('codemirror-demo-room', ydoc) 28 | const yText = ydoc.getText('codemirror') 29 | const yUndoManager = new Y.UndoManager(yText) 30 | 31 | const editor = CodeMirror(editorDiv, { 32 | mode: 'javascript', 33 | lineNumbers: true 34 | }) 35 | 36 | const binding = new CodemirrorBinding(yText, editor, provider.awareness, { yUndoManager }) 37 | ``` 38 | 39 | Also look [here](https://github.com/yjs/yjs-demos/tree/master/codemirror) for a working example. 40 | 41 | ## API 42 | 43 | ```js 44 | const binding = new CodemirrorBinding(yText: Y.Text, editor: CodeMirror.Editor, [ awareness: y-protocols.Awareness|null, [ { yUndoManager: Y.UndoManager } ]]) 45 | ``` 46 | 47 | Binds a Y.Text type to the CodeMirror document that is currently in use. You can swapDoc the CodeMirror document while a binding is active. Make sure to destroy a binding when it is no longer needed. 48 | 49 | When `Y.UndoManager` is defined, y-codemirror will use a custom collaborative undo manager instead of CodeMirror's UndoManager. The collaboration-aware Y.UndoManager tracks only local changes by default and doesn't track changes from remote users. You should undo/redo changes using `yUndoManager.undo()` / `yUndoManager.redo()` instead of using CodeMirror's history manager. See the extensive documentation on [`Y.UndoManager`](https://docs.yjs.dev/api/undo-manager) for documentation on how to filter specific changes. 50 | 51 |
52 | destroy() 53 |
54 | Destroy the CodemirrorBinding, remove all event listeners from the editor and the Yjs document, and destroy the UndoManager. 55 |
56 | cm: CodeMirror.Editor 57 |
58 | Reference to the CodeMirror editor. 59 |
60 | cmDoc: CodeMirror.Doc 61 |
62 | Reference to the CodeMirror document. 63 |
64 | type: Y.Text 65 |
66 | Reference to the Y.Text type that this binding binds to. 67 |
68 | doc: Y.Doc 69 |
70 | Reference to the Yjs document. 71 |
72 | awareness: y-protocols.Awareness 73 |
74 | Reference to the Awareness instance, if defined. 75 |
76 | on('cursorActivity', (editor: CodeMirror) => void) 77 |
78 | This event is similar to CodeMirror's 'cursorActivity' event, but is fired 79 | after all changes have been applied to the editor and to the Y.Text instance. 80 |
81 |
82 | 83 | The shared cursors depend on the Awareness instance that is exported by most providers. The Awareness protocol handles non-permanent data like the number of users, their user names, their cursor location, and their colors. You can change the name and color of the user like this: 84 | 85 | ```js 86 | example.binding.awareness.setLocalStateField('user', { color: '#008833', name: 'My real name' }) 87 | ``` 88 | 89 | In order to render cursor information you need to embed custom CSS for the user icon. This is a template that you can use for styling cursor information. 90 | 91 | ```css 92 | .remote-caret { 93 | position: absolute; 94 | border-left: black; 95 | border-left-style: solid; 96 | border-left-width: 2px; 97 | height: 1em; 98 | } 99 | .remote-caret > div { 100 | position: relative; 101 | top: -1.05em; 102 | font-size: 13px; 103 | background-color: rgb(250, 129, 0); 104 | font-family: serif; 105 | font-style: normal; 106 | font-weight: normal; 107 | line-height: normal; 108 | user-select: none; 109 | color: white; 110 | padding-left: 2px; 111 | padding-right: 2px; 112 | z-index: 3; 113 | } 114 | ``` 115 | 116 | ## License 117 | 118 | [The MIT License](./LICENSE) © Kevin Jahns 119 | -------------------------------------------------------------------------------- /tests/y-codemirror.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' // eslint-disable-line 6 | import { applyRandomTests } from 'yjs/tests/testHelper.js' 7 | 8 | import CodeMirror from 'codemirror' 9 | import { CodemirrorBinding } from '../src/y-codemirror.js' 10 | 11 | /** 12 | * @param {t.TestCase} tc 13 | */ 14 | export const testUndoManager = tc => { 15 | const editor = CodeMirror(document.createElement('div'), { 16 | mode: 'javascript', 17 | lineNumbers: true 18 | }) 19 | const ydoc = new Y.Doc() 20 | const ytext = ydoc.getText() 21 | ytext.insert(0, 'abc') 22 | const yUndoManager = new Y.UndoManager(ytext) 23 | const binding = new CodemirrorBinding(ytext, editor, null, { yUndoManager }) 24 | editor.setSelection(editor.posFromIndex(1), editor.posFromIndex(2)) 25 | editor.replaceSelection('') 26 | const posAfterAnchor = editor.indexFromPos(editor.getCursor('anchor')) 27 | const posAfterHead = editor.indexFromPos(editor.getCursor('head')) 28 | yUndoManager.undo() 29 | const posBeforeAnchor = editor.indexFromPos(editor.getCursor('anchor')) 30 | const posBeforeHead = editor.indexFromPos(editor.getCursor('head')) 31 | t.assert(posBeforeAnchor === 1 && posBeforeHead === 2) 32 | yUndoManager.redo() 33 | t.assert( 34 | editor.indexFromPos(editor.getCursor('anchor')) === posAfterAnchor && 35 | editor.indexFromPos(editor.getCursor('head')) === posAfterHead 36 | ) 37 | yUndoManager.undo() 38 | t.assert( 39 | editor.indexFromPos(editor.getCursor('anchor')) === posBeforeAnchor && 40 | editor.indexFromPos(editor.getCursor('head')) === posBeforeHead 41 | ) 42 | // destroy binding and check that undo still works 43 | binding.destroy() 44 | yUndoManager.redo() 45 | t.assert(ytext.toString() === 'ac') 46 | yUndoManager.undo() 47 | t.assert(ytext.toString() === 'abc') 48 | } 49 | 50 | /** 51 | * @param {any} y 52 | * @return {CodeMirror.Editor} 53 | */ 54 | const createNewCodemirror = y => { 55 | const editor = CodeMirror(document.createElement('div'), { 56 | mode: 'javascript', 57 | lineNumbers: true 58 | }) 59 | const binding = new CodemirrorBinding(y.getText('codemirror'), editor) 60 | return binding.cm 61 | } 62 | 63 | let charCounter = 0 64 | 65 | const cmChanges = [ 66 | /** 67 | * @param {Y.Doc} y 68 | * @param {prng.PRNG} gen 69 | * @param {CodeMirror.Editor} cm 70 | */ 71 | (y, gen, cm) => { // insert text 72 | const insertPos = prng.int32(gen, 0, cm.getValue().length) 73 | const text = charCounter++ + prng.utf16String(gen, 6) 74 | const pos = cm.posFromIndex(insertPos) 75 | cm.replaceRange(text, pos, pos) 76 | }, 77 | /** 78 | * @param {Y.Doc} y 79 | * @param {prng.PRNG} gen 80 | * @param {CodeMirror.Editor} cm 81 | */ 82 | (y, gen, cm) => { // delete text 83 | const insertPos = prng.int32(gen, 0, cm.getValue().length) 84 | const overwrite = prng.int32(gen, 0, cm.getValue().length - insertPos) 85 | cm.replaceRange('', cm.posFromIndex(insertPos), cm.posFromIndex(insertPos + overwrite)) 86 | }, 87 | /** 88 | * @param {Y.Doc} y 89 | * @param {prng.PRNG} gen 90 | * @param {CodeMirror.Editor} cm 91 | */ 92 | (y, gen, cm) => { // replace text 93 | const insertPos = prng.int32(gen, 0, cm.getValue().length) 94 | const overwrite = math.min(prng.int32(gen, 0, cm.getValue().length - insertPos), 3) 95 | const text = charCounter++ + prng.word(gen) 96 | cm.replaceRange(text, cm.posFromIndex(insertPos), cm.posFromIndex(insertPos + overwrite)) 97 | }, 98 | /** 99 | * @param {Y.Doc} y 100 | * @param {prng.PRNG} gen 101 | * @param {CodeMirror.Editor} cm 102 | */ 103 | (y, gen, cm) => { // insert paragraph 104 | const insertPos = prng.int32(gen, 0, cm.getValue().length) 105 | const overwrite = math.min(prng.int32(gen, 0, cm.getValue().length - insertPos), 3) 106 | const text = '\n' 107 | cm.replaceRange(text, cm.posFromIndex(insertPos), cm.posFromIndex(insertPos + overwrite)) 108 | } 109 | ] 110 | 111 | /** 112 | * @param {any} result 113 | */ 114 | const checkResult = result => { 115 | for (let i = 1; i < result.testObjects.length; i++) { 116 | const p1 = result.testObjects[i - 1].getValue() 117 | const p2 = result.testObjects[i].getValue() 118 | t.compare(p1, p2) 119 | } 120 | // console.log(result.testObjects[0].getValue()) 121 | charCounter = 0 122 | } 123 | 124 | /** 125 | * @param {t.TestCase} tc 126 | */ 127 | export const testRepeatGenerateProsemirrorChanges2 = tc => { 128 | checkResult(applyRandomTests(tc, cmChanges, 2, createNewCodemirror)) 129 | } 130 | 131 | /** 132 | * @param {t.TestCase} tc 133 | */ 134 | export const testRepeatGenerateProsemirrorChanges3 = tc => { 135 | checkResult(applyRandomTests(tc, cmChanges, 3, createNewCodemirror)) 136 | } 137 | 138 | /** 139 | * @param {t.TestCase} tc 140 | */ 141 | export const testRepeatGenerateProsemirrorChanges30 = tc => { 142 | checkResult(applyRandomTests(tc, cmChanges, 30, createNewCodemirror)) 143 | } 144 | 145 | /** 146 | * @param {t.TestCase} tc 147 | */ 148 | export const testRepeatGenerateProsemirrorChanges40 = tc => { 149 | checkResult(applyRandomTests(tc, cmChanges, 40, createNewCodemirror)) 150 | } 151 | 152 | /** 153 | * @param {t.TestCase} tc 154 | */ 155 | export const testRepeatGenerateProsemirrorChanges70 = tc => { 156 | checkResult(applyRandomTests(tc, cmChanges, 70, createNewCodemirror)) 157 | } 158 | 159 | /** 160 | * @param {t.TestCase} tc 161 | */ 162 | export const testRepeatGenerateProsemirrorChanges100 = tc => { 163 | checkResult(applyRandomTests(tc, cmChanges, 100, createNewCodemirror)) 164 | } 165 | 166 | /** 167 | * @param {t.TestCase} tc 168 | */ 169 | export const testRepeatGenerateProsemirrorChanges300 = tc => { 170 | checkResult(applyRandomTests(tc, cmChanges, 300, createNewCodemirror)) 171 | } 172 | -------------------------------------------------------------------------------- /src/y-codemirror.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module bindings/textarea 3 | */ 4 | 5 | import { createMutex } from 'lib0/mutex' 6 | import * as math from 'lib0/math' 7 | import * as Y from 'yjs' 8 | import * as func from 'lib0/function' 9 | import * as eventloop from 'lib0/eventloop' 10 | import { Observable } from 'lib0/observable' 11 | import * as diff from 'lib0/diff' 12 | import CodeMirror from 'codemirror' 13 | 14 | export const cmOrigin = 'y-codemirror' 15 | 16 | /** 17 | * @param {CodemirrorBinding} binding 18 | * @param {any} event 19 | */ 20 | const typeObserver = (binding, event) => { 21 | binding._mux(() => { 22 | const cmDoc = binding.cmDoc 23 | const cm = cmDoc.getEditor() 24 | // Normally the position is right-associated 25 | // But when remote changes happen, it looks like the remote user is hijacking your position. 26 | // Just for remote insertions, we make the collapsed cursor left-associated. 27 | // If selection is not collapsed, we only make "to" left associated 28 | let anchor = cm.indexFromPos(cm.getCursor('anchor')) 29 | let head = cm.indexFromPos(cm.getCursor('head')) 30 | const switchSel = head < anchor 31 | // normalize selection so that anchor < head, switch back later 32 | if (switchSel) { 33 | const tmp = head 34 | head = anchor 35 | anchor = tmp 36 | } 37 | const performChange = () => { 38 | const delta = event.delta 39 | let index = 0 40 | for (let i = 0; i < event.delta.length; i++) { 41 | const d = delta[i] 42 | if (d.retain) { 43 | index += d.retain 44 | } else if (d.insert) { 45 | if (index < anchor || (anchor < head && index === anchor)) { 46 | anchor += d.insert.length 47 | } 48 | if (index < head) { 49 | head += d.insert.length 50 | } 51 | const pos = cmDoc.posFromIndex(index) 52 | cmDoc.replaceRange(d.insert, pos, pos, cmOrigin) 53 | index += d.insert.length 54 | } else if (d.delete) { 55 | if (index < anchor) { 56 | anchor = math.max(anchor - d.delete, index) 57 | } 58 | if (index < head) { 59 | head = math.max(head - d.delete, index) 60 | } 61 | const start = cmDoc.posFromIndex(index) 62 | const end = cmDoc.posFromIndex(index + d.delete) 63 | cmDoc.replaceRange('', start, end, cmOrigin) 64 | } 65 | } 66 | } 67 | // if possible, bundle the changes using cm.operation 68 | if (cm) { 69 | cm.operation(performChange) 70 | } else { 71 | performChange() 72 | } 73 | if (switchSel) { 74 | const tmp = head 75 | head = anchor 76 | anchor = tmp 77 | } 78 | cm.setSelection(cm.posFromIndex(anchor), cm.posFromIndex(head), { 79 | scroll: false 80 | }) 81 | }) 82 | } 83 | 84 | /** 85 | * @param {CodemirrorBinding} binding 86 | * @param {Array} changes 87 | */ 88 | const targetObserver = (binding, changes) => { 89 | binding._mux(() => { 90 | binding.doc.transact(() => { 91 | const hasPaste = binding.yUndoManager && 92 | changes.some((change) => change.origin === 'paste') 93 | if (hasPaste) { 94 | binding.yUndoManager.stopCapturing() 95 | } 96 | 97 | if (changes.length > 1) { 98 | // If there are several consecutive changes, we can't reliably compute the positions anymore. See y-codemirror#11 99 | // Instead, we will compute the diff and apply the changes 100 | const d = diff.simpleDiffString( 101 | binding.type.toString(), 102 | binding.cmDoc.getValue() 103 | ) 104 | binding.type.delete(d.index, d.remove) 105 | binding.type.insert(d.index, d.insert) 106 | } else { 107 | const change = changes[0] 108 | const start = binding.cmDoc.indexFromPos(change.from) 109 | const delLen = change.removed.map((s) => s.length).reduce(math.add) + 110 | change.removed.length - 1 111 | if (delLen > 0) { 112 | binding.type.delete(start, delLen) 113 | } 114 | if (change.text.length > 0) { 115 | binding.type.insert(start, change.text.join('\n')) 116 | } 117 | } 118 | 119 | if (hasPaste) { 120 | binding.yUndoManager.stopCapturing() 121 | } 122 | }, binding) 123 | }) 124 | if (binding._pendingCursorEvent) { 125 | binding._pendingCursorEvent = false 126 | binding.emit('cursorActivity', [binding]) 127 | } 128 | } 129 | 130 | const createRemoteCaret = (username, color) => { 131 | const caret = document.createElement('span') 132 | caret.classList.add('remote-caret') 133 | caret.setAttribute('style', `border-color: ${color}`) 134 | const userDiv = document.createElement('div') 135 | userDiv.setAttribute('style', `background-color: ${color}`) 136 | userDiv.insertBefore(document.createTextNode(username), null) 137 | caret.insertBefore(userDiv, null) 138 | setTimeout(() => { 139 | caret.classList.add('hide-name') 140 | }, 2000) 141 | return caret 142 | } 143 | 144 | const createEmptyLinePlaceholder = (color) => { 145 | const placeholder = document.createElement('span') 146 | placeholder.setAttribute('style', 'user-select: none;') 147 | const emptyTxt = document.createElement('span') 148 | emptyTxt.insertBefore(document.createTextNode(''), null) 149 | const sel = document.createElement('span') 150 | sel.setAttribute('class', 'y-line-selection') 151 | sel.setAttribute( 152 | 'style', 153 | `display: inline-block; position: absolute; left: 4px; right: 4px; top: 0; bottom: 0; background-color: ${color}70` 154 | ) 155 | placeholder.insertBefore(sel, null) 156 | placeholder.insertBefore(emptyTxt, null) 157 | return placeholder 158 | } 159 | 160 | const updateRemoteSelection = (y, cm, type, cursors, clientId, awareness) => { 161 | // redraw caret and selection for clientId 162 | const aw = awareness.getStates().get(clientId) 163 | // destroy current text mark 164 | const m = cursors.get(clientId) 165 | if (m !== undefined) { 166 | if (m.caret) { 167 | m.caret.clear() 168 | } 169 | m.sel.forEach((sel) => sel.clear()) 170 | cursors.delete(clientId) 171 | } 172 | if (aw === undefined) { 173 | return 174 | } 175 | const user = aw.user || {} 176 | if (user.color == null) { 177 | user.color = '#ffa500' 178 | } 179 | if (user.name == null) { 180 | user.name = `User: ${clientId}` 181 | } 182 | const cursor = aw.cursor 183 | if (cursor == null || cursor.anchor == null || cursor.head == null) { 184 | return 185 | } 186 | const anchor = Y.createAbsolutePositionFromRelativePosition( 187 | JSON.parse(cursor.anchor), 188 | y 189 | ) 190 | const head = Y.createAbsolutePositionFromRelativePosition( 191 | JSON.parse(cursor.head), 192 | y 193 | ) 194 | if ( 195 | anchor !== null && head !== null && anchor.type === type && 196 | head.type === type 197 | ) { 198 | const headpos = cm.posFromIndex(head.index) 199 | const anchorpos = cm.posFromIndex(anchor.index) 200 | let from, to 201 | if (head.index < anchor.index) { 202 | from = headpos 203 | to = anchorpos 204 | } else { 205 | from = anchorpos 206 | to = headpos 207 | } 208 | const caretEl = createRemoteCaret(user.name, user.color) 209 | // if position was "relatively" the same, do not show name again and hide instead 210 | if ( 211 | m && func.equalityFlat(aw.cursor.anchor, m.awCursor.anchor) && 212 | func.equalityFlat(aw.cursor.head, m.awCursor.head) 213 | ) { 214 | caretEl.classList.add('hide-name') 215 | } 216 | const sel = [] 217 | 218 | if (head.index !== anchor.index) { 219 | if (from.line !== to.line && from.ch !== 0) { 220 | // start of selection will only be a simple text-selection 221 | sel.push( 222 | cm.markText(from, new CodeMirror.Pos(from.line + 1, 0), { 223 | css: `background-color: ${user.color}70;`, 224 | inclusiveRight: false, 225 | inclusiveLeft: false 226 | }) 227 | ) 228 | from = new CodeMirror.Pos(from.line + 1, 0) 229 | } 230 | while (from.line !== to.line) { 231 | // middle of selection is always a whole-line selection. We add a widget at the first position which will fill the background. 232 | sel.push( 233 | cm.setBookmark(new CodeMirror.Pos(from.line, 0), { 234 | widget: createEmptyLinePlaceholder(user.color) 235 | }) 236 | ) 237 | from = new CodeMirror.Pos(from.line + 1, 0) 238 | } 239 | sel.push( 240 | cm.markText(from, to, { 241 | css: `background-color: ${user.color}70;`, 242 | inclusiveRight: false, 243 | inclusiveLeft: false 244 | }) 245 | ) 246 | } 247 | // only render caret if not the complete last line was selected (in this case headpos.ch === 0) 248 | const caret = sel.length > 0 && to === headpos && headpos.ch === 0 249 | ? null 250 | : cm.setBookmark(headpos, { widget: caretEl, insertLeft: true }) 251 | cursors.set(clientId, { caret, sel, awCursor: cursor }) 252 | } 253 | } 254 | 255 | const codemirrorCursorActivity = (y, cm, type, awareness) => { 256 | const aw = awareness.getLocalState() 257 | if ( 258 | !cm.hasFocus() || aw == null || !cm.display.wrapper.ownerDocument.hasFocus() 259 | ) { 260 | return 261 | } 262 | const newAnchor = Y.createRelativePositionFromTypeIndex( 263 | type, 264 | cm.indexFromPos(cm.getCursor('anchor')) 265 | ) 266 | const newHead = Y.createRelativePositionFromTypeIndex( 267 | type, 268 | cm.indexFromPos(cm.getCursor('head')) 269 | ) 270 | let currentAnchor = null 271 | let currentHead = null 272 | if (aw.cursor != null) { 273 | currentAnchor = Y.createRelativePositionFromJSON( 274 | JSON.parse(aw.cursor.anchor) 275 | ) 276 | currentHead = Y.createRelativePositionFromJSON(JSON.parse(aw.cursor.head)) 277 | } 278 | if ( 279 | aw.cursor == null || 280 | !Y.compareRelativePositions(currentAnchor, newAnchor) || 281 | !Y.compareRelativePositions(currentHead, newHead) 282 | ) { 283 | awareness.setLocalStateField('cursor', { 284 | anchor: JSON.stringify(newAnchor), 285 | head: JSON.stringify(newHead) 286 | }) 287 | } 288 | } 289 | 290 | /** 291 | * A binding that binds a YText to a CodeMirror editor. 292 | * 293 | * @example 294 | * const ytext = ydocument.define('codemirror', Y.Text) 295 | * const editor = new CodeMirror(document.querySelector('#container'), { 296 | * mode: 'javascript', 297 | * lineNumbers: true 298 | * }) 299 | * const binding = new CodemirrorBinding(ytext, editor) 300 | */ 301 | export class CodemirrorBinding extends Observable { 302 | /** 303 | * @param {Y.Text} textType 304 | * @param {import('codemirror').Editor} codeMirror 305 | * @param {any | null} [awareness] 306 | * @param {{ yUndoManager?: Y.UndoManager }} [options] 307 | */ 308 | constructor ( 309 | textType, 310 | codeMirror, 311 | awareness = null, 312 | { yUndoManager = null } = {} 313 | ) { 314 | super() 315 | const doc = textType.doc 316 | const cmDoc = codeMirror.getDoc() 317 | this.doc = doc 318 | this.type = textType 319 | this.cm = codeMirror 320 | this.cmDoc = cmDoc 321 | this.awareness = awareness || null 322 | this.yUndoManager = yUndoManager 323 | this._onStackItemAdded = ({ stackItem, changedParentTypes }) => { 324 | // only store metadata if this type was affected 325 | if (changedParentTypes.has(textType) && this._beforeChangeSelection) { 326 | stackItem.meta.set(this, this._beforeChangeSelection) 327 | } 328 | } 329 | this._onStackItemPopped = ({ stackItem }) => { 330 | const sel = stackItem.meta.get(this) 331 | if (sel) { 332 | const anchor = 333 | Y.createAbsolutePositionFromRelativePosition(sel.anchor, doc).index 334 | const head = 335 | Y.createAbsolutePositionFromRelativePosition(sel.head, doc).index 336 | codeMirror.setSelection( 337 | codeMirror.posFromIndex(anchor), 338 | codeMirror.posFromIndex(head) 339 | ) 340 | this._beforeChange() 341 | } 342 | } 343 | if (yUndoManager) { 344 | yUndoManager.trackedOrigins.add(this) // track changes performed by this editor binding 345 | const editorUndo = (cm) => { 346 | // Keymaps always start with an active operation. 347 | // End the current operation so that the event is fired at the correct moment. 348 | // @todo check cm.curOp in typeListener and endOperation always. 349 | cm.endOperation() 350 | yUndoManager.undo() 351 | cm.startOperation() 352 | } 353 | const editorRedo = (cm) => { 354 | cm.endOperation() 355 | yUndoManager.redo() 356 | cm.startOperation() 357 | } 358 | codeMirror.addKeyMap({ 359 | // pc 360 | 'Ctrl-Z': editorUndo, 361 | 'Shift-Ctrl-Z': editorRedo, 362 | 'Ctrl-Y': editorRedo, 363 | // mac 364 | 'Cmd-Z': editorUndo, 365 | 'Shift-Cmd-Z': editorRedo, 366 | 'Cmd-Y': editorRedo 367 | }) 368 | 369 | yUndoManager.on('stack-item-added', this._onStackItemAdded) 370 | yUndoManager.on('stack-item-popped', this._onStackItemPopped) 371 | } 372 | 373 | this._mux = createMutex() 374 | // set initial value 375 | cmDoc.setValue(textType.toString()) 376 | // observe type and target 377 | this._typeObserver = (event) => typeObserver(this, event) 378 | this._targetObserver = (instance, changes) => { 379 | if (instance.getDoc() === cmDoc) { 380 | targetObserver(this, changes) 381 | } 382 | } 383 | this._cursors = new Map() 384 | this._changedCursors = new Set() 385 | this._debounceCursorEvent = eventloop.createDebouncer(10) 386 | this._awarenessListener = (event) => { 387 | if (codeMirror.getDoc() !== cmDoc) { 388 | return 389 | } 390 | const f = (clientId) => { 391 | if (clientId !== doc.clientID) { 392 | this._changedCursors.add(clientId) 393 | } 394 | } 395 | event.added.forEach(f) 396 | event.removed.forEach(f) 397 | event.updated.forEach(f) 398 | if (this._changedCursors.size > 0) { 399 | this._debounceCursorEvent(() => { 400 | this._changedCursors.forEach((clientId) => { 401 | updateRemoteSelection( 402 | doc, 403 | codeMirror, 404 | textType, 405 | this._cursors, 406 | clientId, 407 | awareness 408 | ) 409 | }) 410 | this._changedCursors.clear() 411 | }) 412 | } 413 | } 414 | this._pendingCursorEvent = false 415 | this._cursorListener = () => { 416 | if (codeMirror.getDoc() === cmDoc) { 417 | this._pendingCursorEvent = true 418 | setTimeout(() => { 419 | if (this._pendingCursorEvent) { 420 | this._pendingCursorEvent = false 421 | this.emit('cursorActivity', [codeMirror]) 422 | } 423 | }, 0) 424 | } 425 | } 426 | this.on('cursorActivity', () => { 427 | codemirrorCursorActivity(doc, codeMirror, textType, awareness) 428 | }) 429 | this._blurListeer = () => awareness.setLocalStateField('cursor', null) 430 | 431 | textType.observe(this._typeObserver) 432 | // @ts-ignore 433 | codeMirror.on('changes', this._targetObserver) 434 | /** 435 | * @type {{ anchor: Y.RelativePosition, head: Y.RelativePosition } | null} 436 | */ 437 | this._beforeChangeSelection = null 438 | this._beforeChange = () => { 439 | // update the the beforeChangeSelection that is stored befor each change to the editor (except when applying remote changes) 440 | this._mux(() => { 441 | // store the selection before the change is applied so we can restore it with the undo manager. 442 | const anchor = Y.createRelativePositionFromTypeIndex( 443 | textType, 444 | codeMirror.indexFromPos(codeMirror.getCursor('anchor')) 445 | ) 446 | const head = Y.createRelativePositionFromTypeIndex( 447 | textType, 448 | codeMirror.indexFromPos(codeMirror.getCursor('head')) 449 | ) 450 | this._beforeChangeSelection = { anchor, head } 451 | }) 452 | } 453 | codeMirror.on('beforeChange', this._beforeChange) 454 | if (awareness) { 455 | codeMirror.on('swapDoc', this._blurListeer) 456 | awareness.on('change', this._awarenessListener) 457 | // @ts-ignore 458 | codeMirror.on('cursorActivity', this._cursorListener) 459 | codeMirror.on('blur', this._blurListeer) 460 | codeMirror.on('focus', this._cursorListener) 461 | } 462 | } 463 | 464 | destroy () { 465 | this.type.unobserve(this._typeObserver) 466 | this.cm.off('swapDoc', this._blurListeer) 467 | // @ts-ignore 468 | this.cm.off('changes', this._targetObserver) 469 | this.cm.off('beforeChange', this._beforeChange) 470 | // @ts-ignore 471 | this.cm.off('cursorActivity', this._cursorListener) 472 | this.cm.off('focus', this._cursorListener) 473 | this.cm.off('blur', this._blurListeer) 474 | if (this.awareness) { 475 | this.awareness.off('change', this._awarenessListener) 476 | } 477 | if (this.yUndoManager) { 478 | this.yUndoManager.off('stack-item-added', this._onStackItemAdded) 479 | this.yUndoManager.off('stack-item-popped', this._onStackItemPopped) 480 | this.yUndoManager.trackedOrigins.delete(this) 481 | } 482 | this.type = null 483 | this.cm = null 484 | this.cmDoc = null 485 | super.destroy() 486 | } 487 | } 488 | 489 | export const CodeMirrorBinding = CodemirrorBinding 490 | --------------------------------------------------------------------------------