├── .gitignore ├── .babelrc ├── rollup.node.js ├── rollup.test.js ├── rollup.browser.js ├── LICENSE ├── package.json ├── README.md └── src └── y-array.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | .vscode 3 | /y-array.* 4 | -------------------------------------------------------------------------------- /.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-array.js', 5 | moduleName: 'yArray', 6 | format: 'umd', 7 | dest: 'y-array.node.js', 8 | sourceMap: true, 9 | banner: ` 10 | /** 11 | * ${pkg.name} - ${pkg.description} 12 | * @version v${pkg.version} 13 | * @license ${pkg.license} 14 | */ 15 | ` 16 | } 17 | -------------------------------------------------------------------------------- /rollup.test.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from 'rollup-plugin-node-resolve' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import multiEntry from 'rollup-plugin-multi-entry' 4 | 5 | export default { 6 | entry: 'test/*', 7 | moduleName: 'y-array-tests', 8 | format: 'umd', 9 | plugins: [ 10 | nodeResolve({ 11 | main: true, 12 | module: true, 13 | browser: true 14 | }), 15 | commonjs(), 16 | multiEntry() 17 | ], 18 | dest: 'y-array.test.js', 19 | sourceMap: true 20 | } 21 | -------------------------------------------------------------------------------- /rollup.browser.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import uglify from 'rollup-plugin-uglify' 3 | var pkg = require('./package.json') 4 | 5 | export default { 6 | entry: 'src/y-array.js', 7 | moduleName: 'yArray', 8 | format: 'umd', 9 | plugins: [ 10 | babel(), 11 | uglify({ 12 | output: { 13 | comments: function (node, comment) { 14 | var text = comment.value 15 | var type = comment.type 16 | if (type === 'comment2') { 17 | // multiline comment 18 | return /@license/i.test(text) 19 | } 20 | } 21 | } 22 | }) 23 | ], 24 | dest: 'y-array.js', 25 | sourceMap: true, 26 | banner: ` 27 | /** 28 | * ${pkg.name} - ${pkg.description} 29 | * @version v${pkg.version} 30 | * @license ${pkg.license} 31 | */ 32 | ` 33 | } 34 | -------------------------------------------------------------------------------- /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-array", 3 | "version": "11.0.0-5", 4 | "description": "Array Type for Yjs", 5 | "main": "./y-array.node.js", 6 | "browser": "./y-array.js", 7 | "module": "./src/y-array.js", 8 | "scripts": { 9 | "test": "npm run lint", 10 | "dist": "rollup -c rollup.browser.js && rollup -c rollup.node.js", 11 | "lint": "standard", 12 | "watch": "concurrently 'rollup -wc rollup.browser.js' 'rollup -wc rollup.node.js'", 13 | "postversion": "npm run dist", 14 | "postpublish": "tag-dist-files --overwrite-existing-tag" 15 | }, 16 | "files": [ 17 | "y-array.*" 18 | ], 19 | "standard": { 20 | "ignore": [ 21 | "/y-array.js", 22 | "/y-array.test.js" 23 | ] 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/y-js/y-array" 28 | }, 29 | "keywords": [ 30 | "Yjs", 31 | "OT", 32 | "Collaboration", 33 | "Synchronization", 34 | "ShareJS", 35 | "Coweb", 36 | "Concurrency" 37 | ], 38 | "author": "Kevin Jahns ", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/y-js/yjs/issues" 42 | }, 43 | "homepage": "http://y-js.org", 44 | "devDependencies": { 45 | "babel-plugin-external-helpers": "^6.22.0", 46 | "babel-preset-latest": "^6.24.1", 47 | "chance": "^1.0.10", 48 | "concurrently": "^3.4.0", 49 | "cutest": "^0.1.9", 50 | "rollup-plugin-babel": "^2.7.1", 51 | "rollup-plugin-commonjs": "^8.0.2", 52 | "rollup-plugin-multi-entry": "^2.0.1", 53 | "rollup-plugin-node-resolve": "^3.0.0", 54 | "rollup-plugin-uglify": "^1.0.2", 55 | "rollup-watch": "^3.2.2", 56 | "standard": "^10.0.2", 57 | "tag-dist-files": "^0.1.6" 58 | }, 59 | "peerDependencies": { 60 | "yjs": ">=13.0.0-0 <14.0.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Array Type for [Yjs](https://github.com/y-js/yjs) 2 | 3 | This plugins provides a shareable Array type. You can insert and delete objects in y-array. The objects must either be a custom type, 4 | or fulfill the following property: `v equals JSON.parse(JSON.stringify(v))` (according to your definition of equality) 5 | 6 | ## Use it! 7 | Install this with bower or npm. 8 | 9 | ##### Bower 10 | ``` 11 | bower install y-array --save 12 | ``` 13 | 14 | ##### NPM 15 | ``` 16 | npm install y-array --save 17 | ``` 18 | 19 | ### Array Object 20 | 21 | ##### Reference 22 | 23 | * .insert(position, contents) 24 | * Insert an array of content at a position 25 | * You can also insert types `array.insert(0, Y.Map)` 26 | * If not a shared type, the content should fulfill the following property: `content equals JSON.parse(JSON.stringify(content))` (according to your notion of equality) 27 | * .push(content) 28 | * Insert content at the end of the Array 29 | * Also see `.insert(..)` 30 | * .delete(position, length) 31 | * Delete content. The *length* parameter is optional and defaults to 1 32 | * .toArray() 33 | * Retrieve the complete content as an array 34 | * .get(position) 35 | * Retrieve content from a position 36 | * .observe(function observer(event){..}) 37 | * The `observer` is called whenever something on this array changes 38 | * Throws insert, and delete events (`event.type`) 39 | * Insert event example: `{type: 'insert', index: 0, values: [0, 1, 2], length: 3}` 40 | * Delete event example: `{type: 'delete', index: 0, oldValues: [0, 1, 2], length: 3}` 41 | * .observeDeep(function observer(event){..}) 42 | * Same as .observe, but catches events from all children (if they support .observeDeep) 43 | * `event.path` specifies the path of the change event 44 | * .unobserve(f) 45 | * Delete an observer 46 | 47 | 48 | # A note on intention preservation 49 | If two users insert something at the same position concurrently, the content that was inserted by the user with the higher user-id will be to the right of the other content. In the OT world we often speak of *intention preservation*, which is very loosely defined in most cases. This type has the following notion of intention preservation: When a user inserts content *c* after a set of content *C_left*, and before a set of content *C_right*, then *C_left* will be always to the left of c, and *C_right* will be always to the right of *c*. Since content is only marked as deleted (until all conflicts are resolved), this notion of intention preservation is very strong. 50 | 51 | # A note on time complexities 52 | * .insert(position, content) 53 | * O(contents.length) 54 | * .push(content) 55 | * O(1) 56 | * .delete(position, length) 57 | * O(position + length) 58 | * .get(i) 59 | * O(length) 60 | * Apply a delete operation from another user 61 | * O(contents.length) 62 | * Apply an insert operation from another user 63 | * Yjs does not transform against operations that do not conflict with each other. 64 | * An operation conflicts with another operation if it intends to be inserted at the same position. 65 | * Overall worst case complexety: O(|conflicts|^2) 66 | 67 | ## Changelog 68 | 69 | ### 10.0.0 70 | * Inserting & retrieving types are synchronous operations 71 | * I.e. `y.share.array.get(0) // => returns a type instead of a promise (if there is a type at position 0) 72 | * Relies on Yjs@^12.0.0 73 | 74 | ## License 75 | Yjs is licensed under the [MIT License](./LICENSE). 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/y-array.js: -------------------------------------------------------------------------------- 1 | /* global Y */ 2 | 3 | function extend (Y) { 4 | Y.utils.yarrayEventHandler = function (op) { 5 | if (op.struct === 'Insert') { 6 | // when using indexeddb db adapter, the op could already exist (see y-js/y-indexeddb#2) 7 | if (this._content.some(function (c) { return Y.utils.compareIds(c.id, op.id) })) { 8 | // op exists 9 | return 10 | } 11 | let pos 12 | // we check op.left only!, 13 | // because op.right might not be defined when this is called 14 | if (op.left === null) { 15 | pos = 0 16 | } else { 17 | pos = 1 + this._content.findIndex(function (c) { 18 | return Y.utils.compareIds(c.id, op.left) 19 | }) 20 | if (pos <= 0) { 21 | throw new Error('Unexpected operation!') 22 | } 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 | class YArray extends Y.utils.CustomType { 114 | constructor (os, _model, _content) { 115 | super() 116 | this.os = os 117 | this._model = _model 118 | // Array of all the neccessary content 119 | this._content = _content 120 | // the parent of this type 121 | this._parent = null 122 | this._deepEventHandler = new Y.utils.EventListenerHandler() 123 | this.eventHandler = new Y.utils.EventHandler(Y.utils.yarrayEventHandler.bind(this)) 124 | } 125 | _getPathToChild (childId) { 126 | return this._content.findIndex(c => 127 | c.type != null && Y.utils.compareIds(c.type, childId) 128 | ) 129 | } 130 | _destroy () { 131 | this.eventHandler.destroy() 132 | this.eventHandler = null 133 | this._content = null 134 | this._model = null 135 | this._parent = null 136 | this.os = null 137 | } 138 | get length () { 139 | return this._content.length 140 | } 141 | toJSON () { 142 | return this._content.map(x => { 143 | if (x.type != null) { 144 | let type = this.os.getType(x.type) 145 | if (type.toJSON != null) { 146 | return type.toJSON() 147 | } else if (type.toString != null) { 148 | return type.toString() 149 | } else { 150 | return undefined 151 | } 152 | } else { 153 | return x.val 154 | } 155 | }) 156 | } 157 | get (pos) { 158 | if (pos == null || typeof pos !== 'number') { 159 | throw new Error('pos must be a number!') 160 | } 161 | if (pos >= this._content.length) { 162 | return undefined 163 | } 164 | if (this._content[pos].type == null) { 165 | return this._content[pos].val 166 | } else { 167 | return this.os.getType(this._content[pos].type) 168 | } 169 | } 170 | toArray () { 171 | return this._content.map((x, i) => { 172 | if (x.type != null) { 173 | return this.os.getType(x.type) 174 | } else { 175 | return x.val 176 | } 177 | }) 178 | } 179 | push (contents) { 180 | return this.insert(this._content.length, contents) 181 | } 182 | insert (pos, contents) { 183 | if (typeof pos !== 'number') { 184 | throw new Error('pos must be a number!') 185 | } 186 | if (!Array.isArray(contents)) { 187 | throw new Error('contents must be an Array of objects!') 188 | } 189 | if (contents.length === 0) { 190 | return 191 | } 192 | if (pos > this._content.length || pos < 0) { 193 | throw new Error('This position exceeds the range of the array!') 194 | } 195 | var mostLeft = pos === 0 ? null : this._content[pos - 1].id 196 | 197 | var ops = [] 198 | var prevId = mostLeft 199 | for (var i = 0; i < contents.length;) { 200 | var op = { 201 | left: prevId, 202 | origin: prevId, 203 | // right: mostRight, 204 | // NOTE: I intentionally do not define right here, because it could be deleted 205 | // at the time of inserting this operation (when we get the transaction), 206 | // and would therefore not defined in this._content 207 | parent: this._model, 208 | struct: 'Insert' 209 | } 210 | var _content = [] 211 | var typeDefinition 212 | while (i < contents.length) { 213 | var val = contents[i++] 214 | typeDefinition = Y.utils.isTypeDefinition(val) 215 | if (!typeDefinition) { 216 | _content.push(val) 217 | } else if (_content.length > 0) { 218 | i-- // come back again later 219 | break 220 | } else { 221 | break 222 | } 223 | } 224 | if (_content.length > 0) { 225 | // content is defined 226 | op.content = _content 227 | op.id = this.os.getNextOpId(_content.length) 228 | } else { 229 | // otherwise its a type 230 | var typeid = this.os.getNextOpId(1) 231 | this.os.createType(typeDefinition, typeid) 232 | op.opContent = typeid 233 | op.id = this.os.getNextOpId(1) 234 | } 235 | ops.push(op) 236 | prevId = op.id 237 | } 238 | var eventHandler = this.eventHandler 239 | this.os.requestTransaction(function () { 240 | // now we can set the right reference. 241 | var mostRight 242 | if (mostLeft != null) { 243 | var ml = this.getInsertionCleanEnd(mostLeft) 244 | mostRight = ml.right 245 | } else { 246 | mostRight = (this.getOperation(ops[0].parent)).start 247 | } 248 | for (var j = 0; j < ops.length; j++) { 249 | var op = ops[j] 250 | op.right = mostRight 251 | } 252 | eventHandler.awaitOps(this, this.applyCreatedOperations, [ops]) 253 | }) 254 | // always remember to do that after this.os.requestTransaction 255 | // (otherwise values might contain a undefined reference to type) 256 | eventHandler.awaitAndPrematurelyCall(ops) 257 | } 258 | delete (pos, length) { 259 | if (length == null) { length = 1 } 260 | if (typeof length !== 'number') { 261 | throw new Error('length must be a number!') 262 | } 263 | if (typeof pos !== 'number') { 264 | throw new Error('pos must be a number!') 265 | } 266 | if (pos + length > this._content.length || pos < 0 || length < 0) { 267 | throw new Error('The deletion range exceeds the range of the array!') 268 | } 269 | if (length === 0) { 270 | return 271 | } 272 | var eventHandler = this.eventHandler 273 | var dels = [] 274 | var delLength 275 | for (var i = 0; i < length; i = i + delLength) { 276 | var targetId = this._content[pos + i].id 277 | // how many insertions can we delete in one deletion? 278 | for (delLength = 1; i + delLength < length; delLength++) { 279 | if (!Y.utils.compareIds(this._content[pos + i + delLength].id, [targetId[0], targetId[1] + delLength])) { 280 | break 281 | } 282 | } 283 | dels.push({ 284 | target: targetId, 285 | struct: 'Delete', 286 | length: delLength 287 | }) 288 | } 289 | this.os.requestTransaction(function () { 290 | eventHandler.awaitOps(this, this.applyCreatedOperations, [dels]) 291 | }) 292 | // always remember to do that after this.os.requestTransaction 293 | // (otherwise values might contain a undefined reference to type) 294 | eventHandler.awaitAndPrematurelyCall(dels) 295 | } 296 | observe (f) { 297 | this.eventHandler.addEventListener(f) 298 | } 299 | observeDeep (f) { 300 | this._deepEventHandler.addEventListener(f) 301 | } 302 | unobserve (f) { 303 | this.eventHandler.removeEventListener(f) 304 | } 305 | unobserveDeep (f) { 306 | this._deepEventHandler.removeEventListener(f) 307 | } 308 | _changed (transaction, op) { 309 | if (!op.deleted) { 310 | if (op.struct === 'Insert') { 311 | // update left 312 | var l = op.left 313 | var left 314 | while (l != null) { 315 | left = transaction.getInsertion(l) 316 | if (!left.deleted) { 317 | break 318 | } 319 | l = left.left 320 | } 321 | op.left = l 322 | // if op contains opContent, initialize it 323 | if (op.opContent != null) { 324 | transaction.store.initType.call(transaction, op.opContent) 325 | } 326 | } 327 | this.eventHandler.receivedOp(op) 328 | } 329 | } 330 | } 331 | 332 | Y.extend('Array', new Y.utils.CustomTypeDefinition({ 333 | name: 'Array', 334 | class: YArray, 335 | struct: 'List', 336 | initType: function YArrayInitializer (os, model) { 337 | var _content = [] 338 | var _types = [] 339 | Y.Struct.List.map.call(this, model, function (op) { 340 | if (op.hasOwnProperty('opContent')) { 341 | _content.push({ 342 | id: op.id, 343 | type: op.opContent 344 | }) 345 | _types.push(op.opContent) 346 | } else { 347 | op.content.forEach(function (c, i) { 348 | _content.push({ 349 | id: [op.id[0], op.id[1] + i], 350 | val: op.content[i] 351 | }) 352 | }) 353 | } 354 | }) 355 | for (var i = 0; i < _types.length; i++) { 356 | let type = this.store.initType.call(this, _types[i]) 357 | type._parent = model.id 358 | } 359 | return new YArray(os, model.id, _content) 360 | }, 361 | createType: function YArrayCreateType (os, model) { 362 | return new YArray(os, model.id, []) 363 | } 364 | })) 365 | } 366 | 367 | export default extend 368 | if (typeof Y !== 'undefined') { 369 | extend(Y) 370 | } 371 | --------------------------------------------------------------------------------