├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bower.json ├── gulpfile.js ├── index.html ├── karma.conf.js ├── opentok-editor.css ├── opentok-editor.js ├── opentok-editor.min.js ├── package.json ├── src ├── opentok-adapter.js └── opentok-editor.js └── tests └── unit ├── opentok-adapter-spec.js └── opentok-editor-spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | bower_components/ 3 | coverage/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | before_install: 5 | - "export DISPLAY=:99.0" 6 | - "sh -e /etc/init.d/xvfb start" 7 | before_script: 8 | - ./node_modules/.bin/gulp 9 | after_script: 10 | - cat coverage/*/lcov.info | ./node_modules/codeclimate-test-reporter/bin/codeclimate.js 11 | addons: 12 | code_climate: 13 | repo_token: c0a0b0e0906779f1590e5552cd1f42ecfc8a3ecf3780d33680ce60ef73cd523f 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Adam Ullman 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/aullman/opentok-editor.svg?branch=master)](https://travis-ci.org/aullman/opentok-editor) 2 | [![Code Climate](https://codeclimate.com/github/aullman/opentok-editor/badges/gpa.svg)](https://codeclimate.com/github/aullman/opentok-editor) 3 | [![Test Coverage](https://codeclimate.com/github/aullman/opentok-editor/badges/coverage.svg)](https://codeclimate.com/github/aullman/opentok-editor) 4 | 5 | # opentok-editor 6 | 7 | A real time collaborative editor for OpenTok using CodeMirror and ot.js. More information in [this blog article](http://www.tokbox.com/blog/collaborative-editor/). 8 | 9 | ## Installation 10 | 11 | ### npm with webpack or browserify: 12 | 13 | `npm install opentok-editor` 14 | 15 | ```js 16 | require('opentok-editor'); 17 | require('opentok-editor/opentok-editor.css'); 18 | ``` 19 | 20 | ### [Bower](http://bower.io/): 21 | 22 | `bower install opentok-editor` 23 | 24 | or clone this repo and include the `opentok-editor.js` or `opentok-editor.min.js` file. 25 | 26 | ## Usage 27 | 28 | See [index.html](index.html). You will need to replace the values for API_KEY, SESSION_ID and TOKEN with values using your [OpenTok](https://www.tokbox.com/opentok) account. Then you can run `npm start` and go to localhost:8080 in your browser. 29 | 30 | ## How to Build Yourself 31 | 32 | Keep all custom changes to the `src/` files and then run: 33 | 34 | ``` 35 | npm install 36 | ``` 37 | 38 | Which will install all bower components and run the gulp build to build the `opentok-editor.js` and `opentok-editor.min.js` files. 39 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentok-editor", 3 | "version": "0.3.3", 4 | "authors": [ 5 | "Adam Ullman " 6 | ], 7 | "description": "A real time collaborative editor for OpenTok using CodeMirror and ot.js", 8 | "keywords": [ 9 | "opentok", 10 | "ot", 11 | "CodeMirror", 12 | "operational-transform" 13 | ], 14 | "license": "MIT", 15 | "homepage": "https://github.com/aullman/opentok-editor", 16 | "ignore": [ 17 | "**/.*", 18 | "node_modules", 19 | "bower_components", 20 | "test", 21 | "tests" 22 | ], 23 | "dependencies": { 24 | "CodeMirror": "git://github.com/marijnh/CodeMirror.git#~5.10", 25 | "opentok-angular": "~0.2.3" 26 | }, 27 | "devDependencies": { 28 | "ot": "git://github.com/aullman/ot.js.git#master", 29 | "angular-mocks": "~1.2.23" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | bower = require('gulp-bower'), 3 | jshint = require('gulp-jshint'), 4 | rename = require('gulp-rename'), 5 | concat = require('gulp-concat'), 6 | uglify = require('gulp-uglify'); 7 | 8 | gulp.task('bower', function () { 9 | return bower(); 10 | }); 11 | 12 | gulp.task('default', ['bower'], function() { 13 | gulp.src(['bower_components/ot/dist/ot.js', 'bower_components/ot/lib/server.js', 'src/*.js']) 14 | .pipe(jshint()) 15 | .pipe(concat('opentok-editor.js')) 16 | .pipe(gulp.dest('./')) 17 | .pipe(uglify({preserveComments: "some"})) 18 | .pipe(rename('opentok-editor.min.js')) 19 | .pipe(gulp.dest('./')); 20 | }); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenTok-Editor Demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 25 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Aug 27 2014 15:07:55 GMT+1000 (EST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'https://static.opentok.com/webrtc/v2.2/js/opentok.js', 19 | 'bower_components/jquery/dist/jquery.js', 20 | 'bower_components/angular/angular.js', 21 | 'bower_components/angular-mocks/angular-mocks.js', 22 | 'bower_components/opentok-angular/opentok-angular.js', 23 | 'bower_components/CodeMirror/lib/codemirror.js', 24 | 'bower_components/CodeMirror/mode/javascript/javascript.js', 25 | 'bower_components/ot/dist/ot.js', 26 | 'bower_components/ot/lib/server.js', 27 | 'src/*.js', 28 | 'tests/**/*spec.js' 29 | ], 30 | 31 | 32 | // list of files to exclude 33 | exclude: [ 34 | ], 35 | 36 | 37 | // preprocess matching files before serving them to the browser 38 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 39 | preprocessors: { 40 | 'src/*.js': 'coverage' 41 | }, 42 | 43 | 44 | // test results reporter to use 45 | // possible values: 'dots', 'progress' 46 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 47 | reporters: ['progress', 'coverage'], 48 | 49 | coverageReporter: { 50 | type : 'lcov', 51 | dir : 'coverage/' 52 | }, 53 | 54 | // web server port 55 | port: 9876, 56 | 57 | 58 | // enable / disable colors in the output (reporters and logs) 59 | colors: true, 60 | 61 | 62 | // level of logging 63 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 64 | logLevel: config.LOG_INFO, 65 | 66 | 67 | // enable / disable watching file and executing tests whenever any file changes 68 | autoWatch: true, 69 | 70 | 71 | // start these browsers 72 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 73 | browsers: ['Chrome', 'Firefox'], 74 | 75 | 76 | // Continuous Integration mode 77 | // if true, Karma captures browsers, runs the tests and exits 78 | singleRun: false 79 | }); 80 | }; 81 | -------------------------------------------------------------------------------- /opentok-editor.css: -------------------------------------------------------------------------------- 1 | ot-editor { 2 | border: 1px solid grey; 3 | display: block; 4 | padding: 5px; 5 | } 6 | 7 | ot-editor .opentok-editor-mode-select { 8 | float: right; 9 | } 10 | 11 | ot-editor .opentok-editor-connecting { 12 | width: 100%; 13 | text-align: center; 14 | position: absolute; 15 | top: 10%; 16 | } 17 | 18 | ot-editor div.opentok-editor, ot-editor .CodeMirror { 19 | width: 100%; 20 | height: 100%; 21 | } -------------------------------------------------------------------------------- /opentok-editor.js: -------------------------------------------------------------------------------- 1 | /* 2 | * /\ 3 | * / \ ot 0.0.15 4 | * / \ http://operational-transformation.github.com 5 | * \ / 6 | * \ / (c) 2012-2016 Tim Baumann (http://timbaumann.info) 7 | * \/ ot may be freely distributed under the MIT license. 8 | */ 9 | 10 | if (typeof ot === 'undefined') { 11 | // Export for browsers 12 | var ot = {}; 13 | } 14 | 15 | ot.TextOperation = (function () { 16 | 'use strict'; 17 | 18 | // Constructor for new operations. 19 | function TextOperation () { 20 | if (!this || this.constructor !== TextOperation) { 21 | // => function was called without 'new' 22 | return new TextOperation(); 23 | } 24 | 25 | // When an operation is applied to an input string, you can think of this as 26 | // if an imaginary cursor runs over the entire string and skips over some 27 | // parts, deletes some parts and inserts characters at some positions. These 28 | // actions (skip/delete/insert) are stored as an array in the "ops" property. 29 | this.ops = []; 30 | // An operation's baseLength is the length of every string the operation 31 | // can be applied to. 32 | this.baseLength = 0; 33 | // The targetLength is the length of every string that results from applying 34 | // the operation on a valid input string. 35 | this.targetLength = 0; 36 | } 37 | 38 | TextOperation.prototype.equals = function (other) { 39 | if (this.baseLength !== other.baseLength) { return false; } 40 | if (this.targetLength !== other.targetLength) { return false; } 41 | if (this.ops.length !== other.ops.length) { return false; } 42 | for (var i = 0; i < this.ops.length; i++) { 43 | if (this.ops[i] !== other.ops[i]) { return false; } 44 | } 45 | return true; 46 | }; 47 | 48 | // Operation are essentially lists of ops. There are three types of ops: 49 | // 50 | // * Retain ops: Advance the cursor position by a given number of characters. 51 | // Represented by positive ints. 52 | // * Insert ops: Insert a given string at the current cursor position. 53 | // Represented by strings. 54 | // * Delete ops: Delete the next n characters. Represented by negative ints. 55 | 56 | var isRetain = TextOperation.isRetain = function (op) { 57 | return typeof op === 'number' && op > 0; 58 | }; 59 | 60 | var isInsert = TextOperation.isInsert = function (op) { 61 | return typeof op === 'string'; 62 | }; 63 | 64 | var isDelete = TextOperation.isDelete = function (op) { 65 | return typeof op === 'number' && op < 0; 66 | }; 67 | 68 | 69 | // After an operation is constructed, the user of the library can specify the 70 | // actions of an operation (skip/insert/delete) with these three builder 71 | // methods. They all return the operation for convenient chaining. 72 | 73 | // Skip over a given number of characters. 74 | TextOperation.prototype.retain = function (n) { 75 | if (typeof n !== 'number') { 76 | throw new Error("retain expects an integer"); 77 | } 78 | if (n === 0) { return this; } 79 | this.baseLength += n; 80 | this.targetLength += n; 81 | if (isRetain(this.ops[this.ops.length-1])) { 82 | // The last op is a retain op => we can merge them into one op. 83 | this.ops[this.ops.length-1] += n; 84 | } else { 85 | // Create a new op. 86 | this.ops.push(n); 87 | } 88 | return this; 89 | }; 90 | 91 | // Insert a string at the current position. 92 | TextOperation.prototype.insert = function (str) { 93 | if (typeof str !== 'string') { 94 | throw new Error("insert expects a string"); 95 | } 96 | if (str === '') { return this; } 97 | this.targetLength += str.length; 98 | var ops = this.ops; 99 | if (isInsert(ops[ops.length-1])) { 100 | // Merge insert op. 101 | ops[ops.length-1] += str; 102 | } else if (isDelete(ops[ops.length-1])) { 103 | // It doesn't matter when an operation is applied whether the operation 104 | // is delete(3), insert("something") or insert("something"), delete(3). 105 | // Here we enforce that in this case, the insert op always comes first. 106 | // This makes all operations that have the same effect when applied to 107 | // a document of the right length equal in respect to the `equals` method. 108 | if (isInsert(ops[ops.length-2])) { 109 | ops[ops.length-2] += str; 110 | } else { 111 | ops[ops.length] = ops[ops.length-1]; 112 | ops[ops.length-2] = str; 113 | } 114 | } else { 115 | ops.push(str); 116 | } 117 | return this; 118 | }; 119 | 120 | // Delete a string at the current position. 121 | TextOperation.prototype['delete'] = function (n) { 122 | if (typeof n === 'string') { n = n.length; } 123 | if (typeof n !== 'number') { 124 | throw new Error("delete expects an integer or a string"); 125 | } 126 | if (n === 0) { return this; } 127 | if (n > 0) { n = -n; } 128 | this.baseLength -= n; 129 | if (isDelete(this.ops[this.ops.length-1])) { 130 | this.ops[this.ops.length-1] += n; 131 | } else { 132 | this.ops.push(n); 133 | } 134 | return this; 135 | }; 136 | 137 | // Tests whether this operation has no effect. 138 | TextOperation.prototype.isNoop = function () { 139 | return this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0])); 140 | }; 141 | 142 | // Pretty printing. 143 | TextOperation.prototype.toString = function () { 144 | // map: build a new array by applying a function to every element in an old 145 | // array. 146 | var map = Array.prototype.map || function (fn) { 147 | var arr = this; 148 | var newArr = []; 149 | for (var i = 0, l = arr.length; i < l; i++) { 150 | newArr[i] = fn(arr[i]); 151 | } 152 | return newArr; 153 | }; 154 | return map.call(this.ops, function (op) { 155 | if (isRetain(op)) { 156 | return "retain " + op; 157 | } else if (isInsert(op)) { 158 | return "insert '" + op + "'"; 159 | } else { 160 | return "delete " + (-op); 161 | } 162 | }).join(', '); 163 | }; 164 | 165 | // Converts operation into a JSON value. 166 | TextOperation.prototype.toJSON = function () { 167 | return this.ops; 168 | }; 169 | 170 | // Converts a plain JS object into an operation and validates it. 171 | TextOperation.fromJSON = function (ops) { 172 | var o = new TextOperation(); 173 | for (var i = 0, l = ops.length; i < l; i++) { 174 | var op = ops[i]; 175 | if (isRetain(op)) { 176 | o.retain(op); 177 | } else if (isInsert(op)) { 178 | o.insert(op); 179 | } else if (isDelete(op)) { 180 | o['delete'](op); 181 | } else { 182 | throw new Error("unknown operation: " + JSON.stringify(op)); 183 | } 184 | } 185 | return o; 186 | }; 187 | 188 | // Apply an operation to a string, returning a new string. Throws an error if 189 | // there's a mismatch between the input string and the operation. 190 | TextOperation.prototype.apply = function (str) { 191 | var operation = this; 192 | if (str.length !== operation.baseLength) { 193 | throw new Error("The operation's base length must be equal to the string's length."); 194 | } 195 | var newStr = [], j = 0; 196 | var strIndex = 0; 197 | var ops = this.ops; 198 | for (var i = 0, l = ops.length; i < l; i++) { 199 | var op = ops[i]; 200 | if (isRetain(op)) { 201 | if (strIndex + op > str.length) { 202 | throw new Error("Operation can't retain more characters than are left in the string."); 203 | } 204 | // Copy skipped part of the old string. 205 | newStr[j++] = str.slice(strIndex, strIndex + op); 206 | strIndex += op; 207 | } else if (isInsert(op)) { 208 | // Insert string. 209 | newStr[j++] = op; 210 | } else { // delete op 211 | strIndex -= op; 212 | } 213 | } 214 | if (strIndex !== str.length) { 215 | throw new Error("The operation didn't operate on the whole string."); 216 | } 217 | return newStr.join(''); 218 | }; 219 | 220 | // Computes the inverse of an operation. The inverse of an operation is the 221 | // operation that reverts the effects of the operation, e.g. when you have an 222 | // operation 'insert("hello "); skip(6);' then the inverse is 'delete("hello "); 223 | // skip(6);'. The inverse should be used for implementing undo. 224 | TextOperation.prototype.invert = function (str) { 225 | var strIndex = 0; 226 | var inverse = new TextOperation(); 227 | var ops = this.ops; 228 | for (var i = 0, l = ops.length; i < l; i++) { 229 | var op = ops[i]; 230 | if (isRetain(op)) { 231 | inverse.retain(op); 232 | strIndex += op; 233 | } else if (isInsert(op)) { 234 | inverse['delete'](op.length); 235 | } else { // delete op 236 | inverse.insert(str.slice(strIndex, strIndex - op)); 237 | strIndex -= op; 238 | } 239 | } 240 | return inverse; 241 | }; 242 | 243 | // Compose merges two consecutive operations into one operation, that 244 | // preserves the changes of both. Or, in other words, for each input string S 245 | // and a pair of consecutive operations A and B, 246 | // apply(apply(S, A), B) = apply(S, compose(A, B)) must hold. 247 | TextOperation.prototype.compose = function (operation2) { 248 | var operation1 = this; 249 | if (operation1.targetLength !== operation2.baseLength) { 250 | throw new Error("The base length of the second operation has to be the target length of the first operation"); 251 | } 252 | 253 | var operation = new TextOperation(); // the combined operation 254 | var ops1 = operation1.ops, ops2 = operation2.ops; // for fast access 255 | var i1 = 0, i2 = 0; // current index into ops1 respectively ops2 256 | var op1 = ops1[i1++], op2 = ops2[i2++]; // current ops 257 | while (true) { 258 | // Dispatch on the type of op1 and op2 259 | if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { 260 | // end condition: both ops1 and ops2 have been processed 261 | break; 262 | } 263 | 264 | if (isDelete(op1)) { 265 | operation['delete'](op1); 266 | op1 = ops1[i1++]; 267 | continue; 268 | } 269 | if (isInsert(op2)) { 270 | operation.insert(op2); 271 | op2 = ops2[i2++]; 272 | continue; 273 | } 274 | 275 | if (typeof op1 === 'undefined') { 276 | throw new Error("Cannot compose operations: first operation is too short."); 277 | } 278 | if (typeof op2 === 'undefined') { 279 | throw new Error("Cannot compose operations: first operation is too long."); 280 | } 281 | 282 | if (isRetain(op1) && isRetain(op2)) { 283 | if (op1 > op2) { 284 | operation.retain(op2); 285 | op1 = op1 - op2; 286 | op2 = ops2[i2++]; 287 | } else if (op1 === op2) { 288 | operation.retain(op1); 289 | op1 = ops1[i1++]; 290 | op2 = ops2[i2++]; 291 | } else { 292 | operation.retain(op1); 293 | op2 = op2 - op1; 294 | op1 = ops1[i1++]; 295 | } 296 | } else if (isInsert(op1) && isDelete(op2)) { 297 | if (op1.length > -op2) { 298 | op1 = op1.slice(-op2); 299 | op2 = ops2[i2++]; 300 | } else if (op1.length === -op2) { 301 | op1 = ops1[i1++]; 302 | op2 = ops2[i2++]; 303 | } else { 304 | op2 = op2 + op1.length; 305 | op1 = ops1[i1++]; 306 | } 307 | } else if (isInsert(op1) && isRetain(op2)) { 308 | if (op1.length > op2) { 309 | operation.insert(op1.slice(0, op2)); 310 | op1 = op1.slice(op2); 311 | op2 = ops2[i2++]; 312 | } else if (op1.length === op2) { 313 | operation.insert(op1); 314 | op1 = ops1[i1++]; 315 | op2 = ops2[i2++]; 316 | } else { 317 | operation.insert(op1); 318 | op2 = op2 - op1.length; 319 | op1 = ops1[i1++]; 320 | } 321 | } else if (isRetain(op1) && isDelete(op2)) { 322 | if (op1 > -op2) { 323 | operation['delete'](op2); 324 | op1 = op1 + op2; 325 | op2 = ops2[i2++]; 326 | } else if (op1 === -op2) { 327 | operation['delete'](op2); 328 | op1 = ops1[i1++]; 329 | op2 = ops2[i2++]; 330 | } else { 331 | operation['delete'](op1); 332 | op2 = op2 + op1; 333 | op1 = ops1[i1++]; 334 | } 335 | } else { 336 | throw new Error( 337 | "This shouldn't happen: op1: " + 338 | JSON.stringify(op1) + ", op2: " + 339 | JSON.stringify(op2) 340 | ); 341 | } 342 | } 343 | return operation; 344 | }; 345 | 346 | function getSimpleOp (operation, fn) { 347 | var ops = operation.ops; 348 | var isRetain = TextOperation.isRetain; 349 | switch (ops.length) { 350 | case 1: 351 | return ops[0]; 352 | case 2: 353 | return isRetain(ops[0]) ? ops[1] : (isRetain(ops[1]) ? ops[0] : null); 354 | case 3: 355 | if (isRetain(ops[0]) && isRetain(ops[2])) { return ops[1]; } 356 | } 357 | return null; 358 | } 359 | 360 | function getStartIndex (operation) { 361 | if (isRetain(operation.ops[0])) { return operation.ops[0]; } 362 | return 0; 363 | } 364 | 365 | // When you use ctrl-z to undo your latest changes, you expect the program not 366 | // to undo every single keystroke but to undo your last sentence you wrote at 367 | // a stretch or the deletion you did by holding the backspace key down. This 368 | // This can be implemented by composing operations on the undo stack. This 369 | // method can help decide whether two operations should be composed. It 370 | // returns true if the operations are consecutive insert operations or both 371 | // operations delete text at the same position. You may want to include other 372 | // factors like the time since the last change in your decision. 373 | TextOperation.prototype.shouldBeComposedWith = function (other) { 374 | if (this.isNoop() || other.isNoop()) { return true; } 375 | 376 | var startA = getStartIndex(this), startB = getStartIndex(other); 377 | var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); 378 | if (!simpleA || !simpleB) { return false; } 379 | 380 | if (isInsert(simpleA) && isInsert(simpleB)) { 381 | return startA + simpleA.length === startB; 382 | } 383 | 384 | if (isDelete(simpleA) && isDelete(simpleB)) { 385 | // there are two possibilities to delete: with backspace and with the 386 | // delete key. 387 | return (startB - simpleB === startA) || startA === startB; 388 | } 389 | 390 | return false; 391 | }; 392 | 393 | // Decides whether two operations should be composed with each other 394 | // if they were inverted, that is 395 | // `shouldBeComposedWith(a, b) = shouldBeComposedWithInverted(b^{-1}, a^{-1})`. 396 | TextOperation.prototype.shouldBeComposedWithInverted = function (other) { 397 | if (this.isNoop() || other.isNoop()) { return true; } 398 | 399 | var startA = getStartIndex(this), startB = getStartIndex(other); 400 | var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); 401 | if (!simpleA || !simpleB) { return false; } 402 | 403 | if (isInsert(simpleA) && isInsert(simpleB)) { 404 | return startA + simpleA.length === startB || startA === startB; 405 | } 406 | 407 | if (isDelete(simpleA) && isDelete(simpleB)) { 408 | return startB - simpleB === startA; 409 | } 410 | 411 | return false; 412 | }; 413 | 414 | // Transform takes two operations A and B that happened concurrently and 415 | // produces two operations A' and B' (in an array) such that 416 | // `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the 417 | // heart of OT. 418 | TextOperation.transform = function (operation1, operation2) { 419 | if (operation1.baseLength !== operation2.baseLength) { 420 | throw new Error("Both operations have to have the same base length"); 421 | } 422 | 423 | var operation1prime = new TextOperation(); 424 | var operation2prime = new TextOperation(); 425 | var ops1 = operation1.ops, ops2 = operation2.ops; 426 | var i1 = 0, i2 = 0; 427 | var op1 = ops1[i1++], op2 = ops2[i2++]; 428 | while (true) { 429 | // At every iteration of the loop, the imaginary cursor that both 430 | // operation1 and operation2 have that operates on the input string must 431 | // have the same position in the input string. 432 | 433 | if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { 434 | // end condition: both ops1 and ops2 have been processed 435 | break; 436 | } 437 | 438 | // next two cases: one or both ops are insert ops 439 | // => insert the string in the corresponding prime operation, skip it in 440 | // the other one. If both op1 and op2 are insert ops, prefer op1. 441 | if (isInsert(op1)) { 442 | operation1prime.insert(op1); 443 | operation2prime.retain(op1.length); 444 | op1 = ops1[i1++]; 445 | continue; 446 | } 447 | if (isInsert(op2)) { 448 | operation1prime.retain(op2.length); 449 | operation2prime.insert(op2); 450 | op2 = ops2[i2++]; 451 | continue; 452 | } 453 | 454 | if (typeof op1 === 'undefined') { 455 | throw new Error("Cannot compose operations: first operation is too short."); 456 | } 457 | if (typeof op2 === 'undefined') { 458 | throw new Error("Cannot compose operations: first operation is too long."); 459 | } 460 | 461 | var minl; 462 | if (isRetain(op1) && isRetain(op2)) { 463 | // Simple case: retain/retain 464 | if (op1 > op2) { 465 | minl = op2; 466 | op1 = op1 - op2; 467 | op2 = ops2[i2++]; 468 | } else if (op1 === op2) { 469 | minl = op2; 470 | op1 = ops1[i1++]; 471 | op2 = ops2[i2++]; 472 | } else { 473 | minl = op1; 474 | op2 = op2 - op1; 475 | op1 = ops1[i1++]; 476 | } 477 | operation1prime.retain(minl); 478 | operation2prime.retain(minl); 479 | } else if (isDelete(op1) && isDelete(op2)) { 480 | // Both operations delete the same string at the same position. We don't 481 | // need to produce any operations, we just skip over the delete ops and 482 | // handle the case that one operation deletes more than the other. 483 | if (-op1 > -op2) { 484 | op1 = op1 - op2; 485 | op2 = ops2[i2++]; 486 | } else if (op1 === op2) { 487 | op1 = ops1[i1++]; 488 | op2 = ops2[i2++]; 489 | } else { 490 | op2 = op2 - op1; 491 | op1 = ops1[i1++]; 492 | } 493 | // next two cases: delete/retain and retain/delete 494 | } else if (isDelete(op1) && isRetain(op2)) { 495 | if (-op1 > op2) { 496 | minl = op2; 497 | op1 = op1 + op2; 498 | op2 = ops2[i2++]; 499 | } else if (-op1 === op2) { 500 | minl = op2; 501 | op1 = ops1[i1++]; 502 | op2 = ops2[i2++]; 503 | } else { 504 | minl = -op1; 505 | op2 = op2 + op1; 506 | op1 = ops1[i1++]; 507 | } 508 | operation1prime['delete'](minl); 509 | } else if (isRetain(op1) && isDelete(op2)) { 510 | if (op1 > -op2) { 511 | minl = -op2; 512 | op1 = op1 + op2; 513 | op2 = ops2[i2++]; 514 | } else if (op1 === -op2) { 515 | minl = op1; 516 | op1 = ops1[i1++]; 517 | op2 = ops2[i2++]; 518 | } else { 519 | minl = op1; 520 | op2 = op2 + op1; 521 | op1 = ops1[i1++]; 522 | } 523 | operation2prime['delete'](minl); 524 | } else { 525 | throw new Error("The two operations aren't compatible"); 526 | } 527 | } 528 | 529 | return [operation1prime, operation2prime]; 530 | }; 531 | 532 | return TextOperation; 533 | 534 | }()); 535 | 536 | // Export for CommonJS 537 | if (typeof module === 'object') { 538 | module.exports = ot.TextOperation; 539 | } 540 | if (typeof ot === 'undefined') { 541 | // Export for browsers 542 | var ot = {}; 543 | } 544 | 545 | ot.Selection = (function (global) { 546 | 'use strict'; 547 | 548 | var TextOperation = global.ot ? global.ot.TextOperation : require('./text-operation'); 549 | 550 | // Range has `anchor` and `head` properties, which are zero-based indices into 551 | // the document. The `anchor` is the side of the selection that stays fixed, 552 | // `head` is the side of the selection where the cursor is. When both are 553 | // equal, the range represents a cursor. 554 | function Range (anchor, head) { 555 | this.anchor = anchor; 556 | this.head = head; 557 | } 558 | 559 | Range.fromJSON = function (obj) { 560 | return new Range(obj.anchor, obj.head); 561 | }; 562 | 563 | Range.prototype.equals = function (other) { 564 | return this.anchor === other.anchor && this.head === other.head; 565 | }; 566 | 567 | Range.prototype.isEmpty = function () { 568 | return this.anchor === this.head; 569 | }; 570 | 571 | Range.prototype.transform = function (other) { 572 | function transformIndex (index) { 573 | var newIndex = index; 574 | var ops = other.ops; 575 | for (var i = 0, l = other.ops.length; i < l; i++) { 576 | if (TextOperation.isRetain(ops[i])) { 577 | index -= ops[i]; 578 | } else if (TextOperation.isInsert(ops[i])) { 579 | newIndex += ops[i].length; 580 | } else { 581 | newIndex -= Math.min(index, -ops[i]); 582 | index += ops[i]; 583 | } 584 | if (index < 0) { break; } 585 | } 586 | return newIndex; 587 | } 588 | 589 | var newAnchor = transformIndex(this.anchor); 590 | if (this.anchor === this.head) { 591 | return new Range(newAnchor, newAnchor); 592 | } 593 | return new Range(newAnchor, transformIndex(this.head)); 594 | }; 595 | 596 | // A selection is basically an array of ranges. Every range represents a real 597 | // selection or a cursor in the document (when the start position equals the 598 | // end position of the range). The array must not be empty. 599 | function Selection (ranges) { 600 | this.ranges = ranges || []; 601 | } 602 | 603 | Selection.Range = Range; 604 | 605 | // Convenience method for creating selections only containing a single cursor 606 | // and no real selection range. 607 | Selection.createCursor = function (position) { 608 | return new Selection([new Range(position, position)]); 609 | }; 610 | 611 | Selection.fromJSON = function (obj) { 612 | var objRanges = obj.ranges || obj; 613 | for (var i = 0, ranges = []; i < objRanges.length; i++) { 614 | ranges[i] = Range.fromJSON(objRanges[i]); 615 | } 616 | return new Selection(ranges); 617 | }; 618 | 619 | Selection.prototype.equals = function (other) { 620 | if (this.position !== other.position) { return false; } 621 | if (this.ranges.length !== other.ranges.length) { return false; } 622 | // FIXME: Sort ranges before comparing them? 623 | for (var i = 0; i < this.ranges.length; i++) { 624 | if (!this.ranges[i].equals(other.ranges[i])) { return false; } 625 | } 626 | return true; 627 | }; 628 | 629 | Selection.prototype.somethingSelected = function () { 630 | for (var i = 0; i < this.ranges.length; i++) { 631 | if (!this.ranges[i].isEmpty()) { return true; } 632 | } 633 | return false; 634 | }; 635 | 636 | // Return the more current selection information. 637 | Selection.prototype.compose = function (other) { 638 | return other; 639 | }; 640 | 641 | // Update the selection with respect to an operation. 642 | Selection.prototype.transform = function (other) { 643 | for (var i = 0, newRanges = []; i < this.ranges.length; i++) { 644 | newRanges[i] = this.ranges[i].transform(other); 645 | } 646 | return new Selection(newRanges); 647 | }; 648 | 649 | return Selection; 650 | 651 | }(this)); 652 | 653 | // Export for CommonJS 654 | if (typeof module === 'object') { 655 | module.exports = ot.Selection; 656 | } 657 | 658 | if (typeof ot === 'undefined') { 659 | // Export for browsers 660 | var ot = {}; 661 | } 662 | 663 | ot.WrappedOperation = (function (global) { 664 | 'use strict'; 665 | 666 | // A WrappedOperation contains an operation and corresponing metadata. 667 | function WrappedOperation (operation, meta) { 668 | this.wrapped = operation; 669 | this.meta = meta; 670 | } 671 | 672 | WrappedOperation.prototype.apply = function () { 673 | return this.wrapped.apply.apply(this.wrapped, arguments); 674 | }; 675 | 676 | WrappedOperation.prototype.invert = function () { 677 | var meta = this.meta; 678 | return new WrappedOperation( 679 | this.wrapped.invert.apply(this.wrapped, arguments), 680 | meta && typeof meta === 'object' && typeof meta.invert === 'function' ? 681 | meta.invert.apply(meta, arguments) : meta 682 | ); 683 | }; 684 | 685 | // Copy all properties from source to target. 686 | function copy (source, target) { 687 | for (var key in source) { 688 | if (source.hasOwnProperty(key)) { 689 | target[key] = source[key]; 690 | } 691 | } 692 | } 693 | 694 | function composeMeta (a, b) { 695 | if (a && typeof a === 'object') { 696 | if (typeof a.compose === 'function') { return a.compose(b); } 697 | var meta = {}; 698 | copy(a, meta); 699 | copy(b, meta); 700 | return meta; 701 | } 702 | return b; 703 | } 704 | 705 | WrappedOperation.prototype.compose = function (other) { 706 | return new WrappedOperation( 707 | this.wrapped.compose(other.wrapped), 708 | composeMeta(this.meta, other.meta) 709 | ); 710 | }; 711 | 712 | function transformMeta (meta, operation) { 713 | if (meta && typeof meta === 'object') { 714 | if (typeof meta.transform === 'function') { 715 | return meta.transform(operation); 716 | } 717 | } 718 | return meta; 719 | } 720 | 721 | WrappedOperation.transform = function (a, b) { 722 | var transform = a.wrapped.constructor.transform; 723 | var pair = transform(a.wrapped, b.wrapped); 724 | return [ 725 | new WrappedOperation(pair[0], transformMeta(a.meta, b.wrapped)), 726 | new WrappedOperation(pair[1], transformMeta(b.meta, a.wrapped)) 727 | ]; 728 | }; 729 | 730 | return WrappedOperation; 731 | 732 | }(this)); 733 | 734 | // Export for CommonJS 735 | if (typeof module === 'object') { 736 | module.exports = ot.WrappedOperation; 737 | } 738 | if (typeof ot === 'undefined') { 739 | // Export for browsers 740 | var ot = {}; 741 | } 742 | 743 | ot.UndoManager = (function () { 744 | 'use strict'; 745 | 746 | var NORMAL_STATE = 'normal'; 747 | var UNDOING_STATE = 'undoing'; 748 | var REDOING_STATE = 'redoing'; 749 | 750 | // Create a new UndoManager with an optional maximum history size. 751 | function UndoManager (maxItems) { 752 | this.maxItems = maxItems || 50; 753 | this.state = NORMAL_STATE; 754 | this.dontCompose = false; 755 | this.undoStack = []; 756 | this.redoStack = []; 757 | } 758 | 759 | // Add an operation to the undo or redo stack, depending on the current state 760 | // of the UndoManager. The operation added must be the inverse of the last 761 | // edit. When `compose` is true, compose the operation with the last operation 762 | // unless the last operation was alread pushed on the redo stack or was hidden 763 | // by a newer operation on the undo stack. 764 | UndoManager.prototype.add = function (operation, compose) { 765 | if (this.state === UNDOING_STATE) { 766 | this.redoStack.push(operation); 767 | this.dontCompose = true; 768 | } else if (this.state === REDOING_STATE) { 769 | this.undoStack.push(operation); 770 | this.dontCompose = true; 771 | } else { 772 | var undoStack = this.undoStack; 773 | if (!this.dontCompose && compose && undoStack.length > 0) { 774 | undoStack.push(operation.compose(undoStack.pop())); 775 | } else { 776 | undoStack.push(operation); 777 | if (undoStack.length > this.maxItems) { undoStack.shift(); } 778 | } 779 | this.dontCompose = false; 780 | this.redoStack = []; 781 | } 782 | }; 783 | 784 | function transformStack (stack, operation) { 785 | var newStack = []; 786 | var Operation = operation.constructor; 787 | for (var i = stack.length - 1; i >= 0; i--) { 788 | var pair = Operation.transform(stack[i], operation); 789 | if (typeof pair[0].isNoop !== 'function' || !pair[0].isNoop()) { 790 | newStack.push(pair[0]); 791 | } 792 | operation = pair[1]; 793 | } 794 | return newStack.reverse(); 795 | } 796 | 797 | // Transform the undo and redo stacks against a operation by another client. 798 | UndoManager.prototype.transform = function (operation) { 799 | this.undoStack = transformStack(this.undoStack, operation); 800 | this.redoStack = transformStack(this.redoStack, operation); 801 | }; 802 | 803 | // Perform an undo by calling a function with the latest operation on the undo 804 | // stack. The function is expected to call the `add` method with the inverse 805 | // of the operation, which pushes the inverse on the redo stack. 806 | UndoManager.prototype.performUndo = function (fn) { 807 | this.state = UNDOING_STATE; 808 | if (this.undoStack.length === 0) { throw new Error("undo not possible"); } 809 | fn(this.undoStack.pop()); 810 | this.state = NORMAL_STATE; 811 | }; 812 | 813 | // The inverse of `performUndo`. 814 | UndoManager.prototype.performRedo = function (fn) { 815 | this.state = REDOING_STATE; 816 | if (this.redoStack.length === 0) { throw new Error("redo not possible"); } 817 | fn(this.redoStack.pop()); 818 | this.state = NORMAL_STATE; 819 | }; 820 | 821 | // Is the undo stack not empty? 822 | UndoManager.prototype.canUndo = function () { 823 | return this.undoStack.length !== 0; 824 | }; 825 | 826 | // Is the redo stack not empty? 827 | UndoManager.prototype.canRedo = function () { 828 | return this.redoStack.length !== 0; 829 | }; 830 | 831 | // Whether the UndoManager is currently performing an undo. 832 | UndoManager.prototype.isUndoing = function () { 833 | return this.state === UNDOING_STATE; 834 | }; 835 | 836 | // Whether the UndoManager is currently performing a redo. 837 | UndoManager.prototype.isRedoing = function () { 838 | return this.state === REDOING_STATE; 839 | }; 840 | 841 | return UndoManager; 842 | 843 | }()); 844 | 845 | // Export for CommonJS 846 | if (typeof module === 'object') { 847 | module.exports = ot.UndoManager; 848 | } 849 | 850 | // translation of https://github.com/djspiewak/cccp/blob/master/agent/src/main/scala/com/codecommit/cccp/agent/state.scala 851 | 852 | if (typeof ot === 'undefined') { 853 | var ot = {}; 854 | } 855 | 856 | ot.Client = (function (global) { 857 | 'use strict'; 858 | 859 | // Client constructor 860 | function Client (revision) { 861 | this.revision = revision; // the next expected revision number 862 | this.state = synchronized_; // start state 863 | } 864 | 865 | Client.prototype.setState = function (state) { 866 | this.state = state; 867 | }; 868 | 869 | // Call this method when the user changes the document. 870 | Client.prototype.applyClient = function (operation) { 871 | this.setState(this.state.applyClient(this, operation)); 872 | }; 873 | 874 | // Call this method with a new operation from the server 875 | Client.prototype.applyServer = function (operation) { 876 | this.revision++; 877 | this.setState(this.state.applyServer(this, operation)); 878 | }; 879 | 880 | Client.prototype.serverAck = function () { 881 | this.revision++; 882 | this.setState(this.state.serverAck(this)); 883 | }; 884 | 885 | Client.prototype.serverReconnect = function () { 886 | if (typeof this.state.resend === 'function') { this.state.resend(this); } 887 | }; 888 | 889 | // Transforms a selection from the latest known server state to the current 890 | // client state. For example, if we get from the server the information that 891 | // another user's cursor is at position 3, but the server hasn't yet received 892 | // our newest operation, an insertion of 5 characters at the beginning of the 893 | // document, the correct position of the other user's cursor in our current 894 | // document is 8. 895 | Client.prototype.transformSelection = function (selection) { 896 | return this.state.transformSelection(selection); 897 | }; 898 | 899 | // Override this method. 900 | Client.prototype.sendOperation = function (revision, operation) { 901 | throw new Error("sendOperation must be defined in child class"); 902 | }; 903 | 904 | // Override this method. 905 | Client.prototype.applyOperation = function (operation) { 906 | throw new Error("applyOperation must be defined in child class"); 907 | }; 908 | 909 | 910 | // In the 'Synchronized' state, there is no pending operation that the client 911 | // has sent to the server. 912 | function Synchronized () {} 913 | Client.Synchronized = Synchronized; 914 | 915 | Synchronized.prototype.applyClient = function (client, operation) { 916 | // When the user makes an edit, send the operation to the server and 917 | // switch to the 'AwaitingConfirm' state 918 | client.sendOperation(client.revision, operation); 919 | return new AwaitingConfirm(operation); 920 | }; 921 | 922 | Synchronized.prototype.applyServer = function (client, operation) { 923 | // When we receive a new operation from the server, the operation can be 924 | // simply applied to the current document 925 | client.applyOperation(operation); 926 | return this; 927 | }; 928 | 929 | Synchronized.prototype.serverAck = function (client) { 930 | throw new Error("There is no pending operation."); 931 | }; 932 | 933 | // Nothing to do because the latest server state and client state are the same. 934 | Synchronized.prototype.transformSelection = function (x) { return x; }; 935 | 936 | // Singleton 937 | var synchronized_ = new Synchronized(); 938 | 939 | 940 | // In the 'AwaitingConfirm' state, there's one operation the client has sent 941 | // to the server and is still waiting for an acknowledgement. 942 | function AwaitingConfirm (outstanding) { 943 | // Save the pending operation 944 | this.outstanding = outstanding; 945 | } 946 | Client.AwaitingConfirm = AwaitingConfirm; 947 | 948 | AwaitingConfirm.prototype.applyClient = function (client, operation) { 949 | // When the user makes an edit, don't send the operation immediately, 950 | // instead switch to 'AwaitingWithBuffer' state 951 | return new AwaitingWithBuffer(this.outstanding, operation); 952 | }; 953 | 954 | AwaitingConfirm.prototype.applyServer = function (client, operation) { 955 | // This is another client's operation. Visualization: 956 | // 957 | // /\ 958 | // this.outstanding / \ operation 959 | // / \ 960 | // \ / 961 | // pair[1] \ / pair[0] (new outstanding) 962 | // (can be applied \/ 963 | // to the client's 964 | // current document) 965 | var pair = operation.constructor.transform(this.outstanding, operation); 966 | client.applyOperation(pair[1]); 967 | return new AwaitingConfirm(pair[0]); 968 | }; 969 | 970 | AwaitingConfirm.prototype.serverAck = function (client) { 971 | // The client's operation has been acknowledged 972 | // => switch to synchronized state 973 | return synchronized_; 974 | }; 975 | 976 | AwaitingConfirm.prototype.transformSelection = function (selection) { 977 | return selection.transform(this.outstanding); 978 | }; 979 | 980 | AwaitingConfirm.prototype.resend = function (client) { 981 | // The confirm didn't come because the client was disconnected. 982 | // Now that it has reconnected, we resend the outstanding operation. 983 | client.sendOperation(client.revision, this.outstanding); 984 | }; 985 | 986 | 987 | // In the 'AwaitingWithBuffer' state, the client is waiting for an operation 988 | // to be acknowledged by the server while buffering the edits the user makes 989 | function AwaitingWithBuffer (outstanding, buffer) { 990 | // Save the pending operation and the user's edits since then 991 | this.outstanding = outstanding; 992 | this.buffer = buffer; 993 | } 994 | Client.AwaitingWithBuffer = AwaitingWithBuffer; 995 | 996 | AwaitingWithBuffer.prototype.applyClient = function (client, operation) { 997 | // Compose the user's changes onto the buffer 998 | var newBuffer = this.buffer.compose(operation); 999 | return new AwaitingWithBuffer(this.outstanding, newBuffer); 1000 | }; 1001 | 1002 | AwaitingWithBuffer.prototype.applyServer = function (client, operation) { 1003 | // Operation comes from another client 1004 | // 1005 | // /\ 1006 | // this.outstanding / \ operation 1007 | // / \ 1008 | // /\ / 1009 | // this.buffer / \* / pair1[0] (new outstanding) 1010 | // / \/ 1011 | // \ / 1012 | // pair2[1] \ / pair2[0] (new buffer) 1013 | // the transformed \/ 1014 | // operation -- can 1015 | // be applied to the 1016 | // client's current 1017 | // document 1018 | // 1019 | // * pair1[1] 1020 | var transform = operation.constructor.transform; 1021 | var pair1 = transform(this.outstanding, operation); 1022 | var pair2 = transform(this.buffer, pair1[1]); 1023 | client.applyOperation(pair2[1]); 1024 | return new AwaitingWithBuffer(pair1[0], pair2[0]); 1025 | }; 1026 | 1027 | AwaitingWithBuffer.prototype.serverAck = function (client) { 1028 | // The pending operation has been acknowledged 1029 | // => send buffer 1030 | client.sendOperation(client.revision, this.buffer); 1031 | return new AwaitingConfirm(this.buffer); 1032 | }; 1033 | 1034 | AwaitingWithBuffer.prototype.transformSelection = function (selection) { 1035 | return selection.transform(this.outstanding).transform(this.buffer); 1036 | }; 1037 | 1038 | AwaitingWithBuffer.prototype.resend = function (client) { 1039 | // The confirm didn't come because the client was disconnected. 1040 | // Now that it has reconnected, we resend the outstanding operation. 1041 | client.sendOperation(client.revision, this.outstanding); 1042 | }; 1043 | 1044 | 1045 | return Client; 1046 | 1047 | }(this)); 1048 | 1049 | if (typeof module === 'object') { 1050 | module.exports = ot.Client; 1051 | } 1052 | 1053 | /*global ot */ 1054 | 1055 | ot.CodeMirrorAdapter = (function (global) { 1056 | 'use strict'; 1057 | 1058 | var TextOperation = ot.TextOperation; 1059 | var Selection = ot.Selection; 1060 | 1061 | function CodeMirrorAdapter (cm) { 1062 | this.cm = cm; 1063 | this.ignoreNextChange = false; 1064 | this.changeInProgress = false; 1065 | this.selectionChanged = false; 1066 | 1067 | bind(this, 'onChanges'); 1068 | bind(this, 'onChange'); 1069 | bind(this, 'onCursorActivity'); 1070 | bind(this, 'onFocus'); 1071 | bind(this, 'onBlur'); 1072 | 1073 | cm.on('changes', this.onChanges); 1074 | cm.on('change', this.onChange); 1075 | cm.on('cursorActivity', this.onCursorActivity); 1076 | cm.on('focus', this.onFocus); 1077 | cm.on('blur', this.onBlur); 1078 | } 1079 | 1080 | // Removes all event listeners from the CodeMirror instance. 1081 | CodeMirrorAdapter.prototype.detach = function () { 1082 | this.cm.off('changes', this.onChanges); 1083 | this.cm.off('change', this.onChange); 1084 | this.cm.off('cursorActivity', this.onCursorActivity); 1085 | this.cm.off('focus', this.onFocus); 1086 | this.cm.off('blur', this.onBlur); 1087 | }; 1088 | 1089 | function cmpPos (a, b) { 1090 | if (a.line < b.line) { return -1; } 1091 | if (a.line > b.line) { return 1; } 1092 | if (a.ch < b.ch) { return -1; } 1093 | if (a.ch > b.ch) { return 1; } 1094 | return 0; 1095 | } 1096 | function posEq (a, b) { return cmpPos(a, b) === 0; } 1097 | function posLe (a, b) { return cmpPos(a, b) <= 0; } 1098 | 1099 | function minPos (a, b) { return posLe(a, b) ? a : b; } 1100 | function maxPos (a, b) { return posLe(a, b) ? b : a; } 1101 | 1102 | function codemirrorDocLength (doc) { 1103 | return doc.indexFromPos({ line: doc.lastLine(), ch: 0 }) + 1104 | doc.getLine(doc.lastLine()).length; 1105 | } 1106 | 1107 | // Converts a CodeMirror change array (as obtained from the 'changes' event 1108 | // in CodeMirror v4) or single change or linked list of changes (as returned 1109 | // by the 'change' event in CodeMirror prior to version 4) into a 1110 | // TextOperation and its inverse and returns them as a two-element array. 1111 | CodeMirrorAdapter.operationFromCodeMirrorChanges = function (changes, doc) { 1112 | // Approach: Replay the changes, beginning with the most recent one, and 1113 | // construct the operation and its inverse. We have to convert the position 1114 | // in the pre-change coordinate system to an index. We have a method to 1115 | // convert a position in the coordinate system after all changes to an index, 1116 | // namely CodeMirror's `indexFromPos` method. We can use the information of 1117 | // a single change object to convert a post-change coordinate system to a 1118 | // pre-change coordinate system. We can now proceed inductively to get a 1119 | // pre-change coordinate system for all changes in the linked list. 1120 | // A disadvantage of this approach is its complexity `O(n^2)` in the length 1121 | // of the linked list of changes. 1122 | 1123 | var docEndLength = codemirrorDocLength(doc); 1124 | var operation = new TextOperation().retain(docEndLength); 1125 | var inverse = new TextOperation().retain(docEndLength); 1126 | 1127 | var indexFromPos = function (pos) { 1128 | return doc.indexFromPos(pos); 1129 | }; 1130 | 1131 | function last (arr) { return arr[arr.length - 1]; } 1132 | 1133 | function sumLengths (strArr) { 1134 | if (strArr.length === 0) { return 0; } 1135 | var sum = 0; 1136 | for (var i = 0; i < strArr.length; i++) { sum += strArr[i].length; } 1137 | return sum + strArr.length - 1; 1138 | } 1139 | 1140 | function updateIndexFromPos (indexFromPos, change) { 1141 | return function (pos) { 1142 | if (posLe(pos, change.from)) { return indexFromPos(pos); } 1143 | if (posLe(change.to, pos)) { 1144 | return indexFromPos({ 1145 | line: pos.line + change.text.length - 1 - (change.to.line - change.from.line), 1146 | ch: (change.to.line < pos.line) ? 1147 | pos.ch : 1148 | (change.text.length <= 1) ? 1149 | pos.ch - (change.to.ch - change.from.ch) + sumLengths(change.text) : 1150 | pos.ch - change.to.ch + last(change.text).length 1151 | }) + sumLengths(change.removed) - sumLengths(change.text); 1152 | } 1153 | if (change.from.line === pos.line) { 1154 | return indexFromPos(change.from) + pos.ch - change.from.ch; 1155 | } 1156 | return indexFromPos(change.from) + 1157 | sumLengths(change.removed.slice(0, pos.line - change.from.line)) + 1158 | 1 + pos.ch; 1159 | }; 1160 | } 1161 | 1162 | for (var i = changes.length - 1; i >= 0; i--) { 1163 | var change = changes[i]; 1164 | indexFromPos = updateIndexFromPos(indexFromPos, change); 1165 | 1166 | var fromIndex = indexFromPos(change.from); 1167 | var restLength = docEndLength - fromIndex - sumLengths(change.text); 1168 | 1169 | operation = new TextOperation() 1170 | .retain(fromIndex) 1171 | ['delete'](sumLengths(change.removed)) 1172 | .insert(change.text.join('\n')) 1173 | .retain(restLength) 1174 | .compose(operation); 1175 | 1176 | inverse = inverse.compose(new TextOperation() 1177 | .retain(fromIndex) 1178 | ['delete'](sumLengths(change.text)) 1179 | .insert(change.removed.join('\n')) 1180 | .retain(restLength) 1181 | ); 1182 | 1183 | docEndLength += sumLengths(change.removed) - sumLengths(change.text); 1184 | } 1185 | 1186 | return [operation, inverse]; 1187 | }; 1188 | 1189 | // Singular form for backwards compatibility. 1190 | CodeMirrorAdapter.operationFromCodeMirrorChange = 1191 | CodeMirrorAdapter.operationFromCodeMirrorChanges; 1192 | 1193 | // Apply an operation to a CodeMirror instance. 1194 | CodeMirrorAdapter.applyOperationToCodeMirror = function (operation, cm) { 1195 | cm.operation(function () { 1196 | var ops = operation.ops; 1197 | var index = 0; // holds the current index into CodeMirror's content 1198 | for (var i = 0, l = ops.length; i < l; i++) { 1199 | var op = ops[i]; 1200 | if (TextOperation.isRetain(op)) { 1201 | index += op; 1202 | } else if (TextOperation.isInsert(op)) { 1203 | cm.replaceRange(op, cm.posFromIndex(index)); 1204 | index += op.length; 1205 | } else if (TextOperation.isDelete(op)) { 1206 | var from = cm.posFromIndex(index); 1207 | var to = cm.posFromIndex(index - op); 1208 | cm.replaceRange('', from, to); 1209 | } 1210 | } 1211 | }); 1212 | }; 1213 | 1214 | CodeMirrorAdapter.prototype.registerCallbacks = function (cb) { 1215 | this.callbacks = cb; 1216 | }; 1217 | 1218 | CodeMirrorAdapter.prototype.onChange = function () { 1219 | // By default, CodeMirror's event order is the following: 1220 | // 1. 'change', 2. 'cursorActivity', 3. 'changes'. 1221 | // We want to fire the 'selectionChange' event after the 'change' event, 1222 | // but need the information from the 'changes' event. Therefore, we detect 1223 | // when a change is in progress by listening to the change event, setting 1224 | // a flag that makes this adapter defer all 'cursorActivity' events. 1225 | this.changeInProgress = true; 1226 | }; 1227 | 1228 | CodeMirrorAdapter.prototype.onChanges = function (_, changes) { 1229 | if (!this.ignoreNextChange) { 1230 | var pair = CodeMirrorAdapter.operationFromCodeMirrorChanges(changes, this.cm); 1231 | this.trigger('change', pair[0], pair[1]); 1232 | } 1233 | if (this.selectionChanged) { this.trigger('selectionChange'); } 1234 | this.changeInProgress = false; 1235 | this.ignoreNextChange = false; 1236 | }; 1237 | 1238 | CodeMirrorAdapter.prototype.onCursorActivity = 1239 | CodeMirrorAdapter.prototype.onFocus = function () { 1240 | if (this.changeInProgress) { 1241 | this.selectionChanged = true; 1242 | } else { 1243 | this.trigger('selectionChange'); 1244 | } 1245 | }; 1246 | 1247 | CodeMirrorAdapter.prototype.onBlur = function () { 1248 | if (!this.cm.somethingSelected()) { this.trigger('blur'); } 1249 | }; 1250 | 1251 | CodeMirrorAdapter.prototype.getValue = function () { 1252 | return this.cm.getValue(); 1253 | }; 1254 | 1255 | CodeMirrorAdapter.prototype.getSelection = function () { 1256 | var cm = this.cm; 1257 | 1258 | var selectionList = cm.listSelections(); 1259 | var ranges = []; 1260 | for (var i = 0; i < selectionList.length; i++) { 1261 | ranges[i] = new Selection.Range( 1262 | cm.indexFromPos(selectionList[i].anchor), 1263 | cm.indexFromPos(selectionList[i].head) 1264 | ); 1265 | } 1266 | 1267 | return new Selection(ranges); 1268 | }; 1269 | 1270 | CodeMirrorAdapter.prototype.setSelection = function (selection) { 1271 | var ranges = []; 1272 | for (var i = 0; i < selection.ranges.length; i++) { 1273 | var range = selection.ranges[i]; 1274 | ranges[i] = { 1275 | anchor: this.cm.posFromIndex(range.anchor), 1276 | head: this.cm.posFromIndex(range.head) 1277 | }; 1278 | } 1279 | this.cm.setSelections(ranges); 1280 | }; 1281 | 1282 | var addStyleRule = (function () { 1283 | var added = {}; 1284 | var styleElement = document.createElement('style'); 1285 | document.documentElement.getElementsByTagName('head')[0].appendChild(styleElement); 1286 | var styleSheet = styleElement.sheet; 1287 | 1288 | return function (css) { 1289 | if (added[css]) { return; } 1290 | added[css] = true; 1291 | styleSheet.insertRule(css, (styleSheet.cssRules || styleSheet.rules).length); 1292 | }; 1293 | }()); 1294 | 1295 | CodeMirrorAdapter.prototype.setOtherCursor = function (position, color, clientId) { 1296 | var cursorPos = this.cm.posFromIndex(position); 1297 | var cursorCoords = this.cm.cursorCoords(cursorPos); 1298 | var cursorEl = document.createElement('span'); 1299 | cursorEl.className = 'other-client'; 1300 | cursorEl.style.display = 'inline-block'; 1301 | cursorEl.style.padding = '0'; 1302 | cursorEl.style.marginLeft = cursorEl.style.marginRight = '-1px'; 1303 | cursorEl.style.borderLeftWidth = '2px'; 1304 | cursorEl.style.borderLeftStyle = 'solid'; 1305 | cursorEl.style.borderLeftColor = color; 1306 | cursorEl.style.height = (cursorCoords.bottom - cursorCoords.top) * 0.9 + 'px'; 1307 | cursorEl.style.zIndex = 0; 1308 | cursorEl.setAttribute('data-clientid', clientId); 1309 | return this.cm.setBookmark(cursorPos, { widget: cursorEl, insertLeft: true }); 1310 | }; 1311 | 1312 | CodeMirrorAdapter.prototype.setOtherSelectionRange = function (range, color, clientId) { 1313 | var match = /^#([0-9a-fA-F]{6})$/.exec(color); 1314 | if (!match) { throw new Error("only six-digit hex colors are allowed."); } 1315 | var selectionClassName = 'selection-' + match[1]; 1316 | var rule = '.' + selectionClassName + ' { background: ' + color + '; }'; 1317 | addStyleRule(rule); 1318 | 1319 | var anchorPos = this.cm.posFromIndex(range.anchor); 1320 | var headPos = this.cm.posFromIndex(range.head); 1321 | 1322 | return this.cm.markText( 1323 | minPos(anchorPos, headPos), 1324 | maxPos(anchorPos, headPos), 1325 | { className: selectionClassName } 1326 | ); 1327 | }; 1328 | 1329 | CodeMirrorAdapter.prototype.setOtherSelection = function (selection, color, clientId) { 1330 | var selectionObjects = []; 1331 | for (var i = 0; i < selection.ranges.length; i++) { 1332 | var range = selection.ranges[i]; 1333 | if (range.isEmpty()) { 1334 | selectionObjects[i] = this.setOtherCursor(range.head, color, clientId); 1335 | } else { 1336 | selectionObjects[i] = this.setOtherSelectionRange(range, color, clientId); 1337 | } 1338 | } 1339 | return { 1340 | clear: function () { 1341 | for (var i = 0; i < selectionObjects.length; i++) { 1342 | selectionObjects[i].clear(); 1343 | } 1344 | } 1345 | }; 1346 | }; 1347 | 1348 | CodeMirrorAdapter.prototype.trigger = function (event) { 1349 | var args = Array.prototype.slice.call(arguments, 1); 1350 | var action = this.callbacks && this.callbacks[event]; 1351 | if (action) { action.apply(this, args); } 1352 | }; 1353 | 1354 | CodeMirrorAdapter.prototype.applyOperation = function (operation) { 1355 | if (!operation.isNoop()) { 1356 | this.ignoreNextChange = true; 1357 | } 1358 | CodeMirrorAdapter.applyOperationToCodeMirror(operation, this.cm); 1359 | }; 1360 | 1361 | CodeMirrorAdapter.prototype.registerUndo = function (undoFn) { 1362 | this.cm.undo = undoFn; 1363 | }; 1364 | 1365 | CodeMirrorAdapter.prototype.registerRedo = function (redoFn) { 1366 | this.cm.redo = redoFn; 1367 | }; 1368 | 1369 | // Throws an error if the first argument is falsy. Useful for debugging. 1370 | function assert (b, msg) { 1371 | if (!b) { 1372 | throw new Error(msg || "assertion error"); 1373 | } 1374 | } 1375 | 1376 | // Bind a method to an object, so it doesn't matter whether you call 1377 | // object.method() directly or pass object.method as a reference to another 1378 | // function. 1379 | function bind (obj, method) { 1380 | var fn = obj[method]; 1381 | obj[method] = function () { 1382 | fn.apply(obj, arguments); 1383 | }; 1384 | } 1385 | 1386 | return CodeMirrorAdapter; 1387 | 1388 | }(this)); 1389 | 1390 | /*global ot */ 1391 | 1392 | ot.SocketIOAdapter = (function () { 1393 | 'use strict'; 1394 | 1395 | function SocketIOAdapter (socket) { 1396 | this.socket = socket; 1397 | 1398 | var self = this; 1399 | socket 1400 | .on('client_left', function (clientId) { 1401 | self.trigger('client_left', clientId); 1402 | }) 1403 | .on('set_name', function (clientId, name) { 1404 | self.trigger('set_name', clientId, name); 1405 | }) 1406 | .on('ack', function () { self.trigger('ack'); }) 1407 | .on('operation', function (clientId, operation, selection) { 1408 | self.trigger('operation', operation); 1409 | self.trigger('selection', clientId, selection); 1410 | }) 1411 | .on('selection', function (clientId, selection) { 1412 | self.trigger('selection', clientId, selection); 1413 | }) 1414 | .on('reconnect', function () { 1415 | self.trigger('reconnect'); 1416 | }); 1417 | } 1418 | 1419 | SocketIOAdapter.prototype.sendOperation = function (revision, operation, selection) { 1420 | this.socket.emit('operation', revision, operation, selection); 1421 | }; 1422 | 1423 | SocketIOAdapter.prototype.sendSelection = function (selection) { 1424 | this.socket.emit('selection', selection); 1425 | }; 1426 | 1427 | SocketIOAdapter.prototype.registerCallbacks = function (cb) { 1428 | this.callbacks = cb; 1429 | }; 1430 | 1431 | SocketIOAdapter.prototype.trigger = function (event) { 1432 | var args = Array.prototype.slice.call(arguments, 1); 1433 | var action = this.callbacks && this.callbacks[event]; 1434 | if (action) { action.apply(this, args); } 1435 | }; 1436 | 1437 | return SocketIOAdapter; 1438 | 1439 | }()); 1440 | /*global ot, $ */ 1441 | 1442 | ot.AjaxAdapter = (function () { 1443 | 'use strict'; 1444 | 1445 | function AjaxAdapter (path, ownUserName, revision) { 1446 | if (path[path.length - 1] !== '/') { path += '/'; } 1447 | this.path = path; 1448 | this.ownUserName = ownUserName; 1449 | this.majorRevision = revision.major || 0; 1450 | this.minorRevision = revision.minor || 0; 1451 | this.poll(); 1452 | } 1453 | 1454 | AjaxAdapter.prototype.renderRevisionPath = function () { 1455 | return 'revision/' + this.majorRevision + '-' + this.minorRevision; 1456 | }; 1457 | 1458 | AjaxAdapter.prototype.handleResponse = function (data) { 1459 | var i; 1460 | var operations = data.operations; 1461 | for (i = 0; i < operations.length; i++) { 1462 | if (operations[i].user === this.ownUserName) { 1463 | this.trigger('ack'); 1464 | } else { 1465 | this.trigger('operation', operations[i].operation); 1466 | } 1467 | } 1468 | if (operations.length > 0) { 1469 | this.majorRevision += operations.length; 1470 | this.minorRevision = 0; 1471 | } 1472 | 1473 | var events = data.events; 1474 | if (events) { 1475 | for (i = 0; i < events.length; i++) { 1476 | var user = events[i].user; 1477 | if (user === this.ownUserName) { continue; } 1478 | switch (events[i].event) { 1479 | case 'joined': this.trigger('set_name', user, user); break; 1480 | case 'left': this.trigger('client_left', user); break; 1481 | case 'selection': this.trigger('selection', user, events[i].selection); break; 1482 | } 1483 | } 1484 | this.minorRevision += events.length; 1485 | } 1486 | 1487 | var users = data.users; 1488 | if (users) { 1489 | delete users[this.ownUserName]; 1490 | this.trigger('clients', users); 1491 | } 1492 | 1493 | if (data.revision) { 1494 | this.majorRevision = data.revision.major; 1495 | this.minorRevision = data.revision.minor; 1496 | } 1497 | }; 1498 | 1499 | AjaxAdapter.prototype.poll = function () { 1500 | var self = this; 1501 | $.ajax({ 1502 | url: this.path + this.renderRevisionPath(), 1503 | type: 'GET', 1504 | dataType: 'json', 1505 | timeout: 5000, 1506 | success: function (data) { 1507 | self.handleResponse(data); 1508 | self.poll(); 1509 | }, 1510 | error: function () { 1511 | setTimeout(function () { self.poll(); }, 500); 1512 | } 1513 | }); 1514 | }; 1515 | 1516 | AjaxAdapter.prototype.sendOperation = function (revision, operation, selection) { 1517 | if (revision !== this.majorRevision) { throw new Error("Revision numbers out of sync"); } 1518 | var self = this; 1519 | $.ajax({ 1520 | url: this.path + this.renderRevisionPath(), 1521 | type: 'POST', 1522 | data: JSON.stringify({ operation: operation, selection: selection }), 1523 | contentType: 'application/json', 1524 | processData: false, 1525 | success: function (data) {}, 1526 | error: function () { 1527 | setTimeout(function () { self.sendOperation(revision, operation, selection); }, 500); 1528 | } 1529 | }); 1530 | }; 1531 | 1532 | AjaxAdapter.prototype.sendSelection = function (obj) { 1533 | $.ajax({ 1534 | url: this.path + this.renderRevisionPath() + '/selection', 1535 | type: 'POST', 1536 | data: JSON.stringify(obj), 1537 | contentType: 'application/json', 1538 | processData: false, 1539 | timeout: 1000 1540 | }); 1541 | }; 1542 | 1543 | AjaxAdapter.prototype.registerCallbacks = function (cb) { 1544 | this.callbacks = cb; 1545 | }; 1546 | 1547 | AjaxAdapter.prototype.trigger = function (event) { 1548 | var args = Array.prototype.slice.call(arguments, 1); 1549 | var action = this.callbacks && this.callbacks[event]; 1550 | if (action) { action.apply(this, args); } 1551 | }; 1552 | 1553 | return AjaxAdapter; 1554 | 1555 | })(); 1556 | /*global ot */ 1557 | 1558 | ot.EditorClient = (function () { 1559 | 'use strict'; 1560 | 1561 | var Client = ot.Client; 1562 | var Selection = ot.Selection; 1563 | var UndoManager = ot.UndoManager; 1564 | var TextOperation = ot.TextOperation; 1565 | var WrappedOperation = ot.WrappedOperation; 1566 | 1567 | 1568 | function SelfMeta (selectionBefore, selectionAfter) { 1569 | this.selectionBefore = selectionBefore; 1570 | this.selectionAfter = selectionAfter; 1571 | } 1572 | 1573 | SelfMeta.prototype.invert = function () { 1574 | return new SelfMeta(this.selectionAfter, this.selectionBefore); 1575 | }; 1576 | 1577 | SelfMeta.prototype.compose = function (other) { 1578 | return new SelfMeta(this.selectionBefore, other.selectionAfter); 1579 | }; 1580 | 1581 | SelfMeta.prototype.transform = function (operation) { 1582 | return new SelfMeta( 1583 | this.selectionBefore.transform(operation), 1584 | this.selectionAfter.transform(operation) 1585 | ); 1586 | }; 1587 | 1588 | 1589 | function OtherMeta (clientId, selection) { 1590 | this.clientId = clientId; 1591 | this.selection = selection; 1592 | } 1593 | 1594 | OtherMeta.fromJSON = function (obj) { 1595 | return new OtherMeta( 1596 | obj.clientId, 1597 | obj.selection && Selection.fromJSON(obj.selection) 1598 | ); 1599 | }; 1600 | 1601 | OtherMeta.prototype.transform = function (operation) { 1602 | return new OtherMeta( 1603 | this.clientId, 1604 | this.selection && this.selection.transform(operation) 1605 | ); 1606 | }; 1607 | 1608 | 1609 | function OtherClient (id, listEl, editorAdapter, name, selection) { 1610 | this.id = id; 1611 | this.listEl = listEl; 1612 | this.editorAdapter = editorAdapter; 1613 | this.name = name; 1614 | 1615 | this.li = document.createElement('li'); 1616 | if (name) { 1617 | this.li.textContent = name; 1618 | this.listEl.appendChild(this.li); 1619 | } 1620 | 1621 | this.setColor(name ? hueFromName(name) : Math.random()); 1622 | if (selection) { this.updateSelection(selection); } 1623 | } 1624 | 1625 | OtherClient.prototype.setColor = function (hue) { 1626 | this.hue = hue; 1627 | this.color = hsl2hex(hue, 0.75, 0.5); 1628 | this.lightColor = hsl2hex(hue, 0.5, 0.9); 1629 | if (this.li) { this.li.style.color = this.color; } 1630 | }; 1631 | 1632 | OtherClient.prototype.setName = function (name) { 1633 | if (this.name === name) { return; } 1634 | this.name = name; 1635 | 1636 | this.li.textContent = name; 1637 | if (!this.li.parentNode) { 1638 | this.listEl.appendChild(this.li); 1639 | } 1640 | 1641 | this.setColor(hueFromName(name)); 1642 | }; 1643 | 1644 | OtherClient.prototype.updateSelection = function (selection) { 1645 | this.removeSelection(); 1646 | this.selection = selection; 1647 | this.mark = this.editorAdapter.setOtherSelection( 1648 | selection, 1649 | selection.position === selection.selectionEnd ? this.color : this.lightColor, 1650 | this.id 1651 | ); 1652 | }; 1653 | 1654 | OtherClient.prototype.remove = function () { 1655 | if (this.li) { removeElement(this.li); } 1656 | this.removeSelection(); 1657 | }; 1658 | 1659 | OtherClient.prototype.removeSelection = function () { 1660 | if (this.mark) { 1661 | this.mark.clear(); 1662 | this.mark = null; 1663 | } 1664 | }; 1665 | 1666 | 1667 | function EditorClient (revision, clients, serverAdapter, editorAdapter) { 1668 | Client.call(this, revision); 1669 | this.serverAdapter = serverAdapter; 1670 | this.editorAdapter = editorAdapter; 1671 | this.undoManager = new UndoManager(); 1672 | 1673 | this.initializeClientList(); 1674 | this.initializeClients(clients); 1675 | 1676 | var self = this; 1677 | 1678 | this.editorAdapter.registerCallbacks({ 1679 | change: function (operation, inverse) { self.onChange(operation, inverse); }, 1680 | selectionChange: function () { self.onSelectionChange(); }, 1681 | blur: function () { self.onBlur(); } 1682 | }); 1683 | this.editorAdapter.registerUndo(function () { self.undo(); }); 1684 | this.editorAdapter.registerRedo(function () { self.redo(); }); 1685 | 1686 | this.serverAdapter.registerCallbacks({ 1687 | client_left: function (clientId) { self.onClientLeft(clientId); }, 1688 | set_name: function (clientId, name) { self.getClientObject(clientId).setName(name); }, 1689 | ack: function () { self.serverAck(); }, 1690 | operation: function (operation) { 1691 | self.applyServer(TextOperation.fromJSON(operation)); 1692 | }, 1693 | selection: function (clientId, selection) { 1694 | if (selection) { 1695 | self.getClientObject(clientId).updateSelection( 1696 | self.transformSelection(Selection.fromJSON(selection)) 1697 | ); 1698 | } else { 1699 | self.getClientObject(clientId).removeSelection(); 1700 | } 1701 | }, 1702 | clients: function (clients) { 1703 | var clientId; 1704 | for (clientId in self.clients) { 1705 | if (self.clients.hasOwnProperty(clientId) && !clients.hasOwnProperty(clientId)) { 1706 | self.onClientLeft(clientId); 1707 | } 1708 | } 1709 | 1710 | for (clientId in clients) { 1711 | if (clients.hasOwnProperty(clientId)) { 1712 | var clientObject = self.getClientObject(clientId); 1713 | 1714 | if (clients[clientId].name) { 1715 | clientObject.setName(clients[clientId].name); 1716 | } 1717 | 1718 | var selection = clients[clientId].selection; 1719 | if (selection) { 1720 | self.clients[clientId].updateSelection( 1721 | self.transformSelection(Selection.fromJSON(selection)) 1722 | ); 1723 | } else { 1724 | self.clients[clientId].removeSelection(); 1725 | } 1726 | } 1727 | } 1728 | }, 1729 | reconnect: function () { self.serverReconnect(); } 1730 | }); 1731 | } 1732 | 1733 | inherit(EditorClient, Client); 1734 | 1735 | EditorClient.prototype.addClient = function (clientId, clientObj) { 1736 | this.clients[clientId] = new OtherClient( 1737 | clientId, 1738 | this.clientListEl, 1739 | this.editorAdapter, 1740 | clientObj.name || clientId, 1741 | clientObj.selection ? Selection.fromJSON(clientObj.selection) : null 1742 | ); 1743 | }; 1744 | 1745 | EditorClient.prototype.initializeClients = function (clients) { 1746 | this.clients = {}; 1747 | for (var clientId in clients) { 1748 | if (clients.hasOwnProperty(clientId)) { 1749 | this.addClient(clientId, clients[clientId]); 1750 | } 1751 | } 1752 | }; 1753 | 1754 | EditorClient.prototype.getClientObject = function (clientId) { 1755 | var client = this.clients[clientId]; 1756 | if (client) { return client; } 1757 | return this.clients[clientId] = new OtherClient( 1758 | clientId, 1759 | this.clientListEl, 1760 | this.editorAdapter 1761 | ); 1762 | }; 1763 | 1764 | EditorClient.prototype.onClientLeft = function (clientId) { 1765 | console.log("User disconnected: " + clientId); 1766 | var client = this.clients[clientId]; 1767 | if (!client) { return; } 1768 | client.remove(); 1769 | delete this.clients[clientId]; 1770 | }; 1771 | 1772 | EditorClient.prototype.initializeClientList = function () { 1773 | this.clientListEl = document.createElement('ul'); 1774 | }; 1775 | 1776 | EditorClient.prototype.applyUnredo = function (operation) { 1777 | this.undoManager.add(operation.invert(this.editorAdapter.getValue())); 1778 | this.editorAdapter.applyOperation(operation.wrapped); 1779 | this.selection = operation.meta.selectionAfter; 1780 | this.editorAdapter.setSelection(this.selection); 1781 | this.applyClient(operation.wrapped); 1782 | }; 1783 | 1784 | EditorClient.prototype.undo = function () { 1785 | var self = this; 1786 | if (!this.undoManager.canUndo()) { return; } 1787 | this.undoManager.performUndo(function (o) { self.applyUnredo(o); }); 1788 | }; 1789 | 1790 | EditorClient.prototype.redo = function () { 1791 | var self = this; 1792 | if (!this.undoManager.canRedo()) { return; } 1793 | this.undoManager.performRedo(function (o) { self.applyUnredo(o); }); 1794 | }; 1795 | 1796 | EditorClient.prototype.onChange = function (textOperation, inverse) { 1797 | var selectionBefore = this.selection; 1798 | this.updateSelection(); 1799 | var meta = new SelfMeta(selectionBefore, this.selection); 1800 | var operation = new WrappedOperation(textOperation, meta); 1801 | 1802 | var compose = this.undoManager.undoStack.length > 0 && 1803 | inverse.shouldBeComposedWithInverted(last(this.undoManager.undoStack).wrapped); 1804 | var inverseMeta = new SelfMeta(this.selection, selectionBefore); 1805 | this.undoManager.add(new WrappedOperation(inverse, inverseMeta), compose); 1806 | this.applyClient(textOperation); 1807 | }; 1808 | 1809 | EditorClient.prototype.updateSelection = function () { 1810 | this.selection = this.editorAdapter.getSelection(); 1811 | }; 1812 | 1813 | EditorClient.prototype.onSelectionChange = function () { 1814 | var oldSelection = this.selection; 1815 | this.updateSelection(); 1816 | if (oldSelection && this.selection.equals(oldSelection)) { return; } 1817 | this.sendSelection(this.selection); 1818 | }; 1819 | 1820 | EditorClient.prototype.onBlur = function () { 1821 | this.selection = null; 1822 | this.sendSelection(null); 1823 | }; 1824 | 1825 | EditorClient.prototype.sendSelection = function (selection) { 1826 | if (this.state instanceof Client.AwaitingWithBuffer) { return; } 1827 | this.serverAdapter.sendSelection(selection); 1828 | }; 1829 | 1830 | EditorClient.prototype.sendOperation = function (revision, operation) { 1831 | this.serverAdapter.sendOperation(revision, operation.toJSON(), this.selection); 1832 | }; 1833 | 1834 | EditorClient.prototype.applyOperation = function (operation) { 1835 | this.editorAdapter.applyOperation(operation); 1836 | this.updateSelection(); 1837 | this.undoManager.transform(new WrappedOperation(operation, null)); 1838 | }; 1839 | 1840 | function rgb2hex (r, g, b) { 1841 | function digits (n) { 1842 | var m = Math.round(255*n).toString(16); 1843 | return m.length === 1 ? '0'+m : m; 1844 | } 1845 | return '#' + digits(r) + digits(g) + digits(b); 1846 | } 1847 | 1848 | function hsl2hex (h, s, l) { 1849 | if (s === 0) { return rgb2hex(l, l, l); } 1850 | var var2 = l < 0.5 ? l * (1+s) : (l+s) - (s*l); 1851 | var var1 = 2 * l - var2; 1852 | var hue2rgb = function (hue) { 1853 | if (hue < 0) { hue += 1; } 1854 | if (hue > 1) { hue -= 1; } 1855 | if (6*hue < 1) { return var1 + (var2-var1)*6*hue; } 1856 | if (2*hue < 1) { return var2; } 1857 | if (3*hue < 2) { return var1 + (var2-var1)*6*(2/3 - hue); } 1858 | return var1; 1859 | }; 1860 | return rgb2hex(hue2rgb(h+1/3), hue2rgb(h), hue2rgb(h-1/3)); 1861 | } 1862 | 1863 | function hueFromName (name) { 1864 | var a = 1; 1865 | for (var i = 0; i < name.length; i++) { 1866 | a = 17 * (a+name.charCodeAt(i)) % 360; 1867 | } 1868 | return a/360; 1869 | } 1870 | 1871 | // Set Const.prototype.__proto__ to Super.prototype 1872 | function inherit (Const, Super) { 1873 | function F () {} 1874 | F.prototype = Super.prototype; 1875 | Const.prototype = new F(); 1876 | Const.prototype.constructor = Const; 1877 | } 1878 | 1879 | function last (arr) { return arr[arr.length - 1]; } 1880 | 1881 | // Remove an element from the DOM. 1882 | function removeElement (el) { 1883 | if (el.parentNode) { 1884 | el.parentNode.removeChild(el); 1885 | } 1886 | } 1887 | 1888 | return EditorClient; 1889 | }()); 1890 | 1891 | if (typeof ot === 'undefined') { 1892 | var ot = {}; 1893 | } 1894 | 1895 | ot.Server = (function (global) { 1896 | 'use strict'; 1897 | 1898 | // Constructor. Takes the current document as a string and optionally the array 1899 | // of all operations. 1900 | function Server (document, operations) { 1901 | this.document = document; 1902 | this.operations = operations || []; 1903 | } 1904 | 1905 | // Call this method whenever you receive an operation from a client. 1906 | Server.prototype.receiveOperation = function (revision, operation) { 1907 | if (revision < 0 || this.operations.length < revision) { 1908 | throw new Error("operation revision not in history"); 1909 | } 1910 | // Find all operations that the client didn't know of when it sent the 1911 | // operation ... 1912 | var concurrentOperations = this.operations.slice(revision); 1913 | 1914 | // ... and transform the operation against all these operations ... 1915 | var transform = operation.constructor.transform; 1916 | for (var i = 0; i < concurrentOperations.length; i++) { 1917 | operation = transform(operation, concurrentOperations[i])[0]; 1918 | } 1919 | 1920 | // ... and apply that on the document. 1921 | this.document = operation.apply(this.document); 1922 | // Store operation in history. 1923 | this.operations.push(operation); 1924 | 1925 | // It's the caller's responsibility to send the operation to all connected 1926 | // clients and an acknowledgement to the creator. 1927 | return operation; 1928 | }; 1929 | 1930 | return Server; 1931 | 1932 | }(this)); 1933 | 1934 | if (typeof module === 'object') { 1935 | module.exports = ot.Server; 1936 | } 1937 | var opTrans; 1938 | if (typeof ot === 'undefined' && typeof require !== 'undefined') { 1939 | opTrans = require('ot'); 1940 | } else { 1941 | opTrans = ot; 1942 | } 1943 | 1944 | var OpenTokAdapter = (function () { 1945 | 'use strict'; 1946 | 1947 | function OpenTokAdapter (session, revision, doc, operations) { 1948 | OT.$.eventing(this); 1949 | this.registerCallbacks = this.on; 1950 | this.session = session; 1951 | 1952 | if (operations && revision > operations.length) { 1953 | // the operations have been truncated fill in the beginning with empty space 1954 | var filler = []; 1955 | filler[revision - operations.length - 1] = null; 1956 | this.operations = filler.concat(operations); 1957 | } else { 1958 | this.operations = operations ? operations : []; 1959 | } 1960 | // We pretend to be a server 1961 | var server = new opTrans.Server(doc, this.operations); 1962 | 1963 | this.session.on({ 1964 | connectionDestroyed: function (event) { 1965 | this.trigger('client_left', event.connection.connectionId); 1966 | }, 1967 | connectionCreated: function (event) { 1968 | if (event.connection.data && event.connection.data.name) { 1969 | this.trigger('set_name', event.connection.connectionId, event.connection.data.name); 1970 | } 1971 | }, 1972 | 'signal:opentok-editor-operation': function (event) { 1973 | var data = JSON.parse(event.data), 1974 | wrapped; 1975 | wrapped = new opTrans.WrappedOperation( 1976 | opTrans.TextOperation.fromJSON(data.operation), 1977 | data.selection && opTrans.Selection.fromJSON(data.selection) 1978 | ); 1979 | // Might need to try catch here and if it fails wait a little while and 1980 | // try again. This way if we receive operations out of order we might 1981 | // be able to recover 1982 | var wrappedPrime = server.receiveOperation(data.revision, wrapped); 1983 | console.log("new operation: " + wrapped); 1984 | if (event.from.connectionId === session.connection.connectionId) { 1985 | this.trigger('ack'); 1986 | } else { 1987 | this.trigger('operation', wrappedPrime.wrapped.toJSON()); 1988 | this.trigger('selection', event.from.connectionId, wrappedPrime.meta); 1989 | } 1990 | }, 1991 | 'signal:opentok-editor-selection': function (event) { 1992 | var selection = JSON.parse(event.data); 1993 | this.trigger('selection', event.from.connectionId, selection); 1994 | } 1995 | }, this); 1996 | } 1997 | 1998 | OpenTokAdapter.prototype.sendOperation = function (revision, operation, selection) { 1999 | this.session.signal({ 2000 | type: 'opentok-editor-operation', 2001 | data: JSON.stringify({ 2002 | revision: revision, 2003 | operation: operation, 2004 | selection: selection 2005 | }) 2006 | }, function (err) { 2007 | if (err) console.error('Error sending operation', err); 2008 | }); 2009 | }; 2010 | 2011 | OpenTokAdapter.prototype.sendSelection = function (selection) { 2012 | this.session.signal({ 2013 | type: 'opentok-editor-selection', 2014 | data: JSON.stringify(selection) 2015 | }); 2016 | }; 2017 | 2018 | return OpenTokAdapter; 2019 | 2020 | }()); 2021 | 2022 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 2023 | module.exports = OpenTokAdapter; 2024 | } 2025 | 2026 | var ng, opTrans; 2027 | if (typeof angular === 'undefined' && typeof require !== 'undefined') { 2028 | ng = require('angular'); 2029 | } else { 2030 | ng = angular; 2031 | } 2032 | if (typeof ot === 'undefined' && typeof require !== 'undefined') { 2033 | // fixme: We have to make this a global because CodeMirrorAdapter and EditorClient 2034 | // attach themselves to the global ot 2035 | window.ot = require('ot'); 2036 | ot.UndoManager = require('ot/lib/undo-manager.js'); 2037 | ot.WrappedOperation = require('ot/lib/wrapped-operation.js'); 2038 | require('ot/lib/codemirror-adapter.js'); 2039 | require('ot/lib/editor-client.js'); 2040 | 2041 | window.OpenTokAdapter = require('./opentok-adapter.js'); 2042 | } 2043 | 2044 | if (typeof window.CodeMirror === 'undefined') { 2045 | window.CodeMirror = require('codemirror'); 2046 | } 2047 | 2048 | (function () { 2049 | // Turns the Array of operation Objects into an Array of JSON stringifyable objects 2050 | var serialiseOps = function (operations) { 2051 | return operations.map(function (op) { 2052 | return { 2053 | operation: op.wrapped.toJSON() 2054 | }; 2055 | }); 2056 | }; 2057 | 2058 | // Turns the JSON form of the Array of operations into ot.TextOperations 2059 | var deserialiseOps = function (operations) { 2060 | return operations.map(function (op) { 2061 | return new ot.WrappedOperation( 2062 | ot.TextOperation.fromJSON(op.operation), 2063 | op.cursor && ot.Selection.fromJSON(op.cursor) 2064 | ); 2065 | }); 2066 | }; 2067 | 2068 | ng.module('opentok-editor', ['opentok']) 2069 | .directive('otEditor', ['OTSession', '$window', function (OTSession, $window) { 2070 | return { 2071 | restrict: 'E', 2072 | scope: { 2073 | modes: '=' 2074 | }, 2075 | template: '
' + 2076 | '' + 2077 | '
' + 2078 | '
Connecting...
' + 2079 | '
', 2080 | link: function (scope, element, attrs) { 2081 | var opentokEditor = element.context.querySelector('div.opentok-editor'), 2082 | modeSelect = element.context.querySelector('select'), 2083 | myCodeMirror, 2084 | cmClient, 2085 | doc, 2086 | initialised = false, 2087 | session = OTSession.session, 2088 | otAdapter; 2089 | if (typeof require !== 'undefined') { 2090 | // Require all of the modes 2091 | scope.modes.forEach(function(mode) { 2092 | require('codemirror/mode/' + mode.value + '/' + mode.value + '.js'); 2093 | }); 2094 | } 2095 | scope.connecting = true; 2096 | var selectedMode = scope.modes.filter(function (value) {return value.value === attrs.mode;}); 2097 | scope.selectedMode = selectedMode.length > 0 ? selectedMode[0] : scope.modes[0]; 2098 | 2099 | var createEditorClient = function(revision, clients, doc, operations) { 2100 | if (!cmClient) { 2101 | otAdapter = new OpenTokAdapter(session, revision, doc, operations); 2102 | otAdapter.registerCallbacks('operation', function () { 2103 | scope.$emit('otEditorUpdate'); 2104 | }); 2105 | cmClient = new ot.EditorClient( 2106 | revision, 2107 | clients, 2108 | otAdapter, 2109 | new ot.CodeMirrorAdapter(myCodeMirror) 2110 | ); 2111 | scope.$apply(function () { 2112 | scope.connecting = false; 2113 | setTimeout(function () { 2114 | myCodeMirror.refresh(); 2115 | }, 1000); 2116 | }); 2117 | } 2118 | }; 2119 | 2120 | var initialiseDoc = function () { 2121 | if (myCodeMirror && !initialised) { 2122 | initialised = true; 2123 | if (myCodeMirror.getValue() !== doc.str) { 2124 | myCodeMirror.setValue(doc.str); 2125 | scope.$emit('otEditorUpdate'); 2126 | } 2127 | createEditorClient(doc.revision, doc.clients, doc.str, deserialiseOps(doc.operations)); 2128 | } 2129 | }; 2130 | 2131 | var signalDocState = function (to) { 2132 | var operations = otAdapter && otAdapter.operations ? serialiseOps(otAdapter.operations): []; 2133 | // We only want the most recent 50 because we can't send too much data 2134 | if (operations.length > 50) { 2135 | operations = operations.slice(operations.length - 50); 2136 | } 2137 | var signal = { 2138 | type: 'opentok-editor-doc', 2139 | data: JSON.stringify({ 2140 | revision: cmClient.revision, 2141 | clients: [], 2142 | str: myCodeMirror.getValue(), 2143 | operations: operations 2144 | }) 2145 | }; 2146 | if (to) { 2147 | signal.to = to; 2148 | } 2149 | session.signal(signal); 2150 | }; 2151 | 2152 | var sessionConnected = function () { 2153 | myCodeMirror = CodeMirror(opentokEditor, attrs); 2154 | session.signal({ 2155 | type: 'opentok-editor-request-doc' 2156 | }); 2157 | 2158 | setTimeout(function () { 2159 | // We wait 2 seconds for other clients to send us the doc before 2160 | // initialising it to empty 2161 | if (!initialised) { 2162 | initialised = true; 2163 | createEditorClient(0, [], myCodeMirror.getValue()); 2164 | // Tell anyone that joined after us that we are initialising it 2165 | signalDocState(); 2166 | } 2167 | }, 10000); 2168 | }; 2169 | 2170 | session.on({ 2171 | sessionConnected: function (event) { 2172 | sessionConnected(); 2173 | }, 2174 | 'signal:opentok-editor-request-doc': function (event) { 2175 | if (cmClient && event.from.connectionId !== session.connection.connectionId) { 2176 | signalDocState(event.from); 2177 | } 2178 | }, 2179 | 'signal:opentok-editor-doc': function (event) { 2180 | doc = JSON.parse(event.data); 2181 | initialiseDoc(); 2182 | } 2183 | }); 2184 | 2185 | if (session.isConnected()) { 2186 | sessionConnected(); 2187 | } 2188 | 2189 | scope.$watch('selectedMode', function () { 2190 | if (myCodeMirror) { 2191 | myCodeMirror.setOption("mode", scope.selectedMode.value); 2192 | } 2193 | }); 2194 | 2195 | scope.$on('otEditorRefresh', function () { 2196 | myCodeMirror.refresh(); 2197 | }); 2198 | } 2199 | }; 2200 | }]); 2201 | 2202 | })(); 2203 | -------------------------------------------------------------------------------- /opentok-editor.min.js: -------------------------------------------------------------------------------- 1 | if("undefined"==typeof ot)var ot={};if(ot.TextOperation=function(){"use strict";function t(){return this&&this.constructor===t?(this.ops=[],this.baseLength=0,this.targetLength=0,void 0):new t}function e(e){var n=e.ops,o=t.isRetain;switch(n.length){case 1:return n[0];case 2:return o(n[0])?n[1]:o(n[1])?n[0]:null;case 3:if(o(n[0])&&o(n[2]))return n[1]}return null}function n(t){return o(t.ops[0])?t.ops[0]:0}t.prototype.equals=function(t){if(this.baseLength!==t.baseLength)return!1;if(this.targetLength!==t.targetLength)return!1;if(this.ops.length!==t.ops.length)return!1;for(var e=0;e0},r=t.isInsert=function(t){return"string"==typeof t},i=t.isDelete=function(t){return"number"==typeof t&&0>t};return t.prototype.retain=function(t){if("number"!=typeof t)throw new Error("retain expects an integer");return 0===t?this:(this.baseLength+=t,this.targetLength+=t,o(this.ops[this.ops.length-1])?this.ops[this.ops.length-1]+=t:this.ops.push(t),this)},t.prototype.insert=function(t){if("string"!=typeof t)throw new Error("insert expects a string");if(""===t)return this;this.targetLength+=t.length;var e=this.ops;return r(e[e.length-1])?e[e.length-1]+=t:i(e[e.length-1])?r(e[e.length-2])?e[e.length-2]+=t:(e[e.length]=e[e.length-1],e[e.length-2]=t):e.push(t),this},t.prototype["delete"]=function(t){if("string"==typeof t&&(t=t.length),"number"!=typeof t)throw new Error("delete expects an integer or a string");return 0===t?this:(t>0&&(t=-t),this.baseLength-=t,i(this.ops[this.ops.length-1])?this.ops[this.ops.length-1]+=t:this.ops.push(t),this)},t.prototype.isNoop=function(){return 0===this.ops.length||1===this.ops.length&&o(this.ops[0])},t.prototype.toString=function(){var t=Array.prototype.map||function(t){for(var e=this,n=[],o=0,r=e.length;r>o;o++)n[o]=t(e[o]);return n};return t.call(this.ops,function(t){return o(t)?"retain "+t:r(t)?"insert '"+t+"'":"delete "+-t}).join(", ")},t.prototype.toJSON=function(){return this.ops},t.fromJSON=function(e){for(var n=new t,s=0,a=e.length;a>s;s++){var c=e[s];if(o(c))n.retain(c);else if(r(c))n.insert(c);else{if(!i(c))throw new Error("unknown operation: "+JSON.stringify(c));n["delete"](c)}}return n},t.prototype.apply=function(t){var e=this;if(t.length!==e.baseLength)throw new Error("The operation's base length must be equal to the string's length.");for(var n=[],i=0,s=0,a=this.ops,c=0,p=a.length;p>c;c++){var h=a[c];if(o(h)){if(s+h>t.length)throw new Error("Operation can't retain more characters than are left in the string.");n[i++]=t.slice(s,s+h),s+=h}else r(h)?n[i++]=h:s-=h}if(s!==t.length)throw new Error("The operation didn't operate on the whole string.");return n.join("")},t.prototype.invert=function(e){for(var n=0,i=new t,s=this.ops,a=0,c=s.length;c>a;a++){var p=s[a];o(p)?(i.retain(p),n+=p):r(p)?i["delete"](p.length):(i.insert(e.slice(n,n-p)),n-=p)}return i},t.prototype.compose=function(e){var n=this;if(n.targetLength!==e.baseLength)throw new Error("The base length of the second operation has to be the target length of the first operation");for(var s=new t,a=n.ops,c=e.ops,p=0,h=0,u=a[p++],l=c[h++];;){if("undefined"==typeof u&&"undefined"==typeof l)break;if(i(u))s["delete"](u),u=a[p++];else if(r(l))s.insert(l),l=c[h++];else{if("undefined"==typeof u)throw new Error("Cannot compose operations: first operation is too short.");if("undefined"==typeof l)throw new Error("Cannot compose operations: first operation is too long.");if(o(u)&&o(l))u>l?(s.retain(l),u-=l,l=c[h++]):u===l?(s.retain(u),u=a[p++],l=c[h++]):(s.retain(u),l-=u,u=a[p++]);else if(r(u)&&i(l))u.length>-l?(u=u.slice(-l),l=c[h++]):u.length===-l?(u=a[p++],l=c[h++]):(l+=u.length,u=a[p++]);else if(r(u)&&o(l))u.length>l?(s.insert(u.slice(0,l)),u=u.slice(l),l=c[h++]):u.length===l?(s.insert(u),u=a[p++],l=c[h++]):(s.insert(u),l-=u.length,u=a[p++]);else{if(!o(u)||!i(l))throw new Error("This shouldn't happen: op1: "+JSON.stringify(u)+", op2: "+JSON.stringify(l));u>-l?(s["delete"](l),u+=l,l=c[h++]):u===-l?(s["delete"](l),u=a[p++],l=c[h++]):(s["delete"](u),l+=u,u=a[p++])}}}return s},t.prototype.shouldBeComposedWith=function(t){if(this.isNoop()||t.isNoop())return!0;var o=n(this),s=n(t),a=e(this),c=e(t);return a&&c?r(a)&&r(c)?o+a.length===s:i(a)&&i(c)?s-c===o||o===s:!1:!1},t.prototype.shouldBeComposedWithInverted=function(t){if(this.isNoop()||t.isNoop())return!0;var o=n(this),s=n(t),a=e(this),c=e(t);return a&&c?r(a)&&r(c)?o+a.length===s||o===s:i(a)&&i(c)?s-c===o:!1:!1},t.transform=function(e,n){if(e.baseLength!==n.baseLength)throw new Error("Both operations have to have the same base length");for(var s=new t,a=new t,c=e.ops,p=n.ops,h=0,u=0,l=c[h++],f=p[u++];;){if("undefined"==typeof l&&"undefined"==typeof f)break;if(r(l))s.insert(l),a.retain(l.length),l=c[h++];else if(r(f))s.retain(f.length),a.insert(f),f=p[u++];else{if("undefined"==typeof l)throw new Error("Cannot compose operations: first operation is too short.");if("undefined"==typeof f)throw new Error("Cannot compose operations: first operation is too long.");var d;if(o(l)&&o(f))l>f?(d=f,l-=f,f=p[u++]):l===f?(d=f,l=c[h++],f=p[u++]):(d=l,f-=l,l=c[h++]),s.retain(d),a.retain(d);else if(i(l)&&i(f))-l>-f?(l-=f,f=p[u++]):l===f?(l=c[h++],f=p[u++]):(f-=l,l=c[h++]);else if(i(l)&&o(f))-l>f?(d=f,l+=f,f=p[u++]):-l===f?(d=f,l=c[h++],f=p[u++]):(d=-l,f+=l,l=c[h++]),s["delete"](d);else{if(!o(l)||!i(f))throw new Error("The two operations aren't compatible");l>-f?(d=-f,l+=f,f=p[u++]):l===-f?(d=l,l=c[h++],f=p[u++]):(d=l,f+=l,l=c[h++]),a["delete"](d)}}}return[s,a]},t}(),"object"==typeof module&&(module.exports=ot.TextOperation),"undefined"==typeof ot)var ot={};if(ot.Selection=function(t){"use strict";function e(t,e){this.anchor=t,this.head=e}function n(t){this.ranges=t||[]}var o=t.ot?t.ot.TextOperation:require("./text-operation");return e.fromJSON=function(t){return new e(t.anchor,t.head)},e.prototype.equals=function(t){return this.anchor===t.anchor&&this.head===t.head},e.prototype.isEmpty=function(){return this.anchor===this.head},e.prototype.transform=function(t){function n(e){for(var n=e,r=t.ops,i=0,s=t.ops.length;s>i&&(o.isRetain(r[i])?e-=r[i]:o.isInsert(r[i])?n+=r[i].length:(n-=Math.min(e,-r[i]),e+=r[i]),!(0>e));i++);return n}var r=n(this.anchor);return this.anchor===this.head?new e(r,r):new e(r,n(this.head))},n.Range=e,n.createCursor=function(t){return new n([new e(t,t)])},n.fromJSON=function(t){for(var o=t.ranges||t,r=0,i=[];r=0;r--){var i=o.transform(t[r],e);"function"==typeof i[0].isNoop&&i[0].isNoop()||n.push(i[0]),e=i[1]}return n.reverse()}var n="normal",o="undoing",r="redoing";return t.prototype.add=function(t,e){if(this.state===o)this.redoStack.push(t),this.dontCompose=!0;else if(this.state===r)this.undoStack.push(t),this.dontCompose=!0;else{var n=this.undoStack;!this.dontCompose&&e&&n.length>0?n.push(t.compose(n.pop())):(n.push(t),n.length>this.maxItems&&n.shift()),this.dontCompose=!1,this.redoStack=[]}},t.prototype.transform=function(t){this.undoStack=e(this.undoStack,t),this.redoStack=e(this.redoStack,t)},t.prototype.performUndo=function(t){if(this.state=o,0===this.undoStack.length)throw new Error("undo not possible");t(this.undoStack.pop()),this.state=n},t.prototype.performRedo=function(t){if(this.state=r,0===this.redoStack.length)throw new Error("redo not possible");t(this.redoStack.pop()),this.state=n},t.prototype.canUndo=function(){return 0!==this.undoStack.length},t.prototype.canRedo=function(){return 0!==this.redoStack.length},t.prototype.isUndoing=function(){return this.state===o},t.prototype.isRedoing=function(){return this.state===r},t}(),"object"==typeof module&&(module.exports=ot.UndoManager),"undefined"==typeof ot)var ot={};if(ot.Client=function(){"use strict";function t(t){this.revision=t,this.state=r}function e(){}function n(t){this.outstanding=t}function o(t,e){this.outstanding=t,this.buffer=e}t.prototype.setState=function(t){this.state=t},t.prototype.applyClient=function(t){this.setState(this.state.applyClient(this,t))},t.prototype.applyServer=function(t){this.revision++,this.setState(this.state.applyServer(this,t))},t.prototype.serverAck=function(){this.revision++,this.setState(this.state.serverAck(this))},t.prototype.serverReconnect=function(){"function"==typeof this.state.resend&&this.state.resend(this)},t.prototype.transformSelection=function(t){return this.state.transformSelection(t)},t.prototype.sendOperation=function(){throw new Error("sendOperation must be defined in child class")},t.prototype.applyOperation=function(){throw new Error("applyOperation must be defined in child class")},t.Synchronized=e,e.prototype.applyClient=function(t,e){return t.sendOperation(t.revision,e),new n(e)},e.prototype.applyServer=function(t,e){return t.applyOperation(e),this},e.prototype.serverAck=function(){throw new Error("There is no pending operation.")},e.prototype.transformSelection=function(t){return t};var r=new e;return t.AwaitingConfirm=n,n.prototype.applyClient=function(t,e){return new o(this.outstanding,e)},n.prototype.applyServer=function(t,e){var o=e.constructor.transform(this.outstanding,e);return t.applyOperation(o[1]),new n(o[0])},n.prototype.serverAck=function(){return r},n.prototype.transformSelection=function(t){return t.transform(this.outstanding)},n.prototype.resend=function(t){t.sendOperation(t.revision,this.outstanding)},t.AwaitingWithBuffer=o,o.prototype.applyClient=function(t,e){var n=this.buffer.compose(e);return new o(this.outstanding,n)},o.prototype.applyServer=function(t,e){var n=e.constructor.transform,r=n(this.outstanding,e),i=n(this.buffer,r[1]);return t.applyOperation(i[1]),new o(r[0],i[0])},o.prototype.serverAck=function(t){return t.sendOperation(t.revision,this.buffer),new n(this.buffer)},o.prototype.transformSelection=function(t){return t.transform(this.outstanding).transform(this.buffer)},o.prototype.resend=function(t){t.sendOperation(t.revision,this.outstanding)},t}(this),"object"==typeof module&&(module.exports=ot.Client),ot.CodeMirrorAdapter=function(){"use strict";function t(t){this.cm=t,this.ignoreNextChange=!1,this.changeInProgress=!1,this.selectionChanged=!1,s(this,"onChanges"),s(this,"onChange"),s(this,"onCursorActivity"),s(this,"onFocus"),s(this,"onBlur"),t.on("changes",this.onChanges),t.on("change",this.onChange),t.on("cursorActivity",this.onCursorActivity),t.on("focus",this.onFocus),t.on("blur",this.onBlur)}function e(t,e){return t.linee.line?1:t.che.ch?1:0}function n(t,n){return e(t,n)<=0}function o(t,e){return n(t,e)?t:e}function r(t,e){return n(t,e)?e:t}function i(t){return t.indexFromPos({line:t.lastLine(),ch:0})+t.getLine(t.lastLine()).length}function s(t,e){var n=t[e];t[e]=function(){n.apply(t,arguments)}}var a=ot.TextOperation,c=ot.Selection;t.prototype.detach=function(){this.cm.off("changes",this.onChanges),this.cm.off("change",this.onChange),this.cm.off("cursorActivity",this.onCursorActivity),this.cm.off("focus",this.onFocus),this.cm.off("blur",this.onBlur)},t.operationFromCodeMirrorChanges=function(t,e){function o(t){return t[t.length-1]}function r(t){if(0===t.length)return 0;for(var e=0,n=0;n=0;l--){var f=t[l];u=s(u,f);var d=u(f.from),g=c-d-r(f.text);p=(new a).retain(d)["delete"](r(f.removed)).insert(f.text.join("\n")).retain(g).compose(p),h=h.compose((new a).retain(d)["delete"](r(f.text)).insert(f.removed.join("\n")).retain(g)),c+=r(f.removed)-r(f.text)}return[p,h]},t.operationFromCodeMirrorChange=t.operationFromCodeMirrorChanges,t.applyOperationToCodeMirror=function(t,e){e.operation(function(){for(var n=t.ops,o=0,r=0,i=n.length;i>r;r++){var s=n[r];if(a.isRetain(s))o+=s;else if(a.isInsert(s))e.replaceRange(s,e.posFromIndex(o)),o+=s.length;else if(a.isDelete(s)){var c=e.posFromIndex(o),p=e.posFromIndex(o-s);e.replaceRange("",c,p)}}})},t.prototype.registerCallbacks=function(t){this.callbacks=t},t.prototype.onChange=function(){this.changeInProgress=!0},t.prototype.onChanges=function(e,n){if(!this.ignoreNextChange){var o=t.operationFromCodeMirrorChanges(n,this.cm);this.trigger("change",o[0],o[1])}this.selectionChanged&&this.trigger("selectionChange"),this.changeInProgress=!1,this.ignoreNextChange=!1},t.prototype.onCursorActivity=t.prototype.onFocus=function(){this.changeInProgress?this.selectionChanged=!0:this.trigger("selectionChange")},t.prototype.onBlur=function(){this.cm.somethingSelected()||this.trigger("blur")},t.prototype.getValue=function(){return this.cm.getValue()},t.prototype.getSelection=function(){for(var t=this.cm,e=t.listSelections(),n=[],o=0;o0&&(this.majorRevision+=n.length,this.minorRevision=0);var o=t.events;if(o){for(e=0;en?n*(1+e):n+e-e*n,i=2*n-o,s=function(t){return 0>t&&(t+=1),t>1&&(t-=1),1>6*t?i+6*(o-i)*t:1>2*t?o:2>3*t?i+6*(o-i)*(2/3-t):i};return r(s(t+1/3),s(t),s(t-1/3))}function s(t){for(var e=1,n=0;n0&&n.shouldBeComposedWithInverted(c(this.undoManager.undoStack).wrapped)),s=new t(this.selection,o);this.undoManager.add(new d(n,s),i),this.applyClient(e)},o.prototype.updateSelection=function(){this.selection=this.editorAdapter.getSelection()},o.prototype.onSelectionChange=function(){var t=this.selection;this.updateSelection(),t&&this.selection.equals(t)||this.sendSelection(this.selection)},o.prototype.onBlur=function(){this.selection=null,this.sendSelection(null)},o.prototype.sendSelection=function(t){this.state instanceof h.AwaitingWithBuffer||this.serverAdapter.sendSelection(t)},o.prototype.sendOperation=function(t,e){this.serverAdapter.sendOperation(t,e.toJSON(),this.selection)},o.prototype.applyOperation=function(t){this.editorAdapter.applyOperation(t),this.updateSelection(),this.undoManager.transform(new d(t,null))},o}(),"undefined"==typeof ot)var ot={};ot.Server=function(){"use strict";function t(t,e){this.document=t,this.operations=e||[]}return t.prototype.receiveOperation=function(t,e){if(0>t||this.operations.lengtho.length){var r=[];r[e-o.length-1]=null,this.operations=r.concat(o)}else this.operations=o?o:[];var i=new opTrans.Server(n,this.operations);this.session.on({connectionDestroyed:function(t){this.trigger("client_left",t.connection.connectionId)},connectionCreated:function(t){t.connection.data&&t.connection.data.name&&this.trigger("set_name",t.connection.connectionId,t.connection.data.name)},"signal:opentok-editor-operation":function(e){var n,o=JSON.parse(e.data);n=new opTrans.WrappedOperation(opTrans.TextOperation.fromJSON(o.operation),o.selection&&opTrans.Selection.fromJSON(o.selection));var r=i.receiveOperation(o.revision,n);console.log("new operation: "+n),e.from.connectionId===t.connection.connectionId?this.trigger("ack"):(this.trigger("operation",r.wrapped.toJSON()),this.trigger("selection",e.from.connectionId,r.meta))},"signal:opentok-editor-selection":function(t){var e=JSON.parse(t.data);this.trigger("selection",t.from.connectionId,e)}},this)}return t.prototype.sendOperation=function(t,e,n){this.session.signal({type:"opentok-editor-operation",data:JSON.stringify({revision:t,operation:e,selection:n})},function(t){t&&console.error("Error sending operation",t)})},t.prototype.sendSelection=function(t){this.session.signal({type:"opentok-editor-selection",data:JSON.stringify(t)})},t}();"undefined"!=typeof module&&"undefined"!=typeof module.exports&&(module.exports=OpenTokAdapter);var ng,opTrans;ng="undefined"==typeof angular&&"undefined"!=typeof require?require("angular"):angular,"undefined"==typeof ot&&"undefined"!=typeof require&&(window.ot=require("ot"),ot.UndoManager=require("ot/lib/undo-manager.js"),ot.WrappedOperation=require("ot/lib/wrapped-operation.js"),require("ot/lib/codemirror-adapter.js"),require("ot/lib/editor-client.js"),window.OpenTokAdapter=require("./opentok-adapter.js")),"undefined"==typeof window.CodeMirror&&(window.CodeMirror=require("codemirror")),function(){var t=function(t){return t.map(function(t){return{operation:t.wrapped.toJSON()}})},e=function(t){return t.map(function(t){return new ot.WrappedOperation(ot.TextOperation.fromJSON(t.operation),t.cursor&&ot.Selection.fromJSON(t.cursor))})};ng.module("opentok-editor",["opentok"]).directive("otEditor",["OTSession","$window",function(n){return{restrict:"E",scope:{modes:"="},template:'
Connecting...
',link:function(o,r,i){var s,a,c,p,h=r.context.querySelector("div.opentok-editor"),u=(r.context.querySelector("select"),!1),l=n.session;"undefined"!=typeof require&&o.modes.forEach(function(t){require("codemirror/mode/"+t.value+"/"+t.value+".js")}),o.connecting=!0;var f=o.modes.filter(function(t){return t.value===i.mode});o.selectedMode=f.length>0?f[0]:o.modes[0];var d=function(t,e,n,r){a||(p=new OpenTokAdapter(l,t,n,r),p.registerCallbacks("operation",function(){o.$emit("otEditorUpdate")}),a=new ot.EditorClient(t,e,p,new ot.CodeMirrorAdapter(s)),o.$apply(function(){o.connecting=!1,setTimeout(function(){s.refresh()},1e3)}))},g=function(){s&&!u&&(u=!0,s.getValue()!==c.str&&(s.setValue(c.str),o.$emit("otEditorUpdate")),d(c.revision,c.clients,c.str,e(c.operations)))},m=function(e){var n=p&&p.operations?t(p.operations):[];n.length>50&&(n=n.slice(n.length-50));var o={type:"opentok-editor-doc",data:JSON.stringify({revision:a.revision,clients:[],str:s.getValue(),operations:n})};e&&(o.to=e),l.signal(o)},y=function(){s=CodeMirror(h,i),l.signal({type:"opentok-editor-request-doc"}),setTimeout(function(){u||(u=!0,d(0,[],s.getValue()),m())},1e4)};l.on({sessionConnected:function(){y()},"signal:opentok-editor-request-doc":function(t){a&&t.from.connectionId!==l.connection.connectionId&&m(t.from)},"signal:opentok-editor-doc":function(t){c=JSON.parse(t.data),g()}}),l.isConnected()&&y(),o.$watch("selectedMode",function(){s&&s.setOption("mode",o.selectedMode.value)}),o.$on("otEditorRefresh",function(){s.refresh()})}}}])}(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentok-editor", 3 | "version": "0.3.3", 4 | "description": "A real time collaborative editor for OpenTok using CodeMirror and ot.js", 5 | "main": "src/opentok-editor.js", 6 | "scripts": { 7 | "test": "node node_modules/karma/bin/karma start karma.conf.js --single-run --browsers Firefox", 8 | "start": "http-server" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/aullman/opentok-editor" 13 | }, 14 | "keywords": [ 15 | "opentok", 16 | "editor", 17 | "webrtc", 18 | "operational", 19 | "transform" 20 | ], 21 | "author": "Adam Ullman ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/aullman/opentok-editor/issues" 25 | }, 26 | "homepage": "https://github.com/aullman/opentok-editor", 27 | "devDependencies": { 28 | "codeclimate-test-reporter": "0.0.4", 29 | "gulp": "^3.8.7", 30 | "gulp-bower": "0.0.6", 31 | "gulp-concat": "^2.3.4", 32 | "gulp-jshint": "^1.8.4", 33 | "gulp-rename": "^1.2.0", 34 | "gulp-uglify": "^0.3.1", 35 | "http-server": "^0.9.0", 36 | "karma": "^0.12.22", 37 | "karma-chrome-launcher": "^0.1.4", 38 | "karma-coverage": "^0.2.7", 39 | "karma-firefox-launcher": "^0.1.3", 40 | "karma-jasmine": "~0.2.0" 41 | }, 42 | "dependencies": { 43 | "angular": "^1.5.5", 44 | "codemirror": "^5.14.2", 45 | "ot": "git://github.com/aullman/ot.js.git#master" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/opentok-adapter.js: -------------------------------------------------------------------------------- 1 | var opTrans; 2 | if (typeof ot === 'undefined' && typeof require !== 'undefined') { 3 | opTrans = require('ot'); 4 | } else { 5 | opTrans = ot; 6 | } 7 | 8 | var OpenTokAdapter = (function () { 9 | 'use strict'; 10 | 11 | function OpenTokAdapter (session, revision, doc, operations) { 12 | OT.$.eventing(this); 13 | this.registerCallbacks = this.on; 14 | this.session = session; 15 | 16 | if (operations && revision > operations.length) { 17 | // the operations have been truncated fill in the beginning with empty space 18 | var filler = []; 19 | filler[revision - operations.length - 1] = null; 20 | this.operations = filler.concat(operations); 21 | } else { 22 | this.operations = operations ? operations : []; 23 | } 24 | // We pretend to be a server 25 | var server = new opTrans.Server(doc, this.operations); 26 | 27 | this.session.on({ 28 | connectionDestroyed: function (event) { 29 | this.trigger('client_left', event.connection.connectionId); 30 | }, 31 | connectionCreated: function (event) { 32 | if (event.connection.data && event.connection.data.name) { 33 | this.trigger('set_name', event.connection.connectionId, event.connection.data.name); 34 | } 35 | }, 36 | 'signal:opentok-editor-operation': function (event) { 37 | var data = JSON.parse(event.data), 38 | wrapped; 39 | wrapped = new opTrans.WrappedOperation( 40 | opTrans.TextOperation.fromJSON(data.operation), 41 | data.selection && opTrans.Selection.fromJSON(data.selection) 42 | ); 43 | // Might need to try catch here and if it fails wait a little while and 44 | // try again. This way if we receive operations out of order we might 45 | // be able to recover 46 | var wrappedPrime = server.receiveOperation(data.revision, wrapped); 47 | console.log("new operation: " + wrapped); 48 | if (event.from.connectionId === session.connection.connectionId) { 49 | this.trigger('ack'); 50 | } else { 51 | this.trigger('operation', wrappedPrime.wrapped.toJSON()); 52 | this.trigger('selection', event.from.connectionId, wrappedPrime.meta); 53 | } 54 | }, 55 | 'signal:opentok-editor-selection': function (event) { 56 | var selection = JSON.parse(event.data); 57 | this.trigger('selection', event.from.connectionId, selection); 58 | } 59 | }, this); 60 | } 61 | 62 | OpenTokAdapter.prototype.sendOperation = function (revision, operation, selection) { 63 | this.session.signal({ 64 | type: 'opentok-editor-operation', 65 | data: JSON.stringify({ 66 | revision: revision, 67 | operation: operation, 68 | selection: selection 69 | }) 70 | }, function (err) { 71 | if (err) console.error('Error sending operation', err); 72 | }); 73 | }; 74 | 75 | OpenTokAdapter.prototype.sendSelection = function (selection) { 76 | this.session.signal({ 77 | type: 'opentok-editor-selection', 78 | data: JSON.stringify(selection) 79 | }); 80 | }; 81 | 82 | return OpenTokAdapter; 83 | 84 | }()); 85 | 86 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 87 | module.exports = OpenTokAdapter; 88 | } 89 | -------------------------------------------------------------------------------- /src/opentok-editor.js: -------------------------------------------------------------------------------- 1 | var ng, opTrans; 2 | if (typeof angular === 'undefined' && typeof require !== 'undefined') { 3 | ng = require('angular'); 4 | } else { 5 | ng = angular; 6 | } 7 | if (typeof ot === 'undefined' && typeof require !== 'undefined') { 8 | // fixme: We have to make this a global because CodeMirrorAdapter and EditorClient 9 | // attach themselves to the global ot 10 | window.ot = require('ot'); 11 | ot.UndoManager = require('ot/lib/undo-manager.js'); 12 | ot.WrappedOperation = require('ot/lib/wrapped-operation.js'); 13 | require('ot/lib/codemirror-adapter.js'); 14 | require('ot/lib/editor-client.js'); 15 | 16 | window.OpenTokAdapter = require('./opentok-adapter.js'); 17 | } 18 | 19 | if (typeof window.CodeMirror === 'undefined') { 20 | window.CodeMirror = require('codemirror'); 21 | } 22 | 23 | (function () { 24 | // Turns the Array of operation Objects into an Array of JSON stringifyable objects 25 | var serialiseOps = function (operations) { 26 | return operations.map(function (op) { 27 | return { 28 | operation: op.wrapped.toJSON() 29 | }; 30 | }); 31 | }; 32 | 33 | // Turns the JSON form of the Array of operations into ot.TextOperations 34 | var deserialiseOps = function (operations) { 35 | return operations.map(function (op) { 36 | return new ot.WrappedOperation( 37 | ot.TextOperation.fromJSON(op.operation), 38 | op.cursor && ot.Selection.fromJSON(op.cursor) 39 | ); 40 | }); 41 | }; 42 | 43 | ng.module('opentok-editor', ['opentok']) 44 | .directive('otEditor', ['OTSession', '$window', function (OTSession, $window) { 45 | return { 46 | restrict: 'E', 47 | scope: { 48 | modes: '=' 49 | }, 50 | template: '
' + 51 | '' + 52 | '
' + 53 | '
Connecting...
' + 54 | '
', 55 | link: function (scope, element, attrs) { 56 | var opentokEditor = element.context.querySelector('div.opentok-editor'), 57 | modeSelect = element.context.querySelector('select'), 58 | myCodeMirror, 59 | cmClient, 60 | doc, 61 | initialised = false, 62 | session = OTSession.session, 63 | otAdapter; 64 | if (typeof require !== 'undefined') { 65 | // Require all of the modes 66 | scope.modes.forEach(function(mode) { 67 | require('codemirror/mode/' + mode.value + '/' + mode.value + '.js'); 68 | }); 69 | } 70 | scope.connecting = true; 71 | var selectedMode = scope.modes.filter(function (value) {return value.value === attrs.mode;}); 72 | scope.selectedMode = selectedMode.length > 0 ? selectedMode[0] : scope.modes[0]; 73 | 74 | var createEditorClient = function(revision, clients, doc, operations) { 75 | if (!cmClient) { 76 | otAdapter = new OpenTokAdapter(session, revision, doc, operations); 77 | otAdapter.registerCallbacks('operation', function () { 78 | scope.$emit('otEditorUpdate'); 79 | }); 80 | cmClient = new ot.EditorClient( 81 | revision, 82 | clients, 83 | otAdapter, 84 | new ot.CodeMirrorAdapter(myCodeMirror) 85 | ); 86 | scope.$apply(function () { 87 | scope.connecting = false; 88 | setTimeout(function () { 89 | myCodeMirror.refresh(); 90 | }, 1000); 91 | }); 92 | } 93 | }; 94 | 95 | var initialiseDoc = function () { 96 | if (myCodeMirror && !initialised) { 97 | initialised = true; 98 | if (myCodeMirror.getValue() !== doc.str) { 99 | myCodeMirror.setValue(doc.str); 100 | scope.$emit('otEditorUpdate'); 101 | } 102 | createEditorClient(doc.revision, doc.clients, doc.str, deserialiseOps(doc.operations)); 103 | } 104 | }; 105 | 106 | var signalDocState = function (to) { 107 | var operations = otAdapter && otAdapter.operations ? serialiseOps(otAdapter.operations): []; 108 | // We only want the most recent 50 because we can't send too much data 109 | if (operations.length > 50) { 110 | operations = operations.slice(operations.length - 50); 111 | } 112 | var signal = { 113 | type: 'opentok-editor-doc', 114 | data: JSON.stringify({ 115 | revision: cmClient.revision, 116 | clients: [], 117 | str: myCodeMirror.getValue(), 118 | operations: operations 119 | }) 120 | }; 121 | if (to) { 122 | signal.to = to; 123 | } 124 | session.signal(signal); 125 | }; 126 | 127 | var sessionConnected = function () { 128 | myCodeMirror = CodeMirror(opentokEditor, attrs); 129 | session.signal({ 130 | type: 'opentok-editor-request-doc' 131 | }); 132 | 133 | setTimeout(function () { 134 | // We wait 2 seconds for other clients to send us the doc before 135 | // initialising it to empty 136 | if (!initialised) { 137 | initialised = true; 138 | createEditorClient(0, [], myCodeMirror.getValue()); 139 | // Tell anyone that joined after us that we are initialising it 140 | signalDocState(); 141 | } 142 | }, 10000); 143 | }; 144 | 145 | session.on({ 146 | sessionConnected: function (event) { 147 | sessionConnected(); 148 | }, 149 | 'signal:opentok-editor-request-doc': function (event) { 150 | if (cmClient && event.from.connectionId !== session.connection.connectionId) { 151 | signalDocState(event.from); 152 | } 153 | }, 154 | 'signal:opentok-editor-doc': function (event) { 155 | doc = JSON.parse(event.data); 156 | initialiseDoc(); 157 | } 158 | }); 159 | 160 | if (session.isConnected()) { 161 | sessionConnected(); 162 | } 163 | 164 | scope.$watch('selectedMode', function () { 165 | if (myCodeMirror) { 166 | myCodeMirror.setOption("mode", scope.selectedMode.value); 167 | } 168 | }); 169 | 170 | scope.$on('otEditorRefresh', function () { 171 | myCodeMirror.refresh(); 172 | }); 173 | } 174 | }; 175 | }]); 176 | 177 | })(); 178 | -------------------------------------------------------------------------------- /tests/unit/opentok-adapter-spec.js: -------------------------------------------------------------------------------- 1 | describe('OpenTokAdapter', function () { 2 | describe('constructor', function () { 3 | var session, 4 | adapter; 5 | beforeEach(function () { 6 | session = jasmine.createSpyObj('session', ['on', 'signal']); 7 | adapter = new OpenTokAdapter(session, 1, 'foo', []); 8 | }); 9 | it('adds event listeners to the session', function () { 10 | expect(session.on).toHaveBeenCalled(); 11 | }); 12 | 13 | it('has the right properties', function () { 14 | expect(adapter.registerCallbacks).toBeDefined(); 15 | expect(adapter.sendOperation).toBeDefined(); 16 | expect(adapter.sendSelection).toBeDefined(); 17 | expect(adapter.trigger).toBeDefined(); 18 | }); 19 | 20 | it('operations.length is always equal to the revision number', function () { 21 | expect(adapter.operations.length).toEqual(1); 22 | adapter = new OpenTokAdapter(session, 55, 'foo', [1,2,3,4]); 23 | expect(adapter.operations.length).toEqual(55); 24 | expect(adapter.operations.slice(adapter.operations.length-4)).toEqual([1,2,3,4]); 25 | adapter = new OpenTokAdapter(session, 4, 'foo', [1,2,3,4]); 26 | expect(adapter.operations.length).toEqual(4); 27 | expect(adapter.operations.slice(adapter.operations.length-4)).toEqual([1,2,3,4]); 28 | }); 29 | }); 30 | 31 | describe('sendOperation', function () { 32 | var session, 33 | adapter; 34 | beforeEach(function () { 35 | session = jasmine.createSpyObj('session', ['on', 'signal']); 36 | adapter = new OpenTokAdapter(session, 1, 'foo', []); 37 | }); 38 | 39 | it('calls signal with the right parameters', function () { 40 | var revision = 2, 41 | operation = '123', 42 | selection = 2; 43 | adapter.sendOperation(revision, operation, selection); 44 | expect(session.signal).toHaveBeenCalledWith({ 45 | type: 'opentok-editor-operation', 46 | data: JSON.stringify({ 47 | revision: revision, 48 | operation: operation, 49 | selection: selection 50 | }) 51 | }, jasmine.any(Function)); 52 | }); 53 | }); 54 | 55 | describe('sendSelection', function () { 56 | var session, 57 | adapter; 58 | beforeEach(function () { 59 | session = jasmine.createSpyObj('session', ['on', 'signal']); 60 | adapter = new OpenTokAdapter(session, 1, 'foo', []); 61 | }); 62 | 63 | it('calls signal with the right parameters', function () { 64 | var selection = 2; 65 | adapter.sendSelection(selection); 66 | expect(session.signal).toHaveBeenCalledWith({ 67 | type: 'opentok-editor-selection', 68 | data: JSON.stringify(selection) 69 | }); 70 | }); 71 | }); 72 | 73 | describe('signal event handlers', function () { 74 | var session, 75 | adapter; 76 | beforeEach(function () { 77 | session = {connection: {connectionId: 'sessionConnectionId'}}; 78 | OT.$.eventing(session); 79 | adapter = new OpenTokAdapter(session, 0, '', []); 80 | }); 81 | 82 | it('triggers client_left on connectionDestroyed', function (done) { 83 | var mockConnectionId = 'mockConnectionId', 84 | mockEvent = {connection:{connectionId: mockConnectionId}}; 85 | adapter.on('client_left', function (connectionId) { 86 | expect(connectionId).toEqual(mockConnectionId); 87 | done(); 88 | }); 89 | session.trigger('connectionDestroyed', mockEvent); 90 | }); 91 | 92 | it('triggers operation and cursor on signal:opentok-editor-operation', function (done) { 93 | var mockSignalEvent = { 94 | data: JSON.stringify({ 95 | revision:0, 96 | operation:["1234"], 97 | cursor: { 98 | position:19, 99 | selection:[] 100 | } 101 | }), 102 | from: {connectionId: 'mockConnectionId'} 103 | }; 104 | adapter.on('operation', function (operation) { 105 | expect(operation).toEqual(["1234"]); 106 | done(); 107 | }); 108 | session.trigger('signal:opentok-editor-operation', mockSignalEvent); 109 | }); 110 | 111 | it('triggers ack for your own signal:opentok-editor-operation', function (done) { 112 | var mockSignalEvent = { 113 | data: JSON.stringify({ 114 | revision:0, 115 | operation:["1234"], 116 | cursor: { 117 | position:19, 118 | selection:[] 119 | } 120 | }), 121 | from: {connectionId: 'sessionConnectionId'} 122 | }; 123 | adapter.on('ack', function () { 124 | done(); 125 | }); 126 | session.trigger('signal:opentok-editor-operation', mockSignalEvent); 127 | }); 128 | 129 | it('triggers selection on signal:opentok-editor-selection', function (done) { 130 | var mockSelection = { 131 | position:19, 132 | selection:[] 133 | }, 134 | mockSignalEvent = { 135 | data: JSON.stringify(mockSelection), 136 | from: {connectionId: 'mockConnectionId'} 137 | }; 138 | adapter.on('selection', function (connectionId, selection) { 139 | expect(connectionId).toEqual('mockConnectionId'); 140 | expect(selection).toEqual(mockSelection); 141 | done(); 142 | }); 143 | session.trigger('signal:opentok-editor-selection', mockSignalEvent); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /tests/unit/opentok-editor-spec.js: -------------------------------------------------------------------------------- 1 | describe('directive: opentok-editor', function () { 2 | var element, scope, session, CodeMirror, myCodeMirror, 3 | mockValue = '// Write code here', 4 | mockMode = 'javascript', 5 | mockModes = '[{name: \'Javascript\', value: \'javascript\'},' + 6 | ' {name: \'Markdown\', value: \'markdown\'}]'; 7 | 8 | beforeEach(function () { 9 | var oldCodeMirror = window.CodeMirror; 10 | window.CodeMirror = spyOn(window, 'CodeMirror').and.callFake(function () { 11 | // Implementing my own callthrough that captures the return value 12 | myCodeMirror = oldCodeMirror.apply(this, arguments); 13 | return myCodeMirror; 14 | }); 15 | module('opentok-editor', function ($provide) { 16 | $provide.decorator('OTSession', function ($delegate) { 17 | session = {isConnected: function(){}, signal: function(){}, connection: {connectionId: 'sessionConnectionId'}}; 18 | OT.$.eventing(session); 19 | spyOn(session, 'on').and.callThrough(); 20 | spyOn(session, "isConnected").and.returnValue(true); 21 | spyOn(session, 'signal'); 22 | $delegate.session = session; 23 | return $delegate; 24 | }); 25 | }); 26 | inject(function (_OTSession_) { 27 | OTSession = _OTSession_; 28 | }); 29 | }); 30 | 31 | beforeEach(inject(function ($rootScope, $compile) { 32 | scope = $rootScope.$new(); 33 | 34 | element = ''; 36 | 37 | element = $compile(element)(scope); 38 | scope.$digest(); 39 | })); 40 | 41 | it('should add event listeners to session and check if its connected', function () { 42 | expect(session.on).toHaveBeenCalled(); 43 | expect(session.isConnected).toHaveBeenCalled(); 44 | }); 45 | 46 | it('should setup the isolate scope correctly', function () { 47 | expect(element.isolateScope().connecting).toBe(true); 48 | expect(element.isolateScope().selectedMode).toEqual({ 49 | name: 'Javascript', 50 | value: 'javascript' 51 | }); 52 | }); 53 | 54 | it('should create a selector with the right data', function () { 55 | expect(element.find('select').children().length).toBe(2); 56 | expect(element.find('option')[0].innerHTML).toBe('Javascript'); 57 | expect($(element.find('option')[0]).attr('selected')).toBe('selected'); 58 | expect(element.find('option')[1].innerHTML).toBe('Markdown'); 59 | }); 60 | 61 | it('should create a CodeMirror and signal:opentok-editor-request-doc when connected', function (done) { 62 | session.trigger('sessionConnected'); 63 | 64 | setTimeout(function () { 65 | expect(window.CodeMirror).toHaveBeenCalledWith(jasmine.any(Object), jasmine.objectContaining({ 66 | value: mockValue, 67 | mode: mockMode 68 | })); 69 | expect(session.signal).toHaveBeenCalledWith({ 70 | type: 'opentok-editor-request-doc' 71 | }); 72 | done(); 73 | }, 10); 74 | }); 75 | 76 | describe('connected and document initialised', function () { 77 | var adapter; 78 | beforeEach(function (done) { 79 | spyOn(window, 'OpenTokAdapter').and.callThrough(); 80 | var signal = { 81 | type: 'opentok-editor-doc', 82 | data: JSON.stringify({ 83 | revision: 0, 84 | clients: [], 85 | str: mockValue, 86 | operations: [] 87 | }) 88 | }; 89 | session.trigger('signal:opentok-editor-doc', signal); 90 | setTimeout(function () { 91 | adapter = OpenTokAdapter.calls.mostRecent().object; 92 | done(); 93 | }, 10); 94 | }); 95 | 96 | it('has initialised the document', function () { 97 | expect(OpenTokAdapter).toHaveBeenCalledWith(session, 0, mockValue, []); 98 | expect(element.isolateScope().connecting).toBe(false); 99 | expect(element.find('.CodeMirror').length).toBe(1); 100 | expect(element.find('.CodeMirror-code').length).toBe(1); 101 | expect(myCodeMirror.getValue()).toBe(mockValue); 102 | }); 103 | 104 | it('signals the opentok-editor-doc state when it receives a request', function (done) { 105 | var mockConnection = { 106 | connectionId: 'mockConnectionId' 107 | }, 108 | mockSignalEvent = { 109 | type: 'signal:opentok-editor-request-doc', 110 | from: mockConnection 111 | }; 112 | session.dispatchEvent(mockSignalEvent); 113 | setTimeout(function () { 114 | expect(session.signal).toHaveBeenCalledWith(jasmine.objectContaining({ 115 | type: 'opentok-editor-doc', 116 | to: mockConnection, 117 | data: JSON.stringify({ 118 | revision: 0, 119 | clients: [], 120 | str: mockValue, 121 | operations: [] 122 | }) 123 | })); 124 | done(); 125 | }, 10); 126 | }); 127 | 128 | it('handles a single operation correctly', function (done) { 129 | var mockSignalEvent = { 130 | data: JSON.stringify({ 131 | revision:0, 132 | operation:[18, "1234"], 133 | cursor: { 134 | position:19, 135 | selection:[] 136 | } 137 | }), 138 | from: {connectionId: 'mockConnectionId'} 139 | }; 140 | session.trigger('signal:opentok-editor-operation', mockSignalEvent); 141 | setTimeout(function () { 142 | expect(myCodeMirror.getValue()).toEqual(mockValue + '1234'); 143 | done(); 144 | }, 10); 145 | }); 146 | 147 | it('handles 2 simultaneous operations correctly', function (done) { 148 | var mockSignalEvent1 = { 149 | data: JSON.stringify({ 150 | revision:0, 151 | operation:[18, "1234"], 152 | cursor: { 153 | position:19, 154 | selection:[] 155 | } 156 | }), 157 | from: {connectionId: 'mockConnectionId'} 158 | }; 159 | var mockSignalEvent2 = { 160 | data: JSON.stringify({ 161 | revision:0, 162 | operation:[18, "5678"], 163 | cursor: { 164 | position:19, 165 | selection:[] 166 | } 167 | }), 168 | from: {connectionId: 'mockConnectionId2'} 169 | }; 170 | session.trigger('signal:opentok-editor-operation', mockSignalEvent2); 171 | session.trigger('signal:opentok-editor-operation', mockSignalEvent1); 172 | setTimeout(function () { 173 | expect(myCodeMirror.getValue()).toEqual(mockValue + '1234' + '5678'); 174 | done(); 175 | }, 20); 176 | }); 177 | 178 | }); 179 | }); 180 | --------------------------------------------------------------------------------