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