├── .gitmodules ├── .gitignore ├── .babelrc ├── rollup.node.js ├── src ├── y-xml.js ├── y-xml-fragment.js ├── utils.js ├── y-xml-text.js └── y-xml-element.js ├── rollup.browser.js ├── LICENSE ├── package.json └── README.md /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /y-xml.* 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["latest", { 4 | "es2015": { 5 | "modules": false 6 | } 7 | }] 8 | ], 9 | "plugins": ["external-helpers"] 10 | } 11 | -------------------------------------------------------------------------------- /rollup.node.js: -------------------------------------------------------------------------------- 1 | var pkg = require('./package.json') 2 | 3 | export default { 4 | entry: 'src/y-xml.js', 5 | moduleName: 'yXml', 6 | format: 'cjs', 7 | dest: 'y-xml.node.js', 8 | sourceMap: true, 9 | external: ['fast-diff'], 10 | banner: ` 11 | /** 12 | * ${pkg.name} - ${pkg.description} 13 | * @version v${pkg.version} 14 | * @license ${pkg.license} 15 | */ 16 | ` 17 | } 18 | -------------------------------------------------------------------------------- /src/y-xml.js: -------------------------------------------------------------------------------- 1 | /* global Y, MutationObserver */ 2 | 3 | import yXmlText from './y-xml-text.js' 4 | import yXmlFragment from './y-xml-fragment.js' 5 | import yXmlElement from './y-xml-element.js' 6 | 7 | export default function extendXml (Y, _document, _MutationObserver) { 8 | if (_document == null && typeof document !== 'undefined') { 9 | _document = document 10 | } 11 | if (typeof MutationObserver !== 'undefined') { 12 | _MutationObserver = MutationObserver 13 | } else { 14 | _MutationObserver = null 15 | } 16 | yXmlElement(Y, _document, _MutationObserver) 17 | yXmlText(Y, _document, _MutationObserver) 18 | yXmlFragment(Y, _document, _MutationObserver) 19 | } 20 | 21 | if (typeof Y !== 'undefined') { 22 | extendXml(Y) 23 | } 24 | -------------------------------------------------------------------------------- /rollup.browser.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import uglify from 'rollup-plugin-uglify' 3 | import nodeResolve from 'rollup-plugin-node-resolve' 4 | import commonjs from 'rollup-plugin-commonjs' 5 | var pkg = require('./package.json') 6 | 7 | export default { 8 | entry: 'src/y-xml.js', 9 | moduleName: 'yXml', 10 | format: 'umd', 11 | plugins: [ 12 | nodeResolve({ 13 | main: true, 14 | module: true, 15 | browser: true 16 | }), 17 | commonjs(), 18 | babel(), 19 | uglify({ 20 | output: { 21 | comments: function (node, comment) { 22 | var text = comment.value 23 | var type = comment.type 24 | if (type === 'comment2') { 25 | // multiline comment 26 | return /@license/i.test(text) 27 | } 28 | } 29 | } 30 | }) 31 | ], 32 | dest: 'y-xml.js', 33 | sourceMap: true, 34 | banner: ` 35 | /** 36 | * ${pkg.name} - ${pkg.description} 37 | * @version v${pkg.version} 38 | * @license ${pkg.license} 39 | */ 40 | ` 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "y-xml", 3 | "version": "11.0.0-10", 4 | "description": "Xml Type for Yjs", 5 | "main": "./y-xml.node.js", 6 | "browser": "./y-xml.js", 7 | "module": "./src/y-xml.js", 8 | "scripts": { 9 | "dist": "rollup -c rollup.browser.js && rollup -c rollup.node.js", 10 | "lint": "standard", 11 | "watch": "concurrently 'rollup -wc rollup.browser.js' 'rollup -wc rollup.node.js'", 12 | "postversion": "npm run dist", 13 | "postpublish": "tag-dist-files --overwrite-existing-tag" 14 | }, 15 | "files": [ 16 | "y-xml.*" 17 | ], 18 | "standard": { 19 | "ignore": [ 20 | "/y-array.js", 21 | "/y-array.test.js" 22 | ] 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/y-js/y-xml" 27 | }, 28 | "keywords": [ 29 | "Yjs", 30 | "OT", 31 | "Collaboration", 32 | "Synchronization", 33 | "ShareJS", 34 | "Coweb", 35 | "Concurrency" 36 | ], 37 | "author": "Kevin Jahns ", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/y-js/y-xml/issues" 41 | }, 42 | "homepage": "http://y-js.org", 43 | "devDependencies": { 44 | "babel-plugin-external-helpers": "^6.22.0", 45 | "babel-preset-latest": "^6.24.1", 46 | "concurrently": "^3.5.0", 47 | "rollup-plugin-babel": "^2.7.1", 48 | "rollup-plugin-commonjs": "^8.0.2", 49 | "rollup-plugin-node-resolve": "^3.0.0", 50 | "rollup-plugin-uglify": "^1.0.2", 51 | "rollup-watch": "^3.2.2", 52 | "standard": "^10.0.2", 53 | "tag-dist-files": "^0.1.6" 54 | }, 55 | "peerDependencies": { 56 | "y-array": "^11.0.0-0", 57 | "y-map": "^11.0.0-0", 58 | "yjs": "^13.0.0-0" 59 | }, 60 | "dependencies": { 61 | "fast-diff": "^1.1.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # XML Type for [Yjs](https://github.com/y-js/yjs) 3 | 4 | Share XML documents with this type. You can also get a DOM representation of this shared Y.Xml type. Y.Xml does not yet support the full DOM specification. 5 | 6 | ## Use it! 7 | Retrieve this with bower or npm. 8 | 9 | ##### Bower 10 | ``` 11 | bower install y-xml --save 12 | ``` 13 | 14 | ##### NPM 15 | ``` 16 | npm install y-xml y-array y-map --save 17 | ``` 18 | 19 | This type depends on [y-array](https://github.com/y-js/y-array), and [y-map](https://github.com/y-js/y-map). So you have to extend Yjs in the right order: 20 | 21 | ```js 22 | var Y = require('yjs') 23 | require('y-array')(Y) 24 | require('y-map')(Y) 25 | require('y-xml')(Y) 26 | ``` 27 | 28 | ### Xml Object 29 | 30 | ##### Create 31 | Y.Xml expects a tagname, or a DOM Element as constructor argument. I.e. 32 | 33 | ```js 34 | map.set('myXmlType', Y.XmlElement('div')) 35 | map.set('myOtherXmlType', Y.XmlElement(document.querySelector('div'))) 36 | map.set('myXmlFragment', Y.XmlFragment) 37 | ``` 38 | 39 | When creating a Y.Xml type on y.share, you can specify the tagname like this: 40 | ```js 41 | Y({ 42 | .. 43 | share: { 44 | xml: 'XmlElement("div")' 45 | } 46 | }) 47 | ``` 48 | 49 | ##### Reference 50 | 51 | *Y.XmlFragment* - A collection of nodes 52 | 53 | * bindToDom(dom) 54 | * Bind the children of a dom element to the nodes of this XmlFragment. Useful if you don't want to share the attributes of the root node 55 | 56 | *Y.XmlElement* 57 | 58 | * .setDomFilter((domElement, attributeNames) => filteredAttributes) 59 | * Filter out specific dom elements and attributes 60 | * If `filteredAttributes` is null, the node will not be shared 61 | * Attribute names that are in `attributeNames` but not in `filteredAttributes` will not be shared 62 | * .getDom() 63 | * Returns a DOM Element 64 | * .insert(position, contents) 65 | * Insert an array of children at a position 66 | * .insertDomElements(position, domElements) 67 | * Insert an array of dom elements at a position 68 | * .delete(position, length) 69 | * Delete children. The *length* parameter is optional and defaults to 1 70 | * .get(position) 71 | * Retrieve a child at a position 72 | * Returns a promise if the content is a Y.Xml type 73 | * .observe(function observer(events){..}) 74 | * The `observer` is called synchronously when something changed 75 | * Throws *attributeChanged*, *attributeRemoved*, *childInserted*, and *childRemoved* events (`events[*].type`) 76 | * If value is a Y.Xml type, `events[*].value` is a function that returns a promise for the type 77 | * When employing the DOM binding, you may want to use DOM [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) instead of `.observe(..)` 78 | * .getAttribute(name) 79 | * Get a specific attribute 80 | * .setAttribute(name, value) 81 | * Set a attribute. `value` must be of type string 82 | * .unobserve(f) 83 | * Delete an observer 84 | 85 | ## Changelog 86 | 87 | ### 11.0.0 88 | * `contenteditable` is supported 89 | * Relies on Yjs@^13.0.0 90 | 91 | ### 10.0.0 92 | * Retrieving the dom is a synchronous operation now 93 | * Relies on Yjs@^12.0.0 94 | 95 | ## License 96 | Yjs is licensed under the [MIT License](./LICENSE). 97 | 98 | 99 | -------------------------------------------------------------------------------- /src/y-xml-fragment.js: -------------------------------------------------------------------------------- 1 | import { reflectChangesOnDom, defaultDomFilter, applyChangesFromDom } from './utils.js' 2 | 3 | export default function extendYXmlFragment (Y, _document, _MutationObserver) { 4 | Y.requestModules(['Array']).then(function () { 5 | class YXmlFragment extends Y.Array.typeDefinition['class'] { 6 | constructor (os, _model, _content, args) { 7 | super(os, _model, _content) 8 | this.dom = null 9 | this._domObserver = null 10 | this._domObserverListener = null 11 | this._domFilter = defaultDomFilter 12 | this._scrollElement = null 13 | var token = true 14 | this._mutualExclude = f => { 15 | if (token) { 16 | token = false 17 | try { 18 | f() 19 | } catch (e) { 20 | console.error(e) 21 | } 22 | this._domObserver.takeRecords() 23 | token = true 24 | } 25 | } 26 | reflectChangesOnDom(this) 27 | } 28 | 29 | setDomFilter () { 30 | return Y.XmlElement.typeDefinition.class.prototype 31 | .setDomFilter.apply(this, arguments) 32 | } 33 | enableSmartScrolling () { 34 | return Y.XmlElement.typeDefinition.class.prototype 35 | .enableSmartScrolling.apply(this, arguments) 36 | } 37 | 38 | insertDomElements () { 39 | return Y.XmlElement.typeDefinition.class.prototype.insertDomElements.apply(this, arguments) 40 | } 41 | 42 | bindToDom (dom) { 43 | if (this.dom != null) { 44 | this._unbindFromDom() 45 | } 46 | if (dom.__yxml != null) { 47 | dom.__yxml._unbindFromDom() 48 | } 49 | if (_MutationObserver == null) { 50 | throw new Error('Not able to bind to a DOM element, because MutationObserver is not available!') 51 | } 52 | dom.innerHTML = '' 53 | for (let i = 0; i < this._content.length; i++) { 54 | dom.insertBefore(this.get(i).getDom(), null) 55 | } 56 | this.dom = dom 57 | dom.__yxml = this 58 | this._domObserverListener = () => { 59 | this._mutualExclude(() => applyChangesFromDom(this)) 60 | } 61 | this._domObserver = new _MutationObserver(this._domObserverListener) 62 | this._domObserver.takeRecords() // discard made changes 63 | this._domObserver.observe(this.dom, { childList: true }) 64 | } 65 | 66 | toString () { 67 | return this._content 68 | .map(c => this.os.getType(c.type).toString()) 69 | .join('') 70 | } 71 | 72 | _changed (transaction, op) { 73 | if (this._domObserver != null) { 74 | this._domObserverListener(this._domObserver.takeRecords()) 75 | } 76 | super._changed(transaction, op) 77 | } 78 | 79 | _unbindFromDom () { 80 | if (this._domObserver != null) { 81 | this._domObserver.disconnect() 82 | this._domObserver = null 83 | } 84 | if (this.dom != null) { 85 | this.dom.__yxml = null 86 | this.dom = null 87 | } 88 | } 89 | 90 | _destroy () { 91 | if (this._eventListenerHandler != null) { 92 | this._eventListenerHandler.destroy() 93 | } 94 | this._unbindFromDom() 95 | super._destroy() 96 | } 97 | } 98 | Y.extend('XmlFragment', new Y.utils.CustomTypeDefinition({ 99 | name: 'XmlFragment', 100 | class: YXmlFragment, 101 | struct: 'List', 102 | initType: function YXmlFragmentInitializer (os, model) { 103 | var _content = [] 104 | var _types = [] 105 | Y.Struct.List.map.call(this, model, function (op) { 106 | if (op.hasOwnProperty('opContent')) { 107 | _content.push({ 108 | id: op.id, 109 | type: op.opContent 110 | }) 111 | _types.push(op.opContent) 112 | } else { 113 | op.content.forEach(function (c, i) { 114 | _content.push({ 115 | id: [op.id[0], op.id[1] + i], 116 | val: op.content[i] 117 | }) 118 | }) 119 | } 120 | }) 121 | for (var i = 0; i < _types.length; i++) { 122 | var type = this.store.initType.call(this, _types[i]) 123 | type._parent = model.id 124 | } 125 | return new YXmlFragment(os, model.id, _content) 126 | }, 127 | createType: function YXmlTextCreator (os, model) { 128 | return new YXmlFragment(os, model.id, []) 129 | } 130 | })) 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function reflectChangesOnDom (yxml) { 2 | yxml.observe(event => { 3 | if (yxml.dom != null) { 4 | yxml._mutualExclude(() => { 5 | let anchorViewPosition = getAnchorViewPosition(yxml._scrollElement) 6 | if (event.type === 'attributeChanged') { 7 | yxml.dom.setAttribute(event.name, event.value) 8 | } else if (event.type === 'attributeRemoved') { 9 | yxml.dom.removeAttribute(event.name) 10 | } else if (event.type === 'childInserted' || event.type === 'insert') { 11 | let nodes = event.values 12 | for (let i = nodes.length - 1; i >= 0; i--) { 13 | let node = nodes[i] 14 | node.setDomFilter(yxml._domFilter) 15 | node.enableSmartScrolling(yxml._scrollElement) 16 | let dom = node.getDom() 17 | let fixPosition = null 18 | let nextDom = null 19 | if (yxml._content.length > event.index + i + 1) { 20 | nextDom = yxml.get(event.index + i + 1).getDom() 21 | } 22 | yxml.dom.insertBefore(dom, nextDom) 23 | if (anchorViewPosition === null) { 24 | // nop 25 | } else if (anchorViewPosition.anchor !== null) { 26 | // no scrolling when current selection 27 | if (!dom.contains(anchorViewPosition.anchor) && !anchorViewPosition.anchor.contains(dom)) { 28 | fixPosition = anchorViewPosition 29 | } 30 | } else if (getBoundingClientRect(dom).top <= 0) { 31 | // adjust scrolling if modified element is out of view, 32 | // there is no anchor element, and the browser did not adjust scrollTop (this is checked later) 33 | fixPosition = anchorViewPosition 34 | } 35 | fixScrollPosition(yxml._scrollElement, fixPosition) 36 | } 37 | } else if (event.type === 'childRemoved' || event.type === 'delete') { 38 | for (let i = event.values.length - 1; i >= 0; i--) { 39 | let dom = event.values[i].dom 40 | let fixPosition = null 41 | if (anchorViewPosition === null) { 42 | // nop 43 | } else if (anchorViewPosition.anchor !== null) { 44 | // no scrolling when current selection 45 | if (!dom.contains(anchorViewPosition.anchor) && !anchorViewPosition.anchor.contains(dom)) { 46 | fixPosition = anchorViewPosition 47 | } 48 | } else if (getBoundingClientRect(dom).top <= 0) { 49 | // adjust scrolling if modified element is out of view, 50 | // there is no anchor element, and the browser did not adjust scrollTop (this is checked later) 51 | fixPosition = anchorViewPosition 52 | } 53 | dom.remove() 54 | fixScrollPosition(yxml._scrollElement, fixPosition) 55 | } 56 | } 57 | }) 58 | } 59 | }) 60 | } 61 | 62 | export function getAnchorViewPosition (scrollElement) { 63 | if (scrollElement == null) { 64 | return null 65 | } 66 | let anchor = document.getSelection().anchorNode 67 | if (anchor != null) { 68 | let top = getBoundingClientRect(anchor).top 69 | if (top >= 0 && top <= document.documentElement.clientHeight) { 70 | return { 71 | anchor: anchor, 72 | top: top 73 | } 74 | } 75 | } 76 | return { 77 | anchor: null, 78 | scrollTop: scrollElement.scrollTop, 79 | scrollHeight: scrollElement.scrollHeight 80 | } 81 | } 82 | 83 | // get BoundingClientRect that works on text nodes 84 | export function getBoundingClientRect (element) { 85 | if (element.getBoundingClientRect != null) { 86 | // is element node 87 | return element.getBoundingClientRect() 88 | } else { 89 | // is text node 90 | if (element.parentNode == null) { 91 | // range requires that text nodes have a parent 92 | let span = document.createElement('span') 93 | span.appendChild(element) 94 | } 95 | let range = document.createRange() 96 | range.selectNode(element) 97 | return range.getBoundingClientRect() 98 | } 99 | } 100 | 101 | export function fixScrollPosition (scrollElement, fix) { 102 | if (scrollElement !== null && fix !== null) { 103 | if (fix.anchor === null) { 104 | if (scrollElement.scrollTop === fix.scrollTop) { 105 | scrollElement.scrollTop += scrollElement.scrollHeight - fix.scrollHeight 106 | } 107 | } else { 108 | scrollElement.scrollTop += getBoundingClientRect(fix.anchor).top - fix.top 109 | } 110 | } 111 | } 112 | 113 | export function defaultDomFilter (node, attributes) { 114 | return attributes 115 | } 116 | 117 | /* 118 | * 1. Check if any of the nodes was deleted 119 | * 2. Iterate over the children. 120 | * 2.1 If a node exists without __yxml property, insert a new node 121 | * 2.2 If _contents.length < dom.childNodes.length, fill the 122 | * rest of _content with childNodes 123 | * 2.3 If a node was moved, delete it and 124 | * recreate a new yxml element that is bound to that node. 125 | * You can detect that a node was moved because expectedId 126 | * !== actualId in the list 127 | */ 128 | export function applyChangesFromDom (yxml) { 129 | // list of known children. anything else should be deleted 130 | let knownChildren = 131 | new Set( 132 | Array.prototype.map.call(yxml.dom.childNodes, child => child.__yxml) 133 | .filter(id => id !== undefined) 134 | ) 135 | // 1. Check if any of the nodes was deleted 136 | for (let i = yxml._content.length - 1; i >= 0; i--) { 137 | let childType = yxml.get(i) 138 | if (!knownChildren.has(childType)) { 139 | yxml.delete(i, 1) 140 | } 141 | } 142 | // 2. iterate 143 | let childNodes = yxml.dom.childNodes 144 | let len = childNodes.length 145 | for (let domCnt = 0, yCnt = 0; domCnt < len; domCnt++) { 146 | let child = childNodes[domCnt] 147 | if (child.__yxml != null) { 148 | if (child.__yxml === false) { 149 | // should be ignored or is going to be deleted 150 | continue 151 | } 152 | if (yCnt < yxml.length) { 153 | let expectedNode = yxml.get(yCnt) 154 | if (expectedNode !== child.__yxml) { 155 | // 2.3 Not expected node 156 | let index = yxml._content.findIndex(c => c.type[0] === child.__yxml._model[0] && c.type[1] === child.__yxml._model[1]) 157 | if (index < 0) { 158 | // element is going to be deleted by its previous parent 159 | child.__yxml = null 160 | } else { 161 | yxml.delete(index, 1) 162 | } 163 | yCnt += yxml.insertDomElements(yCnt, [child]) 164 | } else { 165 | yCnt++ 166 | } 167 | // if this is the expected node id, just continue 168 | } else { 169 | // 2.2 fill _conten with child nodes 170 | yCnt += yxml.insertDomElements(yCnt, [child]) 171 | } 172 | } else { 173 | // 2.1 A new node was found 174 | yCnt += yxml.insertDomElements(yCnt, [child]) 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/y-xml-text.js: -------------------------------------------------------------------------------- 1 | /* global getSelection */ 2 | 3 | import diff from 'fast-diff' 4 | import { getAnchorViewPosition, fixScrollPosition, getBoundingClientRect } from './utils.js' 5 | 6 | function fixPosition (event, pos) { 7 | if (event.index <= pos) { 8 | if (event.type === 'delete') { 9 | return pos - Math.min(pos - event.index, event.length) 10 | } else { 11 | return pos + 1 12 | } 13 | } else { 14 | return pos 15 | } 16 | } 17 | 18 | export default function extendYXmlText (Y, _document, _MutationObserver) { 19 | Y.requestModules(['Array']).then(function () { 20 | class YXmlText extends Y.Array.typeDefinition['class'] { 21 | constructor (os, _model, _content, args) { 22 | super(os, _model, _content) 23 | if (args != null && args.content != null && _model[0] !== '_') { 24 | this.insert(0, args.content) 25 | } 26 | this.dom = null 27 | this._domObserver = null 28 | this._domObserverListener = null 29 | this._scrollElement = null 30 | if (args != null && args.dom != null) { 31 | this._setDom(args.dom) 32 | } 33 | var token = true 34 | this._mutualExcluse = f => { 35 | if (token) { 36 | token = false 37 | try { 38 | f() 39 | } catch (e) { 40 | console.error(e) 41 | } 42 | this._domObserver.takeRecords() 43 | token = true 44 | } 45 | } 46 | this.observe(event => { 47 | if (this.dom != null) { 48 | this._mutualExcluse(() => { 49 | let selection = null 50 | let shouldUpdateSelection = false 51 | let anchorNode = null 52 | let anchorOffset = null 53 | let focusNode = null 54 | let focusOffset = null 55 | if (typeof getSelection !== 'undefined') { 56 | selection = getSelection() 57 | if (selection.anchorNode === this.dom) { 58 | anchorNode = selection.anchorNode 59 | anchorOffset = fixPosition(event, selection.anchorOffset) 60 | shouldUpdateSelection = true 61 | } 62 | if (selection.focusNode === this.dom) { 63 | focusNode = selection.focusNode 64 | focusOffset = fixPosition(event, selection.focusOffset) 65 | shouldUpdateSelection = true 66 | } 67 | } 68 | let anchorViewPosition = getAnchorViewPosition(this._scrollElement) 69 | let anchorViewFix 70 | if (anchorViewPosition !== null && (anchorViewPosition.anchor !== null || getBoundingClientRect(this.dom).top <= 0)) { 71 | anchorViewFix = anchorViewPosition 72 | } else { 73 | anchorViewFix = null 74 | } 75 | this.dom.nodeValue = this.toString() 76 | fixScrollPosition(this._scrollElement, anchorViewFix) 77 | if (shouldUpdateSelection) { 78 | selection.setBaseAndExtent( 79 | anchorNode || selection.anchorNode, 80 | anchorOffset || selection.anchorOffset, 81 | focusNode || selection.focusNode, 82 | focusOffset || selection.focusOffset 83 | ) 84 | } 85 | }) 86 | } 87 | }) 88 | } 89 | 90 | setDomFilter () {} 91 | 92 | enableSmartScrolling (scrollElement) { 93 | this._scrollElement = scrollElement 94 | } 95 | 96 | _setDom (dom) { 97 | if (this.dom != null) { 98 | this._unbindFromDom() 99 | } 100 | if (dom.__yxml != null) { 101 | dom.__yxml._unbindFromDom() 102 | } 103 | if (_MutationObserver == null) { 104 | return 105 | } 106 | this.dom = dom 107 | dom.__yxml = this 108 | this._domObserverListener = () => { 109 | this._mutualExcluse(() => { 110 | var diffs = diff(this.toString(), this.dom.nodeValue) 111 | var pos = 0 112 | for (var i = 0; i < diffs.length; i++) { 113 | var d = diffs[i] 114 | if (d[0] === 0) { // EQUAL 115 | pos += d[1].length 116 | } else if (d[0] === -1) { // DELETE 117 | this.delete(pos, d[1].length) 118 | } else { // INSERT 119 | this.insert(pos, d[1]) 120 | pos += d[1].length 121 | } 122 | } 123 | }) 124 | } 125 | this._domObserver = new _MutationObserver(this._domObserverListener) 126 | this._domObserver.observe(this.dom, { characterData: true }) 127 | } 128 | 129 | getDom () { 130 | if (this.dom == null) { 131 | let dom = _document.createTextNode(this.toString()) 132 | if (_MutationObserver !== null) { 133 | this._setDom(dom) 134 | } 135 | return dom 136 | } else { 137 | return this.dom 138 | } 139 | } 140 | 141 | toString () { 142 | return this._content.map(function (c) { 143 | return c.val 144 | }).join('') 145 | } 146 | 147 | insert (pos, content) { 148 | super.insert(pos, content.split('')) 149 | } 150 | 151 | _changed (transaction, op) { 152 | if (this._domObserver != null) { 153 | this._domObserverListener(this._domObserver.takeRecords()) 154 | } 155 | super._changed(transaction, op) 156 | } 157 | 158 | _unbindFromDom () { 159 | if (this._domObserver != null) { 160 | this._domObserver.disconnect() 161 | this._domObserver = null 162 | } 163 | if (this.dom != null) { 164 | this.dom.__yxml = null 165 | this.dom = null 166 | } 167 | } 168 | 169 | _destroy () { 170 | if (this._eventListenerHandler != null) { 171 | this._eventListenerHandler.destroy() 172 | } 173 | this._unbindFromDom() 174 | super._destroy() 175 | } 176 | } 177 | Y.extend('XmlText', new Y.utils.CustomTypeDefinition({ 178 | name: 'XmlText', 179 | class: YXmlText, 180 | struct: 'List', 181 | parseArguments: function (arg) { 182 | if (typeof arg === 'string') { 183 | return [this, { content: arg }] 184 | } else if (arg.nodeType === _document.TEXT_NODE) { 185 | return [this, { content: arg.nodeValue, dom: arg }] 186 | } else { 187 | return [this, {}] 188 | } 189 | }, 190 | initType: function YXmlTextInitializer (os, model, init) { 191 | var _content = [] 192 | Y.Struct.List.map.call(this, model, function (op) { 193 | if (op.hasOwnProperty('opContent')) { 194 | throw new Error('Text must not contain types!') 195 | } else { 196 | op.content.forEach(function (c, i) { 197 | _content.push({ 198 | id: [op.id[0], op.id[1] + i], 199 | val: op.content[i] 200 | }) 201 | }) 202 | } 203 | }) 204 | return new YXmlText(os, model.id, _content, {}, init || {}) 205 | }, 206 | createType: function YXmlTextCreator (os, model, args) { 207 | return new YXmlText(os, model.id, [], args || {}) 208 | } 209 | })) 210 | }) 211 | } 212 | -------------------------------------------------------------------------------- /src/y-xml-element.js: -------------------------------------------------------------------------------- 1 | // import diff from 'fast-diff' 2 | import { reflectChangesOnDom, defaultDomFilter, applyChangesFromDom } from './utils.js' 3 | 4 | export default function extendXmlElement (Y, _document, _MutationObserver) { 5 | function yarrayEventHandler (op) { 6 | if (op.struct === 'Insert') { 7 | // when using indexeddb db adapter, the op could already exist (see y-js/y-indexeddb#2) 8 | if (this._content.some(function (c) { return Y.utils.compareIds(c.id, op.id) })) { 9 | // op exists 10 | return 11 | } 12 | let pos 13 | // we check op.left only!, 14 | // because op.right might not be defined when this is called 15 | if (op.left === null) { 16 | pos = 0 17 | } else { 18 | pos = 1 + this._content.findIndex(function (c) { 19 | return Y.utils.compareIds(c.id, op.left) 20 | }) 21 | if (pos <= 0) { 22 | throw new Error('Unexpected operation!') 23 | } 24 | } 25 | /* 26 | (see above for new approach) 27 | var _e = this._content[pos] 28 | // when using indexeddb db adapter, the op could already exist (see y-js/y-indexeddb#2) 29 | // If the algorithm works correctly, the double should always exist on the correct position (pos - the computed destination) 30 | if (_e != null && Y.utils.compareIds(_e.id, op.id)) { 31 | // is already defined 32 | return 33 | } 34 | */ 35 | var values 36 | var length 37 | if (op.hasOwnProperty('opContent')) { 38 | this._content.splice(pos, 0, { 39 | id: op.id, 40 | type: op.opContent 41 | }) 42 | length = 1 43 | let type = this.os.getType(op.opContent) 44 | type._parent = this._model 45 | values = [type] 46 | } else { 47 | var contents = op.content.map(function (c, i) { 48 | return { 49 | id: [op.id[0], op.id[1] + i], 50 | val: c 51 | } 52 | }) 53 | // insert value in _content 54 | // It is not possible to insert more than ~2^16 elements in an Array (see #5). We handle this case explicitly 55 | if (contents.length < 30000) { 56 | this._content.splice.apply(this._content, [pos, 0].concat(contents)) 57 | } else { 58 | this._content = this._content.slice(0, pos).concat(contents).concat(this._content.slice(pos)) 59 | } 60 | values = op.content 61 | length = op.content.length 62 | } 63 | Y.utils.bubbleEvent(this, { 64 | type: 'insert', 65 | object: this, 66 | index: pos, 67 | values: values, 68 | length: length 69 | }) 70 | } else if (op.struct === 'Delete') { 71 | var i = 0 // current position in _content 72 | for (; i < this._content.length && op.length > 0; i++) { 73 | var c = this._content[i] 74 | if (Y.utils.inDeletionRange(op, c.id)) { 75 | // is in deletion range! 76 | var delLength 77 | // check how many character to delete in one flush 78 | for (delLength = 1; 79 | delLength < op.length && i + delLength < this._content.length && Y.utils.inDeletionRange(op, this._content[i + delLength].id); 80 | delLength++) {} 81 | // last operation that will be deleted 82 | c = this._content[i + delLength - 1] 83 | // update delete operation 84 | op.length -= c.id[1] - op.target[1] + 1 85 | op.target = [c.id[0], c.id[1] + 1] 86 | // apply deletion & find send event 87 | let content = this._content.splice(i, delLength) 88 | let values = content.map((c) => { 89 | if (c.val != null) { 90 | return c.val 91 | } else { 92 | return this.os.getType(c.type) 93 | } 94 | }) 95 | Y.utils.bubbleEvent(this, { 96 | type: 'delete', 97 | object: this, 98 | index: i, 99 | values: values, 100 | _content: content, 101 | length: delLength 102 | }) 103 | // with the fresh delete op, we can continue 104 | // note: we don't have to increment i, because the i-th content was deleted 105 | // but on the other had, the (i+delLength)-th was not in deletion range 106 | // So we don't do i-- 107 | } 108 | } 109 | } else { 110 | throw new Error('Unexpected struct!') 111 | } 112 | } 113 | 114 | function ymapEventHandler (op) { 115 | var oldValue 116 | // key is the name to use to access (op)content 117 | var key = op.struct === 'Delete' ? op.key : op.parentSub 118 | // compute oldValue 119 | if (this.opContents[key] != null) { 120 | oldValue = this.os.getType(this.opContents[key]) 121 | } else { 122 | oldValue = this.contents[key] 123 | } 124 | // compute op event 125 | if (op.struct === 'Insert') { 126 | if (op.left === null && !Y.utils.compareIds(op.id, this.map[key])) { 127 | var value 128 | // TODO: what if op.deleted??? I partially handles this case here.. but need to send delete event instead. somehow related to #4 129 | if (op.opContent != null) { 130 | value = this.os.getType(op.opContent) 131 | value._parent = this._model 132 | delete this.contents[key] 133 | if (op.deleted) { 134 | delete this.opContents[key] 135 | } else { 136 | this.opContents[key] = op.opContent 137 | } 138 | } else { 139 | value = op.content[0] 140 | delete this.opContents[key] 141 | if (op.deleted) { 142 | delete this.contents[key] 143 | } else { 144 | this.contents[key] = op.content[0] 145 | } 146 | } 147 | this.map[key] = op.id 148 | if (oldValue === undefined) { 149 | Y.utils.bubbleEvent(this, { 150 | name: key, 151 | object: this, 152 | type: 'add', 153 | value: value 154 | }) 155 | } else { 156 | Y.utils.bubbleEvent(this, { 157 | name: key, 158 | object: this, 159 | oldValue: oldValue, 160 | type: 'update', 161 | value: value 162 | }) 163 | } 164 | } 165 | } else if (op.struct === 'Delete') { 166 | if (Y.utils.compareIds(this.map[key], op.target)) { 167 | delete this.opContents[key] 168 | delete this.contents[key] 169 | Y.utils.bubbleEvent(this, { 170 | name: key, 171 | object: this, 172 | oldValue: oldValue, 173 | type: 'delete' 174 | }) 175 | } 176 | } else { 177 | throw new Error('Unexpected Operation!') 178 | } 179 | } 180 | 181 | class YXmlElement extends Y.utils.CustomType { 182 | constructor (os, model, arrayContent, contents, opContents, dom, domFilter) { 183 | super() 184 | this._os = os 185 | this.os = os 186 | this._model = model.id 187 | this._parent = null 188 | // map is the map of attributes (y-map convention) 189 | this.map = Y.utils.copyObject(model.map) 190 | this.contents = contents 191 | this.opContents = opContents 192 | // _content is the list of childnotes (y-array convention) 193 | this._content = arrayContent 194 | this.nodeName = model.nodeName 195 | let mapEventHandler = ymapEventHandler.bind(this) 196 | let arrayEventHandler = yarrayEventHandler.bind(this) 197 | let eventHandler = new Y.utils.EventHandler(function (op) { 198 | if (op.parentSub !== undefined || op.key !== undefined) { 199 | mapEventHandler(op) 200 | } else { 201 | arrayEventHandler(op) 202 | } 203 | }) 204 | this.eventHandler = eventHandler 205 | this._deepEventHandler = new Y.utils.EventListenerHandler() 206 | this._eventListenerHandler = eventHandler 207 | this._domObserver = null 208 | this._scrollElement = null 209 | this.dom = null 210 | this._domFilter = domFilter 211 | if (dom != null) { 212 | this._setDom(dom) 213 | } 214 | // this function makes sure that either the 215 | // dom event is executed, or the yjs observer is executed 216 | var token = true 217 | this._mutualExclude = f => { 218 | if (token) { 219 | token = false 220 | try { 221 | f() 222 | } catch (e) { 223 | console.error(e) 224 | } 225 | this._domObserver.takeRecords() 226 | token = true 227 | } 228 | } 229 | // Apply Y.Xml events to dom 230 | reflectChangesOnDom(this) 231 | } 232 | 233 | enableSmartScrolling (scrollElement) { 234 | this._scrollElement = scrollElement 235 | let len = this._content.length 236 | for (let i = 0; i < len; i++) { 237 | this.get(i).enableSmartScrolling(scrollElement) 238 | } 239 | } 240 | 241 | setDomFilter (f) { 242 | this._domFilter = f 243 | let len = this._content.length 244 | for (let i = 0; i < len; i++) { 245 | this.get(i).setDomFilter(f) 246 | } 247 | } 248 | 249 | get length () { 250 | return this._content.length 251 | } 252 | 253 | toString () { 254 | let nodeName = this.nodeName.toLowerCase() 255 | let children = this._content 256 | .map(c => this.os.getType(c.type).toString()) 257 | .join('') 258 | if (children.length === 0) { 259 | return `<${nodeName}/>` 260 | } else { 261 | return `<${nodeName}>${children}` 262 | } 263 | } 264 | 265 | _getPathToChild (childId) { 266 | return this._content.findIndex(c => 267 | c.type != null && Y.utils.compareIds(c.type, childId) 268 | ) 269 | } 270 | 271 | _unbindFromDom () { 272 | if (this._domObserver != null) { 273 | this._domObserver.disconnect() 274 | this._domObserver = null 275 | } 276 | if (this.dom != null) { 277 | this.dom.__yxml = null 278 | this.dom = null 279 | } 280 | } 281 | 282 | _destroy () { 283 | this._unbindFromDom() 284 | if (this._eventListenerHandler != null) { 285 | this._eventListenerHandler.destroy() 286 | this._eventListenerHandler = null 287 | } 288 | this.nodeName = null 289 | // y-array destroy 290 | this._content = null 291 | // y-map destroy 292 | this.contents = null 293 | this.opContents = null 294 | this.map = null 295 | } 296 | 297 | insertDomElements (pos, doms) { 298 | let types = [] 299 | doms.forEach(d => { 300 | if (d.__yxml != null && d.__yxml !== false) { 301 | d.__yxml._unbindFromDom() 302 | } 303 | if (this._domFilter(d, []) !== null) { 304 | let type 305 | if (d.nodeType === _document.TEXT_NODE) { 306 | type = Y.XmlText(d) 307 | } else if (d.nodeType === _document.ELEMENT_NODE) { 308 | // dom filter must be set befor dom is set! 309 | // otherwise this element will not filter children 310 | type = Y.XmlElement(d, this._domFilter) 311 | } else { 312 | throw new Error('Unsupported node!') 313 | } 314 | types.push(type) 315 | } else { 316 | d.__yxml = false 317 | } 318 | }) 319 | this.insert(pos, types) 320 | let len = types.length 321 | for (let i = pos; i < pos + len; i++) { 322 | let type = this.get(i) 323 | type.setDomFilter(this._domFilter) 324 | type.enableSmartScrolling(this._scrollElement) 325 | } 326 | return len 327 | } 328 | 329 | insert (pos, types) { 330 | if (!Array.isArray(types)) { 331 | throw new Error('Expected an Array of content!') 332 | } 333 | for (var i = 0; i < types.length; i++) { 334 | var v = types[i] 335 | var t = Y.utils.isTypeDefinition(v) 336 | if (t == null || (t[0].name !== 'XmlElement' && t[0].name !== 'XmlText')) { 337 | throw new Error('Expected Y.Xml type or String!') 338 | } 339 | } 340 | Y.Array.typeDefinition.class.prototype.insert.call(this, pos, types) 341 | } 342 | 343 | delete () { 344 | return Y.Array.typeDefinition.class.prototype.delete.apply(this, arguments) 345 | } 346 | 347 | get () { 348 | return Y.Array.typeDefinition.class.prototype.get.apply(this, arguments) 349 | } 350 | 351 | removeAttribute () { 352 | return Y.Map.typeDefinition.class.prototype.delete.apply(this, arguments) 353 | } 354 | 355 | setAttribute () { 356 | return Y.Map.typeDefinition.class.prototype.set.apply(this, arguments) 357 | } 358 | 359 | getAttribute () { 360 | return Y.Map.typeDefinition.class.prototype.get.apply(this, arguments) 361 | } 362 | 363 | getAttributes () { 364 | let keys = Y.Map.typeDefinition.class.prototype.keys.apply(this) 365 | let obj = {} 366 | keys.forEach(key => { 367 | let val = Y.Map.typeDefinition.class.prototype.get.call(this, key) 368 | if (val != null) { 369 | obj[key] = val 370 | } 371 | }) 372 | return obj 373 | } 374 | 375 | // binds to a dom element 376 | // Only call if dom and YXml are isomorph 377 | _bindToDom (dom) { 378 | this._domObserverListener = mutations => { 379 | this._mutualExclude(() => { 380 | let diffChildren = false 381 | mutations.forEach(mutation => { 382 | if (mutation.type === 'attributes') { 383 | let name = mutation.attributeName 384 | // check if filter accepts attribute 385 | if (this._domFilter(this.dom, [name]).length > 0) { 386 | var val = mutation.target.getAttribute(name) 387 | if (this.getAttribute(name) !== val) { 388 | if (val == null) { 389 | this.removeAttribute(name) 390 | } else { 391 | this.setAttribute(name, val) 392 | } 393 | } 394 | } 395 | } else if (mutation.type === 'childList') { 396 | diffChildren = true 397 | } 398 | }) 399 | if (diffChildren) { 400 | applyChangesFromDom(this) 401 | } 402 | }) 403 | } 404 | this._domObserver = new _MutationObserver(this._domObserverListener) 405 | this._domObserver.observe(dom, { attributes: true, childList: true }) 406 | return dom 407 | } 408 | 409 | _setDom (dom) { 410 | if (this.dom != null) { 411 | throw new Error('Only call this method if you know what you are doing ;)') 412 | } else if (dom.__yxml != null) { // TODO do i need to check this? - no.. but for dev purps.. 413 | throw new Error('Already bound to an YXml type') 414 | } else { 415 | dom.__yxml = this 416 | // tag is already set in constructor 417 | // set attributes 418 | let attrNames = [] 419 | for (let i = 0; i < dom.attributes.length; i++) { 420 | attrNames.push(dom.attributes[i].name) 421 | } 422 | attrNames = this._domFilter(dom, attrNames) 423 | for (let i = 0; i < attrNames.length; i++) { 424 | let attrName = attrNames[i] 425 | let attrValue = dom.getAttribute(attrName) 426 | this.setAttribute(attrName, attrValue) 427 | } 428 | this.insertDomElements(0, Array.prototype.slice.call(dom.childNodes)) 429 | if (_MutationObserver != null) { 430 | this.dom = this._bindToDom(dom) 431 | } 432 | return dom 433 | } 434 | } 435 | 436 | getDom () { 437 | let dom = this.dom 438 | if (dom == null) { 439 | dom = _document.createElement(this.nodeName) 440 | dom.__yxml = this 441 | let attrs = this.getAttributes() 442 | for (let key in attrs) { 443 | dom.setAttribute(key, attrs[key]) 444 | } 445 | for (var i = 0; i < this._content.length; i++) { 446 | let c = this._content[i] 447 | let type = this.os.getType(c.type) 448 | dom.appendChild(type.getDom()) 449 | } 450 | if (_MutationObserver !== null) { 451 | this.dom = this._bindToDom(dom) 452 | } 453 | } 454 | return dom 455 | } 456 | 457 | observe (f) { 458 | function observeWrapper (event) { 459 | if (event.type === 'insert') { 460 | f({ 461 | type: 'childInserted', 462 | index: event.index, 463 | values: event.values 464 | }) 465 | } else if (event.type === 'delete') { 466 | if (event.index !== undefined) { 467 | f({ 468 | type: 'childRemoved', 469 | index: event.index, 470 | values: event.values, 471 | _content: event._content 472 | }) 473 | } else { 474 | f({ 475 | type: 'attributeRemoved', 476 | name: event.name 477 | }) 478 | } 479 | } else if (event.type === 'update' || event.type === 'add') { 480 | f({ 481 | type: 'attributeChanged', 482 | name: event.name, 483 | value: event.value 484 | }) 485 | } else { 486 | throw new Error('Unexpected event') 487 | } 488 | } 489 | this._eventListenerHandler.addEventListener(observeWrapper) 490 | return observeWrapper 491 | } 492 | 493 | unobserve (f) { 494 | this._eventListenerHandler.removeEventListener(f) 495 | } 496 | observeDeep (f) { 497 | this._deepEventHandler.addEventListener(f) 498 | } 499 | unobserveDeep (f) { 500 | this._deepEventHandler.removeEventListener(f) 501 | } 502 | 503 | _changed (transaction, op) { 504 | if (this._domObserver != null) { 505 | this._domObserverListener(this._domObserver.takeRecords()) 506 | } 507 | if (op.parentSub !== undefined || op.targetParent !== undefined) { 508 | Y.Map.typeDefinition['class'].prototype._changed.apply(this, arguments) 509 | } else { 510 | Y.Array.typeDefinition['class'].prototype._changed.apply(this, arguments) 511 | } 512 | } 513 | } 514 | 515 | Y.extend('XmlElement', new Y.utils.CustomTypeDefinition({ 516 | name: 'XmlElement', 517 | class: YXmlElement, 518 | struct: 'Xml', 519 | parseArguments: function (arg, arg2) { 520 | let domFilter 521 | if (typeof arg2 === 'function') { 522 | domFilter = arg2 523 | } else { 524 | domFilter = defaultDomFilter 525 | } 526 | if (typeof arg === 'string') { 527 | return [this, { 528 | nodeName: arg.toUpperCase(), 529 | dom: null, 530 | domFilter 531 | }] 532 | } else if (arg.nodeType === _document.ELEMENT_NODE) { 533 | return [this, { 534 | nodeName: arg.nodeName, 535 | dom: arg, 536 | domFilter 537 | }] 538 | } else { 539 | throw new Error('Y.XmlElement requires an argument which is a string!') 540 | } 541 | }, 542 | initType: function YXmlElementInitializer (os, model, init) { 543 | // here begins the modified y-array init 544 | var _content = [] 545 | var _types = [] 546 | Y.Struct.Xml.map.call(this, model, function (op) { 547 | if (op.hasOwnProperty('opContent')) { 548 | _content.push({ 549 | id: op.id, 550 | type: op.opContent 551 | }) 552 | _types.push(op.opContent) 553 | } else { 554 | op.content.forEach(function (c, i) { 555 | _content.push({ 556 | id: [op.id[0], op.id[1] + i], 557 | val: op.content[i] 558 | }) 559 | }) 560 | } 561 | }) 562 | for (var i = 0; i < _types.length; i++) { 563 | let type = this.store.initType.call(this, _types[i], init) 564 | type._parent = model.id 565 | } 566 | // here begins the modified y-map init 567 | var contents = {} 568 | var opContents = {} 569 | var map = model.map 570 | for (var name in map) { 571 | var op = this.getOperation(map[name]) 572 | if (op.deleted) continue 573 | if (op.opContent != null) { 574 | opContents[name] = op.opContent 575 | this.store.initType.call(this, op.opContent) 576 | } else { 577 | contents[name] = op.content[0] 578 | } 579 | } 580 | return new YXmlElement(os, model, _content, contents, opContents, init != null ? init.dom : null, init != null ? init.domFilter : defaultDomFilter) 581 | }, 582 | createType: function YXmlElementCreator (os, model, args) { 583 | return new YXmlElement(os, model, [], {}, {}, args.dom, args.domFilter) 584 | } 585 | })) 586 | } 587 | --------------------------------------------------------------------------------