├── .gitignore ├── LICENSE ├── README.md ├── demo ├── prosemirror.css ├── prosemirror.html ├── prosemirror.js └── schema.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── lib.js ├── plugins │ ├── cursor-plugin.js │ ├── keys.js │ ├── sync-plugin.js │ └── undo-plugin.js ├── utils.js └── y-prosemirror.js ├── test.html ├── tests ├── complexSchema.js ├── index.js ├── index.node.js └── y-prosemirror.test.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .idea 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # y-prosemirror 2 | 3 | > [ProseMirror](http://prosemirror.net/) Binding for [Yjs](https://github.com/yjs/yjs) - [Demo](https://demos.yjs.dev/prosemirror/prosemirror.html) 4 | 5 | This binding maps a Y.XmlFragment to the ProseMirror state. 6 | 7 | ## Features 8 | 9 | * Sync ProseMirror state 10 | * Shared Cursors 11 | * Shared Undo / Redo (each client has its own undo-/redo-history) 12 | * Successfully recovers when concurrents edit result in an invalid document schema 13 | 14 | ### Example 15 | 16 | ```js 17 | import { ySyncPlugin, yCursorPlugin, yUndoPlugin, undo, redo, initProseMirrorDoc } from 'y-prosemirror' 18 | import { exampleSetup } from 'prosemirror-example-setup' 19 | import { keymap } from 'prosemirror-keymap' 20 | .. 21 | 22 | const type = ydocument.get('prosemirror', Y.XmlFragment) 23 | const { doc, mapping } = initProseMirrorDoc(type, schema) 24 | 25 | const prosemirrorView = new EditorView(document.querySelector('#editor'), { 26 | state: EditorState.create({ 27 | doc, 28 | schema, 29 | plugins: [ 30 | ySyncPlugin(type, { mapping }), 31 | yCursorPlugin(provider.awareness), 32 | yUndoPlugin(), 33 | keymap({ 34 | 'Mod-z': undo, 35 | 'Mod-y': redo, 36 | 'Mod-Shift-z': redo 37 | }) 38 | ].concat(exampleSetup({ schema })) 39 | }) 40 | }) 41 | ``` 42 | 43 | Also look [here](https://github.com/yjs/yjs-demos/tree/master/prosemirror) for a working example. 44 | 45 | #### Remote Cursors 46 | 47 | The shared cursors depend on the Awareness instance that is exported by most providers. The [Awareness protocol](https://github.com/yjs/y-protocols#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: 48 | 49 | ```js 50 | example.binding.awareness.setLocalStateField('user', { color: '#008833', name: 'My real name' }) 51 | ``` 52 | 53 | 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. 54 | 55 | ```css 56 | /* this is a rough fix for the first cursor position when the first paragraph is empty */ 57 | .ProseMirror > .ProseMirror-yjs-cursor:first-child { 58 | margin-top: 16px; 59 | } 60 | .ProseMirror p:first-child, .ProseMirror h1:first-child, .ProseMirror h2:first-child, .ProseMirror h3:first-child, .ProseMirror h4:first-child, .ProseMirror h5:first-child, .ProseMirror h6:first-child { 61 | margin-top: 16px 62 | } 63 | /* This gives the remote user caret. The colors are automatically overwritten*/ 64 | .ProseMirror-yjs-cursor { 65 | position: relative; 66 | margin-left: -1px; 67 | margin-right: -1px; 68 | border-left: 1px solid black; 69 | border-right: 1px solid black; 70 | border-color: orange; 71 | word-break: normal; 72 | pointer-events: none; 73 | } 74 | /* This renders the username above the caret */ 75 | .ProseMirror-yjs-cursor > div { 76 | position: absolute; 77 | top: -1.05em; 78 | left: -1px; 79 | font-size: 13px; 80 | background-color: rgb(250, 129, 0); 81 | font-family: serif; 82 | font-style: normal; 83 | font-weight: normal; 84 | line-height: normal; 85 | user-select: none; 86 | color: white; 87 | padding-left: 2px; 88 | padding-right: 2px; 89 | white-space: nowrap; 90 | } 91 | ``` 92 | 93 | You can also overwrite the default Widget dom by specifying a cursor builder in the yCursorPlugin 94 | 95 | ```js 96 | /** 97 | * This function receives the remote users "user" awareness state. 98 | */ 99 | export const myCursorBuilder = user => { 100 | const cursor = document.createElement('span') 101 | cursor.classList.add('ProseMirror-yjs-cursor') 102 | cursor.setAttribute('style', `border-color: ${user.color}`) 103 | const userDiv = document.createElement('div') 104 | userDiv.setAttribute('style', `background-color: ${user.color}`) 105 | userDiv.insertBefore(document.createTextNode(user.name), null) 106 | cursor.insertBefore(userDiv, null) 107 | return cursor 108 | } 109 | 110 | const prosemirrorView = new EditorView(document.querySelector('#editor'), { 111 | state: EditorState.create({ 112 | schema, 113 | plugins: [ 114 | ySyncPlugin(type), 115 | yCursorPlugin(provider.awareness, { cursorBuilder: myCursorBuilder }), 116 | yUndoPlugin(), 117 | keymap({ 118 | 'Mod-z': undo, 119 | 'Mod-y': redo, 120 | 'Mod-Shift-z': redo 121 | }) 122 | ].concat(exampleSetup({ schema })) 123 | }) 124 | }) 125 | ``` 126 | 127 | #### Utilities 128 | 129 | The package includes a number of utility methods for converting back and forth between 130 | a Y.Doc and Prosemirror compatible data structures. These can be useful for persisting 131 | to a datastore or for importing existing documents. 132 | 133 | > _Note_: Serializing and deserializing to JSON will not store collaboration history 134 | > steps and as such should not be used as the primary storage. You will still need 135 | > to store the Y.Doc binary update format. 136 | 137 | ```js 138 | import { prosemirrorToYDoc } from 'y-prosemirror' 139 | 140 | // Pass JSON previously output from Prosemirror 141 | const doc = Node.fromJSON(schema, { 142 | type: "doc", 143 | content: [...] 144 | }) 145 | const ydoc = prosemirrorToYDoc(doc) 146 | ``` 147 | 148 | Because JSON is a common usecase there is an equivalent method that skips the need 149 | to create a Prosemirror Node. 150 | 151 | ```js 152 | import { prosemirrorJSONToYDoc } from 'y-prosemirror' 153 | 154 | // Pass JSON previously output from Prosemirror 155 | const ydoc = prosemirrorJSONToYDoc(schema, { 156 | type: "doc", 157 | content: [...] 158 | }) 159 | ``` 160 | 161 | ```js 162 | import { yDocToProsemirror } from 'y-prosemirror' 163 | 164 | // apply binary updates from elsewhere 165 | const ydoc = new Y.Doc() 166 | ydoc.applyUpdate(update) 167 | 168 | const node = yDocToProsemirror(schema, ydoc) 169 | ``` 170 | 171 | Because JSON is a common usecase there is an equivalent method that outputs JSON 172 | directly, this method does not require the Prosemirror schema. 173 | 174 | ```js 175 | import { yDocToProsemirrorJSON } from 'y-prosemirror' 176 | 177 | // apply binary updates from elsewhere 178 | const ydoc = new Y.Doc() 179 | ydoc.applyUpdate(update) 180 | 181 | const node = yDocToProsemirrorJSON(ydoc) 182 | ``` 183 | 184 | ### Undo/Redo 185 | 186 | The package exports `undo` and `redo` commands which can be used in place of 187 | [prosemirror-history](https://prosemirror.net/docs/ref/#history) by mapping the 188 | mod-Z/Y keys - see [ProseMirror](https://github.com/yjs/yjs-demos/blob/main/prosemirror/prosemirror.js#L29) 189 | and [Tiptap](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration/src/collaboration.ts) 190 | examples. 191 | 192 | Undo and redo are be scoped to the local client, so one peer won't undo another's 193 | changes. See [Y.UndoManager](https://docs.yjs.dev/api/undo-manager) for more details. 194 | 195 | Just like prosemirror-history, you can set a transaction's `addToHistory` meta property 196 | to false to prevent that transaction from being rolled back by undo. This can be helpful for programmatic 197 | document changes that aren't initiated by the user. 198 | 199 | ```js 200 | tr.setMeta("addToHistory", false); 201 | ``` 202 | 203 | ### License 204 | 205 | [The MIT License](./LICENSE) © Kevin Jahns 206 | -------------------------------------------------------------------------------- /demo/prosemirror.css: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | position: relative; 3 | } 4 | 5 | .ProseMirror { 6 | word-wrap: break-word; 7 | white-space: pre-wrap; 8 | -webkit-font-variant-ligatures: none; 9 | font-variant-ligatures: none; 10 | } 11 | 12 | .ProseMirror pre { 13 | white-space: pre-wrap; 14 | } 15 | 16 | .ProseMirror li { 17 | position: relative; 18 | } 19 | 20 | .ProseMirror-hideselection *::selection { background: transparent; } 21 | .ProseMirror-hideselection *::-moz-selection { background: transparent; } 22 | .ProseMirror-hideselection { caret-color: transparent; } 23 | 24 | .ProseMirror-selectednode { 25 | outline: 2px solid #8cf; 26 | } 27 | 28 | /* Make sure li selections wrap around markers */ 29 | 30 | li.ProseMirror-selectednode { 31 | outline: none; 32 | } 33 | 34 | li.ProseMirror-selectednode:after { 35 | content: ""; 36 | position: absolute; 37 | left: -32px; 38 | right: -2px; top: -2px; bottom: -2px; 39 | border: 2px solid #8cf; 40 | pointer-events: none; 41 | } 42 | .ProseMirror-textblock-dropdown { 43 | min-width: 3em; 44 | } 45 | 46 | .ProseMirror-menu { 47 | margin: 0 -4px; 48 | line-height: 1; 49 | } 50 | 51 | .ProseMirror-tooltip .ProseMirror-menu { 52 | width: -webkit-fit-content; 53 | width: fit-content; 54 | white-space: pre; 55 | } 56 | 57 | .ProseMirror-menuitem { 58 | margin-right: 3px; 59 | display: inline-block; 60 | } 61 | 62 | .ProseMirror-menuseparator { 63 | border-right: 1px solid #ddd; 64 | margin-right: 3px; 65 | } 66 | 67 | .ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu { 68 | font-size: 90%; 69 | white-space: nowrap; 70 | } 71 | 72 | .ProseMirror-menu-dropdown { 73 | vertical-align: 1px; 74 | cursor: pointer; 75 | position: relative; 76 | padding-right: 15px; 77 | } 78 | 79 | .ProseMirror-menu-dropdown-wrap { 80 | padding: 1px 0 1px 4px; 81 | display: inline-block; 82 | position: relative; 83 | } 84 | 85 | .ProseMirror-menu-dropdown:after { 86 | content: ""; 87 | border-left: 4px solid transparent; 88 | border-right: 4px solid transparent; 89 | border-top: 4px solid currentColor; 90 | opacity: .6; 91 | position: absolute; 92 | right: 4px; 93 | top: calc(50% - 2px); 94 | } 95 | 96 | .ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { 97 | position: absolute; 98 | background: white; 99 | color: #666; 100 | border: 1px solid #aaa; 101 | padding: 2px; 102 | } 103 | 104 | .ProseMirror-menu-dropdown-menu { 105 | z-index: 15; 106 | min-width: 6em; 107 | } 108 | 109 | .ProseMirror-menu-dropdown-item { 110 | cursor: pointer; 111 | padding: 2px 8px 2px 4px; 112 | } 113 | 114 | .ProseMirror-menu-dropdown-item:hover { 115 | background: #f2f2f2; 116 | } 117 | 118 | .ProseMirror-menu-submenu-wrap { 119 | position: relative; 120 | margin-right: -4px; 121 | } 122 | 123 | .ProseMirror-menu-submenu-label:after { 124 | content: ""; 125 | border-top: 4px solid transparent; 126 | border-bottom: 4px solid transparent; 127 | border-left: 4px solid currentColor; 128 | opacity: .6; 129 | position: absolute; 130 | right: 4px; 131 | top: calc(50% - 4px); 132 | } 133 | 134 | .ProseMirror-menu-submenu { 135 | display: none; 136 | min-width: 4em; 137 | left: 100%; 138 | top: -3px; 139 | } 140 | 141 | .ProseMirror-menu-active { 142 | background: #eee; 143 | border-radius: 4px; 144 | } 145 | 146 | .ProseMirror-menu-active { 147 | background: #eee; 148 | border-radius: 4px; 149 | } 150 | 151 | .ProseMirror-menu-disabled { 152 | opacity: .3; 153 | } 154 | 155 | .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { 156 | display: block; 157 | } 158 | 159 | .ProseMirror-menubar { 160 | border-top-left-radius: inherit; 161 | border-top-right-radius: inherit; 162 | position: relative; 163 | min-height: 1em; 164 | color: #666; 165 | padding: 1px 6px; 166 | top: 0; left: 0; right: 0; 167 | border-bottom: 1px solid silver; 168 | background: white; 169 | z-index: 10; 170 | -moz-box-sizing: border-box; 171 | box-sizing: border-box; 172 | overflow: visible; 173 | } 174 | 175 | .ProseMirror-icon { 176 | display: inline-block; 177 | line-height: .8; 178 | vertical-align: -2px; /* Compensate for padding */ 179 | padding: 2px 8px; 180 | cursor: pointer; 181 | } 182 | 183 | .ProseMirror-menu-disabled.ProseMirror-icon { 184 | cursor: default; 185 | } 186 | 187 | .ProseMirror-icon svg { 188 | fill: currentColor; 189 | height: 1em; 190 | } 191 | 192 | .ProseMirror-icon span { 193 | vertical-align: text-top; 194 | } 195 | .ProseMirror-gapcursor { 196 | display: none; 197 | pointer-events: none; 198 | position: absolute; 199 | } 200 | 201 | .ProseMirror-gapcursor:after { 202 | content: ""; 203 | display: block; 204 | position: absolute; 205 | top: -2px; 206 | width: 20px; 207 | border-top: 1px solid black; 208 | animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; 209 | } 210 | 211 | @keyframes ProseMirror-cursor-blink { 212 | to { 213 | visibility: hidden; 214 | } 215 | } 216 | 217 | .ProseMirror-focused .ProseMirror-gapcursor { 218 | display: block; 219 | } 220 | /* Add space around the hr to make clicking it easier */ 221 | 222 | .ProseMirror-example-setup-style hr { 223 | padding: 2px 10px; 224 | border: none; 225 | margin: 1em 0; 226 | } 227 | 228 | .ProseMirror-example-setup-style hr:after { 229 | content: ""; 230 | display: block; 231 | height: 1px; 232 | background-color: silver; 233 | line-height: 2px; 234 | } 235 | 236 | .ProseMirror ul, .ProseMirror ol { 237 | padding-left: 30px; 238 | } 239 | 240 | .ProseMirror blockquote { 241 | padding-left: 1em; 242 | border-left: 3px solid #eee; 243 | margin-left: 0; margin-right: 0; 244 | } 245 | 246 | .ProseMirror-example-setup-style img { 247 | cursor: default; 248 | } 249 | 250 | .ProseMirror-prompt { 251 | background: white; 252 | padding: 5px 10px 5px 15px; 253 | border: 1px solid silver; 254 | position: fixed; 255 | border-radius: 3px; 256 | z-index: 11; 257 | box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); 258 | } 259 | 260 | .ProseMirror-prompt h5 { 261 | margin: 0; 262 | font-weight: normal; 263 | font-size: 100%; 264 | color: #444; 265 | } 266 | 267 | .ProseMirror-prompt input[type="text"], 268 | .ProseMirror-prompt textarea { 269 | background: #eee; 270 | border: none; 271 | outline: none; 272 | } 273 | 274 | .ProseMirror-prompt input[type="text"] { 275 | padding: 0 4px; 276 | } 277 | 278 | .ProseMirror-prompt-close { 279 | position: absolute; 280 | left: 2px; top: 1px; 281 | color: #666; 282 | border: none; background: transparent; padding: 0; 283 | } 284 | 285 | .ProseMirror-prompt-close:after { 286 | content: "✕"; 287 | font-size: 12px; 288 | } 289 | 290 | .ProseMirror-invalid { 291 | background: #ffc; 292 | border: 1px solid #cc7; 293 | border-radius: 4px; 294 | padding: 5px 10px; 295 | position: absolute; 296 | min-width: 10em; 297 | } 298 | 299 | .ProseMirror-prompt-buttons { 300 | margin-top: 5px; 301 | display: none; 302 | } 303 | #editor, .editor { 304 | background: white; 305 | color: black; 306 | background-clip: padding-box; 307 | border-radius: 4px; 308 | border: 2px solid rgba(0, 0, 0, 0.2); 309 | padding: 5px 0; 310 | margin-bottom: 23px; 311 | } 312 | 313 | .ProseMirror p:first-child, 314 | .ProseMirror h1:first-child, 315 | .ProseMirror h2:first-child, 316 | .ProseMirror h3:first-child, 317 | .ProseMirror h4:first-child, 318 | .ProseMirror h5:first-child, 319 | .ProseMirror h6:first-child { 320 | margin-top: 10px; 321 | } 322 | 323 | .ProseMirror { 324 | padding: 4px 8px 4px 14px; 325 | line-height: 1.2; 326 | outline: none; 327 | } 328 | 329 | .ProseMirror p { margin-bottom: 1em } 330 | 331 | -------------------------------------------------------------------------------- /demo/prosemirror.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Yjs Prosemirror Example 6 | 7 | 8 | 66 | 67 | 68 |
69 |
70 | 71 |
72 |

73 |

This is a demo of the YjsProseMirror binding: y-prosemirror.

74 |

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

75 | 76 | 77 | -------------------------------------------------------------------------------- /demo/prosemirror.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import * as Y from 'yjs' 4 | import { WebrtcProvider } from 'y-webrtc' 5 | import { ySyncPlugin, yCursorPlugin, yUndoPlugin, undo, redo, initProseMirrorDoc } from '../src/y-prosemirror.js' 6 | import { EditorState } from 'prosemirror-state' 7 | import { EditorView } from 'prosemirror-view' 8 | import { schema } from './schema.js' 9 | import { exampleSetup } from 'prosemirror-example-setup' 10 | import { keymap } from 'prosemirror-keymap' 11 | 12 | window.addEventListener('load', () => { 13 | const ydoc = new Y.Doc() 14 | const provider = new WebrtcProvider('prosemirror-debug', ydoc) 15 | const type = ydoc.getXmlFragment('prosemirror') 16 | 17 | const editor = document.createElement('div') 18 | editor.setAttribute('id', 'editor') 19 | const editorContainer = document.createElement('div') 20 | editorContainer.insertBefore(editor, null) 21 | const { doc, mapping } = initProseMirrorDoc(type, schema) 22 | const prosemirrorView = new EditorView(editor, { 23 | state: EditorState.create({ 24 | doc, 25 | schema, 26 | plugins: [ 27 | ySyncPlugin(type, { mapping }), 28 | yCursorPlugin(provider.awareness), 29 | yUndoPlugin(), 30 | keymap({ 31 | 'Mod-z': undo, 32 | 'Mod-y': redo, 33 | 'Mod-Shift-z': redo 34 | }) 35 | ].concat(exampleSetup({ schema })) 36 | }) 37 | }) 38 | document.body.insertBefore(editorContainer, null) 39 | 40 | setTimeout(() => { 41 | prosemirrorView.focus() 42 | }) 43 | 44 | const connectBtn = /** @type {HTMLElement} */ (document.getElementById('y-connect-btn')) 45 | connectBtn.addEventListener('click', () => { 46 | if (provider.shouldConnect) { 47 | provider.disconnect() 48 | connectBtn.textContent = 'Connect' 49 | } else { 50 | provider.connect() 51 | connectBtn.textContent = 'Disconnect' 52 | } 53 | }) 54 | 55 | // @ts-ignore 56 | window.example = { provider, ydoc, type, prosemirrorView } 57 | }) 58 | -------------------------------------------------------------------------------- /demo/schema.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'prosemirror-model' 2 | 3 | const brDOM = ['br'] 4 | 5 | const calcYchangeDomAttrs = (attrs, domAttrs = {}) => { 6 | domAttrs = Object.assign({}, domAttrs) 7 | if (attrs.ychange !== null) { 8 | domAttrs.ychange_user = attrs.ychange.user 9 | domAttrs.ychange_state = attrs.ychange.state 10 | } 11 | return domAttrs 12 | } 13 | 14 | // :: Object 15 | // [Specs](#model.NodeSpec) for the nodes defined in this schema. 16 | export const nodes = { 17 | // :: NodeSpec The top level document node. 18 | doc: { 19 | content: 'block+' 20 | }, 21 | 22 | // :: NodeSpec A plain paragraph textblock. Represented in the DOM 23 | // as a `

` element. 24 | paragraph: { 25 | attrs: { ychange: { default: null } }, 26 | content: 'inline*', 27 | group: 'block', 28 | parseDOM: [{ tag: 'p' }], 29 | toDOM (node) { return ['p', calcYchangeDomAttrs(node.attrs), 0] } 30 | }, 31 | 32 | // :: NodeSpec A blockquote (`

`) wrapping one or more blocks. 33 | blockquote: { 34 | attrs: { ychange: { default: null } }, 35 | content: 'block+', 36 | group: 'block', 37 | defining: true, 38 | parseDOM: [{ tag: 'blockquote' }], 39 | toDOM (node) { return ['blockquote', calcYchangeDomAttrs(node.attrs), 0] } 40 | }, 41 | 42 | // :: NodeSpec A horizontal rule (`
`). 43 | horizontal_rule: { 44 | attrs: { ychange: { default: null } }, 45 | group: 'block', 46 | parseDOM: [{ tag: 'hr' }], 47 | toDOM (node) { 48 | return ['hr', calcYchangeDomAttrs(node.attrs)] 49 | } 50 | }, 51 | 52 | // :: NodeSpec A heading textblock, with a `level` attribute that 53 | // should hold the number 1 to 6. Parsed and serialized as `

` to 54 | // `

` elements. 55 | heading: { 56 | attrs: { 57 | level: { default: 1 }, 58 | ychange: { default: null } 59 | }, 60 | content: 'inline*', 61 | group: 'block', 62 | defining: true, 63 | parseDOM: [{ tag: 'h1', attrs: { level: 1 } }, 64 | { tag: 'h2', attrs: { level: 2 } }, 65 | { tag: 'h3', attrs: { level: 3 } }, 66 | { tag: 'h4', attrs: { level: 4 } }, 67 | { tag: 'h5', attrs: { level: 5 } }, 68 | { tag: 'h6', attrs: { level: 6 } }], 69 | toDOM (node) { return ['h' + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0] } 70 | }, 71 | 72 | // :: NodeSpec A code listing. Disallows marks or non-text inline 73 | // nodes by default. Represented as a `
` element with a
 74 |   // `` element inside of it.
 75 |   code_block: {
 76 |     attrs: { ychange: { default: null } },
 77 |     content: 'text*',
 78 |     marks: '',
 79 |     group: 'block',
 80 |     code: true,
 81 |     defining: true,
 82 |     parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
 83 |     toDOM (node) { return ['pre', calcYchangeDomAttrs(node.attrs), ['code', 0]] }
 84 |   },
 85 | 
 86 |   // :: NodeSpec The text node.
 87 |   text: {
 88 |     group: 'inline'
 89 |   },
 90 | 
 91 |   // :: NodeSpec An inline image (``) node. Supports `src`,
 92 |   // `alt`, and `href` attributes. The latter two default to the empty
 93 |   // string.
 94 |   image: {
 95 |     inline: true,
 96 |     attrs: {
 97 |       ychange: { default: null },
 98 |       src: {},
 99 |       alt: { default: null },
100 |       title: { default: null }
101 |     },
102 |     group: 'inline',
103 |     draggable: true,
104 |     parseDOM: [{
105 |       tag: 'img[src]',
106 |       getAttrs (dom) {
107 |         return {
108 |           src: dom.getAttribute('src'),
109 |           title: dom.getAttribute('title'),
110 |           alt: dom.getAttribute('alt')
111 |         }
112 |       }
113 |     }],
114 |     toDOM (node) {
115 |       const domAttrs = {
116 |         src: node.attrs.src,
117 |         title: node.attrs.title,
118 |         alt: node.attrs.alt
119 |       }
120 |       return ['img', calcYchangeDomAttrs(node.attrs, domAttrs)]
121 |     }
122 |   },
123 | 
124 |   // :: NodeSpec A hard line break, represented in the DOM as `
`. 125 | hard_break: { 126 | inline: true, 127 | group: 'inline', 128 | selectable: false, 129 | parseDOM: [{ tag: 'br' }], 130 | toDOM () { return brDOM } 131 | } 132 | } 133 | 134 | const emDOM = ['em', 0]; const strongDOM = ['strong', 0]; const codeDOM = ['code', 0] 135 | 136 | // :: Object [Specs](#model.MarkSpec) for the marks in the schema. 137 | export const marks = { 138 | // :: MarkSpec A link. Has `href` and `title` attributes. `title` 139 | // defaults to the empty string. Rendered and parsed as an `` 140 | // element. 141 | link: { 142 | attrs: { 143 | href: {}, 144 | title: { default: null } 145 | }, 146 | inclusive: false, 147 | parseDOM: [{ 148 | tag: 'a[href]', 149 | getAttrs (dom) { 150 | return { href: dom.getAttribute('href'), title: dom.getAttribute('title') } 151 | } 152 | }], 153 | toDOM (node) { return ['a', node.attrs, 0] } 154 | }, 155 | 156 | // :: MarkSpec An emphasis mark. Rendered as an `` element. 157 | // Has parse rules that also match `` and `font-style: italic`. 158 | em: { 159 | parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }], 160 | toDOM () { return emDOM } 161 | }, 162 | 163 | // :: MarkSpec A strong mark. Rendered as ``, parse rules 164 | // also match `` and `font-weight: bold`. 165 | strong: { 166 | parseDOM: [{ tag: 'strong' }, 167 | // This works around a Google Docs misbehavior where 168 | // pasted content will be inexplicably wrapped in `` 169 | // tags with a font-weight normal. 170 | { tag: 'b', getAttrs: node => node.style.fontWeight !== 'normal' && null }, 171 | { style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null }], 172 | toDOM () { return strongDOM } 173 | }, 174 | 175 | // :: MarkSpec Code font mark. Represented as a `` element. 176 | code: { 177 | parseDOM: [{ tag: 'code' }], 178 | toDOM () { return codeDOM } 179 | }, 180 | ychange: { 181 | attrs: { 182 | user: { default: null }, 183 | state: { default: null } 184 | }, 185 | inclusive: false, 186 | parseDOM: [{ tag: 'ychange' }], 187 | toDOM (node) { 188 | return ['ychange', { ychange_user: node.attrs.user, ychange_state: node.attrs.state }, 0] 189 | } 190 | } 191 | } 192 | 193 | // :: Schema 194 | // This schema rougly corresponds to the document schema used by 195 | // [CommonMark](http://commonmark.org/), minus the list elements, 196 | // which are defined in the [`prosemirror-schema-list`](#schema-list) 197 | // module. 198 | // 199 | // To reuse elements from this schema, extend or read from its 200 | // `spec.nodes` and `spec.marks` [properties](#model.Schema.spec). 201 | export const schema = new Schema({ nodes, marks }) 202 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "y-prosemirror", 3 | "version": "1.3.5", 4 | "description": "Prosemirror bindings for Yjs", 5 | "main": "./dist/y-prosemirror.cjs", 6 | "module": "./src/y-prosemirror.js", 7 | "type": "module", 8 | "types": "./dist/src/y-prosemirror.d.ts", 9 | "sideEffects": false, 10 | "funding": { 11 | "type": "GitHub Sponsors ❤", 12 | "url": "https://github.com/sponsors/dmonad" 13 | }, 14 | "scripts": { 15 | "clean": "rm -rf dist", 16 | "dist": "npm run clean && rollup -c && tsc", 17 | "test": "npm run lint && rollup -c && node dist/test.cjs", 18 | "lint": "standard && tsc", 19 | "watch": "rollup -wc", 20 | "debug": "concurrently '0serve -o test.html' 'npm run watch'", 21 | "preversion": "npm run lint && npm run dist && npm run test", 22 | "start": "concurrently '0serve -o demo/prosemirror.html' 'npm run watch'" 23 | }, 24 | "exports": { 25 | ".": { 26 | "types": "./dist/src/y-prosemirror.d.ts", 27 | "import": "./src/y-prosemirror.js", 28 | "require": "./dist/y-prosemirror.cjs" 29 | } 30 | }, 31 | "files": [ 32 | "dist/*", 33 | "!dist/test.*", 34 | "src/*" 35 | ], 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/yjs/y-prosemirror.git" 39 | }, 40 | "keywords": [ 41 | "Yjs" 42 | ], 43 | "author": "Kevin Jahns ", 44 | "license": "MIT", 45 | "standard": { 46 | "ignore": [ 47 | "/dist", 48 | "/node_modules", 49 | "/docs" 50 | ] 51 | }, 52 | "bugs": { 53 | "url": "https://github.com/yjs/y-prosemirror/issues" 54 | }, 55 | "homepage": "https://github.com/yjs/y-prosemirror#readme", 56 | "dependencies": { 57 | "lib0": "^0.2.100" 58 | }, 59 | "peerDependencies": { 60 | "prosemirror-model": "^1.7.1", 61 | "prosemirror-state": "^1.2.3", 62 | "prosemirror-view": "^1.9.10", 63 | "y-protocols": "^1.0.1", 64 | "yjs": "^13.5.38" 65 | }, 66 | "devDependencies": { 67 | "@rollup/plugin-commonjs": "^21.0.1", 68 | "@rollup/plugin-node-resolve": "^13.0.6", 69 | "concurrently": "^4.1.0", 70 | "http-server": "^0.12.3", 71 | "jsdom": "^15.1.1", 72 | "prosemirror-example-setup": "^1.2.1", 73 | "prosemirror-model": "^1.18.1", 74 | "prosemirror-schema-basic": "^1.2.0", 75 | "prosemirror-state": "^1.4.1", 76 | "prosemirror-transform": "^1.6.0", 77 | "prosemirror-view": "^1.26.2", 78 | "rollup": "^2.59.0", 79 | "standard": "^17.0.0", 80 | "typescript": "^5.4.5", 81 | "y-protocols": "^1.0.5", 82 | "y-webrtc": "^10.2.0", 83 | "yjs": "^13.5.38" 84 | }, 85 | "engines": { 86 | "npm": ">=8.0.0", 87 | "node": ">=16.0.0" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | 4 | /** 5 | * in order to use Yjs' testing framework, we need to depend on the bare-bone (untransformed) Yjs bundle 6 | */ 7 | /* 8 | const debugResolve = { 9 | resolveId (importee) { 10 | if (importee === 'yjs') { 11 | return `${process.cwd()}/node_modules/yjs/src/index.js` 12 | } 13 | } 14 | } 15 | */ 16 | 17 | export default [{ 18 | input: './src/y-prosemirror.js', 19 | output: [{ 20 | name: 'Y', 21 | file: 'dist/y-prosemirror.cjs', 22 | format: 'cjs', 23 | sourcemap: true 24 | }], 25 | external: id => /^(lib0|y-protocols|prosemirror|yjs)/.test(id) 26 | }, { 27 | input: './tests/index.js', 28 | output: { 29 | name: 'test', 30 | file: 'dist/test.js', 31 | format: 'iife', 32 | sourcemap: true 33 | }, 34 | plugins: [ 35 | // debugResolve, 36 | nodeResolve({ 37 | mainFields: ['module', 'browser', 'main'] 38 | }), 39 | commonjs() 40 | ] 41 | }, { 42 | input: './demo/prosemirror.js', 43 | output: { 44 | name: 'demo', 45 | file: 'demo/dist/prosemirror.js', 46 | format: 'iife', 47 | sourcemap: true 48 | }, 49 | plugins: [ 50 | nodeResolve({ 51 | mainFields: ['module', 'browser', 'main'] 52 | }), 53 | commonjs() 54 | ] 55 | }, { 56 | input: './tests/index.node.js', 57 | output: { 58 | name: 'test', 59 | file: 'dist/test.cjs', 60 | format: 'cjs', 61 | sourcemap: true 62 | }, 63 | plugins: [ 64 | // debugResolve, 65 | nodeResolve({ 66 | mainFields: ['module', 'main'] 67 | }), 68 | commonjs() 69 | ], 70 | external: id => /^(lib0|prosemirror|fs|path|jsdom|isomorphic)/.test(id) 71 | }] 72 | -------------------------------------------------------------------------------- /src/lib.js: -------------------------------------------------------------------------------- 1 | import { updateYFragment, createNodeFromYElement, yattr2markname, createEmptyMeta } from './plugins/sync-plugin.js' // eslint-disable-line 2 | import { ySyncPluginKey } from './plugins/keys.js' 3 | import * as Y from 'yjs' 4 | import { EditorView } from 'prosemirror-view' // eslint-disable-line 5 | import { Node, Schema, Fragment } from 'prosemirror-model' // eslint-disable-line 6 | import * as error from 'lib0/error' 7 | import * as map from 'lib0/map' 8 | import * as eventloop from 'lib0/eventloop' 9 | 10 | /** 11 | * Either a node if type is YXmlElement or an Array of text nodes if YXmlText 12 | * @typedef {Map>} ProsemirrorMapping 13 | */ 14 | 15 | /** 16 | * Is null if no timeout is in progress. 17 | * Is defined if a timeout is in progress. 18 | * Maps from view 19 | * @type {Map>|null} 20 | */ 21 | let viewsToUpdate = null 22 | 23 | const updateMetas = () => { 24 | const ups = /** @type {Map>} */ (viewsToUpdate) 25 | viewsToUpdate = null 26 | ups.forEach((metas, view) => { 27 | const tr = view.state.tr 28 | const syncState = ySyncPluginKey.getState(view.state) 29 | if (syncState && syncState.binding && !syncState.binding.isDestroyed) { 30 | metas.forEach((val, key) => { 31 | tr.setMeta(key, val) 32 | }) 33 | view.dispatch(tr) 34 | } 35 | }) 36 | } 37 | 38 | export const setMeta = (view, key, value) => { 39 | if (!viewsToUpdate) { 40 | viewsToUpdate = new Map() 41 | eventloop.timeout(0, updateMetas) 42 | } 43 | map.setIfUndefined(viewsToUpdate, view, map.create).set(key, value) 44 | } 45 | 46 | /** 47 | * Transforms a Prosemirror based absolute position to a Yjs Cursor (relative position in the Yjs model). 48 | * 49 | * @param {number} pos 50 | * @param {Y.XmlFragment} type 51 | * @param {ProsemirrorMapping} mapping 52 | * @return {any} relative position 53 | */ 54 | export const absolutePositionToRelativePosition = (pos, type, mapping) => { 55 | if (pos === 0) { 56 | return Y.createRelativePositionFromTypeIndex(type, 0, -1) 57 | } 58 | /** 59 | * @type {any} 60 | */ 61 | let n = type._first === null ? null : /** @type {Y.ContentType} */ (type._first.content).type 62 | while (n !== null && type !== n) { 63 | if (n instanceof Y.XmlText) { 64 | if (n._length >= pos) { 65 | return Y.createRelativePositionFromTypeIndex(n, pos, -1) 66 | } else { 67 | pos -= n._length 68 | } 69 | if (n._item !== null && n._item.next !== null) { 70 | n = /** @type {Y.ContentType} */ (n._item.next.content).type 71 | } else { 72 | do { 73 | n = n._item === null ? null : n._item.parent 74 | pos-- 75 | } while (n !== type && n !== null && n._item !== null && n._item.next === null) 76 | if (n !== null && n !== type) { 77 | // @ts-gnore we know that n.next !== null because of above loop conditition 78 | n = n._item === null ? null : /** @type {Y.ContentType} */ (/** @type Y.Item */ (n._item.next).content).type 79 | } 80 | } 81 | } else { 82 | const pNodeSize = /** @type {any} */ (mapping.get(n) || { nodeSize: 0 }).nodeSize 83 | if (n._first !== null && pos < pNodeSize) { 84 | n = /** @type {Y.ContentType} */ (n._first.content).type 85 | pos-- 86 | } else { 87 | if (pos === 1 && n._length === 0 && pNodeSize > 1) { 88 | // edge case, should end in this paragraph 89 | return new Y.RelativePosition(n._item === null ? null : n._item.id, n._item === null ? Y.findRootTypeKey(n) : null, null) 90 | } 91 | pos -= pNodeSize 92 | if (n._item !== null && n._item.next !== null) { 93 | n = /** @type {Y.ContentType} */ (n._item.next.content).type 94 | } else { 95 | if (pos === 0) { 96 | // set to end of n.parent 97 | n = n._item === null ? n : n._item.parent 98 | return new Y.RelativePosition(n._item === null ? null : n._item.id, n._item === null ? Y.findRootTypeKey(n) : null, null) 99 | } 100 | do { 101 | n = /** @type {Y.Item} */ (n._item).parent 102 | pos-- 103 | } while (n !== type && /** @type {Y.Item} */ (n._item).next === null) 104 | // if n is null at this point, we have an unexpected case 105 | if (n !== type) { 106 | // We know that n._item.next is defined because of above loop condition 107 | n = /** @type {Y.ContentType} */ (/** @type {Y.Item} */ (/** @type {Y.Item} */ (n._item).next).content).type 108 | } 109 | } 110 | } 111 | } 112 | if (n === null) { 113 | throw error.unexpectedCase() 114 | } 115 | if (pos === 0 && n.constructor !== Y.XmlText && n !== type) { // TODO: set to <= 0 116 | return createRelativePosition(n._item.parent, n._item) 117 | } 118 | } 119 | return Y.createRelativePositionFromTypeIndex(type, type._length, -1) 120 | } 121 | 122 | const createRelativePosition = (type, item) => { 123 | let typeid = null 124 | let tname = null 125 | if (type._item === null) { 126 | tname = Y.findRootTypeKey(type) 127 | } else { 128 | typeid = Y.createID(type._item.id.client, type._item.id.clock) 129 | } 130 | return new Y.RelativePosition(typeid, tname, item.id) 131 | } 132 | 133 | /** 134 | * @param {Y.Doc} y 135 | * @param {Y.XmlFragment} documentType Top level type that is bound to pView 136 | * @param {any} relPos Encoded Yjs based relative position 137 | * @param {ProsemirrorMapping} mapping 138 | * @return {null|number} 139 | */ 140 | export const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) => { 141 | const decodedPos = Y.createAbsolutePositionFromRelativePosition(relPos, y) 142 | if (decodedPos === null || (decodedPos.type !== documentType && !Y.isParentOf(documentType, decodedPos.type._item))) { 143 | return null 144 | } 145 | let type = decodedPos.type 146 | let pos = 0 147 | if (type.constructor === Y.XmlText) { 148 | pos = decodedPos.index 149 | } else if (type._item === null || !type._item.deleted) { 150 | let n = type._first 151 | let i = 0 152 | while (i < type._length && i < decodedPos.index && n !== null) { 153 | if (!n.deleted) { 154 | const t = /** @type {Y.ContentType} */ (n.content).type 155 | i++ 156 | if (t instanceof Y.XmlText) { 157 | pos += t._length 158 | } else { 159 | pos += /** @type {any} */ (mapping.get(t)).nodeSize 160 | } 161 | } 162 | n = /** @type {Y.Item} */ (n.right) 163 | } 164 | pos += 1 // increase because we go out of n 165 | } 166 | while (type !== documentType && type._item !== null) { 167 | // @ts-ignore 168 | const parent = type._item.parent 169 | // @ts-ignore 170 | if (parent._item === null || !parent._item.deleted) { 171 | pos += 1 // the start tag 172 | let n = /** @type {Y.AbstractType} */ (parent)._first 173 | // now iterate until we found type 174 | while (n !== null) { 175 | const contentType = /** @type {Y.ContentType} */ (n.content).type 176 | if (contentType === type) { 177 | break 178 | } 179 | if (!n.deleted) { 180 | if (contentType instanceof Y.XmlText) { 181 | pos += contentType._length 182 | } else { 183 | pos += /** @type {any} */ (mapping.get(contentType)).nodeSize 184 | } 185 | } 186 | n = n.right 187 | } 188 | } 189 | type = /** @type {Y.AbstractType} */ (parent) 190 | } 191 | return pos - 1 // we don't count the most outer tag, because it is a fragment 192 | } 193 | 194 | /** 195 | * Utility function for converting an Y.Fragment to a ProseMirror fragment. 196 | * 197 | * @param {Y.XmlFragment} yXmlFragment 198 | * @param {Schema} schema 199 | */ 200 | export const yXmlFragmentToProseMirrorFragment = (yXmlFragment, schema) => { 201 | const fragmentContent = yXmlFragment.toArray().map((t) => 202 | createNodeFromYElement( 203 | /** @type {Y.XmlElement} */ (t), 204 | schema, 205 | createEmptyMeta() 206 | ) 207 | ).filter((n) => n !== null) 208 | return Fragment.fromArray(fragmentContent) 209 | } 210 | 211 | /** 212 | * Utility function for converting an Y.Fragment to a ProseMirror node. 213 | * 214 | * @param {Y.XmlFragment} yXmlFragment 215 | * @param {Schema} schema 216 | */ 217 | export const yXmlFragmentToProseMirrorRootNode = (yXmlFragment, schema) => 218 | schema.topNodeType.create(null, yXmlFragmentToProseMirrorFragment(yXmlFragment, schema)) 219 | 220 | /** 221 | * The initial ProseMirror content should be supplied by Yjs. This function transforms a Y.Fragment 222 | * to a ProseMirror Doc node and creates a mapping that is used by the sync plugin. 223 | * 224 | * @param {Y.XmlFragment} yXmlFragment 225 | * @param {Schema} schema 226 | * 227 | * @todo deprecate mapping property 228 | */ 229 | export const initProseMirrorDoc = (yXmlFragment, schema) => { 230 | const meta = createEmptyMeta() 231 | const fragmentContent = yXmlFragment.toArray().map((t) => 232 | createNodeFromYElement( 233 | /** @type {Y.XmlElement} */ (t), 234 | schema, 235 | meta 236 | ) 237 | ).filter((n) => n !== null) 238 | const doc = schema.topNodeType.create(null, Fragment.fromArray(fragmentContent)) 239 | return { doc, meta, mapping: meta.mapping } 240 | } 241 | 242 | /** 243 | * Utility method to convert a Prosemirror Doc Node into a Y.Doc. 244 | * 245 | * This can be used when importing existing content to Y.Doc for the first time, 246 | * note that this should not be used to rehydrate a Y.Doc from a database once 247 | * collaboration has begun as all history will be lost 248 | * 249 | * @param {Node} doc 250 | * @param {string} xmlFragment 251 | * @return {Y.Doc} 252 | */ 253 | export function prosemirrorToYDoc (doc, xmlFragment = 'prosemirror') { 254 | const ydoc = new Y.Doc() 255 | const type = /** @type {Y.XmlFragment} */ (ydoc.get(xmlFragment, Y.XmlFragment)) 256 | if (!type.doc) { 257 | return ydoc 258 | } 259 | 260 | prosemirrorToYXmlFragment(doc, type) 261 | return type.doc 262 | } 263 | 264 | /** 265 | * Utility method to update an empty Y.XmlFragment with content from a Prosemirror Doc Node. 266 | * 267 | * This can be used when importing existing content to Y.Doc for the first time, 268 | * note that this should not be used to rehydrate a Y.Doc from a database once 269 | * collaboration has begun as all history will be lost 270 | * 271 | * Note: The Y.XmlFragment does not need to be part of a Y.Doc document at the time that this 272 | * method is called, but it must be added before any other operations are performed on it. 273 | * 274 | * @param {Node} doc prosemirror document. 275 | * @param {Y.XmlFragment} [xmlFragment] If supplied, an xml fragment to be 276 | * populated from the prosemirror state; otherwise a new XmlFragment will be created. 277 | * @return {Y.XmlFragment} 278 | */ 279 | export function prosemirrorToYXmlFragment (doc, xmlFragment) { 280 | const type = xmlFragment || new Y.XmlFragment() 281 | const ydoc = type.doc ? type.doc : { transact: (transaction) => transaction(undefined) } 282 | updateYFragment(ydoc, type, doc, { mapping: new Map(), isOMark: new Map() }) 283 | return type 284 | } 285 | 286 | /** 287 | * Utility method to convert Prosemirror compatible JSON into a Y.Doc. 288 | * 289 | * This can be used when importing existing content to Y.Doc for the first time, 290 | * note that this should not be used to rehydrate a Y.Doc from a database once 291 | * collaboration has begun as all history will be lost 292 | * 293 | * @param {Schema} schema 294 | * @param {any} state 295 | * @param {string} xmlFragment 296 | * @return {Y.Doc} 297 | */ 298 | export function prosemirrorJSONToYDoc (schema, state, xmlFragment = 'prosemirror') { 299 | const doc = Node.fromJSON(schema, state) 300 | return prosemirrorToYDoc(doc, xmlFragment) 301 | } 302 | 303 | /** 304 | * Utility method to convert Prosemirror compatible JSON to a Y.XmlFragment 305 | * 306 | * This can be used when importing existing content to Y.Doc for the first time, 307 | * note that this should not be used to rehydrate a Y.Doc from a database once 308 | * collaboration has begun as all history will be lost 309 | * 310 | * @param {Schema} schema 311 | * @param {any} state 312 | * @param {Y.XmlFragment} [xmlFragment] If supplied, an xml fragment to be 313 | * populated from the prosemirror state; otherwise a new XmlFragment will be created. 314 | * @return {Y.XmlFragment} 315 | */ 316 | export function prosemirrorJSONToYXmlFragment (schema, state, xmlFragment) { 317 | const doc = Node.fromJSON(schema, state) 318 | return prosemirrorToYXmlFragment(doc, xmlFragment) 319 | } 320 | 321 | /** 322 | * @deprecated Use `yXmlFragmentToProseMirrorRootNode` instead 323 | * 324 | * Utility method to convert a Y.Doc to a Prosemirror Doc node. 325 | * 326 | * @param {Schema} schema 327 | * @param {Y.Doc} ydoc 328 | * @return {Node} 329 | */ 330 | export function yDocToProsemirror (schema, ydoc) { 331 | const state = yDocToProsemirrorJSON(ydoc) 332 | return Node.fromJSON(schema, state) 333 | } 334 | 335 | /** 336 | * 337 | * @deprecated Use `yXmlFragmentToProseMirrorRootNode` instead 338 | * 339 | * Utility method to convert a Y.XmlFragment to a Prosemirror Doc node. 340 | * 341 | * @param {Schema} schema 342 | * @param {Y.XmlFragment} xmlFragment 343 | * @return {Node} 344 | */ 345 | export function yXmlFragmentToProsemirror (schema, xmlFragment) { 346 | const state = yXmlFragmentToProsemirrorJSON(xmlFragment) 347 | return Node.fromJSON(schema, state) 348 | } 349 | 350 | /** 351 | * 352 | * @deprecated Use `yXmlFragmentToProseMirrorRootNode` instead 353 | * 354 | * Utility method to convert a Y.Doc to Prosemirror compatible JSON. 355 | * 356 | * @param {Y.Doc} ydoc 357 | * @param {string} xmlFragment 358 | * @return {Record} 359 | */ 360 | export function yDocToProsemirrorJSON ( 361 | ydoc, 362 | xmlFragment = 'prosemirror' 363 | ) { 364 | return yXmlFragmentToProsemirrorJSON(ydoc.getXmlFragment(xmlFragment)) 365 | } 366 | 367 | /** 368 | * @deprecated Use `yXmlFragmentToProseMirrorRootNode` instead 369 | * 370 | * Utility method to convert a Y.Doc to Prosemirror compatible JSON. 371 | * 372 | * @param {Y.XmlFragment} xmlFragment The fragment, which must be part of a Y.Doc. 373 | * @return {Record} 374 | */ 375 | export function yXmlFragmentToProsemirrorJSON (xmlFragment) { 376 | const items = xmlFragment.toArray() 377 | 378 | /** 379 | * @param {Y.AbstractType} item 380 | */ 381 | const serialize = item => { 382 | /** 383 | * @type {Object} NodeObject 384 | * @property {string} NodeObject.type 385 | * @property {Record=} NodeObject.attrs 386 | * @property {Array=} NodeObject.content 387 | */ 388 | let response 389 | 390 | // TODO: Must be a better way to detect text nodes than this 391 | if (item instanceof Y.XmlText) { 392 | const delta = item.toDelta() 393 | response = delta.map(/** @param {any} d */ (d) => { 394 | const text = { 395 | type: 'text', 396 | text: d.insert 397 | } 398 | if (d.attributes) { 399 | text.marks = Object.keys(d.attributes).map((type_) => { 400 | const attrs = d.attributes[type_] 401 | const type = yattr2markname(type_) 402 | const mark = { 403 | type 404 | } 405 | if (Object.keys(attrs)) { 406 | mark.attrs = attrs 407 | } 408 | return mark 409 | }) 410 | } 411 | return text 412 | }) 413 | } else if (item instanceof Y.XmlElement) { 414 | response = { 415 | type: item.nodeName 416 | } 417 | 418 | const attrs = item.getAttributes() 419 | if (Object.keys(attrs).length) { 420 | response.attrs = attrs 421 | } 422 | 423 | const children = item.toArray() 424 | if (children.length) { 425 | response.content = children.map(serialize).flat() 426 | } 427 | } else { 428 | // expected either Y.XmlElement or Y.XmlText 429 | error.unexpectedCase() 430 | } 431 | 432 | return response 433 | } 434 | 435 | return { 436 | type: 'doc', 437 | content: items.map(serialize) 438 | } 439 | } 440 | -------------------------------------------------------------------------------- /src/plugins/cursor-plugin.js: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs' 2 | import { Decoration, DecorationSet } from "prosemirror-view"; // eslint-disable-line 3 | import { Plugin } from "prosemirror-state"; // eslint-disable-line 4 | import { Awareness } from "y-protocols/awareness"; // eslint-disable-line 5 | import { 6 | absolutePositionToRelativePosition, 7 | relativePositionToAbsolutePosition, 8 | setMeta 9 | } from '../lib.js' 10 | import { yCursorPluginKey, ySyncPluginKey } from './keys.js' 11 | 12 | import * as math from 'lib0/math' 13 | 14 | /** 15 | * Default awareness state filter 16 | * 17 | * @param {number} currentClientId current client id 18 | * @param {number} userClientId user client id 19 | * @param {any} _user user data 20 | * @return {boolean} 21 | */ 22 | export const defaultAwarenessStateFilter = (currentClientId, userClientId, _user) => currentClientId !== userClientId 23 | 24 | /** 25 | * Default generator for a cursor element 26 | * 27 | * @param {any} user user data 28 | * @return {HTMLElement} 29 | */ 30 | export const defaultCursorBuilder = (user) => { 31 | const cursor = document.createElement('span') 32 | cursor.classList.add('ProseMirror-yjs-cursor') 33 | cursor.setAttribute('style', `border-color: ${user.color}`) 34 | const userDiv = document.createElement('div') 35 | userDiv.setAttribute('style', `background-color: ${user.color}`) 36 | userDiv.insertBefore(document.createTextNode(user.name), null) 37 | const nonbreakingSpace1 = document.createTextNode('\u2060') 38 | const nonbreakingSpace2 = document.createTextNode('\u2060') 39 | cursor.insertBefore(nonbreakingSpace1, null) 40 | cursor.insertBefore(userDiv, null) 41 | cursor.insertBefore(nonbreakingSpace2, null) 42 | return cursor 43 | } 44 | 45 | /** 46 | * Default generator for the selection attributes 47 | * 48 | * @param {any} user user data 49 | * @return {import('prosemirror-view').DecorationAttrs} 50 | */ 51 | export const defaultSelectionBuilder = (user) => { 52 | return { 53 | style: `background-color: ${user.color}70`, 54 | class: 'ProseMirror-yjs-selection' 55 | } 56 | } 57 | 58 | const rxValidColor = /^#[0-9a-fA-F]{6}$/ 59 | 60 | /** 61 | * @param {any} state 62 | * @param {Awareness} awareness 63 | * @param {function(number, number, any):boolean} awarenessFilter 64 | * @param {(user: { name: string, color: string }, clientId: number) => Element} createCursor 65 | * @param {(user: { name: string, color: string }, clientId: number) => import('prosemirror-view').DecorationAttrs} createSelection 66 | * @return {any} DecorationSet 67 | */ 68 | export const createDecorations = ( 69 | state, 70 | awareness, 71 | awarenessFilter, 72 | createCursor, 73 | createSelection 74 | ) => { 75 | const ystate = ySyncPluginKey.getState(state) 76 | const y = ystate.doc 77 | const decorations = [] 78 | if ( 79 | ystate.snapshot != null || ystate.prevSnapshot != null || 80 | ystate.binding.mapping.size === 0 81 | ) { 82 | // do not render cursors while snapshot is active 83 | return DecorationSet.create(state.doc, []) 84 | } 85 | awareness.getStates().forEach((aw, clientId) => { 86 | if (!awarenessFilter(y.clientID, clientId, aw)) { 87 | return 88 | } 89 | 90 | if (aw.cursor != null) { 91 | const user = aw.user || {} 92 | if (user.color == null) { 93 | user.color = '#ffa500' 94 | } else if (!rxValidColor.test(user.color)) { 95 | // We only support 6-digit RGB colors in y-prosemirror 96 | console.warn('A user uses an unsupported color format', user) 97 | } 98 | if (user.name == null) { 99 | user.name = `User: ${clientId}` 100 | } 101 | let anchor = relativePositionToAbsolutePosition( 102 | y, 103 | ystate.type, 104 | Y.createRelativePositionFromJSON(aw.cursor.anchor), 105 | ystate.binding.mapping 106 | ) 107 | let head = relativePositionToAbsolutePosition( 108 | y, 109 | ystate.type, 110 | Y.createRelativePositionFromJSON(aw.cursor.head), 111 | ystate.binding.mapping 112 | ) 113 | if (anchor !== null && head !== null) { 114 | const maxsize = math.max(state.doc.content.size - 1, 0) 115 | anchor = math.min(anchor, maxsize) 116 | head = math.min(head, maxsize) 117 | decorations.push( 118 | Decoration.widget(head, () => createCursor(user, clientId), { 119 | key: clientId + '', 120 | side: 10 121 | }) 122 | ) 123 | const from = math.min(anchor, head) 124 | const to = math.max(anchor, head) 125 | decorations.push( 126 | Decoration.inline(from, to, createSelection(user, clientId), { 127 | inclusiveEnd: true, 128 | inclusiveStart: false 129 | }) 130 | ) 131 | } 132 | } 133 | }) 134 | return DecorationSet.create(state.doc, decorations) 135 | } 136 | 137 | /** 138 | * A prosemirror plugin that listens to awareness information on Yjs. 139 | * This requires that a `prosemirrorPlugin` is also bound to the prosemirror. 140 | * 141 | * @public 142 | * @param {Awareness} awareness 143 | * @param {object} opts 144 | * @param {function(any, any, any):boolean} [opts.awarenessStateFilter] 145 | * @param {(user: any, clientId: number) => HTMLElement} [opts.cursorBuilder] 146 | * @param {(user: any, clientId: number) => import('prosemirror-view').DecorationAttrs} [opts.selectionBuilder] 147 | * @param {function(any):any} [opts.getSelection] 148 | * @param {string} [cursorStateField] By default all editor bindings use the awareness 'cursor' field to propagate cursor information. 149 | * @return {any} 150 | */ 151 | export const yCursorPlugin = ( 152 | awareness, 153 | { 154 | awarenessStateFilter = defaultAwarenessStateFilter, 155 | cursorBuilder = defaultCursorBuilder, 156 | selectionBuilder = defaultSelectionBuilder, 157 | getSelection = (state) => state.selection 158 | } = {}, 159 | cursorStateField = 'cursor' 160 | ) => 161 | new Plugin({ 162 | key: yCursorPluginKey, 163 | state: { 164 | init (_, state) { 165 | return createDecorations( 166 | state, 167 | awareness, 168 | awarenessStateFilter, 169 | cursorBuilder, 170 | selectionBuilder 171 | ) 172 | }, 173 | apply (tr, prevState, _oldState, newState) { 174 | const ystate = ySyncPluginKey.getState(newState) 175 | const yCursorState = tr.getMeta(yCursorPluginKey) 176 | if ( 177 | (ystate && ystate.isChangeOrigin) || 178 | (yCursorState && yCursorState.awarenessUpdated) 179 | ) { 180 | return createDecorations( 181 | newState, 182 | awareness, 183 | awarenessStateFilter, 184 | cursorBuilder, 185 | selectionBuilder 186 | ) 187 | } 188 | return prevState.map(tr.mapping, tr.doc) 189 | } 190 | }, 191 | props: { 192 | decorations: (state) => { 193 | return yCursorPluginKey.getState(state) 194 | } 195 | }, 196 | view: (view) => { 197 | const awarenessListener = () => { 198 | // @ts-ignore 199 | if (view.docView) { 200 | setMeta(view, yCursorPluginKey, { awarenessUpdated: true }) 201 | } 202 | } 203 | const updateCursorInfo = () => { 204 | const ystate = ySyncPluginKey.getState(view.state) 205 | // @note We make implicit checks when checking for the cursor property 206 | const current = awareness.getLocalState() || {} 207 | if (view.hasFocus()) { 208 | const selection = getSelection(view.state) 209 | /** 210 | * @type {Y.RelativePosition} 211 | */ 212 | const anchor = absolutePositionToRelativePosition( 213 | selection.anchor, 214 | ystate.type, 215 | ystate.binding.mapping 216 | ) 217 | /** 218 | * @type {Y.RelativePosition} 219 | */ 220 | const head = absolutePositionToRelativePosition( 221 | selection.head, 222 | ystate.type, 223 | ystate.binding.mapping 224 | ) 225 | if ( 226 | current.cursor == null || 227 | !Y.compareRelativePositions( 228 | Y.createRelativePositionFromJSON(current.cursor.anchor), 229 | anchor 230 | ) || 231 | !Y.compareRelativePositions( 232 | Y.createRelativePositionFromJSON(current.cursor.head), 233 | head 234 | ) 235 | ) { 236 | awareness.setLocalStateField(cursorStateField, { 237 | anchor, 238 | head 239 | }) 240 | } 241 | } else if ( 242 | current.cursor != null && 243 | relativePositionToAbsolutePosition( 244 | ystate.doc, 245 | ystate.type, 246 | Y.createRelativePositionFromJSON(current.cursor.anchor), 247 | ystate.binding.mapping 248 | ) !== null 249 | ) { 250 | // delete cursor information if current cursor information is owned by this editor binding 251 | awareness.setLocalStateField(cursorStateField, null) 252 | } 253 | } 254 | awareness.on('change', awarenessListener) 255 | view.dom.addEventListener('focusin', updateCursorInfo) 256 | view.dom.addEventListener('focusout', updateCursorInfo) 257 | return { 258 | update: updateCursorInfo, 259 | destroy: () => { 260 | view.dom.removeEventListener('focusin', updateCursorInfo) 261 | view.dom.removeEventListener('focusout', updateCursorInfo) 262 | awareness.off('change', awarenessListener) 263 | awareness.setLocalStateField(cursorStateField, null) 264 | } 265 | } 266 | } 267 | }) 268 | -------------------------------------------------------------------------------- /src/plugins/keys.js: -------------------------------------------------------------------------------- 1 | import { PluginKey } from 'prosemirror-state' // eslint-disable-line 2 | 3 | /** 4 | * The unique prosemirror plugin key for syncPlugin 5 | * 6 | * @public 7 | */ 8 | export const ySyncPluginKey = new PluginKey('y-sync') 9 | 10 | /** 11 | * The unique prosemirror plugin key for undoPlugin 12 | * 13 | * @public 14 | * @type {PluginKey} 15 | */ 16 | export const yUndoPluginKey = new PluginKey('y-undo') 17 | 18 | /** 19 | * The unique prosemirror plugin key for cursorPlugin 20 | * 21 | * @public 22 | */ 23 | export const yCursorPluginKey = new PluginKey('yjs-cursor') 24 | -------------------------------------------------------------------------------- /src/plugins/sync-plugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module bindings/prosemirror 3 | */ 4 | 5 | import { createMutex } from 'lib0/mutex' 6 | import * as PModel from 'prosemirror-model' 7 | import { AllSelection, Plugin, TextSelection, NodeSelection } from "prosemirror-state"; // eslint-disable-line 8 | import * as math from 'lib0/math' 9 | import * as object from 'lib0/object' 10 | import * as set from 'lib0/set' 11 | import { simpleDiff } from 'lib0/diff' 12 | import * as error from 'lib0/error' 13 | import { ySyncPluginKey, yUndoPluginKey } from './keys.js' 14 | import * as Y from 'yjs' 15 | import { 16 | absolutePositionToRelativePosition, 17 | relativePositionToAbsolutePosition 18 | } from '../lib.js' 19 | import * as random from 'lib0/random' 20 | import * as environment from 'lib0/environment' 21 | import * as dom from 'lib0/dom' 22 | import * as eventloop from 'lib0/eventloop' 23 | import * as map from 'lib0/map' 24 | import * as utils from '../utils.js' 25 | 26 | /** 27 | * @typedef {Object} BindingMetadata 28 | * @property {ProsemirrorMapping} BindingMetadata.mapping 29 | * @property {Map} BindingMetadata.isOMark - is overlapping mark 30 | */ 31 | 32 | /** 33 | * @return {BindingMetadata} 34 | */ 35 | export const createEmptyMeta = () => ({ 36 | mapping: new Map(), 37 | isOMark: new Map() 38 | }) 39 | 40 | /** 41 | * @param {Y.Item} item 42 | * @param {Y.Snapshot} [snapshot] 43 | */ 44 | export const isVisible = (item, snapshot) => 45 | snapshot === undefined 46 | ? !item.deleted 47 | : (snapshot.sv.has(item.id.client) && /** @type {number} */ 48 | (snapshot.sv.get(item.id.client)) > item.id.clock && 49 | !Y.isDeleted(snapshot.ds, item.id)) 50 | 51 | /** 52 | * Either a node if type is YXmlElement or an Array of text nodes if YXmlText 53 | * @typedef {Map, PModel.Node | Array>} ProsemirrorMapping 54 | */ 55 | 56 | /** 57 | * @typedef {Object} ColorDef 58 | * @property {string} ColorDef.light 59 | * @property {string} ColorDef.dark 60 | */ 61 | 62 | /** 63 | * @typedef {Object} YSyncOpts 64 | * @property {Array} [YSyncOpts.colors] 65 | * @property {Map} [YSyncOpts.colorMapping] 66 | * @property {Y.PermanentUserData|null} [YSyncOpts.permanentUserData] 67 | * @property {ProsemirrorMapping} [YSyncOpts.mapping] 68 | * @property {function} [YSyncOpts.onFirstRender] Fired when the content from Yjs is initially rendered to ProseMirror 69 | */ 70 | 71 | /** 72 | * @type {Array} 73 | */ 74 | const defaultColors = [{ light: '#ecd44433', dark: '#ecd444' }] 75 | 76 | /** 77 | * @param {Map} colorMapping 78 | * @param {Array} colors 79 | * @param {string} user 80 | * @return {ColorDef} 81 | */ 82 | const getUserColor = (colorMapping, colors, user) => { 83 | // @todo do not hit the same color twice if possible 84 | if (!colorMapping.has(user)) { 85 | if (colorMapping.size < colors.length) { 86 | const usedColors = set.create() 87 | colorMapping.forEach((color) => usedColors.add(color)) 88 | colors = colors.filter((color) => !usedColors.has(color)) 89 | } 90 | colorMapping.set(user, random.oneOf(colors)) 91 | } 92 | return /** @type {ColorDef} */ (colorMapping.get(user)) 93 | } 94 | 95 | /** 96 | * This plugin listens to changes in prosemirror view and keeps yXmlState and view in sync. 97 | * 98 | * This plugin also keeps references to the type and the shared document so other plugins can access it. 99 | * @param {Y.XmlFragment} yXmlFragment 100 | * @param {YSyncOpts} opts 101 | * @return {any} Returns a prosemirror plugin that binds to this type 102 | */ 103 | export const ySyncPlugin = (yXmlFragment, { 104 | colors = defaultColors, 105 | colorMapping = new Map(), 106 | permanentUserData = null, 107 | onFirstRender = () => {}, 108 | mapping 109 | } = {}) => { 110 | let initialContentChanged = false 111 | const binding = new ProsemirrorBinding(yXmlFragment, mapping) 112 | const plugin = new Plugin({ 113 | props: { 114 | editable: (state) => { 115 | const syncState = ySyncPluginKey.getState(state) 116 | return syncState.snapshot == null && syncState.prevSnapshot == null 117 | } 118 | }, 119 | key: ySyncPluginKey, 120 | state: { 121 | /** 122 | * @returns {any} 123 | */ 124 | init: (_initargs, _state) => { 125 | return { 126 | type: yXmlFragment, 127 | doc: yXmlFragment.doc, 128 | binding, 129 | snapshot: null, 130 | prevSnapshot: null, 131 | isChangeOrigin: false, 132 | isUndoRedoOperation: false, 133 | addToHistory: true, 134 | colors, 135 | colorMapping, 136 | permanentUserData 137 | } 138 | }, 139 | apply: (tr, pluginState) => { 140 | const change = tr.getMeta(ySyncPluginKey) 141 | if (change !== undefined) { 142 | pluginState = Object.assign({}, pluginState) 143 | for (const key in change) { 144 | pluginState[key] = change[key] 145 | } 146 | } 147 | pluginState.addToHistory = tr.getMeta('addToHistory') !== false 148 | // always set isChangeOrigin. If undefined, this is not change origin. 149 | pluginState.isChangeOrigin = change !== undefined && 150 | !!change.isChangeOrigin 151 | pluginState.isUndoRedoOperation = change !== undefined && !!change.isChangeOrigin && !!change.isUndoRedoOperation 152 | if (binding.prosemirrorView !== null) { 153 | if ( 154 | change !== undefined && 155 | (change.snapshot != null || change.prevSnapshot != null) 156 | ) { 157 | // snapshot changed, rerender next 158 | eventloop.timeout(0, () => { 159 | if (binding.prosemirrorView == null) { 160 | return 161 | } 162 | if (change.restore == null) { 163 | binding._renderSnapshot( 164 | change.snapshot, 165 | change.prevSnapshot, 166 | pluginState 167 | ) 168 | } else { 169 | binding._renderSnapshot( 170 | change.snapshot, 171 | change.snapshot, 172 | pluginState 173 | ) 174 | // reset to current prosemirror state 175 | delete pluginState.restore 176 | delete pluginState.snapshot 177 | delete pluginState.prevSnapshot 178 | binding.mux(() => { 179 | binding._prosemirrorChanged( 180 | binding.prosemirrorView.state.doc 181 | ) 182 | }) 183 | } 184 | }) 185 | } 186 | } 187 | return pluginState 188 | } 189 | }, 190 | view: (view) => { 191 | binding.initView(view) 192 | if (mapping == null) { 193 | // force rerender to update the bindings mapping 194 | binding._forceRerender() 195 | } 196 | onFirstRender() 197 | return { 198 | update: () => { 199 | const pluginState = plugin.getState(view.state) 200 | if ( 201 | pluginState.snapshot == null && pluginState.prevSnapshot == null 202 | ) { 203 | if ( 204 | // If the content doesn't change initially, we don't render anything to Yjs 205 | // If the content was cleared by a user action, we want to catch the change and 206 | // represent it in Yjs 207 | initialContentChanged || 208 | view.state.doc.content.findDiffStart( 209 | view.state.doc.type.createAndFill().content 210 | ) !== null 211 | ) { 212 | initialContentChanged = true 213 | if ( 214 | pluginState.addToHistory === false && 215 | !pluginState.isChangeOrigin 216 | ) { 217 | const yUndoPluginState = yUndoPluginKey.getState(view.state) 218 | /** 219 | * @type {Y.UndoManager} 220 | */ 221 | const um = yUndoPluginState && yUndoPluginState.undoManager 222 | if (um) { 223 | um.stopCapturing() 224 | } 225 | } 226 | binding.mux(() => { 227 | /** @type {Y.Doc} */ (pluginState.doc).transact((tr) => { 228 | tr.meta.set('addToHistory', pluginState.addToHistory) 229 | binding._prosemirrorChanged(view.state.doc) 230 | }, ySyncPluginKey) 231 | }) 232 | } 233 | } 234 | }, 235 | destroy: () => { 236 | binding.destroy() 237 | } 238 | } 239 | } 240 | }) 241 | return plugin 242 | } 243 | 244 | /** 245 | * @param {import('prosemirror-state').Transaction} tr 246 | * @param {ReturnType} relSel 247 | * @param {ProsemirrorBinding} binding 248 | */ 249 | const restoreRelativeSelection = (tr, relSel, binding) => { 250 | if (relSel !== null && relSel.anchor !== null && relSel.head !== null) { 251 | if (relSel.type === 'all') { 252 | tr.setSelection(new AllSelection(tr.doc)) 253 | } else if (relSel.type === 'node') { 254 | const anchor = relativePositionToAbsolutePosition( 255 | binding.doc, 256 | binding.type, 257 | relSel.anchor, 258 | binding.mapping 259 | ) 260 | tr.setSelection(NodeSelection.create(tr.doc, anchor)) 261 | } else { 262 | const anchor = relativePositionToAbsolutePosition( 263 | binding.doc, 264 | binding.type, 265 | relSel.anchor, 266 | binding.mapping 267 | ) 268 | const head = relativePositionToAbsolutePosition( 269 | binding.doc, 270 | binding.type, 271 | relSel.head, 272 | binding.mapping 273 | ) 274 | if (anchor !== null && head !== null) { 275 | tr.setSelection(TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(head))) 276 | } 277 | } 278 | } 279 | } 280 | 281 | /** 282 | * @param {ProsemirrorBinding} pmbinding 283 | * @param {import('prosemirror-state').EditorState} state 284 | */ 285 | export const getRelativeSelection = (pmbinding, state) => ({ 286 | type: /** @type {any} */ (state.selection).jsonID, 287 | anchor: absolutePositionToRelativePosition( 288 | state.selection.anchor, 289 | pmbinding.type, 290 | pmbinding.mapping 291 | ), 292 | head: absolutePositionToRelativePosition( 293 | state.selection.head, 294 | pmbinding.type, 295 | pmbinding.mapping 296 | ) 297 | }) 298 | 299 | /** 300 | * Binding for prosemirror. 301 | * 302 | * @protected 303 | */ 304 | export class ProsemirrorBinding { 305 | /** 306 | * @param {Y.XmlFragment} yXmlFragment The bind source 307 | * @param {ProsemirrorMapping} mapping 308 | */ 309 | constructor (yXmlFragment, mapping = new Map()) { 310 | this.type = yXmlFragment 311 | /** 312 | * this will be set once the view is created 313 | * @type {any} 314 | */ 315 | this.prosemirrorView = null 316 | this.mux = createMutex() 317 | this.mapping = mapping 318 | /** 319 | * Is overlapping mark - i.e. mark does not exclude itself. 320 | * 321 | * @type {Map} 322 | */ 323 | this.isOMark = new Map() 324 | this._observeFunction = this._typeChanged.bind(this) 325 | /** 326 | * @type {Y.Doc} 327 | */ 328 | // @ts-ignore 329 | this.doc = yXmlFragment.doc 330 | /** 331 | * current selection as relative positions in the Yjs model 332 | */ 333 | this.beforeTransactionSelection = null 334 | this.beforeAllTransactions = () => { 335 | if (this.beforeTransactionSelection === null && this.prosemirrorView != null) { 336 | this.beforeTransactionSelection = getRelativeSelection( 337 | this, 338 | this.prosemirrorView.state 339 | ) 340 | } 341 | } 342 | this.afterAllTransactions = () => { 343 | this.beforeTransactionSelection = null 344 | } 345 | this._domSelectionInView = null 346 | } 347 | 348 | /** 349 | * Create a transaction for changing the prosemirror state. 350 | * 351 | * @returns 352 | */ 353 | get _tr () { 354 | return this.prosemirrorView.state.tr.setMeta('addToHistory', false) 355 | } 356 | 357 | _isLocalCursorInView () { 358 | if (!this.prosemirrorView.hasFocus()) return false 359 | if (environment.isBrowser && this._domSelectionInView === null) { 360 | // Calculate the domSelectionInView and clear by next tick after all events are finished 361 | eventloop.timeout(0, () => { 362 | this._domSelectionInView = null 363 | }) 364 | this._domSelectionInView = this._isDomSelectionInView() 365 | } 366 | return this._domSelectionInView 367 | } 368 | 369 | _isDomSelectionInView () { 370 | const selection = this.prosemirrorView._root.getSelection() 371 | 372 | if (selection == null || selection.anchorNode == null) return false 373 | 374 | const range = this.prosemirrorView._root.createRange() 375 | range.setStart(selection.anchorNode, selection.anchorOffset) 376 | range.setEnd(selection.focusNode, selection.focusOffset) 377 | 378 | // This is a workaround for an edgecase where getBoundingClientRect will 379 | // return zero values if the selection is collapsed at the start of a newline 380 | // see reference here: https://stackoverflow.com/a/59780954 381 | const rects = range.getClientRects() 382 | if (rects.length === 0) { 383 | // probably buggy newline behavior, explicitly select the node contents 384 | if (range.startContainer && range.collapsed) { 385 | range.selectNodeContents(range.startContainer) 386 | } 387 | } 388 | 389 | const bounding = range.getBoundingClientRect() 390 | const documentElement = dom.doc.documentElement 391 | 392 | return bounding.bottom >= 0 && bounding.right >= 0 && 393 | bounding.left <= 394 | (window.innerWidth || documentElement.clientWidth || 0) && 395 | bounding.top <= (window.innerHeight || documentElement.clientHeight || 0) 396 | } 397 | 398 | /** 399 | * @param {Y.Snapshot} snapshot 400 | * @param {Y.Snapshot} prevSnapshot 401 | */ 402 | renderSnapshot (snapshot, prevSnapshot) { 403 | if (!prevSnapshot) { 404 | prevSnapshot = Y.createSnapshot(Y.createDeleteSet(), new Map()) 405 | } 406 | this.prosemirrorView.dispatch( 407 | this._tr.setMeta(ySyncPluginKey, { snapshot, prevSnapshot }) 408 | ) 409 | } 410 | 411 | unrenderSnapshot () { 412 | this.mapping.clear() 413 | this.mux(() => { 414 | const fragmentContent = this.type.toArray().map((t) => 415 | createNodeFromYElement( 416 | /** @type {Y.XmlElement} */ (t), 417 | this.prosemirrorView.state.schema, 418 | this 419 | ) 420 | ).filter((n) => n !== null) 421 | // @ts-ignore 422 | const tr = this._tr.replace( 423 | 0, 424 | this.prosemirrorView.state.doc.content.size, 425 | new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0) 426 | ) 427 | tr.setMeta(ySyncPluginKey, { snapshot: null, prevSnapshot: null }) 428 | this.prosemirrorView.dispatch(tr) 429 | }) 430 | } 431 | 432 | _forceRerender () { 433 | this.mapping.clear() 434 | this.mux(() => { 435 | // If this is a forced rerender, this might neither happen as a pm change nor within a Yjs 436 | // transaction. Then the "before selection" doesn't exist. In this case, we need to create a 437 | // relative position before replacing content. Fixes #126 438 | const sel = this.beforeTransactionSelection !== null ? null : this.prosemirrorView.state.selection 439 | const fragmentContent = this.type.toArray().map((t) => 440 | createNodeFromYElement( 441 | /** @type {Y.XmlElement} */ (t), 442 | this.prosemirrorView.state.schema, 443 | this 444 | ) 445 | ).filter((n) => n !== null) 446 | // @ts-ignore 447 | const tr = this._tr.replace( 448 | 0, 449 | this.prosemirrorView.state.doc.content.size, 450 | new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0) 451 | ) 452 | if (sel) { 453 | /** 454 | * If the Prosemirror document we just created from this.type is 455 | * smaller than the previous document, the selection might be 456 | * out of bound, which would make Prosemirror throw an error. 457 | */ 458 | const clampedAnchor = math.min(math.max(sel.anchor, 0), tr.doc.content.size) 459 | const clampedHead = math.min(math.max(sel.head, 0), tr.doc.content.size) 460 | 461 | tr.setSelection(TextSelection.create(tr.doc, clampedAnchor, clampedHead)) 462 | } 463 | this.prosemirrorView.dispatch( 464 | tr.setMeta(ySyncPluginKey, { isChangeOrigin: true, binding: this }) 465 | ) 466 | }) 467 | } 468 | 469 | /** 470 | * @param {Y.Snapshot|Uint8Array} snapshot 471 | * @param {Y.Snapshot|Uint8Array} prevSnapshot 472 | * @param {Object} pluginState 473 | */ 474 | _renderSnapshot (snapshot, prevSnapshot, pluginState) { 475 | /** 476 | * The document that contains the full history of this document. 477 | * @type {Y.Doc} 478 | */ 479 | let historyDoc = this.doc 480 | let historyType = this.type 481 | if (!snapshot) { 482 | snapshot = Y.snapshot(this.doc) 483 | } 484 | if (snapshot instanceof Uint8Array || prevSnapshot instanceof Uint8Array) { 485 | if (!(snapshot instanceof Uint8Array) || !(prevSnapshot instanceof Uint8Array)) { 486 | // expected both snapshots to be v2 updates 487 | error.unexpectedCase() 488 | } 489 | historyDoc = new Y.Doc({ gc: false }) 490 | Y.applyUpdateV2(historyDoc, prevSnapshot) 491 | prevSnapshot = Y.snapshot(historyDoc) 492 | Y.applyUpdateV2(historyDoc, snapshot) 493 | snapshot = Y.snapshot(historyDoc) 494 | if (historyType._item === null) { 495 | /** 496 | * If is a root type, we need to find the root key in the initial document 497 | * and use it to get the history type. 498 | */ 499 | const rootKey = Array.from(this.doc.share.keys()).find( 500 | (key) => this.doc.share.get(key) === this.type 501 | ) 502 | historyType = historyDoc.getXmlFragment(rootKey) 503 | } else { 504 | /** 505 | * If it is a sub type, we use the item id to find the history type. 506 | */ 507 | const historyStructs = 508 | historyDoc.store.clients.get(historyType._item.id.client) ?? [] 509 | const itemIndex = Y.findIndexSS( 510 | historyStructs, 511 | historyType._item.id.clock 512 | ) 513 | const item = /** @type {Y.Item} */ (historyStructs[itemIndex]) 514 | const content = /** @type {Y.ContentType} */ (item.content) 515 | historyType = /** @type {Y.XmlFragment} */ (content.type) 516 | } 517 | } 518 | // clear mapping because we are going to rerender 519 | this.mapping.clear() 520 | this.mux(() => { 521 | historyDoc.transact((transaction) => { 522 | // before rendering, we are going to sanitize ops and split deleted ops 523 | // if they were deleted by seperate users. 524 | /** 525 | * @type {Y.PermanentUserData} 526 | */ 527 | const pud = pluginState.permanentUserData 528 | if (pud) { 529 | pud.dss.forEach((ds) => { 530 | Y.iterateDeletedStructs(transaction, ds, (_item) => {}) 531 | }) 532 | } 533 | /** 534 | * @param {'removed'|'added'} type 535 | * @param {Y.ID} id 536 | */ 537 | const computeYChange = (type, id) => { 538 | const user = type === 'added' 539 | ? pud.getUserByClientId(id.client) 540 | : pud.getUserByDeletedId(id) 541 | return { 542 | user, 543 | type, 544 | color: getUserColor( 545 | pluginState.colorMapping, 546 | pluginState.colors, 547 | user 548 | ) 549 | } 550 | } 551 | // Create document fragment and render 552 | const fragmentContent = Y.typeListToArraySnapshot( 553 | historyType, 554 | new Y.Snapshot(prevSnapshot.ds, snapshot.sv) 555 | ).map((t) => { 556 | if ( 557 | !t._item.deleted || isVisible(t._item, snapshot) || 558 | isVisible(t._item, prevSnapshot) 559 | ) { 560 | return createNodeFromYElement( 561 | t, 562 | this.prosemirrorView.state.schema, 563 | { mapping: new Map(), isOMark: new Map() }, 564 | snapshot, 565 | prevSnapshot, 566 | computeYChange 567 | ) 568 | } else { 569 | // No need to render elements that are not visible by either snapshot. 570 | // If a client adds and deletes content in the same snapshot the element is not visible by either snapshot. 571 | return null 572 | } 573 | }).filter((n) => n !== null) 574 | // @ts-ignore 575 | const tr = this._tr.replace( 576 | 0, 577 | this.prosemirrorView.state.doc.content.size, 578 | new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0) 579 | ) 580 | this.prosemirrorView.dispatch( 581 | tr.setMeta(ySyncPluginKey, { isChangeOrigin: true }) 582 | ) 583 | }, ySyncPluginKey) 584 | }) 585 | } 586 | 587 | /** 588 | * @param {Array>} events 589 | * @param {Y.Transaction} transaction 590 | */ 591 | _typeChanged (events, transaction) { 592 | if (this.prosemirrorView == null) return 593 | const syncState = ySyncPluginKey.getState(this.prosemirrorView.state) 594 | if ( 595 | events.length === 0 || syncState.snapshot != null || 596 | syncState.prevSnapshot != null 597 | ) { 598 | // drop out if snapshot is active 599 | this.renderSnapshot(syncState.snapshot, syncState.prevSnapshot) 600 | return 601 | } 602 | this.mux(() => { 603 | /** 604 | * @param {any} _ 605 | * @param {Y.AbstractType} type 606 | */ 607 | const delType = (_, type) => this.mapping.delete(type) 608 | Y.iterateDeletedStructs( 609 | transaction, 610 | transaction.deleteSet, 611 | (struct) => { 612 | if (struct.constructor === Y.Item) { 613 | const type = /** @type {Y.ContentType} */ (/** @type {Y.Item} */ (struct).content).type 614 | type && this.mapping.delete(type) 615 | } 616 | } 617 | ) 618 | transaction.changed.forEach(delType) 619 | transaction.changedParentTypes.forEach(delType) 620 | const fragmentContent = this.type.toArray().map((t) => 621 | createNodeIfNotExists( 622 | /** @type {Y.XmlElement | Y.XmlHook} */ (t), 623 | this.prosemirrorView.state.schema, 624 | this 625 | ) 626 | ).filter((n) => n !== null) 627 | // @ts-ignore 628 | let tr = this._tr.replace( 629 | 0, 630 | this.prosemirrorView.state.doc.content.size, 631 | new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0) 632 | ) 633 | restoreRelativeSelection(tr, this.beforeTransactionSelection, this) 634 | tr = tr.setMeta(ySyncPluginKey, { isChangeOrigin: true, isUndoRedoOperation: transaction.origin instanceof Y.UndoManager }) 635 | if ( 636 | this.beforeTransactionSelection !== null && this._isLocalCursorInView() 637 | ) { 638 | tr.scrollIntoView() 639 | } 640 | this.prosemirrorView.dispatch(tr) 641 | }) 642 | } 643 | 644 | /** 645 | * @param {import('prosemirror-model').Node} doc 646 | */ 647 | _prosemirrorChanged (doc) { 648 | this.doc.transact(() => { 649 | updateYFragment(this.doc, this.type, doc, this) 650 | this.beforeTransactionSelection = getRelativeSelection( 651 | this, 652 | this.prosemirrorView.state 653 | ) 654 | }, ySyncPluginKey) 655 | } 656 | 657 | /** 658 | * View is ready to listen to changes. Register observers. 659 | * @param {any} prosemirrorView 660 | */ 661 | initView (prosemirrorView) { 662 | if (this.prosemirrorView != null) this.destroy() 663 | this.prosemirrorView = prosemirrorView 664 | this.doc.on('beforeAllTransactions', this.beforeAllTransactions) 665 | this.doc.on('afterAllTransactions', this.afterAllTransactions) 666 | this.type.observeDeep(this._observeFunction) 667 | } 668 | 669 | destroy () { 670 | if (this.prosemirrorView == null) return 671 | this.prosemirrorView = null 672 | this.type.unobserveDeep(this._observeFunction) 673 | this.doc.off('beforeAllTransactions', this.beforeAllTransactions) 674 | this.doc.off('afterAllTransactions', this.afterAllTransactions) 675 | } 676 | } 677 | 678 | /** 679 | * @private 680 | * @param {Y.XmlElement | Y.XmlHook} el 681 | * @param {PModel.Schema} schema 682 | * @param {BindingMetadata} meta 683 | * @param {Y.Snapshot} [snapshot] 684 | * @param {Y.Snapshot} [prevSnapshot] 685 | * @param {function('removed' | 'added', Y.ID):any} [computeYChange] 686 | * @return {PModel.Node | null} 687 | */ 688 | const createNodeIfNotExists = ( 689 | el, 690 | schema, 691 | meta, 692 | snapshot, 693 | prevSnapshot, 694 | computeYChange 695 | ) => { 696 | const node = /** @type {PModel.Node} */ (meta.mapping.get(el)) 697 | if (node === undefined) { 698 | if (el instanceof Y.XmlElement) { 699 | return createNodeFromYElement( 700 | el, 701 | schema, 702 | meta, 703 | snapshot, 704 | prevSnapshot, 705 | computeYChange 706 | ) 707 | } else { 708 | throw error.methodUnimplemented() // we are currently not handling hooks 709 | } 710 | } 711 | return node 712 | } 713 | 714 | /** 715 | * @private 716 | * @param {Y.XmlElement} el 717 | * @param {any} schema 718 | * @param {BindingMetadata} meta 719 | * @param {Y.Snapshot} [snapshot] 720 | * @param {Y.Snapshot} [prevSnapshot] 721 | * @param {function('removed' | 'added', Y.ID):any} [computeYChange] 722 | * @return {PModel.Node | null} Returns node if node could be created. Otherwise it deletes the yjs type and returns null 723 | */ 724 | export const createNodeFromYElement = ( 725 | el, 726 | schema, 727 | meta, 728 | snapshot, 729 | prevSnapshot, 730 | computeYChange 731 | ) => { 732 | const children = [] 733 | /** 734 | * @param {Y.XmlElement | Y.XmlText} type 735 | */ 736 | const createChildren = (type) => { 737 | if (type instanceof Y.XmlElement) { 738 | const n = createNodeIfNotExists( 739 | type, 740 | schema, 741 | meta, 742 | snapshot, 743 | prevSnapshot, 744 | computeYChange 745 | ) 746 | if (n !== null) { 747 | children.push(n) 748 | } 749 | } else { 750 | // If the next ytext exists and was created by us, move the content to the current ytext. 751 | // This is a fix for #160 -- duplication of characters when two Y.Text exist next to each 752 | // other. 753 | const nextytext = /** @type {Y.ContentType} */ (type._item.right?.content)?.type 754 | if (nextytext instanceof Y.Text && !nextytext._item.deleted && nextytext._item.id.client === nextytext.doc.clientID) { 755 | type.applyDelta([ 756 | { retain: type.length }, 757 | ...nextytext.toDelta() 758 | ]) 759 | nextytext.doc.transact(tr => { 760 | nextytext._item.delete(tr) 761 | }) 762 | } 763 | // now create the prosemirror text nodes 764 | const ns = createTextNodesFromYText( 765 | type, 766 | schema, 767 | meta, 768 | snapshot, 769 | prevSnapshot, 770 | computeYChange 771 | ) 772 | if (ns !== null) { 773 | ns.forEach((textchild) => { 774 | if (textchild !== null) { 775 | children.push(textchild) 776 | } 777 | }) 778 | } 779 | } 780 | } 781 | if (snapshot === undefined || prevSnapshot === undefined) { 782 | el.toArray().forEach(createChildren) 783 | } else { 784 | Y.typeListToArraySnapshot(el, new Y.Snapshot(prevSnapshot.ds, snapshot.sv)) 785 | .forEach(createChildren) 786 | } 787 | try { 788 | const attrs = el.getAttributes(snapshot) 789 | if (snapshot !== undefined) { 790 | if (!isVisible(/** @type {Y.Item} */ (el._item), snapshot)) { 791 | attrs.ychange = computeYChange 792 | ? computeYChange('removed', /** @type {Y.Item} */ (el._item).id) 793 | : { type: 'removed' } 794 | } else if (!isVisible(/** @type {Y.Item} */ (el._item), prevSnapshot)) { 795 | attrs.ychange = computeYChange 796 | ? computeYChange('added', /** @type {Y.Item} */ (el._item).id) 797 | : { type: 'added' } 798 | } 799 | } 800 | const node = schema.node(el.nodeName, attrs, children) 801 | meta.mapping.set(el, node) 802 | return node 803 | } catch (e) { 804 | // an error occured while creating the node. This is probably a result of a concurrent action. 805 | /** @type {Y.Doc} */ (el.doc).transact((transaction) => { 806 | /** @type {Y.Item} */ (el._item).delete(transaction) 807 | }, ySyncPluginKey) 808 | meta.mapping.delete(el) 809 | return null 810 | } 811 | } 812 | 813 | /** 814 | * @private 815 | * @param {Y.XmlText} text 816 | * @param {import('prosemirror-model').Schema} schema 817 | * @param {BindingMetadata} _meta 818 | * @param {Y.Snapshot} [snapshot] 819 | * @param {Y.Snapshot} [prevSnapshot] 820 | * @param {function('removed' | 'added', Y.ID):any} [computeYChange] 821 | * @return {Array|null} 822 | */ 823 | const createTextNodesFromYText = ( 824 | text, 825 | schema, 826 | _meta, 827 | snapshot, 828 | prevSnapshot, 829 | computeYChange 830 | ) => { 831 | const nodes = [] 832 | const deltas = text.toDelta(snapshot, prevSnapshot, computeYChange) 833 | try { 834 | for (let i = 0; i < deltas.length; i++) { 835 | const delta = deltas[i] 836 | nodes.push(schema.text(delta.insert, attributesToMarks(delta.attributes, schema))) 837 | } 838 | } catch (e) { 839 | // an error occured while creating the node. This is probably a result of a concurrent action. 840 | /** @type {Y.Doc} */ (text.doc).transact((transaction) => { 841 | /** @type {Y.Item} */ (text._item).delete(transaction) 842 | }, ySyncPluginKey) 843 | return null 844 | } 845 | // @ts-ignore 846 | return nodes 847 | } 848 | 849 | /** 850 | * @private 851 | * @param {Array} nodes prosemirror node 852 | * @param {BindingMetadata} meta 853 | * @return {Y.XmlText} 854 | */ 855 | const createTypeFromTextNodes = (nodes, meta) => { 856 | const type = new Y.XmlText() 857 | const delta = nodes.map((node) => ({ 858 | // @ts-ignore 859 | insert: node.text, 860 | attributes: marksToAttributes(node.marks, meta) 861 | })) 862 | type.applyDelta(delta) 863 | meta.mapping.set(type, nodes) 864 | return type 865 | } 866 | 867 | /** 868 | * @private 869 | * @param {any} node prosemirror node 870 | * @param {BindingMetadata} meta 871 | * @return {Y.XmlElement} 872 | */ 873 | const createTypeFromElementNode = (node, meta) => { 874 | const type = new Y.XmlElement(node.type.name) 875 | for (const key in node.attrs) { 876 | const val = node.attrs[key] 877 | if (val !== null && key !== 'ychange') { 878 | type.setAttribute(key, val) 879 | } 880 | } 881 | type.insert( 882 | 0, 883 | normalizePNodeContent(node).map((n) => 884 | createTypeFromTextOrElementNode(n, meta) 885 | ) 886 | ) 887 | meta.mapping.set(type, node) 888 | return type 889 | } 890 | 891 | /** 892 | * @private 893 | * @param {PModel.Node|Array} node prosemirror text node 894 | * @param {BindingMetadata} meta 895 | * @return {Y.XmlElement|Y.XmlText} 896 | */ 897 | const createTypeFromTextOrElementNode = (node, meta) => 898 | node instanceof Array 899 | ? createTypeFromTextNodes(node, meta) 900 | : createTypeFromElementNode(node, meta) 901 | 902 | /** 903 | * @param {any} val 904 | */ 905 | const isObject = (val) => typeof val === 'object' && val !== null 906 | 907 | /** 908 | * @param {any} pattrs 909 | * @param {any} yattrs 910 | */ 911 | const equalAttrs = (pattrs, yattrs) => { 912 | const keys = Object.keys(pattrs).filter((key) => pattrs[key] !== null) 913 | let eq = 914 | keys.length === 915 | (yattrs == null ? 0 : Object.keys(yattrs).filter((key) => yattrs[key] !== null).length) 916 | for (let i = 0; i < keys.length && eq; i++) { 917 | const key = keys[i] 918 | const l = pattrs[key] 919 | const r = yattrs[key] 920 | eq = key === 'ychange' || l === r || 921 | (isObject(l) && isObject(r) && equalAttrs(l, r)) 922 | } 923 | return eq 924 | } 925 | 926 | /** 927 | * @typedef {Array|PModel.Node>} NormalizedPNodeContent 928 | */ 929 | 930 | /** 931 | * @param {any} pnode 932 | * @return {NormalizedPNodeContent} 933 | */ 934 | const normalizePNodeContent = (pnode) => { 935 | const c = pnode.content.content 936 | const res = [] 937 | for (let i = 0; i < c.length; i++) { 938 | const n = c[i] 939 | if (n.isText) { 940 | const textNodes = [] 941 | for (let tnode = c[i]; i < c.length && tnode.isText; tnode = c[++i]) { 942 | textNodes.push(tnode) 943 | } 944 | i-- 945 | res.push(textNodes) 946 | } else { 947 | res.push(n) 948 | } 949 | } 950 | return res 951 | } 952 | 953 | /** 954 | * @param {Y.XmlText} ytext 955 | * @param {Array} ptexts 956 | */ 957 | const equalYTextPText = (ytext, ptexts) => { 958 | const delta = ytext.toDelta() 959 | return delta.length === ptexts.length && 960 | delta.every(/** @type {(d:any,i:number) => boolean} */ (d, i) => 961 | d.insert === /** @type {any} */ (ptexts[i]).text && 962 | object.keys(d.attributes || {}).length === ptexts[i].marks.length && 963 | object.every(d.attributes, (attr, yattrname) => { 964 | const markname = yattr2markname(yattrname) 965 | const pmarks = ptexts[i].marks 966 | return equalAttrs(attr, pmarks.find(/** @param {any} mark */ mark => mark.type.name === markname)?.attrs) 967 | }) 968 | ) 969 | } 970 | 971 | /** 972 | * @param {Y.XmlElement|Y.XmlText|Y.XmlHook} ytype 973 | * @param {any|Array} pnode 974 | */ 975 | const equalYTypePNode = (ytype, pnode) => { 976 | if ( 977 | ytype instanceof Y.XmlElement && !(pnode instanceof Array) && 978 | matchNodeName(ytype, pnode) 979 | ) { 980 | const normalizedContent = normalizePNodeContent(pnode) 981 | return ytype._length === normalizedContent.length && 982 | equalAttrs(ytype.getAttributes(), pnode.attrs) && 983 | ytype.toArray().every((ychild, i) => 984 | equalYTypePNode(ychild, normalizedContent[i]) 985 | ) 986 | } 987 | return ytype instanceof Y.XmlText && pnode instanceof Array && 988 | equalYTextPText(ytype, pnode) 989 | } 990 | 991 | /** 992 | * @param {PModel.Node | Array | undefined} mapped 993 | * @param {PModel.Node | Array} pcontent 994 | */ 995 | const mappedIdentity = (mapped, pcontent) => 996 | mapped === pcontent || 997 | (mapped instanceof Array && pcontent instanceof Array && 998 | mapped.length === pcontent.length && mapped.every((a, i) => 999 | pcontent[i] === a 1000 | )) 1001 | 1002 | /** 1003 | * @param {Y.XmlElement} ytype 1004 | * @param {PModel.Node} pnode 1005 | * @param {BindingMetadata} meta 1006 | * @return {{ foundMappedChild: boolean, equalityFactor: number }} 1007 | */ 1008 | const computeChildEqualityFactor = (ytype, pnode, meta) => { 1009 | const yChildren = ytype.toArray() 1010 | const pChildren = normalizePNodeContent(pnode) 1011 | const pChildCnt = pChildren.length 1012 | const yChildCnt = yChildren.length 1013 | const minCnt = math.min(yChildCnt, pChildCnt) 1014 | let left = 0 1015 | let right = 0 1016 | let foundMappedChild = false 1017 | for (; left < minCnt; left++) { 1018 | const leftY = yChildren[left] 1019 | const leftP = pChildren[left] 1020 | if (mappedIdentity(meta.mapping.get(leftY), leftP)) { 1021 | foundMappedChild = true // definite (good) match! 1022 | } else if (!equalYTypePNode(leftY, leftP)) { 1023 | break 1024 | } 1025 | } 1026 | for (; left + right < minCnt; right++) { 1027 | const rightY = yChildren[yChildCnt - right - 1] 1028 | const rightP = pChildren[pChildCnt - right - 1] 1029 | if (mappedIdentity(meta.mapping.get(rightY), rightP)) { 1030 | foundMappedChild = true 1031 | } else if (!equalYTypePNode(rightY, rightP)) { 1032 | break 1033 | } 1034 | } 1035 | return { 1036 | equalityFactor: left + right, 1037 | foundMappedChild 1038 | } 1039 | } 1040 | 1041 | /** 1042 | * @param {Y.Text} ytext 1043 | */ 1044 | const ytextTrans = (ytext) => { 1045 | let str = '' 1046 | /** 1047 | * @type {Y.Item|null} 1048 | */ 1049 | let n = ytext._start 1050 | const nAttrs = {} 1051 | while (n !== null) { 1052 | if (!n.deleted) { 1053 | if (n.countable && n.content instanceof Y.ContentString) { 1054 | str += n.content.str 1055 | } else if (n.content instanceof Y.ContentFormat) { 1056 | nAttrs[n.content.key] = null 1057 | } 1058 | } 1059 | n = n.right 1060 | } 1061 | return { 1062 | str, 1063 | nAttrs 1064 | } 1065 | } 1066 | 1067 | /** 1068 | * @todo test this more 1069 | * 1070 | * @param {Y.Text} ytext 1071 | * @param {Array} ptexts 1072 | * @param {BindingMetadata} meta 1073 | */ 1074 | const updateYText = (ytext, ptexts, meta) => { 1075 | meta.mapping.set(ytext, ptexts) 1076 | const { nAttrs, str } = ytextTrans(ytext) 1077 | const content = ptexts.map((p) => ({ 1078 | insert: /** @type {any} */ (p).text, 1079 | attributes: Object.assign({}, nAttrs, marksToAttributes(p.marks, meta)) 1080 | })) 1081 | const { insert, remove, index } = simpleDiff( 1082 | str, 1083 | content.map((c) => c.insert).join('') 1084 | ) 1085 | ytext.delete(index, remove) 1086 | ytext.insert(index, insert) 1087 | ytext.applyDelta( 1088 | content.map((c) => ({ retain: c.insert.length, attributes: c.attributes })) 1089 | ) 1090 | } 1091 | 1092 | const hashedMarkNameRegex = /(.*)(--[a-zA-Z0-9+/=]{8})$/ 1093 | /** 1094 | * @param {string} attrName 1095 | */ 1096 | export const yattr2markname = attrName => hashedMarkNameRegex.exec(attrName)?.[1] ?? attrName 1097 | 1098 | /** 1099 | * @todo move this to markstoattributes 1100 | * 1101 | * @param {Object} attrs 1102 | * @param {import('prosemirror-model').Schema} schema 1103 | */ 1104 | export const attributesToMarks = (attrs, schema) => { 1105 | /** 1106 | * @type {Array} 1107 | */ 1108 | const marks = [] 1109 | for (const markName in attrs) { 1110 | // remove hashes if necessary 1111 | marks.push(schema.mark(yattr2markname(markName), attrs[markName])) 1112 | } 1113 | return marks 1114 | } 1115 | 1116 | /** 1117 | * @param {Array} marks 1118 | * @param {BindingMetadata} meta 1119 | */ 1120 | const marksToAttributes = (marks, meta) => { 1121 | const pattrs = {} 1122 | marks.forEach((mark) => { 1123 | if (mark.type.name !== 'ychange') { 1124 | const isOverlapping = map.setIfUndefined(meta.isOMark, mark.type, () => !mark.type.excludes(mark.type)) 1125 | pattrs[isOverlapping ? `${mark.type.name}--${utils.hashOfJSON(mark.toJSON())}` : mark.type.name] = mark.attrs 1126 | } 1127 | }) 1128 | return pattrs 1129 | } 1130 | 1131 | /** 1132 | * Update a yDom node by syncing the current content of the prosemirror node. 1133 | * 1134 | * This is a y-prosemirror internal feature that you can use at your own risk. 1135 | * 1136 | * @private 1137 | * @unstable 1138 | * 1139 | * @param {{transact: Function}} y 1140 | * @param {Y.XmlFragment} yDomFragment 1141 | * @param {any} pNode 1142 | * @param {BindingMetadata} meta 1143 | */ 1144 | export const updateYFragment = (y, yDomFragment, pNode, meta) => { 1145 | if ( 1146 | yDomFragment instanceof Y.XmlElement && 1147 | yDomFragment.nodeName !== pNode.type.name 1148 | ) { 1149 | throw new Error('node name mismatch!') 1150 | } 1151 | meta.mapping.set(yDomFragment, pNode) 1152 | // update attributes 1153 | if (yDomFragment instanceof Y.XmlElement) { 1154 | const yDomAttrs = yDomFragment.getAttributes() 1155 | const pAttrs = pNode.attrs 1156 | for (const key in pAttrs) { 1157 | if (pAttrs[key] !== null) { 1158 | if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') { 1159 | yDomFragment.setAttribute(key, pAttrs[key]) 1160 | } 1161 | } else { 1162 | yDomFragment.removeAttribute(key) 1163 | } 1164 | } 1165 | // remove all keys that are no longer in pAttrs 1166 | for (const key in yDomAttrs) { 1167 | if (pAttrs[key] === undefined) { 1168 | yDomFragment.removeAttribute(key) 1169 | } 1170 | } 1171 | } 1172 | // update children 1173 | const pChildren = normalizePNodeContent(pNode) 1174 | const pChildCnt = pChildren.length 1175 | const yChildren = yDomFragment.toArray() 1176 | const yChildCnt = yChildren.length 1177 | const minCnt = math.min(pChildCnt, yChildCnt) 1178 | let left = 0 1179 | let right = 0 1180 | // find number of matching elements from left 1181 | for (; left < minCnt; left++) { 1182 | const leftY = yChildren[left] 1183 | const leftP = pChildren[left] 1184 | if (!mappedIdentity(meta.mapping.get(leftY), leftP)) { 1185 | if (equalYTypePNode(leftY, leftP)) { 1186 | // update mapping 1187 | meta.mapping.set(leftY, leftP) 1188 | } else { 1189 | break 1190 | } 1191 | } 1192 | } 1193 | // find number of matching elements from right 1194 | for (; right + left + 1 < minCnt; right++) { 1195 | const rightY = yChildren[yChildCnt - right - 1] 1196 | const rightP = pChildren[pChildCnt - right - 1] 1197 | if (!mappedIdentity(meta.mapping.get(rightY), rightP)) { 1198 | if (equalYTypePNode(rightY, rightP)) { 1199 | // update mapping 1200 | meta.mapping.set(rightY, rightP) 1201 | } else { 1202 | break 1203 | } 1204 | } 1205 | } 1206 | y.transact(() => { 1207 | // try to compare and update 1208 | while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) { 1209 | const leftY = yChildren[left] 1210 | const leftP = pChildren[left] 1211 | const rightY = yChildren[yChildCnt - right - 1] 1212 | const rightP = pChildren[pChildCnt - right - 1] 1213 | if (leftY instanceof Y.XmlText && leftP instanceof Array) { 1214 | if (!equalYTextPText(leftY, leftP)) { 1215 | updateYText(leftY, leftP, meta) 1216 | } 1217 | left += 1 1218 | } else { 1219 | let updateLeft = leftY instanceof Y.XmlElement && 1220 | matchNodeName(leftY, leftP) 1221 | let updateRight = rightY instanceof Y.XmlElement && 1222 | matchNodeName(rightY, rightP) 1223 | if (updateLeft && updateRight) { 1224 | // decide which which element to update 1225 | const equalityLeft = computeChildEqualityFactor( 1226 | /** @type {Y.XmlElement} */ (leftY), 1227 | /** @type {PModel.Node} */ (leftP), 1228 | meta 1229 | ) 1230 | const equalityRight = computeChildEqualityFactor( 1231 | /** @type {Y.XmlElement} */ (rightY), 1232 | /** @type {PModel.Node} */ (rightP), 1233 | meta 1234 | ) 1235 | if ( 1236 | equalityLeft.foundMappedChild && !equalityRight.foundMappedChild 1237 | ) { 1238 | updateRight = false 1239 | } else if ( 1240 | !equalityLeft.foundMappedChild && equalityRight.foundMappedChild 1241 | ) { 1242 | updateLeft = false 1243 | } else if ( 1244 | equalityLeft.equalityFactor < equalityRight.equalityFactor 1245 | ) { 1246 | updateLeft = false 1247 | } else { 1248 | updateRight = false 1249 | } 1250 | } 1251 | if (updateLeft) { 1252 | updateYFragment( 1253 | y, 1254 | /** @type {Y.XmlFragment} */ (leftY), 1255 | /** @type {PModel.Node} */ (leftP), 1256 | meta 1257 | ) 1258 | left += 1 1259 | } else if (updateRight) { 1260 | updateYFragment( 1261 | y, 1262 | /** @type {Y.XmlFragment} */ (rightY), 1263 | /** @type {PModel.Node} */ (rightP), 1264 | meta 1265 | ) 1266 | right += 1 1267 | } else { 1268 | meta.mapping.delete(yDomFragment.get(left)) 1269 | yDomFragment.delete(left, 1) 1270 | yDomFragment.insert(left, [ 1271 | createTypeFromTextOrElementNode(leftP, meta) 1272 | ]) 1273 | left += 1 1274 | } 1275 | } 1276 | } 1277 | const yDelLen = yChildCnt - left - right 1278 | if ( 1279 | yChildCnt === 1 && pChildCnt === 0 && yChildren[0] instanceof Y.XmlText 1280 | ) { 1281 | meta.mapping.delete(yChildren[0]) 1282 | // Edge case handling https://github.com/yjs/y-prosemirror/issues/108 1283 | // Only delete the content of the Y.Text to retain remote changes on the same Y.Text object 1284 | yChildren[0].delete(0, yChildren[0].length) 1285 | } else if (yDelLen > 0) { 1286 | yDomFragment.slice(left, left + yDelLen).forEach(type => meta.mapping.delete(type)) 1287 | yDomFragment.delete(left, yDelLen) 1288 | } 1289 | if (left + right < pChildCnt) { 1290 | const ins = [] 1291 | for (let i = left; i < pChildCnt - right; i++) { 1292 | ins.push(createTypeFromTextOrElementNode(pChildren[i], meta)) 1293 | } 1294 | yDomFragment.insert(left, ins) 1295 | } 1296 | }, ySyncPluginKey) 1297 | } 1298 | 1299 | /** 1300 | * @function 1301 | * @param {Y.XmlElement} yElement 1302 | * @param {any} pNode Prosemirror Node 1303 | */ 1304 | const matchNodeName = (yElement, pNode) => 1305 | !(pNode instanceof Array) && yElement.nodeName === pNode.type.name 1306 | -------------------------------------------------------------------------------- /src/plugins/undo-plugin.js: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'prosemirror-state' 2 | 3 | import { getRelativeSelection } from './sync-plugin.js' 4 | import { UndoManager, Item, ContentType, XmlElement, Text } from 'yjs' 5 | import { yUndoPluginKey, ySyncPluginKey } from './keys.js' 6 | 7 | /** 8 | * @typedef {Object} UndoPluginState 9 | * @property {import('yjs').UndoManager} undoManager 10 | * @property {ReturnType | null} prevSel 11 | * @property {boolean} hasUndoOps 12 | * @property {boolean} hasRedoOps 13 | */ 14 | 15 | /** 16 | * Undo the last user action 17 | * 18 | * @param {import('prosemirror-state').EditorState} state 19 | * @return {boolean} whether a change was undone 20 | */ 21 | export const undo = state => yUndoPluginKey.getState(state)?.undoManager?.undo() != null 22 | 23 | /** 24 | * Redo the last user action 25 | * 26 | * @param {import('prosemirror-state').EditorState} state 27 | * @return {boolean} whether a change was undone 28 | */ 29 | export const redo = state => yUndoPluginKey.getState(state)?.undoManager?.redo() != null 30 | 31 | /** 32 | * Undo the last user action if there are undo operations available 33 | * @type {import('prosemirror-state').Command} 34 | */ 35 | export const undoCommand = (state, dispatch) => dispatch == null ? yUndoPluginKey.getState(state)?.undoManager?.canUndo() : undo(state) 36 | 37 | /** 38 | * Redo the last user action if there are redo operations available 39 | * @type {import('prosemirror-state').Command} 40 | */ 41 | export const redoCommand = (state, dispatch) => dispatch == null ? yUndoPluginKey.getState(state)?.undoManager?.canRedo() : redo(state) 42 | 43 | export const defaultProtectedNodes = new Set(['paragraph']) 44 | 45 | /** 46 | * @param {import('yjs').Item} item 47 | * @param {Set} protectedNodes 48 | * @returns {boolean} 49 | */ 50 | export const defaultDeleteFilter = (item, protectedNodes) => !(item instanceof Item) || 51 | !(item.content instanceof ContentType) || 52 | !(item.content.type instanceof Text || 53 | (item.content.type instanceof XmlElement && protectedNodes.has(item.content.type.nodeName))) || 54 | item.content.type._length === 0 55 | 56 | /** 57 | * @param {object} [options] 58 | * @param {Set} [options.protectedNodes] 59 | * @param {any[]} [options.trackedOrigins] 60 | * @param {import('yjs').UndoManager | null} [options.undoManager] 61 | */ 62 | export const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins = [], undoManager = null } = {}) => new Plugin({ 63 | key: yUndoPluginKey, 64 | state: { 65 | init: (initargs, state) => { 66 | // TODO: check if plugin order matches and fix 67 | const ystate = ySyncPluginKey.getState(state) 68 | const _undoManager = undoManager || new UndoManager(ystate.type, { 69 | trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)), 70 | deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes), 71 | captureTransaction: tr => tr.meta.get('addToHistory') !== false 72 | }) 73 | return { 74 | undoManager: _undoManager, 75 | prevSel: null, 76 | hasUndoOps: _undoManager.undoStack.length > 0, 77 | hasRedoOps: _undoManager.redoStack.length > 0 78 | } 79 | }, 80 | apply: (tr, val, oldState, state) => { 81 | const binding = ySyncPluginKey.getState(state).binding 82 | const undoManager = val.undoManager 83 | const hasUndoOps = undoManager.undoStack.length > 0 84 | const hasRedoOps = undoManager.redoStack.length > 0 85 | if (binding) { 86 | return { 87 | undoManager, 88 | prevSel: getRelativeSelection(binding, oldState), 89 | hasUndoOps, 90 | hasRedoOps 91 | } 92 | } else { 93 | if (hasUndoOps !== val.hasUndoOps || hasRedoOps !== val.hasRedoOps) { 94 | return Object.assign({}, val, { 95 | hasUndoOps: undoManager.undoStack.length > 0, 96 | hasRedoOps: undoManager.redoStack.length > 0 97 | }) 98 | } else { // nothing changed 99 | return val 100 | } 101 | } 102 | } 103 | }, 104 | view: view => { 105 | const ystate = ySyncPluginKey.getState(view.state) 106 | const undoManager = yUndoPluginKey.getState(view.state).undoManager 107 | undoManager.on('stack-item-added', ({ stackItem }) => { 108 | const binding = ystate.binding 109 | if (binding) { 110 | stackItem.meta.set(binding, yUndoPluginKey.getState(view.state).prevSel) 111 | } 112 | }) 113 | undoManager.on('stack-item-popped', ({ stackItem }) => { 114 | const binding = ystate.binding 115 | if (binding) { 116 | binding.beforeTransactionSelection = stackItem.meta.get(binding) || binding.beforeTransactionSelection 117 | } 118 | }) 119 | return { 120 | destroy: () => { 121 | undoManager.destroy() 122 | } 123 | } 124 | } 125 | }) 126 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import * as sha256 from 'lib0/hash/sha256' 2 | import * as buf from 'lib0/buffer' 3 | 4 | /** 5 | * Custom function to transform sha256 hash to N byte 6 | * 7 | * @param {Uint8Array} digest 8 | */ 9 | const _convolute = digest => { 10 | const N = 6 11 | for (let i = N; i < digest.length; i++) { 12 | digest[i % N] = digest[i % N] ^ digest[i] 13 | } 14 | return digest.slice(0, N) 15 | } 16 | 17 | /** 18 | * @param {any} json 19 | */ 20 | export const hashOfJSON = (json) => buf.toBase64(_convolute(sha256.digest(buf.encodeAny(json)))) 21 | -------------------------------------------------------------------------------- /src/y-prosemirror.js: -------------------------------------------------------------------------------- 1 | export * from './plugins/cursor-plugin.js' 2 | export { ySyncPlugin, isVisible, getRelativeSelection, ProsemirrorBinding, updateYFragment } from './plugins/sync-plugin.js' 3 | export * from './plugins/undo-plugin.js' 4 | export * from './plugins/keys.js' 5 | export { 6 | absolutePositionToRelativePosition, relativePositionToAbsolutePosition, setMeta, 7 | prosemirrorJSONToYDoc, yDocToProsemirrorJSON, yDocToProsemirror, prosemirrorToYDoc, 8 | prosemirrorJSONToYXmlFragment, yXmlFragmentToProsemirrorJSON, yXmlFragmentToProsemirror, 9 | prosemirrorToYXmlFragment, yXmlFragmentToProseMirrorRootNode, yXmlFragmentToProseMirrorFragment, 10 | initProseMirrorDoc 11 | } from './lib.js' 12 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Testing y-prosemirror 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/complexSchema.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'prosemirror-model' 2 | 3 | const brDOM = ['br'] 4 | 5 | const calcYchangeDomAttrs = (attrs, domAttrs = {}) => { 6 | domAttrs = Object.assign({}, domAttrs) 7 | if (attrs.ychange !== null) { 8 | domAttrs.ychange_user = attrs.ychange.user 9 | domAttrs.ychange_state = attrs.ychange.state 10 | } 11 | return domAttrs 12 | } 13 | 14 | // :: Object 15 | // [Specs](#model.NodeSpec) for the nodes defined in this schema. 16 | export const nodes = { 17 | // :: NodeSpec The top level document node. 18 | doc: { 19 | content: 'block+' 20 | }, 21 | 22 | custom: { 23 | atom: true, 24 | group: 'block', 25 | attrs: { checked: { default: false } }, 26 | parseDOM: [{ tag: 'div' }], 27 | toDOM () { 28 | return ['div'] 29 | } 30 | }, 31 | 32 | // :: NodeSpec A plain paragraph textblock. Represented in the DOM 33 | // as a `

` element. 34 | paragraph: { 35 | attrs: { ychange: { default: null } }, 36 | content: 'inline*', 37 | group: 'block', 38 | parseDOM: [{ tag: 'p' }], 39 | toDOM (node) { 40 | return ['p', calcYchangeDomAttrs(node.attrs), 0] 41 | } 42 | }, 43 | 44 | // :: NodeSpec A blockquote (`

`) wrapping one or more blocks. 45 | blockquote: { 46 | attrs: { ychange: { default: null } }, 47 | content: 'block+', 48 | group: 'block', 49 | defining: true, 50 | parseDOM: [{ tag: 'blockquote' }], 51 | toDOM (node) { 52 | return ['blockquote', calcYchangeDomAttrs(node.attrs), 0] 53 | } 54 | }, 55 | 56 | // :: NodeSpec A horizontal rule (`
`). 57 | horizontal_rule: { 58 | attrs: { ychange: { default: null } }, 59 | group: 'block', 60 | parseDOM: [{ tag: 'hr' }], 61 | toDOM (node) { 62 | return ['hr', calcYchangeDomAttrs(node.attrs)] 63 | } 64 | }, 65 | 66 | // :: NodeSpec A heading textblock, with a `level` attribute that 67 | // should hold the number 1 to 6. Parsed and serialized as `

` to 68 | // `

` elements. 69 | heading: { 70 | attrs: { 71 | level: { default: 1 }, 72 | ychange: { default: null } 73 | }, 74 | content: 'inline*', 75 | group: 'block', 76 | defining: true, 77 | parseDOM: [ 78 | { tag: 'h1', attrs: { level: 1 } }, 79 | { tag: 'h2', attrs: { level: 2 } }, 80 | { tag: 'h3', attrs: { level: 3 } }, 81 | { tag: 'h4', attrs: { level: 4 } }, 82 | { tag: 'h5', attrs: { level: 5 } }, 83 | { tag: 'h6', attrs: { level: 6 } } 84 | ], 85 | toDOM (node) { 86 | return ['h' + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0] 87 | } 88 | }, 89 | 90 | // :: NodeSpec A code listing. Disallows marks or non-text inline 91 | // nodes by default. Represented as a `
` element with a
 92 |   // `` element inside of it.
 93 |   code_block: {
 94 |     attrs: { ychange: { default: null } },
 95 |     content: 'text*',
 96 |     marks: '',
 97 |     group: 'block',
 98 |     code: true,
 99 |     defining: true,
100 |     parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
101 |     toDOM (node) {
102 |       return ['pre', calcYchangeDomAttrs(node.attrs), ['code', 0]]
103 |     }
104 |   },
105 | 
106 |   // :: NodeSpec The text node.
107 |   text: {
108 |     group: 'inline'
109 |   },
110 | 
111 |   // :: NodeSpec An inline image (``) node. Supports `src`,
112 |   // `alt`, and `href` attributes. The latter two default to the empty
113 |   // string.
114 |   image: {
115 |     inline: true,
116 |     attrs: {
117 |       ychange: { default: null },
118 |       src: {},
119 |       alt: { default: null },
120 |       title: { default: null }
121 |     },
122 |     group: 'inline',
123 |     draggable: true,
124 |     parseDOM: [
125 |       {
126 |         tag: 'img[src]',
127 |         getAttrs (dom) {
128 |           return {
129 |             src: dom.getAttribute('src'),
130 |             title: dom.getAttribute('title'),
131 |             alt: dom.getAttribute('alt')
132 |           }
133 |         }
134 |       }
135 |     ],
136 |     toDOM (node) {
137 |       const domAttrs = {
138 |         src: node.attrs.src,
139 |         title: node.attrs.title,
140 |         alt: node.attrs.alt
141 |       }
142 |       return ['img', calcYchangeDomAttrs(node.attrs, domAttrs)]
143 |     }
144 |   },
145 | 
146 |   // :: NodeSpec A hard line break, represented in the DOM as `
`. 147 | hard_break: { 148 | inline: true, 149 | group: 'inline', 150 | selectable: false, 151 | parseDOM: [{ tag: 'br' }], 152 | toDOM () { 153 | return brDOM 154 | } 155 | } 156 | } 157 | 158 | const emDOM = ['em', 0] 159 | const strongDOM = ['strong', 0] 160 | const codeDOM = ['code', 0] 161 | 162 | // :: Object [Specs](#model.MarkSpec) for the marks in the schema. 163 | export const marks = { 164 | // :: MarkSpec A link. Has `href` and `title` attributes. `title` 165 | // defaults to the empty string. Rendered and parsed as an `
` 166 | // element. 167 | link: { 168 | attrs: { 169 | href: {}, 170 | title: { default: null } 171 | }, 172 | inclusive: false, 173 | parseDOM: [ 174 | { 175 | tag: 'a[href]', 176 | getAttrs (dom) { 177 | return { 178 | href: dom.getAttribute('href'), 179 | title: dom.getAttribute('title') 180 | } 181 | } 182 | } 183 | ], 184 | toDOM (node) { 185 | return ['a', node.attrs, 0] 186 | } 187 | }, 188 | 189 | // :: MarkSpec An emphasis mark. Rendered as an `` element. 190 | // Has parse rules that also match `` and `font-style: italic`. 191 | em: { 192 | parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }], 193 | toDOM () { 194 | return emDOM 195 | } 196 | }, 197 | 198 | // :: MarkSpec A strong mark. Rendered as ``, parse rules 199 | // also match `` and `font-weight: bold`. 200 | strong: { 201 | parseDOM: [ 202 | { tag: 'strong' }, 203 | // This works around a Google Docs misbehavior where 204 | // pasted content will be inexplicably wrapped in `` 205 | // tags with a font-weight normal. 206 | { 207 | tag: 'b', 208 | getAttrs: node => node.style.fontWeight !== 'normal' && null 209 | }, 210 | { 211 | style: 'font-weight', 212 | getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null 213 | } 214 | ], 215 | toDOM () { 216 | return strongDOM 217 | } 218 | }, 219 | 220 | // :: MarkSpec Code font mark. Represented as a `` element. 221 | code: { 222 | parseDOM: [{ tag: 'code' }], 223 | toDOM () { 224 | return codeDOM 225 | } 226 | }, 227 | 228 | ychange: { 229 | attrs: { 230 | user: { default: null }, 231 | type: { default: null } 232 | }, 233 | inclusive: false, 234 | parseDOM: [{ tag: 'ychange' }], 235 | toDOM (node) { 236 | return ['ychange', { ychange_user: node.attrs.user, ychange_type: node.attrs.type }] 237 | } 238 | }, 239 | 240 | comment: { 241 | attrs: { 242 | id: { default: null } 243 | }, 244 | excludes: '', 245 | parseDOM: [{ tag: 'comment' }], 246 | toDOM (node) { 247 | return ['comment', { comment_id: node.attrs.id }] 248 | } 249 | } 250 | } 251 | 252 | // :: Schema 253 | // This schema rougly corresponds to the document schema used by 254 | // [CommonMark](http://commonmark.org/), minus the list elements, 255 | // which are defined in the [`prosemirror-schema-list`](#schema-list) 256 | // module. 257 | // 258 | // To reuse elements from this schema, extend or read from its 259 | // `spec.nodes` and `spec.marks` [properties](#model.Schema.spec). 260 | export const schema = new Schema({ nodes, marks }) 261 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import * as prosemirror from './y-prosemirror.test.js' 2 | 3 | import { runTests } from 'lib0/testing' 4 | import { isBrowser, isNode } from 'lib0/environment' 5 | import * as log from 'lib0/logging.js' 6 | 7 | if (isBrowser) { 8 | log.createVConsole(document.body) 9 | } 10 | runTests({ 11 | prosemirror 12 | }).then(success => { 13 | /* istanbul ignore next */ 14 | if (isNode) { 15 | process.exit(success ? 0 : 1) 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /tests/index.node.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import fs from 'fs' 3 | import path, { dirname } from 'path' 4 | import jsdom from 'jsdom' 5 | 6 | import * as prosemirror from './y-prosemirror.test.js' 7 | 8 | import { runTests } from 'lib0/testing' 9 | import { isBrowser, isNode } from 'lib0/environment' 10 | import * as log from 'lib0/logging' 11 | import { fileURLToPath } from 'url' 12 | 13 | // eslint-disable-next-line 14 | const __dirname = dirname(fileURLToPath(import.meta.url)) // eslint-disable-line 15 | const documentContent = fs.readFileSync(path.join(__dirname, '../test.html')) 16 | const { window } = new jsdom.JSDOM(documentContent) 17 | 18 | global.window = window 19 | global.document = window.document 20 | global.innerHeight = 0 21 | document.getSelection = () => ({ }) 22 | 23 | document.createRange = () => ({ 24 | setStart () {}, 25 | setEnd () {}, 26 | getClientRects () { 27 | return { 28 | left: 0, 29 | top: 0, 30 | right: 0, 31 | bottom: 0 32 | } 33 | }, 34 | getBoundingClientRect () { 35 | return { 36 | left: 0, 37 | top: 0, 38 | right: 0, 39 | bottom: 0 40 | } 41 | } 42 | }) 43 | 44 | if (isBrowser) { 45 | log.createVConsole(document.body) 46 | } 47 | runTests({ 48 | prosemirror 49 | }).then(success => { 50 | /* istanbul ignore next */ 51 | if (isNode) { 52 | process.exit(success ? 0 : 1) 53 | } 54 | }) 55 | -------------------------------------------------------------------------------- /tests/y-prosemirror.test.js: -------------------------------------------------------------------------------- 1 | import * as t from 'lib0/testing' 2 | import * as prng from 'lib0/prng' 3 | import * as math from 'lib0/math' 4 | import * as Y from 'yjs' 5 | // @ts-ignore 6 | import { applyRandomTests } from 'yjs/testHelper' 7 | 8 | import { 9 | prosemirrorJSONToYDoc, 10 | prosemirrorJSONToYXmlFragment, 11 | redo, 12 | undo, 13 | yDocToProsemirrorJSON, 14 | ySyncPlugin, 15 | ySyncPluginKey, 16 | yUndoPlugin, 17 | yXmlFragmentToProsemirrorJSON 18 | } from '../src/y-prosemirror.js' 19 | import { EditorState, Plugin, TextSelection } from 'prosemirror-state' 20 | import { EditorView } from 'prosemirror-view' 21 | import { Schema } from 'prosemirror-model' 22 | import * as basicSchema from 'prosemirror-schema-basic' 23 | import { findWrapping } from 'prosemirror-transform' 24 | import { schema as complexSchema } from './complexSchema.js' 25 | import * as promise from 'lib0/promise' 26 | 27 | const schema = new Schema({ 28 | nodes: basicSchema.nodes, 29 | marks: Object.assign({}, basicSchema.marks, { 30 | comment: { 31 | attrs: { 32 | id: { default: null } 33 | }, 34 | excludes: '', 35 | parseDOM: [{ tag: 'comment' }], 36 | toDOM (node) { 37 | return ['comment', { comment_id: node.attrs.id }] 38 | } 39 | } 40 | }) 41 | }) 42 | 43 | /** 44 | * Verify that update events in plugins are only fired once. 45 | * 46 | * Initially reported in https://github.com/yjs/y-prosemirror/issues/121 47 | * 48 | * @param {t.TestCase} _tc 49 | */ 50 | export const testPluginIntegrity = (_tc) => { 51 | const ydoc = new Y.Doc() 52 | let viewUpdateEvents = 0 53 | let stateUpdateEvents = 0 54 | const customPlugin = new Plugin({ 55 | state: { 56 | init: () => { 57 | return {} 58 | }, 59 | apply: () => { 60 | stateUpdateEvents++ 61 | } 62 | }, 63 | view: () => { 64 | return { 65 | update () { 66 | viewUpdateEvents++ 67 | } 68 | } 69 | } 70 | }) 71 | const view = new EditorView(null, { 72 | // @ts-ignore 73 | state: EditorState.create({ 74 | schema, 75 | plugins: [ 76 | ySyncPlugin(ydoc.get('prosemirror', Y.XmlFragment)), 77 | yUndoPlugin(), 78 | customPlugin 79 | ] 80 | }) 81 | }) 82 | view.dispatch( 83 | view.state.tr.insert( 84 | 0, 85 | /** @type {any} */ (schema.node( 86 | 'paragraph', 87 | undefined, 88 | schema.text('hello world') 89 | )) 90 | ) 91 | ) 92 | t.compare({ viewUpdateEvents, stateUpdateEvents }, { 93 | viewUpdateEvents: 1, 94 | stateUpdateEvents: 2 // fired twice, because the ySyncPlugin adds additional fields to state after the initial render 95 | }, 'events are fired only once') 96 | } 97 | 98 | /** 99 | * @param {t.TestCase} tc 100 | */ 101 | export const testOverlappingMarks = (_tc) => { 102 | const view = new EditorView(null, { 103 | state: EditorState.create({ 104 | schema, 105 | plugins: [] 106 | }) 107 | }) 108 | view.dispatch( 109 | view.state.tr.insert( 110 | 0, 111 | schema.node( 112 | 'paragraph', 113 | undefined, 114 | schema.text('hello world') 115 | ) 116 | ) 117 | ) 118 | 119 | view.dispatch( 120 | view.state.tr.addMark(1, 3, schema.mark('comment', { id: 4 })) 121 | ) 122 | view.dispatch( 123 | view.state.tr.addMark(2, 4, schema.mark('comment', { id: 5 })) 124 | ) 125 | const stateJSON = JSON.parse(JSON.stringify(view.state.doc.toJSON())) 126 | // attrs.ychange is only available with a schema 127 | delete stateJSON.content[0].attrs 128 | const back = prosemirrorJSONToYDoc(/** @type {any} */ (schema), stateJSON) 129 | // test if transforming back and forth from Yjs doc works 130 | const backandforth = JSON.parse(JSON.stringify(yDocToProsemirrorJSON(back))) 131 | t.compare(stateJSON, backandforth) 132 | 133 | // re-assure that we have overlapping comments 134 | const expected = '[{"type":"text","marks":[{"type":"comment","attrs":{"id":4}}],"text":"h"},{"type":"text","marks":[{"type":"comment","attrs":{"id":4}},{"type":"comment","attrs":{"id":5}}],"text":"e"},{"type":"text","marks":[{"type":"comment","attrs":{"id":5}}],"text":"l"},{"type":"text","text":"lo world"}]' 135 | t.compare(backandforth.content[0].content, JSON.parse(expected)) 136 | } 137 | 138 | /** 139 | * @param {t.TestCase} tc 140 | */ 141 | export const testDocTransformation = (_tc) => { 142 | const view = createNewProsemirrorView(new Y.Doc()) 143 | view.dispatch( 144 | view.state.tr.insert( 145 | 0, 146 | /** @type {any} */ (schema.node( 147 | 'paragraph', 148 | undefined, 149 | schema.text('hello world') 150 | )) 151 | ) 152 | ) 153 | const stateJSON = view.state.doc.toJSON() 154 | // test if transforming back and forth from Yjs doc works 155 | const backandforth = yDocToProsemirrorJSON( 156 | prosemirrorJSONToYDoc(/** @type {any} */ (schema), stateJSON) 157 | ) 158 | t.compare(stateJSON, backandforth) 159 | } 160 | 161 | export const testXmlFragmentTransformation = (_tc) => { 162 | const view = createNewProsemirrorView(new Y.Doc()) 163 | view.dispatch( 164 | view.state.tr.insert( 165 | 0, 166 | /** @type {any} */ (schema.node( 167 | 'paragraph', 168 | undefined, 169 | schema.text('hello world') 170 | )) 171 | ) 172 | ) 173 | const stateJSON = view.state.doc.toJSON() 174 | console.log(JSON.stringify(stateJSON)) 175 | // test if transforming back and forth from yXmlFragment works 176 | const xml = new Y.XmlFragment() 177 | prosemirrorJSONToYXmlFragment(/** @type {any} */ (schema), stateJSON, xml) 178 | const doc = new Y.Doc() 179 | doc.getMap('root').set('firstDoc', xml) 180 | const backandforth = yXmlFragmentToProsemirrorJSON(xml) 181 | console.log(JSON.stringify(backandforth)) 182 | t.compare(stateJSON, backandforth) 183 | } 184 | 185 | export const testChangeOrigin = (_tc) => { 186 | const ydoc = new Y.Doc() 187 | const yXmlFragment = ydoc.get('prosemirror', Y.XmlFragment) 188 | const yundoManager = new Y.UndoManager(yXmlFragment, { trackedOrigins: new Set(['trackme']) }) 189 | const view = createNewProsemirrorView(ydoc) 190 | view.dispatch( 191 | view.state.tr.insert( 192 | 0, 193 | /** @type {any} */ (schema.node( 194 | 'paragraph', 195 | undefined, 196 | schema.text('world') 197 | )) 198 | ) 199 | ) 200 | const ysyncState1 = ySyncPluginKey.getState(view.state) 201 | t.assert(ysyncState1.isChangeOrigin === false) 202 | t.assert(ysyncState1.isUndoRedoOperation === false) 203 | ydoc.transact(() => { 204 | yXmlFragment.get(0).get(0).insert(0, 'hello') 205 | }, 'trackme') 206 | const ysyncState2 = ySyncPluginKey.getState(view.state) 207 | t.assert(ysyncState2.isChangeOrigin === true) 208 | t.assert(ysyncState2.isUndoRedoOperation === false) 209 | yundoManager.undo() 210 | const ysyncState3 = ySyncPluginKey.getState(view.state) 211 | t.assert(ysyncState3.isChangeOrigin === true) 212 | t.assert(ysyncState3.isUndoRedoOperation === true) 213 | } 214 | 215 | /** 216 | * @param {t.TestCase} tc 217 | */ 218 | export const testEmptyNotSync = (_tc) => { 219 | const ydoc = new Y.Doc() 220 | const type = ydoc.getXmlFragment('prosemirror') 221 | const view = createNewComplexProsemirrorView(ydoc) 222 | t.assert(type.toString() === '', 'should only sync after first change') 223 | 224 | view.dispatch( 225 | view.state.tr.setNodeMarkup(0, undefined, { 226 | checked: true 227 | }) 228 | ) 229 | t.compareStrings( 230 | type.toString(), 231 | '' 232 | ) 233 | } 234 | 235 | /** 236 | * @param {t.TestCase} tc 237 | */ 238 | export const testEmptyParagraph = (_tc) => { 239 | const ydoc = new Y.Doc() 240 | const view = createNewProsemirrorView(ydoc) 241 | view.dispatch( 242 | view.state.tr.insert( 243 | 0, 244 | /** @type {any} */ (schema.node( 245 | 'paragraph', 246 | undefined, 247 | schema.text('123') 248 | )) 249 | ) 250 | ) 251 | const yxml = ydoc.get('prosemirror') 252 | t.assert( 253 | yxml.length === 2 && yxml.get(0).length === 1, 254 | 'contains one paragraph containing a ytext' 255 | ) 256 | view.dispatch(view.state.tr.delete(1, 4)) // delete characters 123 257 | t.assert( 258 | yxml.length === 2 && yxml.get(0).length === 1, 259 | "doesn't delete the ytext" 260 | ) 261 | } 262 | 263 | /** 264 | * Test duplication issue https://github.com/yjs/y-prosemirror/issues/161 265 | * 266 | * @param {t.TestCase} tc 267 | */ 268 | export const testInsertDuplication = (_tc) => { 269 | const ydoc1 = new Y.Doc() 270 | ydoc1.clientID = 1 271 | const ydoc2 = new Y.Doc() 272 | ydoc2.clientID = 2 273 | const view1 = createNewProsemirrorView(ydoc1) 274 | const view2 = createNewProsemirrorView(ydoc2) 275 | const yxml1 = ydoc1.getXmlFragment('prosemirror') 276 | const yxml2 = ydoc2.getXmlFragment('prosemirror') 277 | yxml1.observeDeep(events => { 278 | events.forEach(event => { 279 | console.log('yxml1: ', JSON.stringify(event.changes.delta)) 280 | }) 281 | }) 282 | yxml2.observeDeep(events => { 283 | events.forEach(event => { 284 | console.log('yxml2: ', JSON.stringify(event.changes.delta)) 285 | }) 286 | }) 287 | view1.dispatch( 288 | view1.state.tr.insert( 289 | 0, 290 | /** @type {any} */ (schema.node( 291 | 'paragraph' 292 | )) 293 | ) 294 | ) 295 | const sync = () => { 296 | Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc1)) 297 | Y.applyUpdate(ydoc1, Y.encodeStateAsUpdate(ydoc2)) 298 | Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc1)) 299 | Y.applyUpdate(ydoc1, Y.encodeStateAsUpdate(ydoc2)) 300 | } 301 | sync() 302 | view1.dispatch(view1.state.tr.insertText('1', 1, 1)) 303 | view2.dispatch(view2.state.tr.insertText('2', 1, 1)) 304 | sync() 305 | view1.dispatch(view1.state.tr.insertText('1', 2, 2)) 306 | view2.dispatch(view2.state.tr.insertText('2', 3, 3)) 307 | sync() 308 | checkResult({ testObjects: [view1, view2] }) 309 | t.assert(yxml1.toString() === '1122') 310 | } 311 | 312 | export const testAddToHistory = (_tc) => { 313 | const ydoc = new Y.Doc() 314 | const view = createNewProsemirrorViewWithUndoManager(ydoc) 315 | view.dispatch( 316 | view.state.tr.insert( 317 | 0, 318 | /** @type {any} */ (schema.node( 319 | 'paragraph', 320 | undefined, 321 | schema.text('123') 322 | )) 323 | ) 324 | ) 325 | const yxml = ydoc.get('prosemirror') 326 | t.assert( 327 | yxml.length === 2 && yxml.get(0).length === 1, 328 | 'contains inserted content' 329 | ) 330 | undo(view.state) 331 | t.assert(yxml.length === 0, 'insertion was undone') 332 | redo(view.state) 333 | t.assert( 334 | yxml.length === 2 && yxml.get(0).length === 1, 335 | 'contains inserted content' 336 | ) 337 | undo(view.state) 338 | t.assert(yxml.length === 0, 'insertion was undone') 339 | // now insert content again, but with `'addToHistory': false` 340 | view.dispatch( 341 | view.state.tr.insert( 342 | 0, 343 | /** @type {any} */ (schema.node( 344 | 'paragraph', 345 | undefined, 346 | schema.text('123') 347 | )) 348 | ).setMeta('addToHistory', false) 349 | ) 350 | t.assert( 351 | yxml.length === 2 && yxml.get(0).length === 1, 352 | 'contains inserted content' 353 | ) 354 | undo(view.state) 355 | t.assert( 356 | yxml.length === 2 && yxml.get(0).length === 1, 357 | 'insertion was *not* undone' 358 | ) 359 | } 360 | 361 | /** 362 | * Tests for #126 - initial cursor position should be retained, not jump to the end. 363 | * 364 | * @param {t.TestCase} _tc 365 | */ 366 | export const testInitialCursorPosition = async (_tc) => { 367 | const ydoc = new Y.Doc() 368 | const yxml = ydoc.get('prosemirror', Y.XmlFragment) 369 | const p = new Y.XmlElement('paragraph') 370 | p.insert(0, [new Y.XmlText('hello world!')]) 371 | yxml.insert(0, [p]) 372 | console.log('yxml', yxml.toString()) 373 | const view = createNewProsemirrorView(ydoc) 374 | view.focus() 375 | await promise.wait(10) 376 | console.log('anchor', view.state.selection.anchor) 377 | t.assert(view.state.selection.anchor === 1) 378 | t.assert(view.state.selection.head === 1) 379 | } 380 | 381 | export const testInitialCursorPosition2 = async (_tc) => { 382 | const ydoc = new Y.Doc() 383 | const yxml = ydoc.get('prosemirror', Y.XmlFragment) 384 | console.log('yxml', yxml.toString()) 385 | const view = createNewProsemirrorView(ydoc) 386 | view.focus() 387 | await promise.wait(10) 388 | const p = new Y.XmlElement('paragraph') 389 | p.insert(0, [new Y.XmlText('hello world!')]) 390 | yxml.insert(0, [p]) 391 | console.log('anchor', view.state.selection.anchor) 392 | t.assert(view.state.selection.anchor === 1) 393 | t.assert(view.state.selection.head === 1) 394 | } 395 | 396 | export const testVersioning = async (_tc) => { 397 | const ydoc = new Y.Doc({ gc: false }) 398 | const yxml = ydoc.get('prosemirror', Y.XmlFragment) 399 | const permanentUserData = new Y.PermanentUserData(ydoc) 400 | permanentUserData.setUserMapping(ydoc, ydoc.clientID, 'me') 401 | ydoc.gc = false 402 | console.log('yxml', yxml.toString()) 403 | const view = createNewComplexProsemirrorView(ydoc) 404 | const p = new Y.XmlElement('paragraph') 405 | const ytext = new Y.XmlText('hello world!') 406 | p.insert(0, [ytext]) 407 | yxml.insert(0, [p]) 408 | const snapshot1 = Y.snapshot(ydoc) 409 | const snapshotDoc1 = Y.encodeStateAsUpdateV2(ydoc) 410 | ytext.delete(0, 6) 411 | const snapshot2 = Y.snapshot(ydoc) 412 | const snapshotDoc2 = Y.encodeStateAsUpdateV2(ydoc) 413 | view.dispatch( 414 | view.state.tr.setMeta(ySyncPluginKey, { snapshot: snapshot2, prevSnapshot: snapshot1, permanentUserData }) 415 | ) 416 | await promise.wait(50) 417 | console.log('calculated diff via snapshots: ', view.state.doc.toJSON()) 418 | // recreate the JSON, because ProseMirror messes with the constructors 419 | const viewstate1 = JSON.parse(JSON.stringify(view.state.doc.toJSON().content[0].content)) 420 | const expectedState = [{ 421 | type: 'text', 422 | marks: [{ type: 'ychange', attrs: { user: 'me', type: 'removed' } }], 423 | text: 'hello ' 424 | }, { 425 | type: 'text', 426 | text: 'world!' 427 | }] 428 | console.log('calculated diff via snapshots: ', JSON.stringify(viewstate1)) 429 | t.compare(viewstate1, expectedState) 430 | 431 | t.info('now check whether we get the same result when rendering the updates') 432 | view.dispatch( 433 | view.state.tr.setMeta(ySyncPluginKey, { snapshot: snapshotDoc2, prevSnapshot: snapshotDoc1, permanentUserData }) 434 | ) 435 | await promise.wait(50) 436 | 437 | const viewstate2 = JSON.parse(JSON.stringify(view.state.doc.toJSON().content[0].content)) 438 | console.log('calculated diff via updates: ', JSON.stringify(viewstate2)) 439 | t.compare(viewstate2, expectedState) 440 | } 441 | 442 | export const testVersioningWithGarbageCollection = async (_tc) => { 443 | const ydoc = new Y.Doc() 444 | const yxml = ydoc.get('prosemirror', Y.XmlFragment) 445 | const permanentUserData = new Y.PermanentUserData(ydoc) 446 | permanentUserData.setUserMapping(ydoc, ydoc.clientID, 'me') 447 | console.log('yxml', yxml.toString()) 448 | const view = createNewComplexProsemirrorView(ydoc) 449 | const p = new Y.XmlElement('paragraph') 450 | const ytext = new Y.XmlText('hello world!') 451 | p.insert(0, [ytext]) 452 | yxml.insert(0, [p]) 453 | const snapshotDoc1 = Y.encodeStateAsUpdateV2(ydoc) 454 | ytext.delete(0, 6) 455 | const snapshotDoc2 = Y.encodeStateAsUpdateV2(ydoc) 456 | view.dispatch( 457 | view.state.tr.setMeta(ySyncPluginKey, { snapshot: snapshotDoc2, prevSnapshot: snapshotDoc1, permanentUserData }) 458 | ) 459 | await promise.wait(50) 460 | console.log('calculated diff via snapshots: ', view.state.doc.toJSON()) 461 | // recreate the JSON, because ProseMirror messes with the constructors 462 | const viewstate1 = JSON.parse(JSON.stringify(view.state.doc.toJSON().content[0].content)) 463 | const expectedState = [{ 464 | type: 'text', 465 | marks: [{ type: 'ychange', attrs: { user: 'me', type: 'removed' } }], 466 | text: 'hello ' 467 | }, { 468 | type: 'text', 469 | text: 'world!' 470 | }] 471 | console.log('calculated diff via snapshots: ', JSON.stringify(viewstate1)) 472 | t.compare(viewstate1, expectedState) 473 | } 474 | 475 | export const testAddToHistoryIgnore = (_tc) => { 476 | const ydoc = new Y.Doc() 477 | const view = createNewProsemirrorViewWithUndoManager(ydoc) 478 | // perform two changes that are tracked by um - supposed to be merged into a single undo-manager item 479 | view.dispatch( 480 | view.state.tr.insert( 481 | 0, 482 | /** @type {any} */ (schema.node( 483 | 'paragraph', 484 | undefined, 485 | schema.text('123') 486 | )) 487 | ) 488 | ) 489 | view.dispatch( 490 | view.state.tr.insert( 491 | 0, 492 | /** @type {any} */ (schema.node( 493 | 'paragraph', 494 | undefined, 495 | schema.text('456') 496 | )) 497 | ) 498 | ) 499 | const yxml = ydoc.get('prosemirror') 500 | t.assert( 501 | yxml.length === 3 && yxml.get(0).length === 1, 502 | 'contains inserted content (1)' 503 | ) 504 | view.dispatch( 505 | view.state.tr.insert( 506 | 0, 507 | /** @type {any} */ (schema.node( 508 | 'paragraph', 509 | undefined, 510 | schema.text('abc') 511 | )) 512 | ).setMeta('addToHistory', false) 513 | ) 514 | t.assert( 515 | yxml.length === 4 && yxml.get(0).length === 1, 516 | 'contains inserted content (2)' 517 | ) 518 | view.dispatch( 519 | view.state.tr.insert( 520 | 0, 521 | /** @type {any} */ (schema.node( 522 | 'paragraph', 523 | undefined, 524 | schema.text('xyz') 525 | )) 526 | ) 527 | ) 528 | t.assert( 529 | yxml.length === 5 && yxml.get(0).length === 1, 530 | 'contains inserted content (3)' 531 | ) 532 | undo(view.state) 533 | t.assert(yxml.length === 4, 'insertion (3) was undone') 534 | undo(view.state) 535 | console.log(yxml.toString()) 536 | t.assert( 537 | yxml.length === 1 && 538 | yxml.get(0).toString() === 'abc', 539 | 'insertion (1) was undone' 540 | ) 541 | } 542 | 543 | const createNewProsemirrorViewWithSchema = (y, schema, undoManager = false) => { 544 | const view = new EditorView(null, { 545 | // @ts-ignore 546 | state: EditorState.create({ 547 | schema, 548 | plugins: [ySyncPlugin(y.get('prosemirror', Y.XmlFragment))].concat( 549 | undoManager ? [yUndoPlugin()] : [] 550 | ) 551 | }) 552 | }) 553 | return view 554 | } 555 | 556 | const createNewComplexProsemirrorView = (y, undoManager = false) => 557 | createNewProsemirrorViewWithSchema(y, complexSchema, undoManager) 558 | 559 | const createNewProsemirrorView = (y) => 560 | createNewProsemirrorViewWithSchema(y, schema, false) 561 | 562 | const createNewProsemirrorViewWithUndoManager = (y) => 563 | createNewProsemirrorViewWithSchema(y, schema, true) 564 | 565 | let charCounter = 0 566 | 567 | const marksChoices = [ 568 | [schema.mark('strong')], 569 | [schema.mark('comment', { id: 1 })], 570 | [schema.mark('comment', { id: 2 })], 571 | [schema.mark('em')], 572 | [schema.mark('em'), schema.mark('strong')], 573 | [], 574 | [] 575 | ] 576 | 577 | const pmChanges = [ 578 | /** 579 | * @param {Y.Doc} y 580 | * @param {prng.PRNG} gen 581 | * @param {EditorView} p 582 | */ 583 | (_y, gen, p) => { // insert text 584 | const insertPos = prng.int32(gen, 0, p.state.doc.content.size) 585 | const marks = prng.oneOf(gen, marksChoices) 586 | const tr = p.state.tr 587 | const text = charCounter++ + prng.word(gen) 588 | p.dispatch(tr.insert(insertPos, schema.text(text, marks))) 589 | }, 590 | /** 591 | * @param {Y.Doc} y 592 | * @param {prng.PRNG} gen 593 | * @param {EditorView} p 594 | */ 595 | (_y, gen, p) => { // delete text 596 | const insertPos = prng.int32(gen, 0, p.state.doc.content.size) 597 | const overwrite = math.min( 598 | prng.int32(gen, 0, p.state.doc.content.size - insertPos), 599 | 2 600 | ) 601 | p.dispatch(p.state.tr.insertText('', insertPos, insertPos + overwrite)) 602 | }, 603 | /** 604 | * @param {Y.Doc} y 605 | * @param {prng.PRNG} gen 606 | * @param {EditorView} p 607 | */ 608 | (_y, gen, p) => { // format text 609 | const insertPos = prng.int32(gen, 0, p.state.doc.content.size) 610 | const formatLen = math.min( 611 | prng.int32(gen, 0, p.state.doc.content.size - insertPos), 612 | 2 613 | ) 614 | const mark = prng.oneOf(gen, marksChoices.filter(choice => choice.length > 0))[0] 615 | p.dispatch(p.state.tr.addMark(insertPos, insertPos + formatLen, mark)) 616 | }, 617 | /** 618 | * @param {Y.Doc} y 619 | * @param {prng.PRNG} gen 620 | * @param {EditorView} p 621 | */ 622 | (_y, gen, p) => { // replace text 623 | const insertPos = prng.int32(gen, 0, p.state.doc.content.size) 624 | const overwrite = math.min( 625 | prng.int32(gen, 0, p.state.doc.content.size - insertPos), 626 | 2 627 | ) 628 | const text = charCounter++ + prng.word(gen) 629 | p.dispatch(p.state.tr.insertText(text, insertPos, insertPos + overwrite)) 630 | }, 631 | /** 632 | * @param {Y.Doc} y 633 | * @param {prng.PRNG} gen 634 | * @param {EditorView} p 635 | */ 636 | (_y, gen, p) => { // insert paragraph 637 | const insertPos = prng.int32(gen, 0, p.state.doc.content.size) 638 | const marks = prng.oneOf(gen, marksChoices) 639 | const tr = p.state.tr 640 | const text = charCounter++ + prng.word(gen) 641 | p.dispatch( 642 | tr.insert( 643 | insertPos, 644 | schema.node('paragraph', undefined, schema.text(text, marks)) 645 | ) 646 | ) 647 | }, 648 | /** 649 | * @param {Y.Doc} y 650 | * @param {prng.PRNG} gen 651 | * @param {EditorView} p 652 | */ 653 | (_y, gen, p) => { // insert codeblock 654 | const insertPos = prng.int32(gen, 0, p.state.doc.content.size) 655 | const tr = p.state.tr 656 | const text = charCounter++ + prng.word(gen) 657 | p.dispatch( 658 | tr.insert( 659 | insertPos, 660 | schema.node('code_block', undefined, schema.text(text)) 661 | ) 662 | ) 663 | }, 664 | /** 665 | * @param {Y.Doc} y 666 | * @param {prng.PRNG} gen 667 | * @param {EditorView} p 668 | */ 669 | (_y, gen, p) => { // wrap in blockquote 670 | const insertPos = prng.int32(gen, 0, p.state.doc.content.size) 671 | const overwrite = prng.int32(gen, 0, p.state.doc.content.size - insertPos) 672 | const tr = p.state.tr 673 | tr.setSelection( 674 | TextSelection.create(tr.doc, insertPos, insertPos + overwrite) 675 | ) 676 | const $from = tr.selection.$from 677 | const $to = tr.selection.$to 678 | const range = $from.blockRange($to) 679 | const wrapping = range && findWrapping(range, schema.nodes.blockquote) 680 | if (wrapping) { 681 | p.dispatch(tr.wrap(range, wrapping)) 682 | } 683 | } 684 | ] 685 | 686 | /** 687 | * @param {any} result 688 | */ 689 | const checkResult = (result) => { 690 | for (let i = 1; i < result.testObjects.length; i++) { 691 | const p1 = result.testObjects[i - 1].state.doc.toJSON() 692 | const p2 = result.testObjects[i].state.doc.toJSON() 693 | t.compare(p1, p2) 694 | } 695 | } 696 | 697 | /** 698 | * @param {t.TestCase} tc 699 | */ 700 | export const testRepeatGenerateProsemirrorChanges2 = (tc) => { 701 | checkResult(applyRandomTests(tc, pmChanges, 2, createNewProsemirrorView)) 702 | } 703 | 704 | /** 705 | * @param {t.TestCase} tc 706 | */ 707 | export const testRepeatGenerateProsemirrorChanges3 = (tc) => { 708 | checkResult(applyRandomTests(tc, pmChanges, 3, createNewProsemirrorView)) 709 | } 710 | 711 | /** 712 | * @param {t.TestCase} tc 713 | */ 714 | export const testRepeatGenerateProsemirrorChanges30 = (tc) => { 715 | checkResult(applyRandomTests(tc, pmChanges, 30, createNewProsemirrorView)) 716 | } 717 | 718 | /** 719 | * @param {t.TestCase} tc 720 | */ 721 | export const testRepeatGenerateProsemirrorChanges40 = (tc) => { 722 | checkResult(applyRandomTests(tc, pmChanges, 40, createNewProsemirrorView)) 723 | } 724 | 725 | /** 726 | * @param {t.TestCase} tc 727 | */ 728 | export const testRepeatGenerateProsemirrorChanges70 = (tc) => { 729 | checkResult(applyRandomTests(tc, pmChanges, 70, createNewProsemirrorView)) 730 | } 731 | 732 | /** 733 | * @param {t.TestCase} tc 734 | * 735 | export const testRepeatGenerateProsemirrorChanges100 = tc => { 736 | checkResult(applyRandomTests(tc, pmChanges, 100, createNewProsemirrorView)) 737 | } 738 | 739 | /** 740 | * @param {t.TestCase} tc 741 | * 742 | export const testRepeatGenerateProsemirrorChanges300 = tc => { 743 | checkResult(applyRandomTests(tc, pmChanges, 300, createNewProsemirrorView)) 744 | } 745 | */ 746 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2018", 5 | "lib": [ 6 | "es2018", 7 | "dom" 8 | ] /* Specify library files to be included in the compilation. */, 9 | "allowJs": true /* Allow javascript files to be compiled. */, 10 | "checkJs": true /* Report errors in .js files. */, 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true /* Generates corresponding '.d.ts' file. */, 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist" /* Redirect output structure to the directory. */, 17 | "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 18 | // "composite": true, /* Enable project compilation */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": false /* Enable all strict type-checking options. */, 27 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, 28 | "emitDeclarationOnly": true, 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 43 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 44 | "paths": { 45 | // "yjs": ["node_modules/yjs/src/index.js"], 46 | // "lib0/*": ["node_modules/lib0/*"] 47 | }, 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 53 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | }, 65 | "include": ["src"], 66 | "exclude": ["node_modules", "dist"] 67 | } 68 | --------------------------------------------------------------------------------