Index: trunk/extensions/VisualEditor/modules/es.js |
— | — | @@ -0,0 +1,150 @@ |
| 2 | +/** |
| 3 | + * EditSurface namespace. |
| 4 | + * |
| 5 | + * All classes and functions will be attached to this object to keep the global namespace clean. |
| 6 | + */ |
| 7 | +var es = {}; |
| 8 | + |
| 9 | +/* Functions */ |
| 10 | + |
| 11 | +/** |
| 12 | + * Extends a constructor with the prototype of another. |
| 13 | + * |
| 14 | + * When using this, it's required to include a call to the constructor of the parent class as the |
| 15 | + * first code in the child class's constructor. |
| 16 | + * |
| 17 | + * @example |
| 18 | + * // Define parent class |
| 19 | + * function Foo() { |
| 20 | + * // code here |
| 21 | + * } |
| 22 | + * // Define child class |
| 23 | + * function Bar() { |
| 24 | + * // Call parent constructor |
| 25 | + * Foo.call( this ); |
| 26 | + * } |
| 27 | + * // Extend prototype |
| 28 | + * extend( Bar, Foo ); |
| 29 | + * |
| 30 | + * @static |
| 31 | + * @method |
| 32 | + * @param {Function} dst Class to extend |
| 33 | + * @param {Function} src Base class to use methods from |
| 34 | + */ |
| 35 | +es.extendClass = function( dst, src ) { |
| 36 | + var base = new src(); |
| 37 | + for ( var method in base ) { |
| 38 | + if ( typeof base[method] === 'function' && !( method in dst.prototype ) ) { |
| 39 | + dst.prototype[method] = base[method]; |
| 40 | + } |
| 41 | + } |
| 42 | +}; |
| 43 | + |
| 44 | +es.extendObject = $.extend; |
| 45 | + |
| 46 | +es.isPlainObject = $.isPlainObject; |
| 47 | + |
| 48 | +es.isArray = $.isArray; |
| 49 | + |
| 50 | +/** |
| 51 | + * Recursively compares string and number property between two objects. |
| 52 | + * |
| 53 | + * A false result may be caused by property inequality or by properties in one object missing from |
| 54 | + * the other. An asymmetrical test may also be performed, which checks only that properties in the |
| 55 | + * first object are present in the second object, but not the inverse. |
| 56 | + * |
| 57 | + * @static |
| 58 | + * @method |
| 59 | + * @param {Object} a First object to compare |
| 60 | + * @param {Object} b Second object to compare |
| 61 | + * @param {Boolean} [asymmetrical] Whether to check only that b contains values from a |
| 62 | + * @returns {Boolean} If the objects contain the same values as each other |
| 63 | + */ |
| 64 | +es.compareObjects = function( a, b, asymmetrical ) { |
| 65 | + var aValue, bValue, aType, bType; |
| 66 | + var k; |
| 67 | + for ( k in a ) { |
| 68 | + aValue = a[k]; |
| 69 | + bValue = b[k]; |
| 70 | + aType = typeof aValue; |
| 71 | + bType = typeof bValue; |
| 72 | + if ( aType !== bType || |
| 73 | + ( ( aType === 'string' || aType === 'number' ) && aValue !== bValue ) || |
| 74 | + ( es.isPlainObject( aValue ) && !es.compareObjects( aValue, bValue ) ) ) { |
| 75 | + return false; |
| 76 | + } |
| 77 | + } |
| 78 | + // If the check is not asymmetrical, recursing with the arguments swapped will verify our result |
| 79 | + return asymmetrical ? true : es.compareObjects( b, a, true ); |
| 80 | +}; |
| 81 | + |
| 82 | +/** |
| 83 | + * Gets a deep copy of an array's string, number, array and plain-object contents. |
| 84 | + * |
| 85 | + * @static |
| 86 | + * @method |
| 87 | + * @param {Array} source Array to copy |
| 88 | + * @returns {Array} Copy of source array |
| 89 | + */ |
| 90 | +es.copyArray = function( source ) { |
| 91 | + var destination = []; |
| 92 | + for ( var i = 0; i < source.length; i++ ) { |
| 93 | + sourceValue = source[i]; |
| 94 | + sourceType = typeof sourceValue; |
| 95 | + if ( sourceType === 'string' || sourceType === 'number' ) { |
| 96 | + destination.push( sourceValue ); |
| 97 | + } else if ( es.isPlainObject( sourceValue ) ) { |
| 98 | + destination.push( es.copyObject( sourceValue ) ); |
| 99 | + } else if ( es.isArray( sourceValue ) ) { |
| 100 | + destination.push( es.copyArray( sourceValue ) ); |
| 101 | + } |
| 102 | + } |
| 103 | + return destination; |
| 104 | +}; |
| 105 | + |
| 106 | +/** |
| 107 | + * Gets a deep copy of an object's string, number, array and plain-object properties. |
| 108 | + * |
| 109 | + * @static |
| 110 | + * @method |
| 111 | + * @param {Object} source Object to copy |
| 112 | + * @returns {Object} Copy of source object |
| 113 | + */ |
| 114 | +es.copyObject = function( source ) { |
| 115 | + var destination = {}; |
| 116 | + for ( var key in source ) { |
| 117 | + sourceValue = source[key]; |
| 118 | + sourceType = typeof sourceValue; |
| 119 | + if ( sourceType === 'string' || sourceType === 'number' ) { |
| 120 | + destination[key] = sourceValue; |
| 121 | + } else if ( es.isPlainObject( sourceValue ) ) { |
| 122 | + destination[key] = es.copyObject( sourceValue ); |
| 123 | + } else if ( es.isArray( sourceValue ) ) { |
| 124 | + destination[key] = es.copyArray( sourceValue ); |
| 125 | + } |
| 126 | + } |
| 127 | + return destination; |
| 128 | +}; |
| 129 | + |
| 130 | +/** |
| 131 | + * Splice one array into another. This is the equivalent of arr.splice( offset, 0, i1, i2, i3, ... ) |
| 132 | + * except that i1, i2, i3, ... are specified as an array rather than separate parameters. |
| 133 | + * |
| 134 | + * @static |
| 135 | + * @method |
| 136 | + * @param {Array} dst Array to splice insertion into. Will be modified |
| 137 | + * @param {Number} offset Offset in arr to splice insertion in at. May be negative; see the 'index' parameter for Array.prototype.splice() |
| 138 | + * @param {Array} src Array of items to insert |
| 139 | + */ |
| 140 | +es.insertIntoArray = function( dst, offset, src ) { |
| 141 | + // We need to splice insertion in in batches, because of parameter list length limits which vary cross-browser. |
| 142 | + // 1024 seems to be a safe batch size on all browsers. |
| 143 | + var index = 0, batchSize = 1024; |
| 144 | + while ( index < src.length ) { |
| 145 | + // Call arr.splice( offset, 0, i0, i1, i2, ..., i1023 ); |
| 146 | + dst.splice.apply( |
| 147 | + dst, [index + offset, 0].concat( src.slice( index, index + batchSize ) ) |
| 148 | + ); |
| 149 | + index += batchSize; |
| 150 | + } |
| 151 | +}; |
Index: trunk/extensions/VisualEditor/modules/models/es.DocumentModel.js |
— | — | @@ -0,0 +1,1168 @@ |
| 2 | +/** |
| 3 | + * Creates an es.DocumentModel object. |
| 4 | + * |
| 5 | + * es.DocumentModel objects extend the native Array object, so it's contents are directly accessible |
| 6 | + * through the typical methods. |
| 7 | + * |
| 8 | + * @class |
| 9 | + * @constructor |
| 10 | + * @extends {es.DocumentModelNode} |
| 11 | + * @param {Array} data Model data to initialize with, such as data from es.DocumentModel.getData() |
| 12 | + * @param {Object} attributes Document attributes |
| 13 | + */ |
| 14 | +es.DocumentModel = function( data, attributes ) { |
| 15 | + // Inheritance |
| 16 | + es.DocumentModelNode.call( this, null, data ? data.length : 0 ); |
| 17 | + |
| 18 | + // Properties |
| 19 | + this.data = es.isArray( data ) ? data : []; |
| 20 | + this.attributes = es.isPlainObject( attributes ) ? attributes : {}; |
| 21 | + |
| 22 | + // Auto-generate model tree |
| 23 | + var nodes = es.DocumentModel.createNodesFromData( this.data ); |
| 24 | + for ( var i = 0; i < nodes.length; i++ ) { |
| 25 | + this.push( nodes[i] ); |
| 26 | + } |
| 27 | +}; |
| 28 | + |
| 29 | +/* Static Members */ |
| 30 | + |
| 31 | +/** |
| 32 | + * Mapping of symbolic names and node model constructors. |
| 33 | + */ |
| 34 | +es.DocumentModel.nodeModels = {}; |
| 35 | + |
| 36 | +/** |
| 37 | + * Mapping of symbolic names and nesting rules. |
| 38 | + * |
| 39 | + * Each rule is an object with a parents and children property. Each of these properties may contain |
| 40 | + * one of two possible values: |
| 41 | + * Array - List of allowed element types (if empty, no elements will be allowed) |
| 42 | + * Null - Any element type is allowed (as long as the other element also allows it) |
| 43 | + * |
| 44 | + * @example Paragraph rules |
| 45 | + * { |
| 46 | + * 'parents': null, |
| 47 | + * 'children': [] |
| 48 | + * } |
| 49 | + * @example List rules |
| 50 | + * { |
| 51 | + * 'parents': null, |
| 52 | + * 'children': ['listItem'] |
| 53 | + * } |
| 54 | + * @example ListItem rules |
| 55 | + * { |
| 56 | + * 'parents': ['list'], |
| 57 | + * 'children': [] |
| 58 | + * } |
| 59 | + */ |
| 60 | +es.DocumentModel.nodeRules = {}; |
| 61 | + |
| 62 | +/** |
| 63 | + * Mapping of operation types to pure functions. |
| 64 | + * |
| 65 | + * Each function is called in the context of a state, and takes an operation object as a parameter. |
| 66 | + */ |
| 67 | +es.DocumentModel.operations = ( function() { |
| 68 | + function invalidate( from, to ) { |
| 69 | + this.rebuild.push( { 'from': from, 'to': to } ); |
| 70 | + } |
| 71 | + |
| 72 | + function retain( op ) { |
| 73 | + annotate.call( this, this.cursor + op.length ); |
| 74 | + this.cursor += op.length; |
| 75 | + } |
| 76 | + |
| 77 | + function insert( op ) { |
| 78 | + if ( es.DocumentModel.isStructuralOffset( this.data, this.cursor ) ) { |
| 79 | + // TODO: Support tree updates when inserting between elements |
| 80 | + } else { |
| 81 | + // Get the node we are about to insert into |
| 82 | + var node = this.tree.getNodeFromOffset( this.cursor ); |
| 83 | + if ( es.DocumentModel.containsElementData( op.data ) ) { |
| 84 | + var nodeParent = node.getParent(); |
| 85 | + if ( !nodeParent ) { |
| 86 | + throw 'Missing parent error. Node does not have a parent node.'; |
| 87 | + } |
| 88 | + var offset = this.tree.getOffsetFromNode( node ), |
| 89 | + length = node.getElementLength() + op.data.length, |
| 90 | + index = nodeParent.indexOf( node ); |
| 91 | + if ( index === -1 ) { |
| 92 | + throw 'Missing child error. Node could not be found in its parent node.'; |
| 93 | + } |
| 94 | + // Remove the node we are about to insert into from the model tree |
| 95 | + nodeParent.splice( index, 1 ); |
| 96 | + // Perform insert on linear data model |
| 97 | + es.insertIntoArray( this.data, this.cursor, op.data ); |
| 98 | + annotate.call( this, this.cursor + op.data.length ); |
| 99 | + // Regenerate nodes for the data we've affected |
| 100 | + var nodes = es.DocumentModel.createNodesFromData( |
| 101 | + this.data.slice( offset, length ) |
| 102 | + ); |
| 103 | + // Insert new elements into the tree where the old one used to be |
| 104 | + for ( var i = nodes.length; i >= 0; i-- ) { |
| 105 | + this.tree.splice( index, nodes[i] ); |
| 106 | + } |
| 107 | + } else { |
| 108 | + // Perform insert on linear data model |
| 109 | + es.insertIntoArray( this.data, this.cursor, op.data ); |
| 110 | + annotate.call( this, this.cursor + op.data.length ); |
| 111 | + // Update model tree |
| 112 | + node.adjustContentLength( op.data.length ); |
| 113 | + } |
| 114 | + } |
| 115 | + this.cursor += op.data.length; |
| 116 | + } |
| 117 | + |
| 118 | + function remove( op ) { |
| 119 | + var elementLeft = es.DocumentModel.isElementData( this.data, this.cursor ), |
| 120 | + elementRight = es.DocumentModel.isElementData( this.cursor + op.data.length ); |
| 121 | + if ( elementLeft && elementRight ) { |
| 122 | + // TODO: Support tree updates when removing whole elements |
| 123 | + } else { |
| 124 | + if ( es.DocumentModel.containsElementData( op.data ) ) { |
| 125 | + // TODO: Support tree updates when removing partial elements |
| 126 | + } else { |
| 127 | + // Get the node we are removing content from |
| 128 | + var node = this.tree.getNodeFromOffset( this.cursor ); |
| 129 | + // Remove content from linear data model |
| 130 | + this.data.splice( this.cursor, op.data.length ); |
| 131 | + // Update model tree |
| 132 | + node.adjustContentLength( -op.data.length ); |
| 133 | + } |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + function attribute( op, invert ) { |
| 138 | + var element = this.data[this.cursor]; |
| 139 | + if ( element.type === undefined ) { |
| 140 | + throw 'Invalid element error. Can not set attributes on non-element data.'; |
| 141 | + } |
| 142 | + if ( ( op.method === 'set' && !invert ) || ( op.method === 'clear' && invert ) ) { |
| 143 | + // Automatically initialize attributes object |
| 144 | + if ( !element.attributes ) { |
| 145 | + element.attributes = {}; |
| 146 | + } |
| 147 | + element.attributes[op.key] = op.value; |
| 148 | + } else if ( ( op.method === 'clear' && !invert ) || ( op.method === 'set' && invert ) ) { |
| 149 | + if ( element.attributes ) { |
| 150 | + delete element.attributes[op.key]; |
| 151 | + } |
| 152 | + // Automatically clean up attributes object |
| 153 | + var empty = true; |
| 154 | + for ( var key in element.attributes ) { |
| 155 | + empty = false; |
| 156 | + break; |
| 157 | + } |
| 158 | + if ( empty ) { |
| 159 | + delete element.attributes; |
| 160 | + } |
| 161 | + } else { |
| 162 | + throw 'Invalid method error. Can not operate attributes this way: ' + method; |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + function annotate( to ) { |
| 167 | + var i, |
| 168 | + j, |
| 169 | + length, |
| 170 | + annotation; |
| 171 | + // Handle annotations |
| 172 | + if ( this.set.length ) { |
| 173 | + for ( i = 0, length = this.set.length; i < length; i++ ) { |
| 174 | + annotation = this.set[i]; |
| 175 | + // Auto-build annotation hash |
| 176 | + if ( annotation.hash === undefined ) { |
| 177 | + annotation.hash = es.DocumentModel.getAnnotationHash( annotation ); |
| 178 | + } |
| 179 | + for ( j = this.cursor; j < to; j++ ) { |
| 180 | + // Auto-convert to array |
| 181 | + if ( es.isArray( this.data[j] ) ) { |
| 182 | + this.data[j].push( annotation ); |
| 183 | + } else { |
| 184 | + this.data[j] = [this.data[j], annotation]; |
| 185 | + } |
| 186 | + } |
| 187 | + } |
| 188 | + } |
| 189 | + if ( this.clear.length ) { |
| 190 | + for ( i = 0, length = this.clear.length; i < length; i++ ) { |
| 191 | + annotation = this.clear[i]; |
| 192 | + // Auto-build annotation hash |
| 193 | + if ( annotation.hash === undefined ) { |
| 194 | + annotation.hash = es.DocumentModel.getAnnotationHash( annotation ); |
| 195 | + } |
| 196 | + for ( j = this.cursor; j < to; j++ ) { |
| 197 | + var index = es.DocumentModel.getIndexOfAnnotation( this.data[j], annotation ); |
| 198 | + if ( index !== -1 ) { |
| 199 | + this.data[j].splice( index, 1 ); |
| 200 | + } |
| 201 | + // Auto-convert to string |
| 202 | + if ( this.data[j].length === 1 ) { |
| 203 | + this.data[j] = this.data[j][0]; |
| 204 | + } |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + function mark( op, invert ) { |
| 211 | + var target; |
| 212 | + if ( ( op.method === 'set' && !invert ) || ( op.method === 'clear' && invert ) ) { |
| 213 | + target = this.set; |
| 214 | + } else if ( ( op.method === 'clear' && !invert ) || ( op.method === 'set' && invert ) ) { |
| 215 | + target = this.clear; |
| 216 | + } else { |
| 217 | + throw 'Invalid method error. Can not operate attributes this way: ' + method; |
| 218 | + } |
| 219 | + if ( op.bias === 'start' ) { |
| 220 | + target.push( op.annotation ); |
| 221 | + } else if ( op.bias === 'stop' ) { |
| 222 | + var index = es.DocumentModel.getIndexOfAnnotation( target, op.annotation ); |
| 223 | + if ( index === -1 ) { |
| 224 | + throw 'Annotation stack error. Annotation is missing.'; |
| 225 | + } |
| 226 | + target.splice( index, 1 ); |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + return { |
| 231 | + // Retain |
| 232 | + 'retain': { |
| 233 | + 'commit': retain, |
| 234 | + 'rollback': retain |
| 235 | + }, |
| 236 | + // Insert |
| 237 | + 'insert': { |
| 238 | + 'commit': insert, |
| 239 | + 'rollback': remove |
| 240 | + }, |
| 241 | + // Remove |
| 242 | + 'remove': { |
| 243 | + 'commit': remove, |
| 244 | + 'rollback': insert |
| 245 | + }, |
| 246 | + // Change element attributes |
| 247 | + 'attribute': { |
| 248 | + 'commit': function( op ) { |
| 249 | + attribute.call( this, op, false ); |
| 250 | + }, |
| 251 | + 'rollback': function( op ) { |
| 252 | + attribute.call( this, op, true ); |
| 253 | + } |
| 254 | + }, |
| 255 | + // Change content annotations |
| 256 | + 'annotate': { |
| 257 | + 'commit': function( op ) { |
| 258 | + mark.call( this, op, false ); |
| 259 | + }, |
| 260 | + 'rollback': function( op ) { |
| 261 | + mark.call( this, op, true ); |
| 262 | + } |
| 263 | + } |
| 264 | + }; |
| 265 | +} )(); |
| 266 | + |
| 267 | +/* Static Methods */ |
| 268 | + |
| 269 | +/* |
| 270 | + * Create child nodes from an array of data. |
| 271 | + * |
| 272 | + * These child nodes are used for the model tree, which is a space partitioning data structure in |
| 273 | + * which each node contains the length of itself (1 for opening, 1 for closing) and the lengths of |
| 274 | + * it's child nodes. |
| 275 | + */ |
| 276 | +es.DocumentModel.createNodesFromData = function( data ) { |
| 277 | + var currentNode = new es.DocumentModelNode(); |
| 278 | + for ( var i = 0, length = data.length; i < length; i++ ) { |
| 279 | + if ( data[i].type !== undefined ) { |
| 280 | + // It's an element, figure out it's type |
| 281 | + var element = data[i], |
| 282 | + type = element.type, |
| 283 | + open = type[0] !== '/'; |
| 284 | + // Trim the "/" off the beginning of closing tag types |
| 285 | + if ( !open ) { |
| 286 | + type = type.substr( 1 ); |
| 287 | + } |
| 288 | + if ( open ) { |
| 289 | + // Validate the element type |
| 290 | + if ( !( type in es.DocumentModel.nodeModels ) ) { |
| 291 | + throw 'Unsuported element error. No class registered for element type: ' + type; |
| 292 | + } |
| 293 | + // Create a model node for the element |
| 294 | + var newNode = new es.DocumentModel.nodeModels[element.type]( element ); |
| 295 | + // Add the new model node as a child |
| 296 | + currentNode.push( newNode ); |
| 297 | + // Descend into the new model node |
| 298 | + currentNode = newNode; |
| 299 | + } else { |
| 300 | + // Return to the parent node |
| 301 | + currentNode = currentNode.getParent(); |
| 302 | + } |
| 303 | + } else { |
| 304 | + // It's content, let's start tracking the length |
| 305 | + var start = i; |
| 306 | + // Move forward to the next object, tracking the length as we go |
| 307 | + while ( data[i].type === undefined && i < length ) { |
| 308 | + i++; |
| 309 | + } |
| 310 | + // Now we know how long the current node is |
| 311 | + currentNode.setContentLength( i - start ); |
| 312 | + // The while loop left us 1 element to far |
| 313 | + i--; |
| 314 | + } |
| 315 | + } |
| 316 | + return currentNode.getChildren().slice( 0 ); |
| 317 | +}; |
| 318 | + |
| 319 | +/** |
| 320 | + * Creates a document model from a plain object. |
| 321 | + * |
| 322 | + * @static |
| 323 | + * @method |
| 324 | + * @param {Object} obj Object to create new document model from |
| 325 | + * @returns {es.DocumentModel} Document model created from obj |
| 326 | + */ |
| 327 | +es.DocumentModel.newFromPlainObject = function( obj ) { |
| 328 | + if ( obj.type === 'document' ) { |
| 329 | + var data = [], |
| 330 | + attributes = es.isPlainObject( obj.attributes ) ? es.copyObject( obj.attributes ) : {}; |
| 331 | + for ( var i = 0; i < obj.children.length; i++ ) { |
| 332 | + data = data.concat( es.DocumentModel.flattenPlainObjectElementNode( obj.children[i] ) ); |
| 333 | + } |
| 334 | + return new es.DocumentModel( data, attributes ); |
| 335 | + } |
| 336 | + throw 'Invalid object error. Object is not a valid document object.'; |
| 337 | +}; |
| 338 | + |
| 339 | +/** |
| 340 | + * Generates a hash of an annotation object based on it's name and data. |
| 341 | + * |
| 342 | + * TODO: Add support for deep hashing of array and object properties of annotation data. |
| 343 | + * |
| 344 | + * @static |
| 345 | + * @method |
| 346 | + * @param {Object} annotation Annotation object to generate hash for |
| 347 | + * @returns {String} Hash of annotation |
| 348 | + */ |
| 349 | +es.DocumentModel.getAnnotationHash = function( annotation ) { |
| 350 | + var hash = '#' + annotation.type; |
| 351 | + if ( annotation.data ) { |
| 352 | + var keys = []; |
| 353 | + for ( var key in annotation.data ) { |
| 354 | + keys.push( key + ':' + annotation.data ); |
| 355 | + } |
| 356 | + keys.sort(); |
| 357 | + hash += '|' + keys.join( '|' ); |
| 358 | + } |
| 359 | + return hash; |
| 360 | +}; |
| 361 | + |
| 362 | +es.DocumentModel.getIndexOfAnnotation = function( annotations, annotation ) { |
| 363 | + if ( annotation === undefined || annotation.type === undefined ) { |
| 364 | + throw 'Invalid annotation error. Can not find non-annotation data in character.'; |
| 365 | + } |
| 366 | + if ( es.isArray( annotations ) ) { |
| 367 | + // Find the index of a comparable annotation (checking for same value, not reference) |
| 368 | + for ( var i = 0; i < annotations.length; i++ ) { |
| 369 | + // Skip over character data - used when this is called on a content data item |
| 370 | + if ( typeof annotations[i] === 'string' ) { |
| 371 | + continue; |
| 372 | + } |
| 373 | + if ( annotations[i].hash === annotation.hash ) { |
| 374 | + return i; |
| 375 | + } |
| 376 | + } |
| 377 | + } |
| 378 | + return -1; |
| 379 | +}; |
| 380 | + |
| 381 | +/** |
| 382 | + * Creates an es.ContentModel object from a plain content object. |
| 383 | + * |
| 384 | + * A plain content object contains plain text and a series of annotations to be applied to ranges of |
| 385 | + * the text. |
| 386 | + * |
| 387 | + * @example |
| 388 | + * { |
| 389 | + * 'text': '1234', |
| 390 | + * 'annotations': [ |
| 391 | + * // Makes "23" bold |
| 392 | + * { |
| 393 | + * 'type': 'bold', |
| 394 | + * 'range': { |
| 395 | + * 'start': 1, |
| 396 | + * 'end': 3 |
| 397 | + * } |
| 398 | + * } |
| 399 | + * ] |
| 400 | + * } |
| 401 | + * |
| 402 | + * @static |
| 403 | + * @method |
| 404 | + * @param {Object} obj Plain content object, containing a "text" property and optionally |
| 405 | + * an "annotations" property, the latter of which being an array of annotation objects including |
| 406 | + * range information |
| 407 | + * @returns {Array} |
| 408 | + */ |
| 409 | +es.DocumentModel.flattenPlainObjectContentNode = function( obj ) { |
| 410 | + if ( !es.isPlainObject( obj ) ) { |
| 411 | + // Use empty content |
| 412 | + return []; |
| 413 | + } else { |
| 414 | + // Convert string to array of characters |
| 415 | + var data = obj.text.split(''); |
| 416 | + // Render annotations |
| 417 | + if ( es.isArray( obj.annotations ) ) { |
| 418 | + for ( var i = 0, length = obj.annotations.length; i < length; i++ ) { |
| 419 | + var src = obj.annotations[i]; |
| 420 | + // Build simplified annotation object |
| 421 | + var dst = { 'type': src.type }; |
| 422 | + if ( 'data' in src ) { |
| 423 | + dst.data = es.copyObject( src.data ); |
| 424 | + } |
| 425 | + // Add a hash to the annotation for faster comparison |
| 426 | + dst.hash = es.DocumentModel.getAnnotationHash( dst ); |
| 427 | + // Apply annotation to range |
| 428 | + if ( src.start < 0 ) { |
| 429 | + // TODO: The start can not be lower than 0! Throw error? |
| 430 | + // Clamp start value |
| 431 | + src.start = 0; |
| 432 | + } |
| 433 | + if ( src.end > data.length ) { |
| 434 | + // TODO: The end can not be higher than the length! Throw error? |
| 435 | + // Clamp end value |
| 436 | + src.end = data.length; |
| 437 | + } |
| 438 | + for ( var j = src.start; j < src.end; j++ ) { |
| 439 | + // Auto-convert to array |
| 440 | + if ( typeof data[j] === 'string' ) { |
| 441 | + data[j] = [data[j]]; |
| 442 | + } |
| 443 | + // Append |
| 444 | + data[j].push( dst ); |
| 445 | + } |
| 446 | + } |
| 447 | + } |
| 448 | + return data; |
| 449 | + } |
| 450 | +}; |
| 451 | + |
| 452 | +/** |
| 453 | + * Flatten a plain node object into a data array, recursively. |
| 454 | + * |
| 455 | + * TODO: where do we document this whole structure - aka "WikiDom"? |
| 456 | + * |
| 457 | + * @static |
| 458 | + * @method |
| 459 | + * @param {Object} obj Plain node object to flatten |
| 460 | + * @returns {Array} Flattened version of obj |
| 461 | + */ |
| 462 | +es.DocumentModel.flattenPlainObjectElementNode = function( obj ) { |
| 463 | + var i, |
| 464 | + data = [], |
| 465 | + element = { 'type': obj.type }; |
| 466 | + if ( es.isPlainObject( obj.attributes ) ) { |
| 467 | + element.attributes = es.copyObject( obj.attributes ); |
| 468 | + } |
| 469 | + // Open element |
| 470 | + data.push( element ); |
| 471 | + if ( es.isPlainObject( obj.content ) ) { |
| 472 | + // Add content |
| 473 | + data = data.concat( es.DocumentModel.flattenPlainObjectContentNode( obj.content ) ); |
| 474 | + } else if ( es.isArray( obj.children ) ) { |
| 475 | + // Add children - only do this if there is no content property |
| 476 | + for ( i = 0; i < obj.children.length; i++ ) { |
| 477 | + // TODO: Figure out if all this concatenating is inefficient. I think it is |
| 478 | + data = data.concat( es.DocumentModel.flattenPlainObjectElementNode( obj.children[i] ) ); |
| 479 | + } |
| 480 | + } |
| 481 | + // Close element - TODO: Do we need attributes here or not? |
| 482 | + data.push( { 'type': '/' + obj.type } ); |
| 483 | + return data; |
| 484 | +}; |
| 485 | + |
| 486 | +/** |
| 487 | + * Checks if a data at a given offset is content. |
| 488 | + * |
| 489 | + * @example Content data: |
| 490 | + * <paragraph> a b c </paragraph> <list> <listItem> d e f </listItem> </list> |
| 491 | + * ^ ^ ^ ^ ^ ^ |
| 492 | + * |
| 493 | + * @static |
| 494 | + * @method |
| 495 | + * @param {Array} data Data to evaluate offset within |
| 496 | + * @param {Integer} offset Offset in data to check |
| 497 | + * @returns {Boolean} If data at offset is content |
| 498 | + */ |
| 499 | +es.DocumentModel.isContentData = function( data, offset ) { |
| 500 | + // Shortcut: if there's already content there, we will trust it's supposed to be there |
| 501 | + return typeof data[offset] === 'string' || es.isArray( data[offset] ); |
| 502 | +}; |
| 503 | + |
| 504 | +/** |
| 505 | + * Checks if a data at a given offset is an element. |
| 506 | + * |
| 507 | + * @example Element data: |
| 508 | + * <paragraph> a b c </paragraph> <list> <listItem> d e f </listItem> </list> |
| 509 | + * ^ ^ ^ ^ ^ ^ |
| 510 | + * |
| 511 | + * @static |
| 512 | + * @method |
| 513 | + * @param {Array} data Data to evaluate offset within |
| 514 | + * @param {Integer} offset Offset in data to check |
| 515 | + * @returns {Boolean} If data at offset is an element |
| 516 | + */ |
| 517 | +es.DocumentModel.isElementData = function( data, offset ) { |
| 518 | + // TODO: Is there a safer way to check if it's a plain object without sacrificing speed? |
| 519 | + return offset >= 0 && offset < data.length && data[offset].type !== undefined; |
| 520 | +}; |
| 521 | + |
| 522 | +/** |
| 523 | + * Checks if an offset within given data is structural. |
| 524 | + * |
| 525 | + * Structural offsets are those at the beginning, end or surrounded by elements. This differs |
| 526 | + * from a location at which an element is present in that elements can be safely inserted at a |
| 527 | + * structural location, but not nessecarily where an element is present. |
| 528 | + * |
| 529 | + * @example Structural offsets: |
| 530 | + * <paragraph> a b c </paragraph> <list> <listItem> d e f </listItem> </list> |
| 531 | + * ^ ^ ^ ^ ^ |
| 532 | + * |
| 533 | + * @static |
| 534 | + * @method |
| 535 | + * @param {Array} data Data to evaluate offset within |
| 536 | + * @param {Integer} offset Offset to check |
| 537 | + * @returns {Boolean} Whether offset is structural or not |
| 538 | + */ |
| 539 | +es.DocumentModel.isStructuralOffset = function( data, offset ) { |
| 540 | + // Edges are always structural |
| 541 | + if ( offset === 0 || offset === data.length ) { |
| 542 | + return true; |
| 543 | + } |
| 544 | + // Structual offsets will have elements on each side |
| 545 | + if ( data[offset - 1].type !== undefined && data[offset].type !== undefined ) { |
| 546 | + return true; |
| 547 | + } |
| 548 | + return false; |
| 549 | +}; |
| 550 | + |
| 551 | +/** |
| 552 | + * Checks if elements are present within data. |
| 553 | + * |
| 554 | + * @static |
| 555 | + * @method |
| 556 | + * @param {Array} data Data to look for elements within |
| 557 | + * @returns {Boolean} If elements exist in data |
| 558 | + */ |
| 559 | +es.DocumentModel.containsElementData = function( data ) { |
| 560 | + for ( var i = 0, length = data.length; i < length; i++ ) { |
| 561 | + if ( data[i].type !== undefined ) { |
| 562 | + return true; |
| 563 | + } |
| 564 | + } |
| 565 | + return false; |
| 566 | +}; |
| 567 | + |
| 568 | +/* Methods */ |
| 569 | + |
| 570 | +/** |
| 571 | + * Creates a document view for this model. |
| 572 | + * |
| 573 | + * @method |
| 574 | + * @returns {es.DocumentView} |
| 575 | + */ |
| 576 | +es.DocumentModel.prototype.createView = function() { |
| 577 | + return new es.DocumentView( this ); |
| 578 | +}; |
| 579 | + |
| 580 | +/** |
| 581 | + * Gets copy of the document data. |
| 582 | + * |
| 583 | + * @method |
| 584 | + * @param {es.Range} [range] Range of data to get, all data will be given by default |
| 585 | + * @param {Boolean} [deep=false] Whether to return a deep copy (WARNING! This may be very slow) |
| 586 | + * @returns {Array} Copy of document data |
| 587 | + */ |
| 588 | +es.DocumentModel.prototype.getData = function( range, deep ) { |
| 589 | + var start = 0, |
| 590 | + end; |
| 591 | + if ( range !== undefined ) { |
| 592 | + range.normalize(); |
| 593 | + start = Math.max( 0, Math.min( this.data.length, range.start ) ); |
| 594 | + end = Math.max( 0, Math.min( this.data.length, range.end ) ); |
| 595 | + } |
| 596 | + var data = this.data.slice( start, end ); |
| 597 | + return deep ? es.copyArray( data ) : data; |
| 598 | +}; |
| 599 | + |
| 600 | +/** |
| 601 | + * Gets the element object of a node. |
| 602 | + * |
| 603 | + * @method |
| 604 | + * @param {es.DocumentModelNode} node Node to get element object for |
| 605 | + * @returns {Object|null} Element object |
| 606 | + */ |
| 607 | +es.DocumentModel.prototype.getElementFromNode = function( node ) { |
| 608 | + var offset = this.getOffsetFromNode( node ); |
| 609 | + if ( offset !== false ) { |
| 610 | + return this.data[offset]; |
| 611 | + } |
| 612 | + return null; |
| 613 | +}; |
| 614 | + |
| 615 | +/** |
| 616 | + * Gets the content data of a node. |
| 617 | + * |
| 618 | + * @method |
| 619 | + * @param {es.DocumentModelNode} node Node to get content data for |
| 620 | + * @returns {Array|null} List of content and elements inside node or null if node is not found |
| 621 | + */ |
| 622 | +es.DocumentModel.prototype.getContentFromNode = function( node, range ) { |
| 623 | + var length = node.getContentLength(); |
| 624 | + if ( range ) { |
| 625 | + range.normalize(); |
| 626 | + if ( range.start < 0 ) { |
| 627 | + throw 'Invalid range error. Range can not start before node start: ' + range.start; |
| 628 | + } |
| 629 | + if ( range.end > length ) { |
| 630 | + throw 'Invalid range error. Range can not end after node end: ' + range.end; |
| 631 | + } |
| 632 | + } else { |
| 633 | + range = { |
| 634 | + 'start': 0, |
| 635 | + 'end': length |
| 636 | + }; |
| 637 | + } |
| 638 | + var offset = this.getOffsetFromNode( node ); |
| 639 | + if ( offset !== -1 ) { |
| 640 | + offset++; |
| 641 | + return this.data.slice( offset + range.start, offset + range.end ); |
| 642 | + } |
| 643 | + return null; |
| 644 | +}; |
| 645 | + |
| 646 | +/** |
| 647 | + * Gets the range of content surrounding a given offset that's covered by a given annotation. |
| 648 | + * |
| 649 | + * @method |
| 650 | + * @param {Integer} offset Offset to begin looking forward and backward from |
| 651 | + * @param {Object} annotation Annotation to test for coverage with |
| 652 | + * @returns {es.Range|null} Range of content covered by annotation, or null if offset is not covered |
| 653 | + */ |
| 654 | +es.DocumentModel.prototype.getAnnotationBoundaries = function( offset, annotation ) { |
| 655 | + if ( annotation.hash === undefined ) { |
| 656 | + annotation.hash = es.DocumentModel.getAnnotationHash( annotation ); |
| 657 | + } |
| 658 | + if ( es.DocumentModel.getIndexOfAnnotation( this.data[offset], annotation ) === -1 ) { |
| 659 | + return null; |
| 660 | + } |
| 661 | + var start = offset, |
| 662 | + end = offset, |
| 663 | + item; |
| 664 | + while ( start > 0 ) { |
| 665 | + start--; |
| 666 | + if ( es.DocumentModel.getIndexOfAnnotation( this.data[start], annotation ) === -1 ) { |
| 667 | + start++; |
| 668 | + break; |
| 669 | + } |
| 670 | + } |
| 671 | + while ( end < this.data.length ) { |
| 672 | + if ( es.DocumentModel.getIndexOfAnnotation( this.data[end], annotation ) === -1 ) { |
| 673 | + break; |
| 674 | + } |
| 675 | + end++; |
| 676 | + } |
| 677 | + return new es.Range( start, end ); |
| 678 | +}; |
| 679 | + |
| 680 | +/** |
| 681 | + * Gets a list of annotations that a given offset is covered by. |
| 682 | + * |
| 683 | + * @method |
| 684 | + * @param {Integer} offset Offset to get annotations for |
| 685 | + * @returns {Object[]} A copy of all annotation objects offset is covered by |
| 686 | + */ |
| 687 | +es.DocumentModel.prototype.getAnnotationsFromOffset = function( offset ) { |
| 688 | + if ( es.isArray( this.data[offset] ) ) { |
| 689 | + return es.copyArray( this.data[offset].slice( 1 ) ); |
| 690 | + } |
| 691 | + return []; |
| 692 | +}; |
| 693 | + |
| 694 | +/** |
| 695 | + * Gets the range of content surrounding a given offset that makes up a whole word. |
| 696 | + * |
| 697 | + * @method |
| 698 | + * @param {Integer} offset Offset to begin looking forward and backward from |
| 699 | + * @returns {es.Range|null} Range of content making up a whole word or null if offset is not content |
| 700 | + */ |
| 701 | +es.DocumentModel.prototype.getWordBoundaries = function( offset ) { |
| 702 | + if ( es.DocumentModel.isStructuralOffset( this.data, offset ) ) { |
| 703 | + return null; |
| 704 | + } |
| 705 | + var start = offset, |
| 706 | + end = offset, |
| 707 | + item; |
| 708 | + while ( start > 0 ) { |
| 709 | + start--; |
| 710 | + if ( typeof this.data[start] !== 'string' && !es.isArray( this.data[start] ) ) { |
| 711 | + start++; |
| 712 | + break; |
| 713 | + } |
| 714 | + item = typeof this.data[start] === 'string' ? this.data[start] : this.data[start][0]; |
| 715 | + if ( item.match( /\B/ ) ) { |
| 716 | + start++; |
| 717 | + break; |
| 718 | + } |
| 719 | + } |
| 720 | + while ( end < this.data.length ) { |
| 721 | + if ( typeof this.data[end] !== 'string' && !es.isArray( this.data[end] ) ) { |
| 722 | + break; |
| 723 | + } |
| 724 | + item = typeof this.data[end] === 'string' ? this.data[end] : this.data[end][0]; |
| 725 | + if ( item.match( /\B/ ) ) { |
| 726 | + break; |
| 727 | + } |
| 728 | + end++; |
| 729 | + } |
| 730 | + return new es.Range( start, end ); |
| 731 | +}; |
| 732 | + |
| 733 | +/** |
| 734 | + * Gets a content offset a given distance forwards or backwards from another. |
| 735 | + * |
| 736 | + * @method |
| 737 | + * @param {Integer} offset Offset to start from |
| 738 | + * @param {Integer} distance Number of content offsets to move |
| 739 | + * @param {Integer} Offset a given distance from the given offset |
| 740 | + */ |
| 741 | +es.DocumentModel.prototype.getRelativeContentOffset = function( offset, distance ) { |
| 742 | + if ( es.DocumentModel.isStructuralOffset( this.data, offset ) ) { |
| 743 | + throw 'Invalid offset error. Can not get relative content offset from non-content offset.'; |
| 744 | + } |
| 745 | + if ( distance === 0 ) { |
| 746 | + return offset; |
| 747 | + } |
| 748 | + var direction = distance > 0 ? 1 : -1, |
| 749 | + i = offset + direction, |
| 750 | + steps = 0; |
| 751 | + distance = Math.abs( distance ); |
| 752 | + while ( i > 0 && i < this.data.length - 1 ) { |
| 753 | + if ( !es.DocumentModel.isStructuralOffset( this.data, i ) ) { |
| 754 | + steps++; |
| 755 | + offset = i; |
| 756 | + if ( distance === steps ) { |
| 757 | + return offset; |
| 758 | + } |
| 759 | + } |
| 760 | + i += direction; |
| 761 | + } |
| 762 | + return offset; |
| 763 | +}; |
| 764 | + |
| 765 | +/** |
| 766 | + * Generates a transaction which inserts data at a given offset. |
| 767 | + * |
| 768 | + * @method |
| 769 | + * @param {Integer} offset |
| 770 | + * @param {Array} data |
| 771 | + * @returns {es.Transaction} |
| 772 | + */ |
| 773 | +es.DocumentModel.prototype.prepareInsertion = function( offset, data ) { |
| 774 | + /** |
| 775 | + * Balances mismatched openings/closings in data |
| 776 | + * @return data itself if nothing was changed, or a clone of data with balancing changes made. |
| 777 | + * data itself is never touched |
| 778 | + */ |
| 779 | + function balance( data ) { |
| 780 | + var i, stack = [], element, workingData = null; |
| 781 | + |
| 782 | + for ( i = 0; i < data.length; i++ ) { |
| 783 | + if ( data[i].type === undefined ) { |
| 784 | + // Not an opening or a closing, skip |
| 785 | + } else if ( data[i].type.charAt( 0 ) != '/' ) { |
| 786 | + // Opening |
| 787 | + stack.push( data[i].type ); |
| 788 | + } else { |
| 789 | + // Closing |
| 790 | + if ( stack.length === 0 ) { |
| 791 | + // The stack is empty, so this is an unopened closing |
| 792 | + // Remove it |
| 793 | + if ( workingData === null ) { |
| 794 | + workingData = data.slice( 0 ); |
| 795 | + } |
| 796 | + workingData.splice( i, 1 ); |
| 797 | + } else { |
| 798 | + element = stack.pop(); |
| 799 | + if ( element != data[i].type.substr( 1 ) ) { |
| 800 | + // Closing doesn't match what's expected |
| 801 | + // This means the input is malformed and cannot possibly |
| 802 | + // have been a fragment taken from well-formed data |
| 803 | + throw 'Input is malformed: expected /' + element + ' but got ' + data[i].type + |
| 804 | + ' at index ' + i; |
| 805 | + } |
| 806 | + } |
| 807 | + } |
| 808 | + } |
| 809 | + |
| 810 | + // Check whether there are any unclosed tags and close them |
| 811 | + if ( stack.length > 0 && workingData === null ) { |
| 812 | + workingData = data.slice( 0 ); |
| 813 | + } |
| 814 | + while ( stack.length > 0 ) { |
| 815 | + element = stack.pop(); |
| 816 | + workingData.push( { 'type': '/' + element } ); |
| 817 | + } |
| 818 | + |
| 819 | + // TODO |
| 820 | + // Check whether there is any raw unenclosed content and deal with that somehow |
| 821 | + |
| 822 | + return workingData || data; |
| 823 | + } |
| 824 | + |
| 825 | + var tx = new es.Transaction(), |
| 826 | + insertedData = data, // may be cloned and modified |
| 827 | + isStructuralLoc, |
| 828 | + wrappingElementType; |
| 829 | + |
| 830 | + if ( offset < 0 || offset > this.data.length ) { |
| 831 | + throw 'Offset ' + offset + ' out of bounds [0..' + this.data.length + ']'; |
| 832 | + } |
| 833 | + |
| 834 | + // Has to be after the bounds check, because isStructuralOffset doesn't like out-of-bounds offsets |
| 835 | + isStructuralLoc = es.DocumentModel.isStructuralOffset( this.data, offset ); |
| 836 | + |
| 837 | + if ( offset > 0 ) { |
| 838 | + tx.pushRetain( offset ); |
| 839 | + } |
| 840 | + |
| 841 | + if ( es.DocumentModel.containsElementData( insertedData ) ) { |
| 842 | + if ( insertedData[0].type !== undefined && insertedData[0].type.charAt( 0 ) != '/' ) { |
| 843 | + // insertedData starts with an opening, so this is really intended to insert structure |
| 844 | + // Balance it to make it sane, if it's not already |
| 845 | + // TODO we need an actual validator and check that the insertion is really valid |
| 846 | + insertedData = balance( insertedData ); |
| 847 | + if ( !isStructuralLoc ) { |
| 848 | + // We're inserting structure at a content location, |
| 849 | + // so we need to split up the wrapping element |
| 850 | + wrappingElementType = this.getNodeFromOffset( offset ).getElementType(); |
| 851 | + var arr = [ { 'type': '/' + wrappingElementType }, { 'type': wrappingElementType } ]; |
| 852 | + es.insertIntoArray( arr, 1, insertedData ); |
| 853 | + insertedData = arr; |
| 854 | + } |
| 855 | + // else we're inserting structure at a structural location, which is fine |
| 856 | + } else { |
| 857 | + // insertedData starts with content but contains structure |
| 858 | + // TODO balance and validate, will be different for this case |
| 859 | + } |
| 860 | + } else { |
| 861 | + if ( isStructuralLoc ) { |
| 862 | + // We're inserting content into a structural location, |
| 863 | + // so we need to wrap the inserted content in a paragraph. |
| 864 | + insertedData = [ { 'type': 'paragraph' }, { 'type': '/paragraph' } ]; |
| 865 | + es.insertIntoArray( insertedData, 1, data ); |
| 866 | + } else { |
| 867 | + // Content being inserted in content is fine, do nothing |
| 868 | + } |
| 869 | + } |
| 870 | + |
| 871 | + tx.pushInsert( insertedData ); |
| 872 | + if ( offset < this.data.length ) { |
| 873 | + tx.pushRetain( this.data.length - offset ); |
| 874 | + } |
| 875 | + |
| 876 | + return tx; |
| 877 | + |
| 878 | + /* |
| 879 | + * // Structural changes |
| 880 | + * There are 2 basic types of locations the insertion point can be: |
| 881 | + * Structural locations |
| 882 | + * |<p>a</p><p>b</p> - Beginning of the document |
| 883 | + * <p>a</p>|<p>b</p> - Between elements (like in a document or list) |
| 884 | + * <p>a</p><p>b</p>| - End of the document |
| 885 | + * Content locations |
| 886 | + * <p>|a</p><p>b</p> - Inside an element (like in a paragraph or listItem) |
| 887 | + * <p>a|</p><p>b</p> - May also be inside an element but right before/after an |
| 888 | + * open/close |
| 889 | + * |
| 890 | + * if ( Incoming data contains structural elements ) { |
| 891 | + // We're assuming the incoming data is balanced, is that OK? |
| 892 | + * if ( Insertion point is a structural location ) { |
| 893 | + * if ( Incoming data is not a complete structural element ) { |
| 894 | + * Incoming data must be balanced |
| 895 | + * } |
| 896 | + * } else { |
| 897 | + * Closing and opening elements for insertion point must be added to incoming data |
| 898 | + * } |
| 899 | + * } else { |
| 900 | + * if ( Insertion point is a structural location ) { |
| 901 | + * Incoming data must be balanced //how? Should this even be allowed? |
| 902 | + * } else { |
| 903 | + * Content being inserted into content is OK, do nothing |
| 904 | + * } |
| 905 | + * } |
| 906 | + */ |
| 907 | +}; |
| 908 | + |
| 909 | +/** |
| 910 | + * Generates a transaction which removes data from a given range. |
| 911 | + * |
| 912 | + * When removing data inside an element, the data is simply discarded and the node's length is |
| 913 | + * adjusted accordingly. When removing data across elements, there are two situations that can cause |
| 914 | + * added complexity: |
| 915 | + * 1. A range spans between nodes of different levels or types |
| 916 | + * 2. A range only partially covers one or two nodes |
| 917 | + * |
| 918 | + * To resolve these issues in a predictable way the following rules must be obeyed: |
| 919 | + * 1. Structural elements are retained unless the range being removed covers the entire element |
| 920 | + * 2. Elements can only be merged if they are of the same time and share a common parent |
| 921 | + * |
| 922 | + * @method |
| 923 | + * @param {es.Range} range |
| 924 | + * @returns {es.Transaction} |
| 925 | + */ |
| 926 | +es.DocumentModel.prototype.prepareRemoval = function( range ) { |
| 927 | + var doc = this; |
| 928 | + //debugger; |
| 929 | + |
| 930 | + /** |
| 931 | + * Return true if can merge the remaining contents of the elements after a selection is deleted |
| 932 | + * across them. For instance, if a selection is painted across two paragraphs, and then the text |
| 933 | + * is deleted, the two paragraphs can become one paragraph. However, if the selection crosses |
| 934 | + * into a table, those cannot be merged. |
| 935 | + * @param {Number} integer offset |
| 936 | + * @param {Number} integer offset |
| 937 | + * @return {Boolean} |
| 938 | + */ |
| 939 | + function canMerge( range ) { |
| 940 | + var node1 = doc.getNodeFromOffset( range.start ); |
| 941 | + var node2 = doc.getNodeFromOffset( range.end - 1 ); |
| 942 | + // This is the simple rule we are following for now -- same type & same parent = can merge. |
| 943 | + // So you can merge adjacent paragraphs, or listitems. And you can't merge a paragraph into |
| 944 | + // a table row. There may be other rules we will want in here later, for instance, special |
| 945 | + // casing merging a listitem into a paragraph. |
| 946 | + |
| 947 | + // wait, some nodes don't have types? Is this the top document node? |
| 948 | + return ( |
| 949 | + ( |
| 950 | + ( node1 && node2 ) && |
| 951 | + ( node1.type !== undefined && node2.type !== undefined ) && |
| 952 | + ( node1.type === node2.type ) |
| 953 | + ) && |
| 954 | + ( node1.getParent() === node2.getParent() ) |
| 955 | + ); |
| 956 | + } |
| 957 | + |
| 958 | + function mergeDelete( range, tx ) { |
| 959 | + // yay, content can be removed in one fell swoop |
| 960 | + var removed = doc.data.slice( range.start, range.end ); |
| 961 | + tx.pushRemove( removed ); |
| 962 | + } |
| 963 | + |
| 964 | + // remove string content only, retain structure |
| 965 | + function stripDelete( range, tx ) { |
| 966 | + var lastOperation, operationStart; |
| 967 | + |
| 968 | + var ops = [], |
| 969 | + op; |
| 970 | + //debugger; |
| 971 | + |
| 972 | + // get a list of operations, with 0-based indexes |
| 973 | + for (var i = range.start; i < range.end; i++ ) { |
| 974 | + var neededOp = doc.data[i].type === undefined ? 'remove' : 'retain'; |
| 975 | + op = ops[ ops.length - 1 ]; |
| 976 | + if ( op === undefined || op.type !== neededOp ) { |
| 977 | + ops.push( { type: neededOp, start: i, end: i } ); |
| 978 | + } else { |
| 979 | + op.end = i; |
| 980 | + } |
| 981 | + } |
| 982 | + |
| 983 | + //debugger; |
| 984 | + // insert operations as transactions (end must be adjusted) |
| 985 | + for (var j = 0; j < ops.length; j++ ) { |
| 986 | + op = ops[j]; |
| 987 | + if ( op.type === 'retain' ) { |
| 988 | + // we add one because retain(3,3) really means retain 1 char at pos 3 |
| 989 | + tx.pushRetain( op.end - op.start + 1 ); |
| 990 | + } else if ( op.type === 'remove' ) { |
| 991 | + // we add one because to remove(3,5) we need to slice(3,6), the ending is last |
| 992 | + // subscript removed + 1. |
| 993 | + tx.pushRemove( this.data.slice( op.start, op.end + 1 ) ); |
| 994 | + } else { |
| 995 | + console.log( "this is impossible" ); |
| 996 | + } |
| 997 | + } |
| 998 | + |
| 999 | + } |
| 1000 | + |
| 1001 | + |
| 1002 | + var tx = new es.Transaction(); |
| 1003 | + range.normalize(); |
| 1004 | + |
| 1005 | + // Retain to the start of the range |
| 1006 | + if ( range.start > 0 ) { |
| 1007 | + tx.pushRetain( range.start ); |
| 1008 | + } |
| 1009 | + |
| 1010 | + |
| 1011 | + // choose a deletion strategy; merging nodes together, or stripping content from existing |
| 1012 | + // structure. |
| 1013 | + if ( canMerge( range ) ) { |
| 1014 | + mergeDelete( range, tx ); |
| 1015 | + } else { |
| 1016 | + stripDelete( range, tx ); |
| 1017 | + } |
| 1018 | + |
| 1019 | + // Retain up to the end of the document. Why do we do this? |
| 1020 | + if ( range.end < doc.data.length ) { |
| 1021 | + tx.pushRetain( doc.data.length - range.end ); |
| 1022 | + } |
| 1023 | + |
| 1024 | + return tx; |
| 1025 | +}; |
| 1026 | + |
| 1027 | +/** |
| 1028 | + * Generates a transaction which annotates content within a given range. |
| 1029 | + * |
| 1030 | + * @method |
| 1031 | + * @returns {es.Transaction} |
| 1032 | + */ |
| 1033 | +es.DocumentModel.prototype.prepareContentAnnotation = function( range, method, annotation ) { |
| 1034 | + var tx = new es.Transaction(); |
| 1035 | + range.normalize(); |
| 1036 | + if ( annotation.hash === undefined ) { |
| 1037 | + annotation.hash = es.DocumentModel.getAnnotationHash( annotation ); |
| 1038 | + } |
| 1039 | + var i = range.start, |
| 1040 | + span = i, |
| 1041 | + on = this.data[i].type !== undefined; |
| 1042 | + while ( i < range.end ) { |
| 1043 | + if ( this.data[i].type !== undefined ) { |
| 1044 | + // Don't annotate structural elements |
| 1045 | + if ( on ) { |
| 1046 | + tx.pushStopAnnotating( method, annotation ); |
| 1047 | + span = 0; |
| 1048 | + on = false; |
| 1049 | + } |
| 1050 | + } else { |
| 1051 | + var covered = es.DocumentModel.getIndexOfAnnotation( this.data[i], annotation ) !== -1; |
| 1052 | + if ( covered && method === 'set' || !covered && method === 'clear' ) { |
| 1053 | + // Don't set/clear annotations on content that's already set/cleared |
| 1054 | + if ( on ) { |
| 1055 | + if ( span ) { |
| 1056 | + tx.pushRetain( span ); |
| 1057 | + } |
| 1058 | + tx.pushStopAnnotating( method, annotation ); |
| 1059 | + span = 0; |
| 1060 | + on = false; |
| 1061 | + } |
| 1062 | + } else { |
| 1063 | + // Content |
| 1064 | + if ( !on ) { |
| 1065 | + if ( span ) { |
| 1066 | + tx.pushRetain( span ); |
| 1067 | + } |
| 1068 | + tx.pushStartAnnotating( method, annotation ); |
| 1069 | + span = 0; |
| 1070 | + on = true; |
| 1071 | + } |
| 1072 | + } |
| 1073 | + } |
| 1074 | + span++; |
| 1075 | + i++; |
| 1076 | + } |
| 1077 | + if ( on ) { |
| 1078 | + if ( span ) { |
| 1079 | + tx.pushRetain( span ); |
| 1080 | + } |
| 1081 | + tx.pushStopAnnotating( method, annotation ); |
| 1082 | + } |
| 1083 | + if ( range.end < this.data.length ) { |
| 1084 | + tx.pushRetain( this.data.length - range.end ); |
| 1085 | + } |
| 1086 | + return tx; |
| 1087 | +}; |
| 1088 | + |
| 1089 | +/** |
| 1090 | + * Generates a transaction which changes attributes on an element at a given offset. |
| 1091 | + * |
| 1092 | + * @method |
| 1093 | + * @returns {es.Transaction} |
| 1094 | + */ |
| 1095 | +es.DocumentModel.prototype.prepareElementAttributeChange = function( offset, method, key, value ) { |
| 1096 | + var tx = new es.Transaction(); |
| 1097 | + if ( offset ) { |
| 1098 | + tx.pushRetain( offset ); |
| 1099 | + } |
| 1100 | + if ( this.data[offset].type === undefined ) { |
| 1101 | + throw 'Invalid element offset error. Can not set attributes to non-element data.'; |
| 1102 | + } |
| 1103 | + if ( this.data[offset].type[0] === '/' ) { |
| 1104 | + throw 'Invalid element offset error. Can not set attributes on closing element.'; |
| 1105 | + } |
| 1106 | + tx.pushChangeElementAttribute( method, key, value ); |
| 1107 | + if ( offset < this.data.length ) { |
| 1108 | + tx.pushRetain( this.data.length - offset ); |
| 1109 | + } |
| 1110 | + return tx; |
| 1111 | +}; |
| 1112 | + |
| 1113 | +/** |
| 1114 | + * Applies a transaction to the content data. |
| 1115 | + * |
| 1116 | + * @method |
| 1117 | + * @param {es.Transaction} |
| 1118 | + */ |
| 1119 | +es.DocumentModel.prototype.commit = function( transaction ) { |
| 1120 | + var state = { |
| 1121 | + 'data': this.data, |
| 1122 | + 'tree': this, |
| 1123 | + 'cursor': 0, |
| 1124 | + 'set': [], |
| 1125 | + 'clear': [], |
| 1126 | + 'rebuild': [] |
| 1127 | + }, |
| 1128 | + operations = transaction.getOperations(); |
| 1129 | + for ( var i = 0, length = operations.length; i < length; i++ ) { |
| 1130 | + var operation = operations[i]; |
| 1131 | + if ( operation.type in es.DocumentModel.operations ) { |
| 1132 | + es.DocumentModel.operations[operation.type].commit.call( state, operation ); |
| 1133 | + } else { |
| 1134 | + throw 'Invalid operation error. Operation type is not supported: ' + operation.type; |
| 1135 | + } |
| 1136 | + } |
| 1137 | + // TODO: Synchronize op.tree - insert elements and adjust lengths |
| 1138 | +}; |
| 1139 | + |
| 1140 | +/** |
| 1141 | + * Reverses a transaction's effects on the content data. |
| 1142 | + * |
| 1143 | + * @method |
| 1144 | + * @param {es.Transaction} |
| 1145 | + */ |
| 1146 | +es.DocumentModel.prototype.rollback = function( transaction ) { |
| 1147 | + var state = { |
| 1148 | + 'data': this.data, |
| 1149 | + 'tree': this, |
| 1150 | + 'cursor': 0, |
| 1151 | + 'set': [], |
| 1152 | + 'clear': [], |
| 1153 | + 'rebuild': [] |
| 1154 | + }, |
| 1155 | + operations = transaction.getOperations(); |
| 1156 | + for ( var i = 0, length = operations.length; i < length; i++ ) { |
| 1157 | + var operation = operations[i]; |
| 1158 | + if ( operation.type in es.DocumentModel.operations ) { |
| 1159 | + es.DocumentModel.operations[operation.type].rollback.call( state, operation ); |
| 1160 | + } else { |
| 1161 | + throw 'Invalid operation error. Operation type is not supported: ' + operation.type; |
| 1162 | + } |
| 1163 | + } |
| 1164 | + // TODO: Synchronize op.tree - insert elements and adjust lengths |
| 1165 | +}; |
| 1166 | + |
| 1167 | +/* Inheritance */ |
| 1168 | + |
| 1169 | +es.extendClass( es.DocumentModel, es.DocumentModelNode ); |
Index: trunk/extensions/VisualEditor/modules/models/es.HeadingModel.js |
— | — | @@ -0,0 +1,38 @@ |
| 2 | +/** |
| 3 | + * Creates an es.HeadingModel object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentModelNode} |
| 8 | + * @param {Object} element Document data element of this node |
| 9 | + * @param {Integer} length Length of document data element |
| 10 | + */ |
| 11 | +es.HeadingModel = function( element, length ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentModelNode.call( this, element, length ); |
| 14 | +}; |
| 15 | + |
| 16 | +/* Methods */ |
| 17 | + |
| 18 | +/** |
| 19 | + * Creates a heading view for this model. |
| 20 | + * |
| 21 | + * @method |
| 22 | + * @returns {es.ParagraphView} |
| 23 | + */ |
| 24 | +es.HeadingModel.prototype.createView = function() { |
| 25 | + return new es.HeadingView( this ); |
| 26 | +}; |
| 27 | + |
| 28 | +/* Registration */ |
| 29 | + |
| 30 | +es.DocumentModel.nodeModels.heading = es.HeadingModel; |
| 31 | + |
| 32 | +es.DocumentModel.nodeRules.heading = { |
| 33 | + 'parents': null, |
| 34 | + 'children': [] |
| 35 | +}; |
| 36 | + |
| 37 | +/* Inheritance */ |
| 38 | + |
| 39 | +es.extendClass( es.HeadingModel, es.DocumentModelNode ); |
Index: trunk/extensions/VisualEditor/modules/models/es.TableRowModel.js |
— | — | @@ -0,0 +1,38 @@ |
| 2 | +/** |
| 3 | + * Creates an es.TableRowModel object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentModelNode} |
| 8 | + * @param {Object} element Document data element of this node |
| 9 | + * @param {es.DocumentModelNode[]} contents List of child nodes to initially add |
| 10 | + */ |
| 11 | +es.TableRowModel = function( element, contents ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentModelNode.call( this, element, contents ); |
| 14 | +}; |
| 15 | + |
| 16 | +/* Methods */ |
| 17 | + |
| 18 | +/** |
| 19 | + * Creates a table row view for this model. |
| 20 | + * |
| 21 | + * @method |
| 22 | + * @returns {es.TableRowView} |
| 23 | + */ |
| 24 | +es.TableRowModel.prototype.createView = function() { |
| 25 | + return new es.TableRowView( this ); |
| 26 | +}; |
| 27 | + |
| 28 | +/* Registration */ |
| 29 | + |
| 30 | +es.DocumentModel.nodeModels.tableRow = es.TableRowModel; |
| 31 | + |
| 32 | +es.DocumentModel.nodeRules.listItem = { |
| 33 | + 'parents': ['table'], |
| 34 | + 'children': ['tableCell'] |
| 35 | +}; |
| 36 | + |
| 37 | +/* Inheritance */ |
| 38 | + |
| 39 | +es.extendClass( es.TableRowModel, es.DocumentModelNode ); |
Index: trunk/extensions/VisualEditor/modules/models/es.ParagraphModel.js |
— | — | @@ -0,0 +1,38 @@ |
| 2 | +/** |
| 3 | + * Creates an es.ParagraphModel object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentModelNode} |
| 8 | + * @param {Object} element Document data element of this node |
| 9 | + * @param {Integer} length Length of document data element |
| 10 | + */ |
| 11 | +es.ParagraphModel = function( element, length ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentModelNode.call( this, element, length ); |
| 14 | +}; |
| 15 | + |
| 16 | +/* Methods */ |
| 17 | + |
| 18 | +/** |
| 19 | + * Creates a paragraph view for this model. |
| 20 | + * |
| 21 | + * @method |
| 22 | + * @returns {es.ParagraphView} |
| 23 | + */ |
| 24 | +es.ParagraphModel.prototype.createView = function() { |
| 25 | + return new es.ParagraphView( this ); |
| 26 | +}; |
| 27 | + |
| 28 | +/* Registration */ |
| 29 | + |
| 30 | +es.DocumentModel.nodeModels.paragraph = es.ParagraphModel; |
| 31 | + |
| 32 | +es.DocumentModel.nodeRules.paragraph = { |
| 33 | + 'parents': null, |
| 34 | + 'children': [] |
| 35 | +}; |
| 36 | + |
| 37 | +/* Inheritance */ |
| 38 | + |
| 39 | +es.extendClass( es.ParagraphModel, es.DocumentModelNode ); |
Index: trunk/extensions/VisualEditor/modules/models/es.TableCellModel.js |
— | — | @@ -0,0 +1,38 @@ |
| 2 | +/** |
| 3 | + * Creates an es.TableCellModel object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentModelNode} |
| 8 | + * @param {Object} element Document data element of this node |
| 9 | + * @param {es.DocumentModelNode[]} contents List of child nodes to initially add |
| 10 | + */ |
| 11 | +es.TableCellModel = function( element, contents ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentModelNode.call( this, element, contents ); |
| 14 | +}; |
| 15 | + |
| 16 | +/* Methods */ |
| 17 | + |
| 18 | +/** |
| 19 | + * Creates a table cell view for this model. |
| 20 | + * |
| 21 | + * @method |
| 22 | + * @returns {es.TableCellView} |
| 23 | + */ |
| 24 | +es.TableCellModel.prototype.createView = function() { |
| 25 | + return new es.TableCellView( this ); |
| 26 | +}; |
| 27 | + |
| 28 | +/* Registration */ |
| 29 | + |
| 30 | +es.DocumentModel.nodeModels.tableCell = es.TableCellModel; |
| 31 | + |
| 32 | +es.DocumentModel.nodeRules.listItem = { |
| 33 | + 'parents': ['tableRow'], |
| 34 | + 'children': null |
| 35 | +}; |
| 36 | + |
| 37 | +/* Inheritance */ |
| 38 | + |
| 39 | +es.extendClass( es.TableCellModel, es.DocumentModelNode ); |
Index: trunk/extensions/VisualEditor/modules/models/es.TableModel.js |
— | — | @@ -0,0 +1,38 @@ |
| 2 | +/** |
| 3 | + * Creates an es.TableModel object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentModelNode} |
| 8 | + * @param {Object} element Document data element of this node |
| 9 | + * @param {es.DocumentModelNode[]} contents List of child nodes to initially add |
| 10 | + */ |
| 11 | +es.TableModel = function( element, contents ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentModelNode.call( this, element, contents ); |
| 14 | +}; |
| 15 | + |
| 16 | +/* Methods */ |
| 17 | + |
| 18 | +/** |
| 19 | + * Creates a table view for this model. |
| 20 | + * |
| 21 | + * @method |
| 22 | + * @returns {es.TableView} |
| 23 | + */ |
| 24 | +es.TableModel.prototype.createView = function() { |
| 25 | + return new es.TableView( this ); |
| 26 | +}; |
| 27 | + |
| 28 | +/* Registration */ |
| 29 | + |
| 30 | +es.DocumentModel.nodeModels.table = es.TableModel; |
| 31 | + |
| 32 | +es.DocumentModel.nodeRules.table = { |
| 33 | + 'parents': null, |
| 34 | + 'children': ['tableRow'] |
| 35 | +}; |
| 36 | + |
| 37 | +/* Inheritance */ |
| 38 | + |
| 39 | +es.extendClass( es.TableModel, es.DocumentModelNode ); |
Index: trunk/extensions/VisualEditor/modules/models/es.SurfaceModel.js |
— | — | @@ -0,0 +1,16 @@ |
| 2 | +/** |
| 3 | + * Creates an es.SurfaceModel object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @param {es.DocumentModel} doc Document model to create surface for |
| 8 | + */ |
| 9 | +es.SurfaceModel = function( doc ) { |
| 10 | + this.doc = doc; |
| 11 | +}; |
| 12 | + |
| 13 | +/* Methods */ |
| 14 | + |
| 15 | +es.SurfaceModel.prototype.getDocument = function() { |
| 16 | + return this.doc; |
| 17 | +}; |
Index: trunk/extensions/VisualEditor/modules/models/es.ListItemModel.js |
— | — | @@ -0,0 +1,38 @@ |
| 2 | +/** |
| 3 | + * Creates an es.ListItemModel object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentModelNode} |
| 8 | + * @param {Object} element Document data element of this node |
| 9 | + * @param {Integer} length Length of document data element |
| 10 | + */ |
| 11 | +es.ListItemModel = function( element, length ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentModelNode.call( this, element, length ); |
| 14 | +}; |
| 15 | + |
| 16 | +/* Methods */ |
| 17 | + |
| 18 | +/** |
| 19 | + * Creates a list item view for this model. |
| 20 | + * |
| 21 | + * @method |
| 22 | + * @returns {es.ListItemView} |
| 23 | + */ |
| 24 | +es.ListItemModel.prototype.createView = function() { |
| 25 | + return new es.ListItemView( this ); |
| 26 | +}; |
| 27 | + |
| 28 | +/* Registration */ |
| 29 | + |
| 30 | +es.DocumentModel.nodeModels.listItem = es.ListItemModel; |
| 31 | + |
| 32 | +es.DocumentModel.nodeRules.listItem = { |
| 33 | + 'parents': ['list'], |
| 34 | + 'children': [] |
| 35 | +}; |
| 36 | + |
| 37 | +/* Inheritance */ |
| 38 | + |
| 39 | +es.extendClass( es.ListItemModel, es.DocumentModelNode ); |
Index: trunk/extensions/VisualEditor/modules/models/es.ListModel.js |
— | — | @@ -0,0 +1,38 @@ |
| 2 | +/** |
| 3 | + * Creates an es.ListModel object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentModelNode} |
| 8 | + * @param {Object} element Document data element of this node |
| 9 | + * @param {es.DocumentModelNode[]} contents List of child nodes to initially add |
| 10 | + */ |
| 11 | +es.ListModel = function( element, contents ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentModelNode.call( this, element, contents ); |
| 14 | +}; |
| 15 | + |
| 16 | +/* Methods */ |
| 17 | + |
| 18 | +/** |
| 19 | + * Creates a list view for this model. |
| 20 | + * |
| 21 | + * @method |
| 22 | + * @returns {es.ListView} |
| 23 | + */ |
| 24 | +es.ListModel.prototype.createView = function() { |
| 25 | + return new es.ListView( this ); |
| 26 | +}; |
| 27 | + |
| 28 | +/* Registration */ |
| 29 | + |
| 30 | +es.DocumentModel.nodeModels.list = es.ListModel; |
| 31 | + |
| 32 | +es.DocumentModel.nodeRules.list = { |
| 33 | + 'parents': null, |
| 34 | + 'children': ['listItem'] |
| 35 | +}; |
| 36 | + |
| 37 | +/* Inheritance */ |
| 38 | + |
| 39 | +es.extendClass( es.ListModel, es.DocumentModelNode ); |
Index: trunk/extensions/VisualEditor/modules/es.Position.js |
— | — | @@ -0,0 +1,218 @@ |
| 2 | +/** |
| 3 | + * Pixel position. |
| 4 | + * |
| 5 | + * This can also support an optional bottom field, to represent a vertical line, such as a cursor. |
| 6 | + * |
| 7 | + * @class |
| 8 | + * @constructor |
| 9 | + * @param left {Integer} Horizontal position |
| 10 | + * @param top {Integer} Vertical top position |
| 11 | + * @param bottom {Integer} Vertical bottom position of bottom (optional, default: top) |
| 12 | + * @property left {Integer} Horizontal position |
| 13 | + * @property top {Integer} Vertical top position |
| 14 | + * @property bottom {Integer} Vertical bottom position of bottom |
| 15 | + */ |
| 16 | +es.Position = function( left, top, bottom ) { |
| 17 | + this.left = left || 0; |
| 18 | + this.top = top || 0; |
| 19 | + this.bottom = bottom || this.top; |
| 20 | +}; |
| 21 | + |
| 22 | +/* Static Methods */ |
| 23 | + |
| 24 | +/** |
| 25 | + * Creates position object from the page position of an element. |
| 26 | + * |
| 27 | + * @static |
| 28 | + * @method |
| 29 | + * @param $element {jQuery} Element to get offset from |
| 30 | + * @returns {es.Position} Position with element data applied |
| 31 | + */ |
| 32 | +es.Position.newFromElementPagePosition = function( $element ) { |
| 33 | + var offset = $element.offset(); |
| 34 | + return new es.Position( offset.left, offset.top ); |
| 35 | +}; |
| 36 | + |
| 37 | +/** |
| 38 | + * Creates position object from the layer position of an element. |
| 39 | + * |
| 40 | + * @static |
| 41 | + * @method |
| 42 | + * @param $element {jQuery} Element to get position from |
| 43 | + * @returns {es.Position} Position with element data applied |
| 44 | + */ |
| 45 | +es.Position.newFromElementLayerPosition = function( $element ) { |
| 46 | + var position = $element.position(); |
| 47 | + return new es.Position( position.left, position.top ); |
| 48 | +}; |
| 49 | + |
| 50 | +/** |
| 51 | + * Creates position object from the screen position data in an Event object. |
| 52 | + * |
| 53 | + * @static |
| 54 | + * @method |
| 55 | + * @param event {Event} Event to get position data from |
| 56 | + * @returns {es.Position} Position with event data applied |
| 57 | + */ |
| 58 | +es.Position.newFromEventScreenPosition = function( event ) { |
| 59 | + return new es.Position( event.screenX, event.screenY ); |
| 60 | +}; |
| 61 | + |
| 62 | +/** |
| 63 | + * Creates position object from the page position data in an Event object. |
| 64 | + * |
| 65 | + * @static |
| 66 | + * @method |
| 67 | + * @param event {Event} Event to get position data from |
| 68 | + * @returns {es.Position} Position with event data applied |
| 69 | + */ |
| 70 | +es.Position.newFromEventPagePosition = function( event ) { |
| 71 | + return new es.Position( event.pageX, event.pageY ); |
| 72 | +}; |
| 73 | + |
| 74 | +/** |
| 75 | + * Creates position object from the layer position data in an Event object. |
| 76 | + * |
| 77 | + * @static |
| 78 | + * @method |
| 79 | + * @param event {Event} Event to get position data from |
| 80 | + * @returns {es.Position} Position with event data applied |
| 81 | + */ |
| 82 | +es.Position.newFromEventLayerPosition = function( event ) { |
| 83 | + return new es.Position( event.layerX, event.layerY ); |
| 84 | +}; |
| 85 | + |
| 86 | +/* Methods */ |
| 87 | + |
| 88 | +/** |
| 89 | + * Adds the values of a given position to this one. |
| 90 | + * |
| 91 | + * @method |
| 92 | + * @param position {es.Position} Position to add values from |
| 93 | + */ |
| 94 | +es.Position.prototype.add = function( position ) { |
| 95 | + this.top += position.top; |
| 96 | + this.bottom += position.bottom; |
| 97 | + this.left += position.left; |
| 98 | +}; |
| 99 | + |
| 100 | +/** |
| 101 | + * Subtracts the values of a given position to this one. |
| 102 | + * |
| 103 | + * @method |
| 104 | + * @param position {es.Position} Position to subtract values from |
| 105 | + */ |
| 106 | +es.Position.prototype.subtract = function( position ) { |
| 107 | + this.top -= position.top; |
| 108 | + this.bottom -= position.bottom; |
| 109 | + this.left -= position.left; |
| 110 | +}; |
| 111 | + |
| 112 | +/** |
| 113 | + * Checks if this position is the same as another one. |
| 114 | + * |
| 115 | + * @method |
| 116 | + * @param position {es.Position} Position to compare with |
| 117 | + * @returns {Boolean} If positions have the same left and top values |
| 118 | + */ |
| 119 | +es.Position.prototype.at = function( position ) { |
| 120 | + return this.left === position.left && this.top === position.top; |
| 121 | +}; |
| 122 | + |
| 123 | +/** |
| 124 | + * Checks if this position perpendicular with another one, sharing either a top or left value. |
| 125 | + * |
| 126 | + * @method |
| 127 | + * @param position {es.Position} Position to compare with |
| 128 | + * @returns {Boolean} If positions share a top or a left value |
| 129 | + */ |
| 130 | +es.Position.prototype.perpendicularWith = function( position ) { |
| 131 | + return this.left === position.left || this.top === position.top; |
| 132 | +}; |
| 133 | + |
| 134 | +/** |
| 135 | + * Checks if this position is level with another one, having the same top value. |
| 136 | + * |
| 137 | + * @method |
| 138 | + * @param position {es.Position} Position to compare with |
| 139 | + * @returns {Boolean} If positions have the same top value |
| 140 | + */ |
| 141 | +es.Position.prototype.levelWith = function( position ) { |
| 142 | + return this.top === position.top; |
| 143 | +}; |
| 144 | + |
| 145 | +/** |
| 146 | + * Checks if this position is plumb with another one, having the same left value. |
| 147 | + * |
| 148 | + * @method |
| 149 | + * @param position {es.Position} Position to compare with |
| 150 | + * @returns {Boolean} If positions have the same left value |
| 151 | + */ |
| 152 | +es.Position.prototype.plumbWith = function( position ) { |
| 153 | + return this.left === position.left; |
| 154 | +}; |
| 155 | + |
| 156 | +/** |
| 157 | + * Checks if this position is nearby another one. |
| 158 | + * |
| 159 | + * Distance is measured radially. |
| 160 | + * |
| 161 | + * @method |
| 162 | + * @param position {es.Position} Position to compare with |
| 163 | + * @param radius {Integer} Pixel distance from this position to consider "near-by" |
| 164 | + * @returns {Boolean} If positions are near-by each other |
| 165 | + */ |
| 166 | +es.Position.prototype.near = function( position, radius ) { |
| 167 | + return Math.sqrt( |
| 168 | + Math.pow( this.left - position.left, 2 ), |
| 169 | + Math.pow( this.top - position.top ) |
| 170 | + ) <= radius; |
| 171 | +}; |
| 172 | + |
| 173 | +/** |
| 174 | + * Checks if this position is above another one. |
| 175 | + * |
| 176 | + * This method utilizes the bottom property. |
| 177 | + * |
| 178 | + * @method |
| 179 | + * @param position {es.Position} Position to compare with |
| 180 | + * @returns {Boolean} If this position is above the other |
| 181 | + */ |
| 182 | +es.Position.prototype.above = function( position ) { |
| 183 | + return this.bottom < position.top; |
| 184 | +}; |
| 185 | + |
| 186 | +/** |
| 187 | + * Checks if this position is below another one. |
| 188 | + * |
| 189 | + * This method utilizes the bottom property. |
| 190 | + * |
| 191 | + * @method |
| 192 | + * @param position {es.Position} Position to compare with |
| 193 | + * @returns {Boolean} If this position is below the other |
| 194 | + */ |
| 195 | +es.Position.prototype.below = function( position ) { |
| 196 | + return this.top > position.bottom; |
| 197 | +}; |
| 198 | + |
| 199 | +/** |
| 200 | + * Checks if this position is to the left of another one. |
| 201 | + * |
| 202 | + * @method |
| 203 | + * @param position {es.Position} Position to compare with |
| 204 | + * @returns {Boolean} If this position is the left the other |
| 205 | + */ |
| 206 | +es.Position.prototype.leftOf = function( left ) { |
| 207 | + return this.left < left; |
| 208 | +}; |
| 209 | + |
| 210 | +/** |
| 211 | + * Checks if this position is to the right of another one. |
| 212 | + * |
| 213 | + * @method |
| 214 | + * @param position {es.Position} Position to compare with |
| 215 | + * @returns {Boolean} If this position is the right the other |
| 216 | + */ |
| 217 | +es.Position.prototype.rightOf = function( left ) { |
| 218 | + return this.left > left; |
| 219 | +}; |
Index: trunk/extensions/VisualEditor/modules/es.Surface.css |
— | — | @@ -0,0 +1,222 @@ |
| 2 | +.es-surfaceView { |
| 3 | + overflow: hidden; |
| 4 | +} |
| 5 | + |
| 6 | +.es-surfaceView-input { |
| 7 | + position: absolute; |
| 8 | + z-index: -1; |
| 9 | + opacity: 0; |
| 10 | + color: white; |
| 11 | + background-color: white; |
| 12 | + border: none; |
| 13 | + padding: 0; |
| 14 | + margin: 0; |
| 15 | + width: 1px; |
| 16 | +} |
| 17 | + |
| 18 | +.es-surfaceView-input:focus { |
| 19 | + outline: none; |
| 20 | +} |
| 21 | + |
| 22 | +.es-surfaceView-cursor { |
| 23 | + position: absolute; |
| 24 | + background-color: black; |
| 25 | + width: 1px; |
| 26 | + height: 1.5em; |
| 27 | + min-height: 1.5em; |
| 28 | + display: none; |
| 29 | +} |
| 30 | + |
| 31 | +.es-documentView { |
| 32 | + cursor: text; |
| 33 | + margin-top: 1em; |
| 34 | + overflow: hidden; |
| 35 | + -webkit-user-select: none; |
| 36 | +} |
| 37 | + |
| 38 | +.es-contentView { |
| 39 | + position: relative; |
| 40 | +} |
| 41 | + |
| 42 | +.es-headingView, |
| 43 | +.es-tableView, |
| 44 | +.es-listView, |
| 45 | +.es-paragraphView { |
| 46 | + margin: 1em; |
| 47 | + margin-top: 0; |
| 48 | + position: relative; |
| 49 | + min-height: 1.5em; |
| 50 | + font-size: 1em; |
| 51 | +} |
| 52 | + |
| 53 | +.es-headingView-level1, |
| 54 | +.es-headingView-level2 { |
| 55 | + border-bottom: 1px solid #AAA; |
| 56 | +} |
| 57 | + |
| 58 | +.es-headingView-level1 > * { |
| 59 | + font-size:188%; |
| 60 | + font-weight: normal; |
| 61 | +} |
| 62 | + |
| 63 | +.es-headingView-level2 > * { |
| 64 | + font-size:150%; |
| 65 | + font-weight: normal; |
| 66 | +} |
| 67 | + |
| 68 | +.es-headingView-level3 > * { |
| 69 | + font-size:132%; |
| 70 | + font-weight: bold; |
| 71 | +} |
| 72 | + |
| 73 | +.es-headingView-level4 > * { |
| 74 | + font-size:116%; |
| 75 | + font-weight: bold; |
| 76 | +} |
| 77 | + |
| 78 | +.es-headingView-level5 > * { |
| 79 | + font-size:100%; |
| 80 | + font-weight: bold; |
| 81 | +} |
| 82 | + |
| 83 | +.es-headingView-level6 > * { |
| 84 | + font-size:80%; |
| 85 | + font-weight: bold; |
| 86 | +} |
| 87 | + |
| 88 | +.es-listItemView { |
| 89 | + position: relative; |
| 90 | + padding: 0 0 0 1em; |
| 91 | + background-position: left 0.6em; |
| 92 | + background-repeat: no-repeat; |
| 93 | +} |
| 94 | + |
| 95 | +.es-listItemView-bullet { |
| 96 | + background-image: url(images/bullet.png); |
| 97 | +} |
| 98 | + |
| 99 | +.es-listItemView-number { |
| 100 | + /* */ |
| 101 | +} |
| 102 | + |
| 103 | +.es-listItemView-bullet .es-listItemView-icon { |
| 104 | + display: none; |
| 105 | +} |
| 106 | + |
| 107 | +.es-listItemView-number .es-listItemView-icon { |
| 108 | + position: absolute; |
| 109 | + right: 100%; |
| 110 | + margin-right: -0.5em; |
| 111 | + height: 1.5em; |
| 112 | + line-height: 1.5em; |
| 113 | +} |
| 114 | + |
| 115 | +.es-listItemView-level0 { |
| 116 | + margin-left: 0.5em; |
| 117 | +} |
| 118 | + |
| 119 | +.es-listItemView-level1 { |
| 120 | + margin-left: 2.5em; |
| 121 | +} |
| 122 | + |
| 123 | +.es-listItemView-level2 { |
| 124 | + margin-left: 4.5em; |
| 125 | +} |
| 126 | + |
| 127 | +.es-listItemView-level3 { |
| 128 | + margin-left: 6.5em; |
| 129 | +} |
| 130 | + |
| 131 | +.es-listItemView-level4 { |
| 132 | + margin-left: 8.5em; |
| 133 | +} |
| 134 | + |
| 135 | +.es-listItemView-level5 { |
| 136 | + margin-left: 10.5em; |
| 137 | +} |
| 138 | + |
| 139 | +.es-listItemView-level6 { |
| 140 | + margin-left: 12.5em; |
| 141 | +} |
| 142 | + |
| 143 | +.es-contentView-line, |
| 144 | +.es-contentView-ruler { |
| 145 | + line-height: 1.5em; |
| 146 | + cursor: text; |
| 147 | + white-space: nowrap; |
| 148 | + color: #000000; |
| 149 | +} |
| 150 | + |
| 151 | +.es-contentView-ruler { |
| 152 | + position: absolute; |
| 153 | + top: 0; |
| 154 | + left: 0; |
| 155 | + display: inline-block; |
| 156 | + z-index: -1000; |
| 157 | +} |
| 158 | + |
| 159 | +.es-contentView-line.empty { |
| 160 | + display: block; |
| 161 | + width: 0px; |
| 162 | +} |
| 163 | + |
| 164 | +.es-contentView-whitespace { |
| 165 | + color: #ffffff; |
| 166 | +} |
| 167 | + |
| 168 | +.es-contentView-range { |
| 169 | + display: none; |
| 170 | + position: absolute; |
| 171 | + background-color: #bbffcc; |
| 172 | + cursor: text; |
| 173 | + z-index: -1; |
| 174 | +} |
| 175 | + |
| 176 | +.es-contentView-format-object { |
| 177 | + background-color: rgba(0,0,0,0.05); |
| 178 | + border-radius: 0.25em; |
| 179 | + margin: 1px 0 1px 1px; |
| 180 | + padding: 0.25em 0; |
| 181 | + cursor: default; |
| 182 | +} |
| 183 | + |
| 184 | +.es-contentView-format-object * { |
| 185 | + cursor: default !important; |
| 186 | +} |
| 187 | + |
| 188 | +.es-contentView-format-object a:link, |
| 189 | +.es-contentView-format-object a:visited, |
| 190 | +.es-contentView-format-object a:active { |
| 191 | + color: blue; |
| 192 | +} |
| 193 | + |
| 194 | +.es-contentView-format-italic { |
| 195 | + font-style: italic; |
| 196 | +} |
| 197 | + |
| 198 | +.es-contentView-format-bold { |
| 199 | + font-weight: bold; |
| 200 | +} |
| 201 | + |
| 202 | +.es-contentView-format-link { |
| 203 | + color: blue; |
| 204 | + text-decoration: underline; |
| 205 | +} |
| 206 | + |
| 207 | +.es-contentView-format-big { |
| 208 | + font-size: 1.2em; |
| 209 | +} |
| 210 | + |
| 211 | +.es-contentView-format-small, |
| 212 | +.es-contentView-format-sub, |
| 213 | +.es-contentView-format-super { |
| 214 | + font-size: .8em; |
| 215 | +} |
| 216 | + |
| 217 | +.es-contentView-format-sub { |
| 218 | + vertical-align: sub; |
| 219 | +} |
| 220 | + |
| 221 | +.es-contentView-format-super { |
| 222 | + vertical-align: super; |
| 223 | +} |
Index: trunk/extensions/VisualEditor/modules/images/bullet.png |
Cannot display: file marked as a binary type. |
svn:mime-type = application/octet-stream |
Property changes on: trunk/extensions/VisualEditor/modules/images/bullet.png |
___________________________________________________________________ |
Added: svn:mime-type |
1 | 224 | + application/octet-stream |
Index: trunk/extensions/VisualEditor/modules/es.Transaction.js |
— | — | @@ -0,0 +1,64 @@ |
| 2 | +/** |
| 3 | + * Creates an es.Transaction object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @param {Object[]} operations List of operations |
| 8 | + */ |
| 9 | +es.Transaction = function( operations ) { |
| 10 | + this.operations = es.isArray( operations ) ? operations : []; |
| 11 | +}; |
| 12 | + |
| 13 | +/* Methods */ |
| 14 | + |
| 15 | +es.Transaction.prototype.getOperations = function() { |
| 16 | + return this.operations; |
| 17 | +}; |
| 18 | + |
| 19 | +es.Transaction.prototype.pushRetain = function( length ) { |
| 20 | + this.operations.push( { |
| 21 | + 'type': 'retain', |
| 22 | + 'length': length |
| 23 | + } ); |
| 24 | +}; |
| 25 | + |
| 26 | +es.Transaction.prototype.pushInsert = function( content ) { |
| 27 | + this.operations.push( { |
| 28 | + 'type': 'insert', |
| 29 | + 'data': content |
| 30 | + } ); |
| 31 | +}; |
| 32 | + |
| 33 | +es.Transaction.prototype.pushRemove = function( data ) { |
| 34 | + this.operations.push( { |
| 35 | + 'type': 'remove', |
| 36 | + 'data': data |
| 37 | + } ); |
| 38 | +}; |
| 39 | + |
| 40 | +es.Transaction.prototype.pushChangeElementAttribute = function( method, key, value ) { |
| 41 | + this.operations.push( { |
| 42 | + 'type': 'attribute', |
| 43 | + 'method': method, |
| 44 | + 'key': key, |
| 45 | + 'value': value |
| 46 | + } ); |
| 47 | +}; |
| 48 | + |
| 49 | +es.Transaction.prototype.pushStartAnnotating = function( method, annotation ) { |
| 50 | + this.operations.push( { |
| 51 | + 'type': 'annotate', |
| 52 | + 'method': method, |
| 53 | + 'bias': 'start', |
| 54 | + 'annotation': annotation |
| 55 | + } ); |
| 56 | +}; |
| 57 | + |
| 58 | +es.Transaction.prototype.pushStopAnnotating = function( method, annotation ) { |
| 59 | + this.operations.push( { |
| 60 | + 'type': 'annotate', |
| 61 | + 'method': method, |
| 62 | + 'bias': 'stop', |
| 63 | + 'annotation': annotation |
| 64 | + } ); |
| 65 | +}; |
Index: trunk/extensions/VisualEditor/modules/bases/es.DocumentViewNode.js |
— | — | @@ -0,0 +1,204 @@ |
| 2 | +/** |
| 3 | + * Creates an es.DocumentViewNode object. |
| 4 | + * |
| 5 | + * es.DocumentViewNode extends native JavaScript Array objects, without changing Array.prototype by |
| 6 | + * dynamically extending an array literal with the methods of es.DocumentViewNode. |
| 7 | + * |
| 8 | + * View nodes follow the operations performed on model nodes and keep elements in the DOM in sync. |
| 9 | + * |
| 10 | + * Child objects must extend es.DocumentViewNode. |
| 11 | + * |
| 12 | + * @class |
| 13 | + * @constructor |
| 14 | + * @extends {es.DocumentNode} |
| 15 | + * @extends {es.EventEmitter} |
| 16 | + * @param model {es.ModelNode} Model to observe |
| 17 | + * @param {jQuery} [$element=New DIV element] Element to use as a container |
| 18 | + * @property {es.ModelItem} model Model being observed |
| 19 | + * @property {jQuery} $ Container element |
| 20 | + */ |
| 21 | +es.DocumentViewNode = function( model, $element ) { |
| 22 | + // Inheritance |
| 23 | + es.DocumentNode.call( this ); |
| 24 | + es.EventEmitter.call( this ); |
| 25 | + |
| 26 | + // Properties |
| 27 | + this.model = model; |
| 28 | + this.children = []; |
| 29 | + this.$ = $element || $( '<div/>' ); |
| 30 | + |
| 31 | + // Reusable function for passing update events upstream |
| 32 | + var _this = this; |
| 33 | + this.emitUpdate = function() { |
| 34 | + _this.emit( 'update' ); |
| 35 | + }; |
| 36 | + |
| 37 | + if ( model ) { |
| 38 | + // Append existing model children |
| 39 | + var childModels = model.getChildren(); |
| 40 | + for ( var i = 0; i < childModels.length; i++ ) { |
| 41 | + this.onAfterPush( childModels[i] ); |
| 42 | + } |
| 43 | + |
| 44 | + // Observe and mimic changes on model |
| 45 | + this.addListenerMethods( this, { |
| 46 | + 'afterPush': 'onAfterPush', |
| 47 | + 'afterUnshift': 'onAfterUnshift', |
| 48 | + 'afterPop': 'onAfterPop', |
| 49 | + 'afterShift': 'onAfterShift', |
| 50 | + 'afterSplice': 'onAfterSplice', |
| 51 | + 'afterSort': 'onAfterSort', |
| 52 | + 'afterReverse': 'onAfterReverse' |
| 53 | + } ); |
| 54 | + } |
| 55 | +}; |
| 56 | + |
| 57 | +es.DocumentViewNode.prototype.onAfterPush = function( childModel ) { |
| 58 | + var childView = childModel.createView(); |
| 59 | + this.emit( 'beforePush', childView ); |
| 60 | + childView.attach( this ); |
| 61 | + childView.on( 'update', this.emitUpdate ); |
| 62 | + // Update children |
| 63 | + this.children.push( childView ); |
| 64 | + // Update DOM |
| 65 | + this.$.append( childView.$ ); |
| 66 | + this.emit( 'afterPush', childView ); |
| 67 | + this.emit( 'update' ); |
| 68 | +}; |
| 69 | + |
| 70 | +es.DocumentViewNode.prototype.onAfterUnshift = function( childModel ) { |
| 71 | + var childView = childModel.createView(); |
| 72 | + this.emit( 'beforeUnshift', childView ); |
| 73 | + childView.attach( this ); |
| 74 | + childView.on( 'update', this.emitUpdate ); |
| 75 | + // Update children |
| 76 | + this.children.unshift( childView ); |
| 77 | + // Update DOM |
| 78 | + this.$.prepend( childView.$ ); |
| 79 | + this.emit( 'afterUnshift', childView ); |
| 80 | + this.emit( 'update' ); |
| 81 | +}; |
| 82 | + |
| 83 | +es.DocumentViewNode.prototype.onAfterPop = function() { |
| 84 | + this.emit( 'beforePop' ); |
| 85 | + // Update children |
| 86 | + var childView = this.children.pop(); |
| 87 | + childView.detach(); |
| 88 | + childView.removeEventListener( 'update', this.emitUpdate ); |
| 89 | + // Update DOM |
| 90 | + childView.$.detach(); |
| 91 | + this.emit( 'afterPop' ); |
| 92 | + this.emit( 'update' ); |
| 93 | +}; |
| 94 | + |
| 95 | +es.DocumentViewNode.prototype.onAfterShift = function() { |
| 96 | + this.emit( 'beforeShift' ); |
| 97 | + // Update children |
| 98 | + var childView = this.children.shift(); |
| 99 | + childView.detach(); |
| 100 | + childView.removeEventListener( 'update', this.emitUpdate ); |
| 101 | + // Update DOM |
| 102 | + childView.$.detach(); |
| 103 | + this.emit( 'afterShift' ); |
| 104 | + this.emit( 'update' ); |
| 105 | +}; |
| 106 | + |
| 107 | +es.DocumentViewNode.prototype.onAfterSplice = function( index, howmany ) { |
| 108 | + var args = Array.prototype.slice( arguments, 0 ); |
| 109 | + this.emit.apply( ['beforeSplice'].concat( args ) ); |
| 110 | + // Update children |
| 111 | + this.splice.apply( this, args ); |
| 112 | + // Update DOM |
| 113 | + this.$.children() |
| 114 | + // Removals |
| 115 | + .slice( index, index + howmany ) |
| 116 | + .detach() |
| 117 | + .end() |
| 118 | + // Insertions |
| 119 | + .get( index ) |
| 120 | + .after( $.map( args.slice( 2 ), function( childView ) { |
| 121 | + return childView.$; |
| 122 | + } ) ); |
| 123 | + this.emit.apply( ['afterSplice'].concat( args ) ); |
| 124 | + this.emit( 'update' ); |
| 125 | +}; |
| 126 | + |
| 127 | +es.DocumentViewNode.prototype.onAfterSort = function() { |
| 128 | + this.emit( 'beforeSort' ); |
| 129 | + var childModels = this.model.getChildren(); |
| 130 | + for ( var i = 0; i < childModels.length; i++ ) { |
| 131 | + for ( var j = 0; j < this.children.length; j++ ) { |
| 132 | + if ( this.children[j].getModel() === childModels[i] ) { |
| 133 | + var childView = this.children[j]; |
| 134 | + // Update children |
| 135 | + this.children.splice( j, 1 ); |
| 136 | + this.children.push( childView ); |
| 137 | + // Update DOM |
| 138 | + this.$.append( childView.$ ); |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + this.emit( 'afterSort' ); |
| 143 | + this.emit( 'update' ); |
| 144 | +}; |
| 145 | + |
| 146 | +es.DocumentViewNode.prototype.onAfterReverse = function() { |
| 147 | + this.emit( 'beforeReverse' ); |
| 148 | + // Update children |
| 149 | + this.reverse(); |
| 150 | + // Update DOM |
| 151 | + this.$.children().each( function() { |
| 152 | + $(this).prependTo( $(this).parent() ); |
| 153 | + } ); |
| 154 | + this.emit( 'afterReverse' ); |
| 155 | + this.emit( 'update' ); |
| 156 | +}; |
| 157 | + |
| 158 | +/** |
| 159 | + * Gets a reference to the model this node observes. |
| 160 | + * |
| 161 | + * @method |
| 162 | + * @returns {es.ModelNode} Reference to the model this node observes |
| 163 | + */ |
| 164 | +es.DocumentViewNode.prototype.getModel = function() { |
| 165 | + return this.model; |
| 166 | +}; |
| 167 | + |
| 168 | +/** |
| 169 | + * Gets a reference to this node's parent. |
| 170 | + * |
| 171 | + * @method |
| 172 | + * @returns {es.DocumentViewNode} Reference to this node's parent |
| 173 | + */ |
| 174 | +es.DocumentViewNode.prototype.getParent = function() { |
| 175 | + return this.parent; |
| 176 | +}; |
| 177 | + |
| 178 | +/** |
| 179 | + * Attaches node as a child to another node. |
| 180 | + * |
| 181 | + * @method |
| 182 | + * @param {es.DocumentViewNode} parent Node to attach to |
| 183 | + * @emits attach (parent) |
| 184 | + */ |
| 185 | +es.DocumentViewNode.prototype.attach = function( parent ) { |
| 186 | + this.parent = parent; |
| 187 | + this.emit( 'attach', parent ); |
| 188 | +}; |
| 189 | + |
| 190 | +/** |
| 191 | + * Detaches node from it's parent. |
| 192 | + * |
| 193 | + * @method |
| 194 | + * @emits detach (parent) |
| 195 | + */ |
| 196 | +es.DocumentViewNode.prototype.detach = function() { |
| 197 | + var parent = this.parent; |
| 198 | + this.parent = null; |
| 199 | + this.emit( 'detach', parent ); |
| 200 | +}; |
| 201 | + |
| 202 | +/* Inheritance */ |
| 203 | + |
| 204 | +es.extendClass( es.DocumentViewNode, es.DocumentNode ); |
| 205 | +es.extendClass( es.DocumentViewNode, es.EventEmitter ); |
Index: trunk/extensions/VisualEditor/modules/bases/es.DocumentNode.js |
— | — | @@ -0,0 +1,236 @@ |
| 2 | +/** |
| 3 | + * Creates an es.DocumentNode object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @abstract |
| 7 | + * @constructor |
| 8 | + * @param {es.DocumentNode[]} nodes List of document nodes to initially add |
| 9 | + */ |
| 10 | +es.DocumentNode = function( nodes ) { |
| 11 | + this.children = es.isArray( nodes ) ? nodes : []; |
| 12 | +}; |
| 13 | + |
| 14 | +/* Abstract Methods */ |
| 15 | + |
| 16 | +/** |
| 17 | + * Gets the element length. |
| 18 | + * |
| 19 | + * @abstract |
| 20 | + * @method |
| 21 | + * @returns {Integer} Length of element and it's content |
| 22 | + */ |
| 23 | +es.DocumentNode.prototype.getElementLength = function() { |
| 24 | + throw 'DocumentNode.getElementLength not implemented in this subclass: ' + this.constructor; |
| 25 | +}; |
| 26 | + |
| 27 | +/* Methods */ |
| 28 | + |
| 29 | +/** |
| 30 | + * Gets a list of child nodes. |
| 31 | + * |
| 32 | + * @abstract |
| 33 | + * @method |
| 34 | + * @returns {es.DocumentNode[]} List of document nodes |
| 35 | + */ |
| 36 | +es.DocumentNode.prototype.getChildren = function() { |
| 37 | + return this.children; |
| 38 | +}; |
| 39 | + |
| 40 | +/** |
| 41 | + * Gets the range within this node that a given child node covers. |
| 42 | + * |
| 43 | + * @method |
| 44 | + * @param {es.ModelNode} node Node to get range for |
| 45 | + * @param {Boolean} [shallow] Do not iterate into child nodes of child nodes |
| 46 | + * @returns {es.Range|null} Range of node or null if node was not found |
| 47 | + */ |
| 48 | +es.DocumentNode.prototype.getRangeFromNode = function( node, shallow ) { |
| 49 | + if ( this.children.length ) { |
| 50 | + for ( var i = 0, length = this.children.length, left = 0; i < length; i++ ) { |
| 51 | + if ( this.children[i] === node ) { |
| 52 | + return new es.Range( left, left + this.children[i].getElementLength() ); |
| 53 | + } |
| 54 | + if ( !shallow && this.children[i].getChildren().length ) { |
| 55 | + var range = this.children[i].getRangeFromNode( node ); |
| 56 | + if ( range !== null ) { |
| 57 | + // Include opening of parent |
| 58 | + left++; |
| 59 | + return es.Range.newFromTranslatedRange( range, left ); |
| 60 | + } |
| 61 | + } |
| 62 | + left += this.children[i].getElementLength(); |
| 63 | + } |
| 64 | + } |
| 65 | + return null; |
| 66 | +}; |
| 67 | + |
| 68 | +/** |
| 69 | + * Gets the content offset of a node. |
| 70 | + * |
| 71 | + * This method is pretty expensive. If you need to get different slices of the same content, get |
| 72 | + * the content first, then slice it up locally. |
| 73 | + * |
| 74 | + * TODO: Rewrite this method to not use recursion, because the function call overhead is expensive |
| 75 | + * |
| 76 | + * @method |
| 77 | + * @param {es.DocumentModelNode} node Node to get offset of |
| 78 | + * @param {Boolean} [shallow] Do not iterate into child nodes of child nodes |
| 79 | + * @returns {Integer} Offset of node or -1 of node was not found |
| 80 | + */ |
| 81 | +es.DocumentNode.prototype.getOffsetFromNode = function( node, shallow ) { |
| 82 | + if ( this.children.length ) { |
| 83 | + var offset = 0; |
| 84 | + for ( var i = 0, length = this.children.length; i < length; i++ ) { |
| 85 | + if ( this.children[i] === node ) { |
| 86 | + return offset; |
| 87 | + } |
| 88 | + if ( !shallow && this.children[i].getChildren().length ) { |
| 89 | + var childOffset = this.getOffsetFromNode.call( this.children[i], node ); |
| 90 | + if ( childOffset !== -1 ) { |
| 91 | + return offset + 1 + childOffset; |
| 92 | + } |
| 93 | + } |
| 94 | + offset += this.children[i].getElementLength(); |
| 95 | + } |
| 96 | + } |
| 97 | + return -1; |
| 98 | +}; |
| 99 | + |
| 100 | +/** |
| 101 | + * Gets the node at a given offset. |
| 102 | + * |
| 103 | + * This method is pretty expensive. If you need to get different slices of the same content, get |
| 104 | + * the content first, then slice it up locally. |
| 105 | + * |
| 106 | + * TODO: Rewrite this method to not use recursion, because the function call overhead is expensive |
| 107 | + * |
| 108 | + * @method |
| 109 | + * @param {Integer} offset Offset get node for |
| 110 | + * @param {Boolean} [shallow] Do not iterate into child nodes of child nodes |
| 111 | + * @returns {es.DocumentModelNode|null} Node at offset, or null if non was found |
| 112 | + */ |
| 113 | +es.DocumentNode.prototype.getNodeFromOffset = function( offset, shallow ) { |
| 114 | + if ( this.children.length ) { |
| 115 | + var nodeOffset = 0, |
| 116 | + nodeLength; |
| 117 | + for ( var i = 0, length = this.children.length; i < length; i++ ) { |
| 118 | + nodeLength = this.children[i].getElementLength(); |
| 119 | + if ( offset >= nodeOffset && offset < nodeOffset + nodeLength ) { |
| 120 | + if ( !shallow && this.children[i].getChildren().length ) { |
| 121 | + return this.getNodeFromOffset.call( this.children[i], offset - nodeOffset - 1 ); |
| 122 | + } else { |
| 123 | + return this.children[i]; |
| 124 | + } |
| 125 | + } |
| 126 | + nodeOffset += nodeLength; |
| 127 | + } |
| 128 | + } |
| 129 | + return null; |
| 130 | +}; |
| 131 | + |
| 132 | +/** |
| 133 | + * Gets a list of nodes and their sub-ranges which are covered by a given range. |
| 134 | + * |
| 135 | + * @method |
| 136 | + * @param {es.Range} range Range to select nodes within |
| 137 | + * @param {Boolean} [shallow] Do not recurse into child nodes of child nodes |
| 138 | + * @returns {Array} List of objects with 'node' and 'range' properties describing nodes which are |
| 139 | + * covered by the range and the range within the node that is covered |
| 140 | + */ |
| 141 | +es.DocumentNode.prototype.selectNodes = function( range, shallow ) { |
| 142 | + range.normalize(); |
| 143 | + var nodes = [], |
| 144 | + i, |
| 145 | + left, |
| 146 | + right, |
| 147 | + start = range.start, |
| 148 | + end = range.end, |
| 149 | + startInside, |
| 150 | + endInside; |
| 151 | + |
| 152 | + if ( start < 0 ) { |
| 153 | + throw 'The start offset of the range is negative'; |
| 154 | + } |
| 155 | + |
| 156 | + if ( this.children.length === 0 ) { |
| 157 | + // Special case: this node doesn't have any children |
| 158 | + // The return value is simply the range itself, if it is not out of bounds |
| 159 | + if ( end > this.getContentLength() ) { |
| 160 | + throw 'The end offset of the range is past the end of the node'; |
| 161 | + } |
| 162 | + return [{ 'node': this, 'range': new es.Range( start, end ) }]; |
| 163 | + } |
| 164 | + |
| 165 | + // This node has children, loop over them |
| 166 | + left = 1; // First offset inside the first child. Offset 0 is before the first child |
| 167 | + for ( i = 0; i < this.children.length; i++ ) { |
| 168 | + // left <= any offset inside this.children[i] <= right |
| 169 | + right = left + this.children[i].getContentLength(); |
| 170 | + |
| 171 | + if ( start == end && ( start == left - 1 || start == right + 1 ) ) { |
| 172 | + // Empty range outside of any node |
| 173 | + return []; |
| 174 | + } |
| 175 | + if ( start == left - 1 && end == right + 1 ) { |
| 176 | + // The range covers the entire node, including its opening and closing elements |
| 177 | + return [ { 'node': this.children[i] } ]; |
| 178 | + } |
| 179 | + if ( start == left - 1 ) { |
| 180 | + // start is between this.children[i-1] and this.children[i], move it to left for convenience |
| 181 | + // We don't need to check for start < end here because we already have start != end and |
| 182 | + // start <= end |
| 183 | + start = left; |
| 184 | + } |
| 185 | + if ( end == right + 1 ) { |
| 186 | + // end is between this.children[i] and this.children[i+1], move it to right for convenience |
| 187 | + // We don't need to check for start < end here because we already have start != end and |
| 188 | + // start <= end |
| 189 | + end = right; |
| 190 | + } |
| 191 | + |
| 192 | + startInside = start >= left && start <= right; // is the start inside this.children[i]? |
| 193 | + endInside = end >= left && end <= right; // is the end inside this.children[i]? |
| 194 | + |
| 195 | + if ( startInside && endInside ) { |
| 196 | + // The range is entirely inside this.children[i] |
| 197 | + if ( shallow ) { |
| 198 | + nodes = [{ 'node': this.children[i], 'range': new es.Range( start - left, end - left ) }]; |
| 199 | + } else { |
| 200 | + // Recurse into this.children[i] |
| 201 | + nodes = this.children[i].selectNodes( new es.Range( start - left, end - left ) ); |
| 202 | + } |
| 203 | + // Since the start and end are both inside this.children[i], we know for sure that we're done, so |
| 204 | + // return |
| 205 | + return nodes; |
| 206 | + } else if ( startInside ) { |
| 207 | + // The start is inside this.children[i] but the end isn't |
| 208 | + // Add a range from the start of the range to the end of this.children[i] |
| 209 | + nodes.push( { 'node': this.children[i], 'range': new es.Range( start - left, right - left ) } ); |
| 210 | + } else if ( endInside ) { |
| 211 | + // The end is inside this.children[i] but the start isn't |
| 212 | + // Add a range from the start of this.children[i] to the end of the range |
| 213 | + nodes.push( { 'node': this.children[i], 'range': new es.Range( 0, end - left ) } ); |
| 214 | + // We've found the end, so we're done |
| 215 | + return nodes; |
| 216 | + } else if ( nodes.length > 0 ) { |
| 217 | + // Neither the start nor the end is inside this.children[i], but nodes is non-empty, |
| 218 | + // so this.children[i] must be between the start and the end |
| 219 | + // Add the entire node, so no range property |
| 220 | + nodes.push( { 'node': this.children[i] } ); |
| 221 | + } |
| 222 | + |
| 223 | + // Move left to the start of this.children[i+1] for the next iteration |
| 224 | + // +2 because we need to jump over the offset between this.children[i] and this.children[i+1] |
| 225 | + left = right + 2; |
| 226 | + } |
| 227 | + |
| 228 | + // If we got here, that means that at least some part of the range is out of bounds |
| 229 | + // This is an error |
| 230 | + if ( nodes.length === 0 ) { |
| 231 | + throw 'The start offset of the range is past the end of the node'; |
| 232 | + } else { |
| 233 | + // Apparently the start was inside this node, but the end wasn't |
| 234 | + throw 'The end offset of the range is past the end of the node'; |
| 235 | + } |
| 236 | + return nodes; |
| 237 | +}; |
Index: trunk/extensions/VisualEditor/modules/bases/es.DocumentModelNode.js |
— | — | @@ -0,0 +1,423 @@ |
| 2 | +/** |
| 3 | + * Creates an es.DocumentModelNode object. |
| 4 | + * |
| 5 | + * es.DocumentModelNode is a simple wrapper around es.ModelNode, which adds functionality for model |
| 6 | + * nodes to be used as nodes in a space partitioning tree. |
| 7 | + * |
| 8 | + * @class |
| 9 | + * @constructor |
| 10 | + * @extends {es.DocumentNode} |
| 11 | + * @extends {es.EventEmitter} |
| 12 | + * @param {Integer|Array} contents Either Length of content or array of child nodes to append |
| 13 | + * @property {Integer} contentLength Length of content |
| 14 | + */ |
| 15 | +es.DocumentModelNode = function( element, contents ) { |
| 16 | + // Inheritance |
| 17 | + es.DocumentNode.call( this ); |
| 18 | + es.EventEmitter.call( this ); |
| 19 | + |
| 20 | + // Reusable function for passing update events upstream |
| 21 | + var _this = this; |
| 22 | + this.emitUpdate = function() { |
| 23 | + _this.emit( 'update' ); |
| 24 | + }; |
| 25 | + |
| 26 | + // Properties |
| 27 | + this.parent = null; |
| 28 | + this.root = this; |
| 29 | + this.element = element || null; |
| 30 | + this.contentLength = 0; |
| 31 | + if ( typeof contents === 'number' ) { |
| 32 | + if ( contents < 0 ) { |
| 33 | + throw 'Invalid content length error. Content length can not be less than 0.'; |
| 34 | + } |
| 35 | + this.contentLength = contents; |
| 36 | + } else if ( es.isArray( contents ) ) { |
| 37 | + for ( var i = 0; i < contents.length; i++ ) { |
| 38 | + this.push( contents[i] ); |
| 39 | + } |
| 40 | + } |
| 41 | +}; |
| 42 | + |
| 43 | +/* Abstract Methods */ |
| 44 | + |
| 45 | +/** |
| 46 | + * Creates a view for this node. |
| 47 | + * |
| 48 | + * @abstract |
| 49 | + * @method |
| 50 | + * @returns {es.DocumentViewNode} New item view associated with this model |
| 51 | + */ |
| 52 | +es.DocumentModelNode.prototype.createView = function() { |
| 53 | + throw 'DocumentModelNode.createView not implemented in this subclass:' + this.constructor; |
| 54 | +}; |
| 55 | + |
| 56 | +/* Methods */ |
| 57 | + |
| 58 | +/** |
| 59 | + * Adds a node to the end of this node's children. |
| 60 | + * |
| 61 | + * @method |
| 62 | + * @param {es.DocumentModelNode} childModel Item to add |
| 63 | + * @returns {Integer} New number of children |
| 64 | + * @emits beforePush (childModel) |
| 65 | + * @emits afterPush (childModel) |
| 66 | + * @emits update |
| 67 | + */ |
| 68 | +es.DocumentModelNode.prototype.push = function( childModel ) { |
| 69 | + this.emit( 'beforePush', childModel ); |
| 70 | + childModel.attach( this ); |
| 71 | + childModel.on( 'update', this.emitUpdate ); |
| 72 | + this.children.push( childModel ); |
| 73 | + this.adjustContentLength( childModel.getElementLength(), true ); |
| 74 | + this.emit( 'afterPush', childModel ); |
| 75 | + this.emit( 'update' ); |
| 76 | + return this.children.length; |
| 77 | +}; |
| 78 | + |
| 79 | +/** |
| 80 | + * Adds a node to the beginning of this node's children. |
| 81 | + * |
| 82 | + * @method |
| 83 | + * @param {es.DocumentModelNode} childModel Item to add |
| 84 | + * @returns {Integer} New number of children |
| 85 | + * @emits beforeUnshift (childModel) |
| 86 | + * @emits afterUnshift (childModel) |
| 87 | + * @emits update |
| 88 | + */ |
| 89 | +es.DocumentModelNode.prototype.unshift = function( childModel ) { |
| 90 | + this.emit( 'beforeUnshift', childModel ); |
| 91 | + childModel.attach( this ); |
| 92 | + childModel.on( 'update', this.emitUpdate ); |
| 93 | + this.children.unshift( childModel ); |
| 94 | + this.adjustContentLength( childModel.getElementLength(), true ); |
| 95 | + this.emit( 'afterUnshift', childModel ); |
| 96 | + this.emit( 'update' ); |
| 97 | + return this.children.length; |
| 98 | +}; |
| 99 | + |
| 100 | +/** |
| 101 | + * Removes a node from the end of this node's children |
| 102 | + * |
| 103 | + * @method |
| 104 | + * @returns {es.DocumentModelNode} Removed childModel |
| 105 | + * @emits beforePop |
| 106 | + * @emits afterPop |
| 107 | + * @emits update |
| 108 | + */ |
| 109 | +es.DocumentModelNode.prototype.pop = function() { |
| 110 | + if ( this.children.length ) { |
| 111 | + this.emit( 'beforePop' ); |
| 112 | + var childModel = this.children[this.children.length - 1]; |
| 113 | + childModel.detach(); |
| 114 | + childModel.removeListener( 'update', this.emitUpdate ); |
| 115 | + this.children.pop(); |
| 116 | + this.adjustContentLength( -childModel.getElementLength(), true ); |
| 117 | + this.emit( 'afterPop' ); |
| 118 | + this.emit( 'update' ); |
| 119 | + return childModel; |
| 120 | + } |
| 121 | +}; |
| 122 | + |
| 123 | +/** |
| 124 | + * Removes a node from the beginning of this node's children |
| 125 | + * |
| 126 | + * @method |
| 127 | + * @returns {es.DocumentModelNode} Removed childModel |
| 128 | + * @emits beforeShift |
| 129 | + * @emits afterShift |
| 130 | + * @emits update |
| 131 | + */ |
| 132 | +es.DocumentModelNode.prototype.shift = function() { |
| 133 | + if ( this.children.length ) { |
| 134 | + this.emit( 'beforeShift' ); |
| 135 | + var childModel = this.children[0]; |
| 136 | + childModel.detach(); |
| 137 | + childModel.removeListener( 'update', this.emitUpdate ); |
| 138 | + this.children.shift(); |
| 139 | + this.adjustContentLength( -childModel.getElementLength(), true ); |
| 140 | + this.emit( 'afterShift' ); |
| 141 | + this.emit( 'update' ); |
| 142 | + return childModel; |
| 143 | + } |
| 144 | +}; |
| 145 | + |
| 146 | +/** |
| 147 | + * Adds and removes nodes from this node's children. |
| 148 | + * |
| 149 | + * @method |
| 150 | + * @param {Integer} index Index to remove and or insert nodes at |
| 151 | + * @param {Integer} howmany Number of nodes to remove |
| 152 | + * @param {es.DocumentModelNode} [...] Variadic list of nodes to insert |
| 153 | + * @returns {es.DocumentModelNode[]} Removed nodes |
| 154 | + * @emits beforeSplice (index, howmany, [...]) |
| 155 | + * @emits afterSplice (index, howmany, [...]) |
| 156 | + * @emits update |
| 157 | + */ |
| 158 | +es.DocumentModelNode.prototype.splice = function( index, howmany ) { |
| 159 | + var i, |
| 160 | + length, |
| 161 | + args = Array.prototype.slice.call( arguments, 0 ), |
| 162 | + diff = 0; |
| 163 | + this.emit.apply( this, ['beforeSplice'].concat( args ) ); |
| 164 | + if ( args.length >= 3 ) { |
| 165 | + for ( i = 2, length = args.length; i < length; i++ ) { |
| 166 | + diff += args[i].getElementLength(); |
| 167 | + args[i].attach( this ); |
| 168 | + } |
| 169 | + } |
| 170 | + var removed = this.children.splice.apply( this.children, args ); |
| 171 | + for ( i = 0, length = removed.length; i < length; i++ ) { |
| 172 | + diff -= removed[i].getElementLength(); |
| 173 | + removed[i].detach(); |
| 174 | + removed[i].removeListener( 'update', this.emitUpdate ); |
| 175 | + } |
| 176 | + this.adjustContentLength( diff, true ); |
| 177 | + this.emit.apply( this, ['afterSplice'].concat( args ) ); |
| 178 | + this.emit( 'update' ); |
| 179 | + return removed; |
| 180 | +}; |
| 181 | + |
| 182 | +/** |
| 183 | + * Sorts this node's children. |
| 184 | + * |
| 185 | + * @method |
| 186 | + * @param {Function} sortfunc Function to use when sorting |
| 187 | + * @emits beforeSort (sortfunc) |
| 188 | + * @emits afterSort (sortfunc) |
| 189 | + * @emits update |
| 190 | + */ |
| 191 | +es.DocumentModelNode.prototype.sort = function( sortfunc ) { |
| 192 | + this.emit( 'beforeSort', sortfunc ); |
| 193 | + this.children.sort( sortfunc ); |
| 194 | + this.emit( 'afterSort', sortfunc ); |
| 195 | + this.emit( 'update' ); |
| 196 | +}; |
| 197 | + |
| 198 | +/** |
| 199 | + * Reverses the order of this node's children. |
| 200 | + * |
| 201 | + * @method |
| 202 | + * @emits beforeReverse |
| 203 | + * @emits afterReverse |
| 204 | + * @emits update |
| 205 | + */ |
| 206 | +es.DocumentModelNode.prototype.reverse = function() { |
| 207 | + this.emit( 'beforeReverse' ); |
| 208 | + this.children.reverse(); |
| 209 | + this.emit( 'afterReverse' ); |
| 210 | + this.emit( 'update' ); |
| 211 | +}; |
| 212 | + |
| 213 | +/** |
| 214 | + * Gets a reference to this node's parent. |
| 215 | + * |
| 216 | + * @method |
| 217 | + * @returns {es.DocumentModelNode} Reference to this node's parent |
| 218 | + */ |
| 219 | +es.DocumentModelNode.prototype.getParent = function() { |
| 220 | + return this.parent; |
| 221 | +}; |
| 222 | + |
| 223 | +/** |
| 224 | + * Gets the root node in the tree this node is currently attached to. |
| 225 | + * |
| 226 | + * @method |
| 227 | + * @returns {es.DocumentModelNode} Root node |
| 228 | + */ |
| 229 | +es.DocumentModelNode.prototype.getRoot = function() { |
| 230 | + return this.root; |
| 231 | +}; |
| 232 | + |
| 233 | +/** |
| 234 | + * Sets the root node to this and all of it's children. |
| 235 | + * |
| 236 | + * @method |
| 237 | + * @param {es.DocumentModelNode} root Node to use as root |
| 238 | + */ |
| 239 | +es.DocumentModelNode.prototype.setRoot = function( root ) { |
| 240 | + this.root = root; |
| 241 | + for ( var i = 0; i < this.children.length; i++ ) { |
| 242 | + this.children[i].setRoot( root ); |
| 243 | + } |
| 244 | +}; |
| 245 | + |
| 246 | +/** |
| 247 | + * Clears the root node from this and all of it's children. |
| 248 | + * |
| 249 | + * @method |
| 250 | + */ |
| 251 | +es.DocumentModelNode.prototype.clearRoot = function() { |
| 252 | + this.root = null; |
| 253 | + for ( var i = 0; i < this.children.length; i++ ) { |
| 254 | + this.children[i].clearRoot(); |
| 255 | + } |
| 256 | +}; |
| 257 | + |
| 258 | +/** |
| 259 | + * Attaches this node to another as a child. |
| 260 | + * |
| 261 | + * @method |
| 262 | + * @param {es.DocumentModelNode} parent Node to attach to |
| 263 | + * @emits attach (parent) |
| 264 | + */ |
| 265 | +es.DocumentModelNode.prototype.attach = function( parent ) { |
| 266 | + this.emit( 'beforeAttach', parent ); |
| 267 | + this.parent = parent; |
| 268 | + this.setRoot( parent.getRoot() ); |
| 269 | + this.emit( 'afterAttach', parent ); |
| 270 | +}; |
| 271 | + |
| 272 | +/** |
| 273 | + * Detaches this node from it's parent. |
| 274 | + * |
| 275 | + * @method |
| 276 | + * @emits detach |
| 277 | + */ |
| 278 | +es.DocumentModelNode.prototype.detach = function() { |
| 279 | + this.emit( 'beforeDetach' ); |
| 280 | + this.parent = null; |
| 281 | + this.clearRoot(); |
| 282 | + this.emit( 'afterDetach' ); |
| 283 | +}; |
| 284 | + |
| 285 | +/** |
| 286 | + * Sets the content length. |
| 287 | + * |
| 288 | + * @method |
| 289 | + * @param {Integer} contentLength Length of content |
| 290 | + * @throws Invalid content length error if contentLength is less than 0 |
| 291 | + */ |
| 292 | +es.DocumentModelNode.prototype.setContentLength = function( contentLength ) { |
| 293 | + if ( contentLength < 0 ) { |
| 294 | + throw 'Invalid content length error. Content length can not be less than 0.'; |
| 295 | + } |
| 296 | + var diff = contentLength - this.contentLength; |
| 297 | + this.contentLength = contentLength; |
| 298 | + if ( this.parent ) { |
| 299 | + this.parent.adjustContentLength( diff ); |
| 300 | + } |
| 301 | +}; |
| 302 | + |
| 303 | +/** |
| 304 | + * Adjust the content length. |
| 305 | + * |
| 306 | + * @method |
| 307 | + * @param {Integer} adjustment Amount to adjust content length by |
| 308 | + * @throws Invalid adjustment error if resulting length is less than 0 |
| 309 | + */ |
| 310 | +es.DocumentModelNode.prototype.adjustContentLength = function( adjustment, quiet ) { |
| 311 | + this.contentLength += adjustment; |
| 312 | + // Make sure the adjustment was sane |
| 313 | + if ( this.contentLength < 0 ) { |
| 314 | + // Reverse the adjustment |
| 315 | + this.contentLength -= adjustment; |
| 316 | + // Complain about it |
| 317 | + throw 'Invalid adjustment error. Content length can not be less than 0.'; |
| 318 | + } |
| 319 | + if ( this.parent ) { |
| 320 | + this.parent.adjustContentLength( adjustment, true ); |
| 321 | + } |
| 322 | + if ( !quiet ) { |
| 323 | + this.emit( 'update' ); |
| 324 | + } |
| 325 | +}; |
| 326 | + |
| 327 | +/** |
| 328 | + * Gets the content length. |
| 329 | + * |
| 330 | + * @method |
| 331 | + * @returns {Integer} Length of content |
| 332 | + */ |
| 333 | +es.DocumentModelNode.prototype.getContentLength = function() { |
| 334 | + return this.contentLength; |
| 335 | +}; |
| 336 | + |
| 337 | +/** |
| 338 | + * Gets the element length. |
| 339 | + * |
| 340 | + * @method |
| 341 | + * @returns {Integer} Length of content |
| 342 | + */ |
| 343 | +es.DocumentModelNode.prototype.getElementLength = function() { |
| 344 | + return this.contentLength + 2; |
| 345 | +}; |
| 346 | + |
| 347 | +/** |
| 348 | + * Gets the element object. |
| 349 | + * |
| 350 | + * @method |
| 351 | + * @returns {Object} Element object in linear data model |
| 352 | + */ |
| 353 | +es.DocumentModelNode.prototype.getElement = function() { |
| 354 | + return this.element; |
| 355 | +}; |
| 356 | + |
| 357 | +/** |
| 358 | + * Gets the symbolic element type name. |
| 359 | + * |
| 360 | + * @method |
| 361 | + * @returns {String} Symbolic name of element type |
| 362 | + */ |
| 363 | +es.DocumentModelNode.prototype.getElementType = function() { |
| 364 | + return this.element.type; |
| 365 | +}; |
| 366 | + |
| 367 | +/** |
| 368 | + * Gets an element attribute value. |
| 369 | + * |
| 370 | + * @method |
| 371 | + * @returns {Mixed} Value of attribute, or null if no such attribute exists |
| 372 | + */ |
| 373 | +es.DocumentModelNode.prototype.getElementAttribute = function( key ) { |
| 374 | + if ( this.element.attributes && key in this.element.attributes ) { |
| 375 | + return this.element.attributes[key]; |
| 376 | + } |
| 377 | + return null; |
| 378 | +}; |
| 379 | + |
| 380 | +/** |
| 381 | + * Gets the content length. |
| 382 | + * |
| 383 | + * FIXME: This method makes assumptions that a node with a data property is a DocumentModel, which |
| 384 | + * may be an issue if sub-classes of DocumentModelNode other than DocumentModel have a data property |
| 385 | + * as well. A safer way of determining this would be helpful in preventing future bugs. |
| 386 | + * |
| 387 | + * @method |
| 388 | + * @param {es.Range} [range] Range of content to get |
| 389 | + * @returns {Integer} Length of content |
| 390 | + */ |
| 391 | +es.DocumentModelNode.prototype.getContent = function( range ) { |
| 392 | + // Find root |
| 393 | + var root = this.data ? this : ( this.root.data ? this.root : null ); |
| 394 | + |
| 395 | + if ( root ) { |
| 396 | + return root.getContentFromNode( this, range ); |
| 397 | + } |
| 398 | + return []; |
| 399 | +}; |
| 400 | + |
| 401 | +/** |
| 402 | + * Gets plain text version of the content within a specific range. |
| 403 | + * |
| 404 | + * @method |
| 405 | + * @param {es.Range} [range] Range of text to get |
| 406 | + * @returns {String} Text within given range |
| 407 | + */ |
| 408 | +es.DocumentModelNode.prototype.getText = function( range ) { |
| 409 | + var content = this.getContent( range ); |
| 410 | + // Copy characters |
| 411 | + var text = ''; |
| 412 | + for ( var i = 0, length = content.length; i < length; i++ ) { |
| 413 | + // If not using in IE6 or IE7 (which do not support array access for strings) use this.. |
| 414 | + // text += this.data[i][0]; |
| 415 | + // Otherwise use this... |
| 416 | + text += typeof content[i] === 'string' ? content[i] : content[i][0]; |
| 417 | + } |
| 418 | + return text; |
| 419 | +}; |
| 420 | + |
| 421 | +/* Inheritance */ |
| 422 | + |
| 423 | +es.extendClass( es.DocumentModelNode, es.DocumentNode ); |
| 424 | +es.extendClass( es.DocumentModelNode, es.EventEmitter ); |
Index: trunk/extensions/VisualEditor/modules/bases/es.DocumentViewLeafNode.js |
— | — | @@ -0,0 +1,92 @@ |
| 2 | +/** |
| 3 | + * Creates an es.DocumentViewLeafNode object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewNode} |
| 8 | + * @param model {es.ModelNode} Model to observe |
| 9 | + * @param {jQuery} [$element] Element to use as a container |
| 10 | + */ |
| 11 | +es.DocumentViewLeafNode = function( model, $element ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentViewNode.call( this, model, $element ); |
| 14 | + |
| 15 | + // Properties |
| 16 | + this.$content = $( '<div class="es-content"></div>' ).appendTo( this.$ ); |
| 17 | + this.contentView = new es.ContentView( this.$content, model ); |
| 18 | +}; |
| 19 | + |
| 20 | +/* Methods */ |
| 21 | + |
| 22 | +/** |
| 23 | + * Render content. |
| 24 | + * |
| 25 | + * @method |
| 26 | + */ |
| 27 | +es.DocumentViewLeafNode.prototype.renderContent = function() { |
| 28 | + this.contentView.render(); |
| 29 | +}; |
| 30 | + |
| 31 | +/** |
| 32 | + * Draw selection around a given range. |
| 33 | + * |
| 34 | + * @method |
| 35 | + * @param {es.Range} range Range of content to draw selection around |
| 36 | + */ |
| 37 | +es.DocumentViewLeafNode.prototype.drawSelection = function( range ) { |
| 38 | + this.contentView.drawSelection( range ); |
| 39 | +}; |
| 40 | + |
| 41 | +/** |
| 42 | + * Clear selection. |
| 43 | + * |
| 44 | + * @method |
| 45 | + */ |
| 46 | +es.DocumentViewLeafNode.prototype.clearSelection = function() { |
| 47 | + this.contentView.clearSelection(); |
| 48 | +}; |
| 49 | + |
| 50 | +/** |
| 51 | + * Gets the nearest offset of a rendered position. |
| 52 | + * |
| 53 | + * @method |
| 54 | + * @param {es.Position} position Position to get offset for |
| 55 | + * @returns {Integer} Offset of position |
| 56 | + */ |
| 57 | +es.DocumentViewLeafNode.prototype.getOffsetFromRenderedPosition = function( position ) { |
| 58 | + return this.contentView.getOffsetFromRenderedPosition( position ); |
| 59 | +}; |
| 60 | + |
| 61 | +/** |
| 62 | + * Gets rendered position of offset within content. |
| 63 | + * |
| 64 | + * @method |
| 65 | + * @param {Integer} offset Offset to get position for |
| 66 | + * @returns {es.Position} Position of offset |
| 67 | + */ |
| 68 | +es.DocumentViewLeafNode.prototype.getRenderedPositionFromOffset = function( offset, leftBias ) { |
| 69 | + var position = this.contentView.getRenderedPositionFromOffset( offset, leftBias ), |
| 70 | + offset = this.$content.offset(); |
| 71 | + position.top += offset.top; |
| 72 | + position.left += offset.left; |
| 73 | + position.bottom += offset.top; |
| 74 | + return position; |
| 75 | +}; |
| 76 | + |
| 77 | +/** |
| 78 | + * Gets the length of the content in the model. |
| 79 | + * |
| 80 | + * @method |
| 81 | + * @returns {Integer} Length of content |
| 82 | + */ |
| 83 | +es.DocumentViewLeafNode.prototype.getElementLength = function() { |
| 84 | + return this.model.getElementLength(); |
| 85 | +}; |
| 86 | + |
| 87 | +es.DocumentViewLeafNode.prototype.getRenderedLineRangeFromOffset = function( offset ) { |
| 88 | + return this.contentView.getRenderedLineRangeFromOffset( offset ); |
| 89 | +}; |
| 90 | + |
| 91 | +/* Inheritance */ |
| 92 | + |
| 93 | +es.extendClass( es.DocumentViewLeafNode, es.DocumentViewNode ); |
Index: trunk/extensions/VisualEditor/modules/bases/es.EventEmitter.js |
— | — | @@ -0,0 +1,185 @@ |
| 2 | +/** |
| 3 | + * Event emitter. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @property events {Object} |
| 8 | + */ |
| 9 | +es.EventEmitter = function() { |
| 10 | + this.events = {}; |
| 11 | +}; |
| 12 | + |
| 13 | +/* Methods */ |
| 14 | + |
| 15 | +/** |
| 16 | + * Emits an event. |
| 17 | + * |
| 18 | + * @method |
| 19 | + * @param type {String} Type of event |
| 20 | + * @param args {Mixed} First in a list of variadic arguments passed to event handler (optional) |
| 21 | + * @returns {Boolean} If event was handled by at least one listener |
| 22 | + */ |
| 23 | +es.EventEmitter.prototype.emit = function( type ) { |
| 24 | + if ( type === 'error' && !( 'error' in this.events ) ) { |
| 25 | + throw 'Missing error handler error.'; |
| 26 | + } |
| 27 | + if ( !( type in this.events ) ) { |
| 28 | + return false; |
| 29 | + } |
| 30 | + var listeners = this.events[type].slice(); |
| 31 | + var args = Array.prototype.slice.call( arguments, 1 ); |
| 32 | + for ( var i = 0; i < listeners.length; i++ ) { |
| 33 | + listeners[i].apply( this, args ); |
| 34 | + } |
| 35 | + return true; |
| 36 | +}; |
| 37 | + |
| 38 | +/** |
| 39 | + * Adds a listener to events of a specific type. |
| 40 | + * |
| 41 | + * @method |
| 42 | + * @param type {String} Type of event to listen to |
| 43 | + * @param listener {Function} Listener to call when event occurs |
| 44 | + * @returns {es.EventEmitter} This object |
| 45 | + * @throws "Invalid listener error" if listener argument is not a function |
| 46 | + */ |
| 47 | +es.EventEmitter.prototype.addListener = function( type, listener ) { |
| 48 | + if ( typeof listener !== 'function' ) { |
| 49 | + throw 'Invalid listener error. Function expected.'; |
| 50 | + } |
| 51 | + this.emit( 'newListener', type, listener ); |
| 52 | + if ( type in this.events ) { |
| 53 | + this.events[type].push( listener ); |
| 54 | + } else { |
| 55 | + this.events[type] = [listener]; |
| 56 | + } |
| 57 | + return this; |
| 58 | +}; |
| 59 | + |
| 60 | +/** |
| 61 | + * Add multiple listeners at once. |
| 62 | + * |
| 63 | + * @method |
| 64 | + * @param listeners {Object} List of event/callback pairs |
| 65 | + * @returns {es.EventEmitter} This object |
| 66 | + */ |
| 67 | +es.EventEmitter.prototype.addListeners = function( listeners ) { |
| 68 | + for ( var event in listeners ) { |
| 69 | + this.addListener( event, listeners[event] ); |
| 70 | + } |
| 71 | + return this; |
| 72 | +}; |
| 73 | + |
| 74 | +/** |
| 75 | + * Add a listener, mapped to a method on a target object. |
| 76 | + * |
| 77 | + * @method |
| 78 | + * @param target {Object} Object to call methods on when events occur |
| 79 | + * @param event {String} Name of event to trigger on |
| 80 | + * @param method {String} Name of method to call |
| 81 | + * @returns {es.EventEmitter} This object |
| 82 | + */ |
| 83 | +es.EventEmitter.prototype.addListenerMethod = function( target, event, method ) { |
| 84 | + return this.addListener( event, function() { |
| 85 | + if ( typeof target[method] === 'function' ) { |
| 86 | + target[method].apply( target, Array.prototype.slice.call( arguments, 0 ) ); |
| 87 | + } else { |
| 88 | + throw 'Listener method error. Target has no such method: ' + method; |
| 89 | + } |
| 90 | + } ); |
| 91 | +}; |
| 92 | + |
| 93 | +/** |
| 94 | + * Add multiple listeners, each mapped to a method on a target object. |
| 95 | + * |
| 96 | + * @method |
| 97 | + * @param target {Object} Object to call methods on when events occur |
| 98 | + * @param methods {Object} List of event/method name pairs |
| 99 | + * @returns {es.EventEmitter} This object |
| 100 | + */ |
| 101 | +es.EventEmitter.prototype.addListenerMethods = function( target, methods ) { |
| 102 | + for ( var event in methods ) { |
| 103 | + this.addListenerMethod( target, event, methods[event] ); |
| 104 | + } |
| 105 | + return this; |
| 106 | +}; |
| 107 | + |
| 108 | +/** |
| 109 | + * Alias for addListener |
| 110 | + * |
| 111 | + * @method |
| 112 | + */ |
| 113 | +es.EventEmitter.prototype.on = es.EventEmitter.prototype.addListener; |
| 114 | + |
| 115 | +/** |
| 116 | + * Adds a one-time listener to a specific event. |
| 117 | + * |
| 118 | + * @method |
| 119 | + * @param type {String} Type of event to listen to |
| 120 | + * @param listener {Function} Listener to call when event occurs |
| 121 | + * @returns {es.EventEmitter} This object |
| 122 | + */ |
| 123 | +es.EventEmitter.prototype.once = function( type, listener ) { |
| 124 | + var eventEmitter = this; |
| 125 | + return this.addListener( type, function listenerWrapper() { |
| 126 | + eventEmitter.removeListener( type, listenerWrapper ); |
| 127 | + listener.apply( eventEmitter, Array.prototype.slice.call( arguments, 0 ) ); |
| 128 | + } ); |
| 129 | +}; |
| 130 | + |
| 131 | +/** |
| 132 | + * Removes a specific listener from a specific event. |
| 133 | + * |
| 134 | + * @method |
| 135 | + * @param type {String} Type of event to remove listener from |
| 136 | + * @param listener {Function} Listener to remove |
| 137 | + * @returns {es.EventEmitter} This object |
| 138 | + * @throws "Invalid listener error" if listener argument is not a function |
| 139 | + */ |
| 140 | +es.EventEmitter.prototype.removeListener = function( type, listener ) { |
| 141 | + if ( typeof listener !== 'function' ) { |
| 142 | + throw 'Invalid listener error. Function expected.'; |
| 143 | + } |
| 144 | + if ( !( type in this.events ) || !this.events[type].length ) { |
| 145 | + return this; |
| 146 | + } |
| 147 | + var handlers = this.events[type]; |
| 148 | + if ( handlers.length === 1 && handlers[0] === listener ) { |
| 149 | + delete this.events[type]; |
| 150 | + } else { |
| 151 | + var i = handlers.indexOf( listener ); |
| 152 | + if ( i < 0 ) { |
| 153 | + return this; |
| 154 | + } |
| 155 | + handlers.splice( i, 1 ); |
| 156 | + if ( handlers.length === 0 ) { |
| 157 | + delete this.events[type]; |
| 158 | + } |
| 159 | + } |
| 160 | + return this; |
| 161 | +}; |
| 162 | + |
| 163 | +/** |
| 164 | + * Removes all listeners from a specific event. |
| 165 | + * |
| 166 | + * @method |
| 167 | + * @param type {String} Type of event to remove listeners from |
| 168 | + * @returns {es.EventEmitter} This object |
| 169 | + */ |
| 170 | +es.EventEmitter.prototype.removeAllListeners = function( type ) { |
| 171 | + if ( type in this.events ) { |
| 172 | + delete this.events[type]; |
| 173 | + } |
| 174 | + return this; |
| 175 | +}; |
| 176 | + |
| 177 | +/** |
| 178 | + * Gets a list of listeners attached to a specific event. |
| 179 | + * |
| 180 | + * @method |
| 181 | + * @param type {String} Type of event to get listeners for |
| 182 | + * @returns {Array} List of listeners to an event |
| 183 | + */ |
| 184 | +es.EventEmitter.prototype.listeners = function( type ) { |
| 185 | + return type in this.events ? this.events[type] : []; |
| 186 | +}; |
Index: trunk/extensions/VisualEditor/modules/bases/es.DocumentViewBranchNode.js |
— | — | @@ -0,0 +1,128 @@ |
| 2 | +/** |
| 3 | + * Creates an es.DocumentViewBranchNode object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewNode} |
| 8 | + * @param model {es.ModelNode} Model to observe |
| 9 | + * @param {jQuery} [$element] Element to use as a container |
| 10 | + */ |
| 11 | +es.DocumentViewBranchNode = function( model, $element, horizontal ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentViewNode.call( this, model, $element ); |
| 14 | + |
| 15 | + // Properties |
| 16 | + this.horizontal = horizontal || false; |
| 17 | +}; |
| 18 | + |
| 19 | +/* Methods */ |
| 20 | + |
| 21 | +/** |
| 22 | + * Render content. |
| 23 | + * |
| 24 | + * @method |
| 25 | + */ |
| 26 | +es.DocumentViewBranchNode.prototype.renderContent = function() { |
| 27 | + for ( var i = 0; i < this.children.length; i++ ) { |
| 28 | + this.children[i].renderContent(); |
| 29 | + } |
| 30 | +}; |
| 31 | + |
| 32 | +/** |
| 33 | + * Draw selection around a given range. |
| 34 | + * |
| 35 | + * @method |
| 36 | + * @param {es.Range} range Range of content to draw selection around |
| 37 | + */ |
| 38 | +es.DocumentViewBranchNode.prototype.drawSelection = function( range ) { |
| 39 | + var nodes = this.selectNodes( range, true ); |
| 40 | + for ( var i = 0; i < this.children.length; i++ ) { |
| 41 | + if ( nodes.length && this.children[i] === nodes[0].node ) { |
| 42 | + for ( var j = 0; j < nodes.length; j++ ) { |
| 43 | + nodes[j].node.drawSelection( nodes[j].range ); |
| 44 | + i++; |
| 45 | + } |
| 46 | + } else { |
| 47 | + this.children[i].clearSelection(); |
| 48 | + } |
| 49 | + } |
| 50 | +}; |
| 51 | + |
| 52 | +/** |
| 53 | + * Clear selection. |
| 54 | + * |
| 55 | + * @method |
| 56 | + */ |
| 57 | +es.DocumentViewBranchNode.prototype.clearSelection = function() { |
| 58 | + for ( var i = 0; i < this.children.length; i++ ) { |
| 59 | + this.children[i].clearSelection(); |
| 60 | + } |
| 61 | +}; |
| 62 | + |
| 63 | +/** |
| 64 | + * Gets the nearest offset of a rendered position. |
| 65 | + * |
| 66 | + * @method |
| 67 | + * @param {es.Position} position Position to get offset for |
| 68 | + * @returns {Integer} Offset of position |
| 69 | + */ |
| 70 | +es.DocumentViewBranchNode.prototype.getOffsetFromRenderedPosition = function( position ) { |
| 71 | + if ( this.children.length === 0 ) { |
| 72 | + return 0; |
| 73 | + } |
| 74 | + var node = this.children[0]; |
| 75 | + for ( var i = 1; i < this.children.length; i++ ) { |
| 76 | + if ( this.horizontal && this.children[i].$.offset().left > position.left ) { |
| 77 | + break; |
| 78 | + } else if ( !this.horizontal && this.children[i].$.offset().top > position.top ) { |
| 79 | + break; |
| 80 | + } |
| 81 | + node = this.children[i]; |
| 82 | + } |
| 83 | + return node.getParent().getOffsetFromNode( node, true ) + |
| 84 | + node.getOffsetFromRenderedPosition( position ) + 1; |
| 85 | +}; |
| 86 | + |
| 87 | +/** |
| 88 | + * Gets rendered position of offset within content. |
| 89 | + * |
| 90 | + * @method |
| 91 | + * @param {Integer} offset Offset to get position for |
| 92 | + * @returns {es.Position} Position of offset |
| 93 | + */ |
| 94 | +es.DocumentViewBranchNode.prototype.getRenderedPositionFromOffset = function( offset, leftBias ) { |
| 95 | + var node = this.getNodeFromOffset( offset, true ); |
| 96 | + if ( node !== null ) { |
| 97 | + return node.getRenderedPositionFromOffset( |
| 98 | + offset - this.getOffsetFromNode( node, true ) - 1, |
| 99 | + leftBias |
| 100 | + ); |
| 101 | + } |
| 102 | + return null; |
| 103 | +}; |
| 104 | + |
| 105 | +/** |
| 106 | + * Gets the length of the content in the model. |
| 107 | + * |
| 108 | + * @method |
| 109 | + * @returns {Integer} Length of content |
| 110 | + */ |
| 111 | +es.DocumentViewBranchNode.prototype.getElementLength = function() { |
| 112 | + return this.model.getElementLength(); |
| 113 | +}; |
| 114 | + |
| 115 | +es.DocumentViewBranchNode.prototype.getRenderedLineRangeFromOffset = function( offset ) { |
| 116 | + var node = this.getNodeFromOffset( offset, true ); |
| 117 | + if ( node !== null ) { |
| 118 | + var nodeOffset = this.getOffsetFromNode( node, true ); |
| 119 | + return es.Range.newFromTranslatedRange( |
| 120 | + node.getRenderedLineRangeFromOffset( offset - nodeOffset - 1 ), |
| 121 | + nodeOffset + 1 |
| 122 | + ); |
| 123 | + } |
| 124 | + return null; |
| 125 | +}; |
| 126 | + |
| 127 | +/* Inheritance */ |
| 128 | + |
| 129 | +es.extendClass( es.DocumentViewBranchNode, es.DocumentViewNode ); |
Index: trunk/extensions/VisualEditor/modules/es.Range.js |
— | — | @@ -0,0 +1,72 @@ |
| 2 | +/** |
| 3 | + * Range of content. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @param from {Integer} Starting offset |
| 8 | + * @param to {Integer} Ending offset |
| 9 | + * @property from {Integer} Starting offset |
| 10 | + * @property to {Integer} Ending offset |
| 11 | + * @property start {Integer} Normalized starting offset |
| 12 | + * @property end {Integer} Normalized ending offset |
| 13 | + */ |
| 14 | +es.Range = function( from, to ) { |
| 15 | + this.from = from || 0; |
| 16 | + this.to = typeof to === 'undefined' ? this.from : to; |
| 17 | + this.normalize(); |
| 18 | +}; |
| 19 | + |
| 20 | + |
| 21 | +/** |
| 22 | + * Creates a new es.Range object that's a translated version of another. |
| 23 | + * |
| 24 | + * @method |
| 25 | + * @param {es.Range} range Range to base new range on |
| 26 | + * @param {Integer} distance Distance to move range by |
| 27 | + * @returns {es.Range} New translated range |
| 28 | + */ |
| 29 | +es.Range.newFromTranslatedRange = function( range, distance ) { |
| 30 | + return new es.Range( range.from + distance, range.to + distance ); |
| 31 | +}; |
| 32 | + |
| 33 | +/* Methods */ |
| 34 | + |
| 35 | +/** |
| 36 | + * Checks if an offset is within this range. |
| 37 | + * |
| 38 | + * @method |
| 39 | + * @param offset {Integer} Offset to check |
| 40 | + * @returns {Boolean} If offset is within this range |
| 41 | + */ |
| 42 | +es.Range.prototype.containsOffset = function( offset ) { |
| 43 | + this.normalize(); |
| 44 | + return offset >= this.start && offset < this.end; |
| 45 | +}; |
| 46 | + |
| 47 | +/** |
| 48 | + * Gets the length of the range. |
| 49 | + * |
| 50 | + * @method |
| 51 | + * @returns {Integer} Length of range |
| 52 | + */ |
| 53 | +es.Range.prototype.getLength = function() { |
| 54 | + return Math.abs( this.from - this.to ); |
| 55 | +}; |
| 56 | + |
| 57 | +/** |
| 58 | + * Sets start and end properties, ensuring start is always before end. |
| 59 | + * |
| 60 | + * This should always be called before using the start or end properties. Do not call this unless |
| 61 | + * you are about to use these properties. |
| 62 | + * |
| 63 | + * @method |
| 64 | + */ |
| 65 | +es.Range.prototype.normalize = function() { |
| 66 | + if ( this.from < this.to ) { |
| 67 | + this.start = this.from; |
| 68 | + this.end = this.to; |
| 69 | + } else { |
| 70 | + this.start = this.to; |
| 71 | + this.end = this.from; |
| 72 | + } |
| 73 | +}; |
Index: trunk/extensions/VisualEditor/modules/views/es.ParagraphView.js |
— | — | @@ -0,0 +1,19 @@ |
| 2 | +/** |
| 3 | + * Creates an es.ParagraphView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewLeafNode} |
| 8 | + * @param {es.ParagraphModel} model Paragraph model to view |
| 9 | + */ |
| 10 | +es.ParagraphView = function( model ) { |
| 11 | + // Inheritance |
| 12 | + es.DocumentViewLeafNode.call( this, model ); |
| 13 | + |
| 14 | + // DOM Changes |
| 15 | + this.$.addClass( 'es-paragraphView' ); |
| 16 | +}; |
| 17 | + |
| 18 | +/* Inheritance */ |
| 19 | + |
| 20 | +es.extendClass( es.ParagraphView, es.DocumentViewLeafNode ); |
Index: trunk/extensions/VisualEditor/modules/views/es.TableCellView.js |
— | — | @@ -0,0 +1,21 @@ |
| 2 | +/** |
| 3 | + * Creates an es.TableCellView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewBranchNode} |
| 8 | + * @param {es.TableCellModel} model Table cell model to view |
| 9 | + */ |
| 10 | +es.TableCellView = function( model ) { |
| 11 | + // Inheritance |
| 12 | + es.DocumentViewBranchNode.call( this, model, $( '<td>' ) ); |
| 13 | + |
| 14 | + // DOM Changes |
| 15 | + this.$ |
| 16 | + .attr( 'style', model.getElementAttribute( 'html/style' ) ) |
| 17 | + .addClass( 'es-tableCellView' ); |
| 18 | +}; |
| 19 | + |
| 20 | +/* Inheritance */ |
| 21 | + |
| 22 | +es.extendClass( es.TableCellView, es.DocumentViewBranchNode ); |
Index: trunk/extensions/VisualEditor/modules/views/es.TableView.js |
— | — | @@ -0,0 +1,21 @@ |
| 2 | +/** |
| 3 | + * Creates an es.TableView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewBranchNode} |
| 8 | + * @param {es.TableModel} model Table model to view |
| 9 | + */ |
| 10 | +es.TableView = function( model ) { |
| 11 | + // Inheritance |
| 12 | + es.DocumentViewBranchNode.call( this, model, $( '<table>' ) ); |
| 13 | + |
| 14 | + // DOM Changes |
| 15 | + this.$ |
| 16 | + .attr( 'style', model.getElementAttribute( 'html/style' ) ) |
| 17 | + .addClass( 'es-tableView' ); |
| 18 | +}; |
| 19 | + |
| 20 | +/* Inheritance */ |
| 21 | + |
| 22 | +es.extendClass( es.TableView, es.DocumentViewBranchNode ); |
Index: trunk/extensions/VisualEditor/modules/views/es.SurfaceView.js |
— | — | @@ -0,0 +1,275 @@ |
| 2 | +/** |
| 3 | + * Creates an es.SurfaceView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @param {jQuery} $container DOM Container to render surface into |
| 8 | + * @param {es.SurfaceModel} model Surface model to view |
| 9 | + */ |
| 10 | +es.SurfaceView = function( $container, model ) { |
| 11 | + this.$ = $container.addClass( 'es-surfaceView' ); |
| 12 | + this.$window = $( window ); |
| 13 | + this.model = model; |
| 14 | + |
| 15 | + // Initialize document view |
| 16 | + this.documentView = new es.DocumentView( this.model.getDocument(), this ); |
| 17 | + this.$.append( this.documentView.$ ); |
| 18 | + |
| 19 | + // Interaction state |
| 20 | + this.mouse = { |
| 21 | + selecting: false, |
| 22 | + clicks: 0, |
| 23 | + clickDelay: 500, |
| 24 | + clickTimeout: null, |
| 25 | + clickPosition: null, |
| 26 | + hotSpotRadius: 1, |
| 27 | + lastMovePosition: null |
| 28 | + }; |
| 29 | + this.cursor = { |
| 30 | + $: $( '<div class="es-surfaceView-cursor"></div>' ).appendTo( this.$ ), |
| 31 | + interval: null, |
| 32 | + offset: null, |
| 33 | + initialLeft: null, |
| 34 | + initialBias: false |
| 35 | + }; |
| 36 | + this.keyboard = { |
| 37 | + selecting: false, |
| 38 | + cursorAnchor: null, |
| 39 | + keydownTimeout: null, |
| 40 | + keys: { |
| 41 | + shift: false, |
| 42 | + control: false, |
| 43 | + command: false, |
| 44 | + alt: false |
| 45 | + } |
| 46 | + }; |
| 47 | + this.selection = { |
| 48 | + from: 0, |
| 49 | + to: 0 |
| 50 | + }; |
| 51 | + |
| 52 | + // References for use in closures |
| 53 | + var surfaceView = this, |
| 54 | + $document = $( document ); |
| 55 | + |
| 56 | + // MouseDown on surface |
| 57 | + this.$.on( { |
| 58 | + 'mousedown' : function(e) { |
| 59 | + return surfaceView.onMouseDown( e ); |
| 60 | + } |
| 61 | + } ); |
| 62 | + |
| 63 | + // Hidden input |
| 64 | + this.$input = $( '<input class="es-surfaceView-input" />' ) |
| 65 | + .prependTo( this.$ ) |
| 66 | + .on( { |
| 67 | + 'focus' : function() { |
| 68 | + //console.log("focus"); |
| 69 | + $document.off( '.es-surfaceView' ); |
| 70 | + $document.on({ |
| 71 | + 'mousemove.es-surfaceView': function(e) { |
| 72 | + //return surfaceView.onMouseMove( e ); |
| 73 | + }, |
| 74 | + 'mouseup.es-surfaceView': function(e) { |
| 75 | + //return surfaceView.onMouseUp( e ); |
| 76 | + }, |
| 77 | + 'keydown.es-surfaceView': function( e ) { |
| 78 | + return surfaceView.onKeyDown( e ); |
| 79 | + }, |
| 80 | + 'keyup.es-surfaceView': function( e ) { |
| 81 | + return surfaceView.onKeyUp( e ); |
| 82 | + } |
| 83 | + }); |
| 84 | + }, |
| 85 | + 'blur': function( e ) { |
| 86 | + //console.log("blur"); |
| 87 | + $document.off( '.es-surfaceView' ); |
| 88 | + surfaceView.hideCursor(); |
| 89 | + } |
| 90 | + } ).focus(); |
| 91 | + |
| 92 | + // First render |
| 93 | + this.documentView.renderContent(); |
| 94 | + |
| 95 | + this.dimensions = { |
| 96 | + width: this.$.width(), |
| 97 | + height: this.$window.height(), |
| 98 | + scrollTop: this.$window.scrollTop() |
| 99 | + }; |
| 100 | + |
| 101 | + // Re-render when resizing horizontally |
| 102 | + this.$window.resize( function() { |
| 103 | + surfaceView.hideCursor(); |
| 104 | + surfaceView.dimensions.height = surfaceView.$window.height(); |
| 105 | + var width = surfaceView.$.width(); |
| 106 | + if ( surfaceView.dimensions.width !== width ) { |
| 107 | + surfaceView.dimensions.width = width; |
| 108 | + surfaceView.documentView.renderContent(); |
| 109 | + } |
| 110 | + } ); |
| 111 | + |
| 112 | + this.$window.scroll( function() { |
| 113 | + surfaceView.dimensions.scrollTop = surfaceView.$window.scrollTop(); |
| 114 | + } ); |
| 115 | +}; |
| 116 | + |
| 117 | +es.SurfaceView.prototype.onMouseDown = function( e ) { |
| 118 | + var position = es.Position.newFromEventPagePosition( e ), |
| 119 | + offset = this.documentView.getOffsetFromEvent( e ), |
| 120 | + nodeView = this.documentView.getNodeFromOffset( offset, false ); |
| 121 | + this.showCursor( offset, position.left > nodeView.$.offset().left ); |
| 122 | + if ( !this.$input.is( ':focus' ) ) { |
| 123 | + this.$input.focus().select(); |
| 124 | + } |
| 125 | + return false; |
| 126 | +}; |
| 127 | + |
| 128 | +es.SurfaceView.prototype.onKeyDown = function( e ) { |
| 129 | + switch ( e.keyCode ) { |
| 130 | + case 16: // Shift |
| 131 | + this.keyboard.keys.shift = true; |
| 132 | + break; |
| 133 | + case 17: // Control |
| 134 | + this.keyboard.keys.control = true; |
| 135 | + break; |
| 136 | + case 18: // Alt |
| 137 | + this.keyboard.keys.alt = true; |
| 138 | + break; |
| 139 | + case 91: // Command |
| 140 | + this.keyboard.keys.command = true; |
| 141 | + break; |
| 142 | + case 36: // Home |
| 143 | + this.moveCursor( 'home' ); |
| 144 | + break; |
| 145 | + case 35: // End |
| 146 | + this.moveCursor( 'end' ); |
| 147 | + break; |
| 148 | + case 37: // Left arrow |
| 149 | + if ( this.keyboard.keys.command ) { |
| 150 | + this.moveCursor( 'home' ); |
| 151 | + } else { |
| 152 | + this.moveCursor( 'left' ); |
| 153 | + } |
| 154 | + break; |
| 155 | + case 38: // Up arrow |
| 156 | + this.moveCursor( 'up' ); |
| 157 | + break; |
| 158 | + case 39: // Right arrow |
| 159 | + if ( this.keyboard.keys.command ) { |
| 160 | + this.moveCursor( 'end' ); |
| 161 | + } else { |
| 162 | + this.moveCursor( 'right' ); |
| 163 | + } |
| 164 | + break; |
| 165 | + case 40: // Down arrow |
| 166 | + this.moveCursor( 'down' ); |
| 167 | + break; |
| 168 | + case 8: // Backspace |
| 169 | + break; |
| 170 | + case 46: // Delete |
| 171 | + break; |
| 172 | + default: // Insert content (maybe) |
| 173 | + break; |
| 174 | + } |
| 175 | + return false; |
| 176 | +}; |
| 177 | + |
| 178 | +es.SurfaceView.prototype.onKeyUp = function( e ) { |
| 179 | + switch ( e.keyCode ) { |
| 180 | + case 16: // Shift |
| 181 | + this.keyboard.keys.shift = false; |
| 182 | + if ( this.keyboard.selecting ) { |
| 183 | + this.keyboard.selecting = false; |
| 184 | + } |
| 185 | + break; |
| 186 | + case 17: // Control |
| 187 | + this.keyboard.keys.control = false; |
| 188 | + break; |
| 189 | + case 18: // Alt |
| 190 | + this.keyboard.keys.alt = false; |
| 191 | + break; |
| 192 | + case 91: // Command |
| 193 | + this.keyboard.keys.command = false; |
| 194 | + break; |
| 195 | + default: |
| 196 | + break; |
| 197 | + } |
| 198 | + return true; |
| 199 | +}; |
| 200 | + |
| 201 | +es.SurfaceView.prototype.moveCursor = function( instruction ) { |
| 202 | + if ( instruction === 'left') { |
| 203 | + this.showCursor( |
| 204 | + this.documentView.getModel().getRelativeContentOffset( this.cursor.offset, -1 ) |
| 205 | + ); |
| 206 | + } else if ( instruction === 'right' ) { |
| 207 | + this.showCursor( |
| 208 | + this.documentView.getModel().getRelativeContentOffset( this.cursor.offset, 1 ) |
| 209 | + ); |
| 210 | + } else if ( instruction === 'up' || instruction === 'down' ) { |
| 211 | + // ... |
| 212 | + } else if ( instruction === 'home' || instruction === 'end' ) { |
| 213 | + var offset; |
| 214 | + if ( this.cursor.initialBias ) { |
| 215 | + offset = this.documentView.getModel().getRelativeContentOffset( |
| 216 | + this.cursor.offset, -1 ); |
| 217 | + } else { |
| 218 | + offset = this.cursor.offset; |
| 219 | + } |
| 220 | + if ( instruction === 'home' ) { |
| 221 | + this.showCursor( |
| 222 | + this.documentView.getRenderedLineRangeFromOffset( offset ).start, false ); |
| 223 | + } else { // end |
| 224 | + this.showCursor( this.documentView.getRenderedLineRangeFromOffset( offset ).end, true ); |
| 225 | + } |
| 226 | + } |
| 227 | +}; |
| 228 | + |
| 229 | +/** |
| 230 | + * Shows the cursor in a new position. |
| 231 | + * |
| 232 | + * @method |
| 233 | + * @param offset {Integer} Position to show the cursor at |
| 234 | + */ |
| 235 | +es.SurfaceView.prototype.showCursor = function( offset, leftBias ) { |
| 236 | + if ( typeof offset !== 'undefined' ) { |
| 237 | + this.cursor.initialBias = leftBias ? true : false; |
| 238 | + this.cursor.offset = offset; |
| 239 | + var position = this.documentView.getRenderedPositionFromOffset( |
| 240 | + this.cursor.offset, leftBias |
| 241 | + ); |
| 242 | + this.cursor.$.css( { |
| 243 | + 'left': position.left, |
| 244 | + 'top': position.top, |
| 245 | + 'height': position.bottom - position.top |
| 246 | + } ); |
| 247 | + this.$input.css({ |
| 248 | + 'top': position.top, |
| 249 | + 'height': position.bottom - position.top |
| 250 | + }); |
| 251 | + } |
| 252 | + |
| 253 | + this.cursor.$.show(); |
| 254 | + |
| 255 | + // cursor blinking |
| 256 | + if ( this.cursor.interval ) { |
| 257 | + clearInterval( this.cursor.interval ); |
| 258 | + } |
| 259 | + this.cursor.interval = setInterval( function( surface ) { |
| 260 | + surface.cursor.$.css( 'display', function( index, value ) { |
| 261 | + return value === 'block' ? 'none' : 'block'; |
| 262 | + } ); |
| 263 | + }, 500, this ); |
| 264 | +}; |
| 265 | + |
| 266 | +/** |
| 267 | + * Hides the cursor. |
| 268 | + * |
| 269 | + * @method |
| 270 | + */ |
| 271 | +es.SurfaceView.prototype.hideCursor = function() { |
| 272 | + if( this.cursor.interval ) { |
| 273 | + clearInterval( this.cursor.interval ); |
| 274 | + } |
| 275 | + this.cursor.$.hide(); |
| 276 | +}; |
Index: trunk/extensions/VisualEditor/modules/views/es.ContentView.js |
— | — | @@ -0,0 +1,898 @@ |
| 2 | +/** |
| 3 | + * Creates an es.ContentView object. |
| 4 | + * |
| 5 | + * A content view flows text into a DOM element and provides methods to get information about the |
| 6 | + * rendered output. HTML serialized specifically for rendering into and editing surface. |
| 7 | + * |
| 8 | + * Rendering occurs automatically when content is modified, by responding to "update" events from |
| 9 | + * the model. Rendering is iterative and interruptable to reduce user feedback latency. |
| 10 | + * |
| 11 | + * TODO: Cleanup code and comments |
| 12 | + * |
| 13 | + * @class |
| 14 | + * @constructor |
| 15 | + * @param {jQuery} $container Element to render into |
| 16 | + * @param {es.ModelNode} model Model to produce view for |
| 17 | + * @property {jQuery} $ |
| 18 | + * @property {es.ContentModel} model |
| 19 | + * @property {Array} boundaries |
| 20 | + * @property {Array} lines |
| 21 | + * @property {Integer} width |
| 22 | + * @property {RegExp} bondaryTest |
| 23 | + * @property {Object} widthCache |
| 24 | + * @property {Object} renderState |
| 25 | + * @property {Object} contentCache |
| 26 | + */ |
| 27 | +es.ContentView = function( $container, model ) { |
| 28 | + // Inheritance |
| 29 | + es.EventEmitter.call( this ); |
| 30 | + |
| 31 | + // Properties |
| 32 | + this.$ = $container; |
| 33 | + this.model = model; |
| 34 | + this.boundaries = []; |
| 35 | + this.lines = []; |
| 36 | + this.width = null; |
| 37 | + this.boundaryTest = /([ \-\t\r\n\f])/g; |
| 38 | + this.widthCache = {}; |
| 39 | + this.renderState = {}; |
| 40 | + this.contentCache = null; |
| 41 | + |
| 42 | + if ( model ) { |
| 43 | + // Events |
| 44 | + var _this = this; |
| 45 | + this.model.on( 'update', function( args ) { |
| 46 | + _this.scanBoundaries(); |
| 47 | + _this.render( args ? args.offset : 0 ); |
| 48 | + } ); |
| 49 | + |
| 50 | + // DOM Changes |
| 51 | + this.$ranges = $( '<div class="es-contentView-ranges"></div>' ); |
| 52 | + this.$rangeStart = $( '<div class="es-contentView-range"></div>' ); |
| 53 | + this.$rangeFill = $( '<div class="es-contentView-range"></div>' ); |
| 54 | + this.$rangeEnd = $( '<div class="es-contentView-range"></div>' ); |
| 55 | + this.$.prepend( this.$ranges.append( this.$rangeStart, this.$rangeFill, this.$rangeEnd ) ); |
| 56 | + |
| 57 | + // Initialization |
| 58 | + this.scanBoundaries(); |
| 59 | + } |
| 60 | +}; |
| 61 | + |
| 62 | +/* Static Members */ |
| 63 | + |
| 64 | +/** |
| 65 | + * List of annotation rendering implementations. |
| 66 | + * |
| 67 | + * Each supported annotation renderer must have an open and close property, each either a string or |
| 68 | + * a function which accepts a data argument. |
| 69 | + * |
| 70 | + * @static |
| 71 | + * @member |
| 72 | + */ |
| 73 | +es.ContentView.annotationRenderers = { |
| 74 | + 'template': { |
| 75 | + 'open': function( data ) { |
| 76 | + return '<span class="es-contentView-format-object">' + data.html; |
| 77 | + }, |
| 78 | + 'close': '</span>', |
| 79 | + 'float': function( data ) { |
| 80 | + console.log( data.html ); |
| 81 | + return $( data.html ).css( 'float' ); |
| 82 | + } |
| 83 | + }, |
| 84 | + 'bold': { |
| 85 | + 'open': '<span class="es-contentView-format-bold">', |
| 86 | + 'close': '</span>', |
| 87 | + 'float': false |
| 88 | + }, |
| 89 | + 'italic': { |
| 90 | + 'open': '<span class="es-contentView-format-italic">', |
| 91 | + 'close': '</span>', |
| 92 | + 'float': false |
| 93 | + }, |
| 94 | + 'size': { |
| 95 | + 'open': function( data ) { |
| 96 | + return '<span class="es-contentView-format-' + data.type + '">'; |
| 97 | + }, |
| 98 | + 'close': '</span>', |
| 99 | + 'float': false |
| 100 | + }, |
| 101 | + 'script': { |
| 102 | + 'open': function( data ) { |
| 103 | + return '<span class="es-contentView-format-' + data.type + '">'; |
| 104 | + }, |
| 105 | + 'close': '</span>', |
| 106 | + 'float': false |
| 107 | + }, |
| 108 | + 'xlink': { |
| 109 | + 'open': function( data ) { |
| 110 | + return '<span class="es-contentView-format-link" data-href="' + data.href + '">'; |
| 111 | + }, |
| 112 | + 'close': '</span>', |
| 113 | + 'float': false |
| 114 | + }, |
| 115 | + 'ilink': { |
| 116 | + 'open': function( data ) { |
| 117 | + return '<span class="es-contentView-format-link" data-title="' + data.title + '">'; |
| 118 | + }, |
| 119 | + 'close': '</span>', |
| 120 | + 'float': false |
| 121 | + } |
| 122 | +}; |
| 123 | + |
| 124 | +/** |
| 125 | + * Mapping of character and HTML entities or renderings. |
| 126 | + * |
| 127 | + * @static |
| 128 | + * @member |
| 129 | + */ |
| 130 | +es.ContentView.htmlCharacters = { |
| 131 | + '&': '&', |
| 132 | + '<': '<', |
| 133 | + '>': '>', |
| 134 | + '\'': ''', |
| 135 | + '"': '"', |
| 136 | + '\n': '<span class="es-contentView-whitespace">¶</span>', |
| 137 | + '\t': '<span class="es-contentView-whitespace">⇾</span>', |
| 138 | + ' ': ' ' |
| 139 | +}; |
| 140 | + |
| 141 | +/* Static Methods */ |
| 142 | + |
| 143 | +/** |
| 144 | + * Gets a rendered opening or closing of an annotation. |
| 145 | + * |
| 146 | + * Tag nesting is handled using a stack, which keeps track of what is currently open. A common stack |
| 147 | + * argument should be used while rendering content. |
| 148 | + * |
| 149 | + * @static |
| 150 | + * @method |
| 151 | + * @param {String} bias Which side of the annotation to render, either "open" or "close" |
| 152 | + * @param {Object} annotation Annotation to render |
| 153 | + * @param {Array} stack List of currently open annotations |
| 154 | + * @returns {String} Rendered annotation |
| 155 | + */ |
| 156 | +es.ContentView.renderAnnotation = function( bias, annotation, stack ) { |
| 157 | + var renderers = es.ContentView.annotationRenderers, |
| 158 | + type = annotation.type, |
| 159 | + out = ''; |
| 160 | + if ( type in renderers ) { |
| 161 | + if ( bias === 'open' ) { |
| 162 | + // Add annotation to the top of the stack |
| 163 | + stack.push( annotation ); |
| 164 | + // Open annotation |
| 165 | + out += typeof renderers[type].open === 'function' ? |
| 166 | + renderers[type].open( annotation.data ) : renderers[type].open; |
| 167 | + } else { |
| 168 | + if ( stack[stack.length - 1] === annotation ) { |
| 169 | + // Remove annotation from top of the stack |
| 170 | + stack.pop(); |
| 171 | + // Close annotation |
| 172 | + out += typeof renderers[type].close === 'function' ? |
| 173 | + renderers[type].close( annotation.data ) : renderers[type].close; |
| 174 | + } else { |
| 175 | + // Find the annotation in the stack |
| 176 | + var depth = stack.indexOf( annotation ), |
| 177 | + i; |
| 178 | + if ( depth === -1 ) { |
| 179 | + throw 'Invalid stack error. An element is missing from the stack.'; |
| 180 | + } |
| 181 | + // Close each already opened annotation |
| 182 | + for ( i = stack.length - 1; i >= depth + 1; i-- ) { |
| 183 | + out += typeof renderers[stack[i].type].close === 'function' ? |
| 184 | + renderers[stack[i].type].close( stack[i].data ) : |
| 185 | + renderers[stack[i].type].close; |
| 186 | + } |
| 187 | + // Close the buried annotation |
| 188 | + out += typeof renderers[type].close === 'function' ? |
| 189 | + renderers[type].close( annotation.data ) : renderers[type].close; |
| 190 | + // Re-open each previously opened annotation |
| 191 | + for ( i = depth + 1; i < stack.length; i++ ) { |
| 192 | + out += typeof renderers[stack[i].type].open === 'function' ? |
| 193 | + renderers[stack[i].type].open( stack[i].data ) : |
| 194 | + renderers[stack[i].type].open; |
| 195 | + } |
| 196 | + // Remove the annotation from the middle of the stack |
| 197 | + stack.splice( depth, 1 ); |
| 198 | + } |
| 199 | + } |
| 200 | + } |
| 201 | + return out; |
| 202 | +}; |
| 203 | + |
| 204 | +/* Methods */ |
| 205 | + |
| 206 | +/** |
| 207 | + * Draws selection around a given range of content. |
| 208 | + * |
| 209 | + * @method |
| 210 | + * @param {es.Range} range Range to draw selection around |
| 211 | + */ |
| 212 | +es.ContentView.prototype.drawSelection = function( range ) { |
| 213 | + range.normalize(); |
| 214 | + |
| 215 | + var fromLineIndex = this.getRenderedLineIndexFromOffset( range.start ), |
| 216 | + toLineIndex = this.getRenderedLineIndexFromOffset( range.end ), |
| 217 | + fromPosition = this.getRenderedPositionFromOffset( range.start ), |
| 218 | + toPosition = this.getRenderedPositionFromOffset( range.end ); |
| 219 | + |
| 220 | + if ( fromLineIndex === toLineIndex ) { |
| 221 | + // Single line selection |
| 222 | + this.$rangeStart.css( { |
| 223 | + 'top': fromPosition.top, |
| 224 | + 'left': fromPosition.left, |
| 225 | + 'width': toPosition.left - fromPosition.left, |
| 226 | + 'height': fromPosition.bottom - fromPosition.top |
| 227 | + } ).show(); |
| 228 | + this.$rangeFill.hide(); |
| 229 | + this.$rangeEnd.hide(); |
| 230 | + } else { |
| 231 | + // Multiple line selection |
| 232 | + var contentWidth = this.$.width(); |
| 233 | + this.$rangeStart.css( { |
| 234 | + 'top': fromPosition.top, |
| 235 | + 'left': fromPosition.left, |
| 236 | + 'width': contentWidth - fromPosition.left, |
| 237 | + 'height': fromPosition.bottom - fromPosition.top |
| 238 | + } ).show(); |
| 239 | + this.$rangeEnd.css( { |
| 240 | + 'top': toPosition.top, |
| 241 | + 'left': 0, |
| 242 | + 'width': toPosition.left, |
| 243 | + 'height': toPosition.bottom - toPosition.top |
| 244 | + } ).show(); |
| 245 | + if ( fromLineIndex + 1 < toLineIndex ) { |
| 246 | + this.$rangeFill.css( { |
| 247 | + 'top': fromPosition.bottom, |
| 248 | + 'left': 0, |
| 249 | + 'width': contentWidth, |
| 250 | + 'height': toPosition.top - fromPosition.bottom |
| 251 | + } ).show(); |
| 252 | + } else { |
| 253 | + this.$rangeFill.hide(); |
| 254 | + } |
| 255 | + } |
| 256 | +}; |
| 257 | + |
| 258 | +/** |
| 259 | + * Clears selection if any was drawn. |
| 260 | + * |
| 261 | + * @method |
| 262 | + */ |
| 263 | +es.ContentView.prototype.clearSelection = function() { |
| 264 | + this.$rangeStart.hide(); |
| 265 | + this.$rangeFill.hide(); |
| 266 | + this.$rangeEnd.hide(); |
| 267 | +}; |
| 268 | + |
| 269 | +/** |
| 270 | + * Gets the index of the rendered line a given offset is within. |
| 271 | + * |
| 272 | + * Offsets that are out of range will always return the index of the last line. |
| 273 | + * |
| 274 | + * @method |
| 275 | + * @param {Integer} offset Offset to get line for |
| 276 | + * @returns {Integer} Index of rendered lin offset is within |
| 277 | + */ |
| 278 | +es.ContentView.prototype.getRenderedLineIndexFromOffset = function( offset ) { |
| 279 | + for ( var i = 0; i < this.lines.length; i++ ) { |
| 280 | + if ( this.lines[i].range.containsOffset( offset ) ) { |
| 281 | + return i; |
| 282 | + } |
| 283 | + } |
| 284 | + return this.lines.length - 1; |
| 285 | +}; |
| 286 | + |
| 287 | +/* |
| 288 | + * Gets the index of the rendered line closest to a given position. |
| 289 | + * |
| 290 | + * If the position is above the first line, the offset will always be 0, and if the position is |
| 291 | + * below the last line the offset will always be the content length. All other vertical |
| 292 | + * positions will fall inside of one of the lines. |
| 293 | + * |
| 294 | + * @method |
| 295 | + * @returns {Integer} Index of rendered line closest to position |
| 296 | + */ |
| 297 | +es.ContentView.prototype.getRenderedLineIndexFromPosition = function( position ) { |
| 298 | + var lineCount = this.lines.length; |
| 299 | + // Positions above the first line always jump to the first offset |
| 300 | + if ( !lineCount || position.top < 0 ) { |
| 301 | + return 0; |
| 302 | + } |
| 303 | + // Find which line the position is inside of |
| 304 | + var i = 0, |
| 305 | + top = 0; |
| 306 | + while ( i < lineCount ) { |
| 307 | + top += this.lines[i].height; |
| 308 | + if ( position.top < top ) { |
| 309 | + break; |
| 310 | + } |
| 311 | + i++; |
| 312 | + } |
| 313 | + // Positions below the last line always jump to the last offset |
| 314 | + if ( i === lineCount ) { |
| 315 | + return i - 1; |
| 316 | + } |
| 317 | + return i; |
| 318 | +}; |
| 319 | + |
| 320 | +/** |
| 321 | + * Gets the range of the rendered line a given offset is within. |
| 322 | + * |
| 323 | + * Offsets that are out of range will always return the range of the last line. |
| 324 | + * |
| 325 | + * @method |
| 326 | + * @param {Integer} offset Offset to get line for |
| 327 | + * @returns {es.Range} Range of line offset is within |
| 328 | + */ |
| 329 | +es.ContentView.prototype.getRenderedLineRangeFromOffset = function( offset ) { |
| 330 | + for ( var i = 0; i < this.lines.length; i++ ) { |
| 331 | + if ( this.lines[i].range.containsOffset( offset ) ) { |
| 332 | + return this.lines[i].range; |
| 333 | + } |
| 334 | + } |
| 335 | + return this.lines[this.lines.length - 1].range; |
| 336 | +}; |
| 337 | + |
| 338 | +/** |
| 339 | + * Gets offset within content model closest to of a given position. |
| 340 | + * |
| 341 | + * Position is assumed to be local to the container the text is being flowed in. |
| 342 | + * |
| 343 | + * @method |
| 344 | + * @param {Object} position Position to find offset for |
| 345 | + * @param {Integer} position.left Horizontal position in pixels |
| 346 | + * @param {Integer} position.top Vertical position in pixels |
| 347 | + * @returns {Integer} Offset within content model nearest the given coordinates |
| 348 | + */ |
| 349 | +es.ContentView.prototype.getOffsetFromRenderedPosition = function( position ) { |
| 350 | + // Empty content model shortcut |
| 351 | + if ( this.model.getContentLength() === 0 ) { |
| 352 | + return 0; |
| 353 | + } |
| 354 | + |
| 355 | + // Localize position |
| 356 | + position.subtract( es.Position.newFromElementPagePosition( this.$ ) ); |
| 357 | + |
| 358 | + // Get the line object nearest the position |
| 359 | + var line = this.lines[this.getRenderedLineIndexFromPosition( position )]; |
| 360 | + |
| 361 | + /* |
| 362 | + * Offset finding |
| 363 | + * |
| 364 | + * Now that we know which line we are on, we can just use the "fitCharacters" method to get the |
| 365 | + * last offset before "position.left". |
| 366 | + * |
| 367 | + * TODO: The offset needs to be chosen based on nearest offset to the cursor, not offset before |
| 368 | + * the cursor. |
| 369 | + */ |
| 370 | + var $ruler = $( '<div class="es-contentView-ruler"></div>' ).appendTo( this.$ ), |
| 371 | + ruler = $ruler[0], |
| 372 | + fit = this.fitCharacters( line.range, ruler, position.left ), |
| 373 | + center; |
| 374 | + ruler.innerHTML = this.getHtml( new es.Range( line.range.start, fit.end ) ); |
| 375 | + if ( fit.end < this.model.getContentLength() ) { |
| 376 | + var left = ruler.clientWidth; |
| 377 | + ruler.innerHTML = this.getHtml( new es.Range( line.range.start, fit.end + 1 ) ); |
| 378 | + center = Math.round( left + ( ( ruler.clientWidth - left ) / 2 ) ); |
| 379 | + } else { |
| 380 | + center = ruler.clientWidth; |
| 381 | + } |
| 382 | + $ruler.remove(); |
| 383 | + // Reset RegExp object's state |
| 384 | + this.boundaryTest.lastIndex = 0; |
| 385 | + return Math.min( |
| 386 | + // If the position is right of the center of the character it's on top of, increment offset |
| 387 | + fit.end + ( position.left >= center ? 1 : 0 ), |
| 388 | + // Don't allow the value to be higher than the end |
| 389 | + line.range.end |
| 390 | + ); |
| 391 | +}; |
| 392 | + |
| 393 | +/** |
| 394 | + * Gets position coordinates of a given offset. |
| 395 | + * |
| 396 | + * Offsets are boundaries between plain or annotated characters within content model. Results are |
| 397 | + * given in left, top and bottom positions, which could be used to draw a cursor, highlighting, etc. |
| 398 | + * |
| 399 | + * @method |
| 400 | + * @param {Integer} offset Offset within content model |
| 401 | + * @returns {Object} Object containing left, top and bottom properties, each positions in pixels as |
| 402 | + * well as a line index |
| 403 | + */ |
| 404 | +es.ContentView.prototype.getRenderedPositionFromOffset = function( offset, leftBias ) { |
| 405 | + /* |
| 406 | + * Range validation |
| 407 | + * |
| 408 | + * Rather than clamping the range, which can hide errors, exceptions will be thrown if offset is |
| 409 | + * less than 0 or greater than the length of the content model. |
| 410 | + */ |
| 411 | + if ( offset < 0 ) { |
| 412 | + throw 'Out of range error. Offset is expected to be greater than or equal to 0.'; |
| 413 | + } else if ( offset > this.model.getContentLength() ) { |
| 414 | + throw 'Out of range error. Offset is expected to be less than or equal to text length.'; |
| 415 | + } |
| 416 | + /* |
| 417 | + * Line finding |
| 418 | + * |
| 419 | + * It's possible that a more efficient method could be used here, but the number of lines to be |
| 420 | + * iterated through will rarely be over 100, so it's unlikely that any significant gains will be |
| 421 | + * had. Plus, as long as we are iterating over each line, we can also sum up the top and bottom |
| 422 | + * positions, which is a nice benefit of this method. |
| 423 | + */ |
| 424 | + var line, |
| 425 | + lineCount = this.lines.length, |
| 426 | + lineIndex = 0, |
| 427 | + position = new es.Position(); |
| 428 | + while ( lineIndex < lineCount ) { |
| 429 | + line = this.lines[lineIndex]; |
| 430 | + if ( line.range.containsOffset( offset ) || ( leftBias && line.range.end === offset ) ) { |
| 431 | + position.bottom = position.top + line.height; |
| 432 | + break; |
| 433 | + } |
| 434 | + position.top += line.height; |
| 435 | + lineIndex++; |
| 436 | + } |
| 437 | + /* |
| 438 | + * Virtual n+1 position |
| 439 | + * |
| 440 | + * To allow access to position information of the right side of the last character on the last |
| 441 | + * line, a virtual n+1 position is supported. Offsets beyond this virtual position will cause |
| 442 | + * an exception to be thrown. |
| 443 | + */ |
| 444 | + if ( lineIndex === lineCount ) { |
| 445 | + position.bottom = position.top; |
| 446 | + position.top -= line.height; |
| 447 | + } |
| 448 | + /* |
| 449 | + * Offset measuring |
| 450 | + * |
| 451 | + * Since the left position will be zero for the first character in the line, so we can skip |
| 452 | + * measuring for those cases. |
| 453 | + */ |
| 454 | + if ( line.range.start < offset ) { |
| 455 | + var $ruler = $( '<div class="es-contentView-ruler"></div>' ).appendTo( this.$ ), |
| 456 | + ruler = $ruler[0]; |
| 457 | + ruler.innerHTML = this.getHtml( new es.Range( line.range.start, offset ) ); |
| 458 | + position.left = ruler.clientWidth; |
| 459 | + $ruler.remove(); |
| 460 | + } |
| 461 | + return position; |
| 462 | +}; |
| 463 | + |
| 464 | +/** |
| 465 | + * Updates the word boundary cache, which is used for word fitting. |
| 466 | + * |
| 467 | + * @method |
| 468 | + */ |
| 469 | +es.ContentView.prototype.scanBoundaries = function() { |
| 470 | + /* |
| 471 | + * Word boundary scan |
| 472 | + * |
| 473 | + * To perform binary-search on words, rather than characters, we need to collect word boundary |
| 474 | + * offsets into an array. The offset of the right side of the breaking character is stored, so |
| 475 | + * the gaps between stored offsets always include the breaking character at the end. |
| 476 | + * |
| 477 | + * To avoid encoding the same words as HTML over and over while fitting text to lines, we also |
| 478 | + * build a list of HTML escaped strings for each gap between the offsets stored in the |
| 479 | + * "boundaries" array. Slices of the "words" array can be joined, producing the escaped HTML of |
| 480 | + * the words. |
| 481 | + */ |
| 482 | + // Get and cache a copy of all content, the make a plain-text version of the cached content |
| 483 | + var data = this.contentCache = this.model.getContent(), |
| 484 | + text = ''; |
| 485 | + debugger; |
| 486 | + for ( var i = 0, length = data.length; i < length; i++ ) { |
| 487 | + text += typeof data[i] === 'string' ? data[i] : data[i][0]; |
| 488 | + } |
| 489 | + // Purge "boundaries" and "words" arrays |
| 490 | + this.boundaries = [0]; |
| 491 | + // Reset RegExp object's state |
| 492 | + this.boundaryTest.lastIndex = 0; |
| 493 | + // Iterate over each word+boundary sequence, capturing offsets and encoding text as we go |
| 494 | + var match, |
| 495 | + end; |
| 496 | + while ( ( match = this.boundaryTest.exec( text ) ) ) { |
| 497 | + // Include the boundary character in the range |
| 498 | + end = match.index + 1; |
| 499 | + // Store the boundary offset |
| 500 | + this.boundaries.push( end ); |
| 501 | + } |
| 502 | + // If the last character is not a boundary character, we need to append the final range to the |
| 503 | + // "boundaries" and "words" arrays |
| 504 | + if ( end < text.length || this.boundaries.length === 1 ) { |
| 505 | + this.boundaries.push( text.length ); |
| 506 | + } |
| 507 | +}; |
| 508 | + |
| 509 | +/** |
| 510 | + * Renders a batch of lines and then yields execution before rendering another batch. |
| 511 | + * |
| 512 | + * In cases where a single word is too long to fit on a line, the word will be "virtually" wrapped, |
| 513 | + * causing them to be fragmented. Word fragments are rendered on their own lines, except for their |
| 514 | + * remainder, which is combined with whatever proceeding words can fit on the same line. |
| 515 | + * |
| 516 | + * @method |
| 517 | + * @param {Integer} limit Maximum number of iterations to render before yeilding |
| 518 | + */ |
| 519 | +es.ContentView.prototype.renderIteration = function( limit ) { |
| 520 | + var rs = this.renderState, |
| 521 | + iteration = 0, |
| 522 | + fractional = false, |
| 523 | + lineStart = this.boundaries[rs.wordOffset], |
| 524 | + lineEnd, |
| 525 | + wordFit = null, |
| 526 | + charOffset = 0, |
| 527 | + charFit = null, |
| 528 | + wordCount = this.boundaries.length; |
| 529 | + while ( ++iteration <= limit && rs.wordOffset < wordCount - 1 ) { |
| 530 | + wordFit = this.fitWords( new es.Range( rs.wordOffset, wordCount - 1 ), rs.ruler, rs.width ); |
| 531 | + fractional = false; |
| 532 | + if ( wordFit.width > rs.width ) { |
| 533 | + // The first word didn't fit, we need to split it up |
| 534 | + charOffset = lineStart; |
| 535 | + var lineOffset = rs.wordOffset; |
| 536 | + rs.wordOffset++; |
| 537 | + lineEnd = this.boundaries[rs.wordOffset]; |
| 538 | + do { |
| 539 | + charFit = this.fitCharacters( |
| 540 | + new es.Range( charOffset, lineEnd ), rs.ruler, rs.width |
| 541 | + ); |
| 542 | + // If we were able to get the rest of the characters on the line OK |
| 543 | + if ( charFit.end === lineEnd) { |
| 544 | + // Try to fit more words on the line |
| 545 | + wordFit = this.fitWords( |
| 546 | + new es.Range( rs.wordOffset, wordCount - 1 ), |
| 547 | + rs.ruler, |
| 548 | + rs.width - charFit.width |
| 549 | + ); |
| 550 | + if ( wordFit.end > rs.wordOffset ) { |
| 551 | + lineOffset = rs.wordOffset; |
| 552 | + rs.wordOffset = wordFit.end; |
| 553 | + charFit.end = lineEnd = this.boundaries[rs.wordOffset]; |
| 554 | + } |
| 555 | + } |
| 556 | + this.appendLine( new es.Range( charOffset, charFit.end ), lineOffset, fractional ); |
| 557 | + // Move on to another line |
| 558 | + charOffset = charFit.end; |
| 559 | + // Mark the next line as fractional |
| 560 | + fractional = true; |
| 561 | + } while ( charOffset < lineEnd ); |
| 562 | + } else { |
| 563 | + lineEnd = this.boundaries[wordFit.end]; |
| 564 | + this.appendLine( new es.Range( lineStart, lineEnd ), rs.wordOffset, fractional ); |
| 565 | + rs.wordOffset = wordFit.end; |
| 566 | + } |
| 567 | + lineStart = lineEnd; |
| 568 | + } |
| 569 | + // Only perform on actual last iteration |
| 570 | + if ( rs.wordOffset >= wordCount - 1 ) { |
| 571 | + // Cleanup |
| 572 | + rs.$ruler.remove(); |
| 573 | + this.lines = rs.lines; |
| 574 | + this.$.find( '.es-contentView-line[line-index=' + ( this.lines.length - 1 ) + ']' ) |
| 575 | + .nextAll() |
| 576 | + .remove(); |
| 577 | + rs.timeout = undefined; |
| 578 | + this.emit( 'update' ); |
| 579 | + } else { |
| 580 | + rs.ruler.innerHTML = ''; |
| 581 | + var that = this; |
| 582 | + rs.timeout = setTimeout( function() { |
| 583 | + that.renderIteration( 3 ); |
| 584 | + }, 0 ); |
| 585 | + } |
| 586 | +}; |
| 587 | + |
| 588 | +/** |
| 589 | + * Renders text into a series of HTML elements, each a single line of wrapped text. |
| 590 | + * |
| 591 | + * The offset parameter can be used to reduce the amount of work involved in re-rendering the same |
| 592 | + * text, but will be automatically ignored if the text or width of the container has changed. |
| 593 | + * |
| 594 | + * Rendering happens asynchronously, and yields execution between iterations. Iterative rendering |
| 595 | + * provides the JavaScript engine an ability to process events between rendering batches of lines, |
| 596 | + * allowing rendering to be interrupted and restarted if changes to content model are happening before |
| 597 | + * rendering of all lines is complete. |
| 598 | + * |
| 599 | + * @method |
| 600 | + * @param {Integer} offset Offset to re-render from, if possible (not yet implemented) |
| 601 | + */ |
| 602 | +es.ContentView.prototype.render = function( offset ) { |
| 603 | + var rs = this.renderState; |
| 604 | + // Check if rendering is currently underway |
| 605 | + if ( rs.timeout !== undefined ) { |
| 606 | + // Cancel the active rendering process |
| 607 | + clearTimeout( rs.timeout ); |
| 608 | + // Cleanup |
| 609 | + rs.$ruler.remove(); |
| 610 | + } |
| 611 | + // Clear caches that were specific to the previous render |
| 612 | + this.widthCache = {}; |
| 613 | + // In case of empty content model we still want to display empty with non-breaking space inside |
| 614 | + // This is very important for lists |
| 615 | + if(this.model.getContentLength() === 0) { |
| 616 | + var $line = $( '<div class="es-contentView-line" line-index="0"> </div>' ); |
| 617 | + this.$.empty().append( $line ); |
| 618 | + this.lines = [{ |
| 619 | + 'text': ' ', |
| 620 | + 'range': new es.Range( 0,0 ), |
| 621 | + 'width': 0, |
| 622 | + 'height': $line.outerHeight(), |
| 623 | + 'wordOffset': 0, |
| 624 | + 'fractional': false |
| 625 | + }]; |
| 626 | + this.emit( 'update' ); |
| 627 | + return; |
| 628 | + } |
| 629 | + /* |
| 630 | + * Container measurement |
| 631 | + * |
| 632 | + * To get an accurate measurement of the inside of the container, without having to deal with |
| 633 | + * inconsistencies between browsers and box models, we can just create an element inside the |
| 634 | + * container and measure it. |
| 635 | + */ |
| 636 | + rs.$ruler = $( '<div> </div>' ).appendTo( this.$ ); |
| 637 | + rs.width = rs.$ruler.innerWidth(); |
| 638 | + rs.ruler = rs.$ruler.addClass('es-contentView-ruler')[0]; |
| 639 | + // Ignore offset optimization if the width has changed or the text has never been flowed before |
| 640 | + if (this.width !== rs.width) { |
| 641 | + offset = undefined; |
| 642 | + } |
| 643 | + this.width = rs.width; |
| 644 | + // Reset the render state |
| 645 | + if ( offset ) { |
| 646 | + var gap, |
| 647 | + currentLine = this.lines.length - 1; |
| 648 | + for ( var i = this.lines.length - 1; i >= 0; i-- ) { |
| 649 | + var line = this.lines[i]; |
| 650 | + if ( line.range.start < offset && line.range.end > offset ) { |
| 651 | + currentLine = i; |
| 652 | + } |
| 653 | + if ( ( line.range.end < offset && !line.fractional ) || i === 0 ) { |
| 654 | + rs.lines = this.lines.slice( 0, i ); |
| 655 | + rs.wordOffset = line.wordOffset; |
| 656 | + gap = currentLine - i; |
| 657 | + break; |
| 658 | + } |
| 659 | + } |
| 660 | + this.renderIteration( 2 + gap ); |
| 661 | + } else { |
| 662 | + rs.lines = []; |
| 663 | + rs.wordOffset = 0; |
| 664 | + this.renderIteration( 3 ); |
| 665 | + } |
| 666 | +}; |
| 667 | + |
| 668 | +/** |
| 669 | + * Adds a line containing a given range of text to the end of the DOM and the "lines" array. |
| 670 | + * |
| 671 | + * @method |
| 672 | + * @param {es.Range} range Range of data within content model to append |
| 673 | + * @param {Integer} start Beginning of text range for line |
| 674 | + * @param {Integer} end Ending of text range for line |
| 675 | + * @param {Integer} wordOffset Index within this.words which the line begins with |
| 676 | + * @param {Boolean} fractional If the line begins in the middle of a word |
| 677 | + */ |
| 678 | +es.ContentView.prototype.appendLine = function( range, wordOffset, fractional ) { |
| 679 | + var rs = this.renderState, |
| 680 | + lineCount = rs.lines.length, |
| 681 | + $line = this.$.children( '[line-index=' + lineCount + ']' ); |
| 682 | + if ( !$line.length ) { |
| 683 | + $line = $( |
| 684 | + '<div class="es-contentView-line" line-index="' + lineCount + '"></div>' |
| 685 | + ); |
| 686 | + this.$.append( $line ); |
| 687 | + } |
| 688 | + $line[0].innerHTML = this.getHtml( range ); |
| 689 | + // Collect line information |
| 690 | + rs.lines.push({ |
| 691 | + 'text': this.model.getText( range ), |
| 692 | + 'range': range, |
| 693 | + 'width': $line.outerWidth(), |
| 694 | + 'height': $line.outerHeight(), |
| 695 | + 'wordOffset': wordOffset, |
| 696 | + 'fractional': fractional |
| 697 | + }); |
| 698 | + // Disable links within rendered content |
| 699 | + $line.find( '.es-contentView-format-object a' ) |
| 700 | + .mousedown( function( e ) { |
| 701 | + e.preventDefault(); |
| 702 | + } ) |
| 703 | + .click( function( e ) { |
| 704 | + e.preventDefault(); |
| 705 | + } ); |
| 706 | +}; |
| 707 | + |
| 708 | +/** |
| 709 | + * Gets the index of the boundary of last word that fits inside the line |
| 710 | + * |
| 711 | + * The "words" and "boundaries" arrays provide linear access to the offsets around non-breakable |
| 712 | + * areas within the text. Using these, we can perform a binary-search for the best fit of words |
| 713 | + * within a line, just as we would with characters. |
| 714 | + * |
| 715 | + * Results are given as an object containing both an index and a width, the later of which can be |
| 716 | + * used to detect when the first word was too long to fit on a line. In such cases the result will |
| 717 | + * contain the index of the boundary of the first word and it's width. |
| 718 | + * |
| 719 | + * TODO: Because limit is most likely given as "words.length", it may be possible to improve the |
| 720 | + * efficiency of this code by making a best guess and working from there, rather than always |
| 721 | + * starting with [offset .. limit], which usually results in reducing the end position in all but |
| 722 | + * the last line, and in most cases more than 3 times, before changing directions. |
| 723 | + * |
| 724 | + * @method |
| 725 | + * @param {es.Range} range Range of data within content model to try to fit |
| 726 | + * @param {HTMLElement} ruler Element to take measurements with |
| 727 | + * @param {Integer} width Maximum width to allow the line to extend to |
| 728 | + * @returns {Integer} Last index within "words" that contains a word that fits |
| 729 | + */ |
| 730 | +es.ContentView.prototype.fitWords = function( range, ruler, width ) { |
| 731 | + var offset = range.start, |
| 732 | + start = range.start, |
| 733 | + end = range.end, |
| 734 | + charOffset = this.boundaries[offset], |
| 735 | + middle, |
| 736 | + charMiddle, |
| 737 | + lineWidth, |
| 738 | + cacheKey; |
| 739 | + do { |
| 740 | + // Place "middle" directly in the center of "start" and "end" |
| 741 | + middle = Math.ceil( ( start + end ) / 2 ); |
| 742 | + charMiddle = this.boundaries[middle]; |
| 743 | + // Measure and cache width of substring |
| 744 | + cacheKey = charOffset + ':' + charMiddle; |
| 745 | + // Prepare the line for measurement using pre-escaped HTML |
| 746 | + ruler.innerHTML = this.getHtml( new es.Range( charOffset, charMiddle ) ); |
| 747 | + // Test for over/under using width of the rendered line |
| 748 | + this.widthCache[cacheKey] = lineWidth = ruler.clientWidth; |
| 749 | + // Test for over/under using width of the rendered line |
| 750 | + if ( lineWidth > width ) { |
| 751 | + // Detect impossible fit (the first word won't fit by itself) |
| 752 | + if (middle - offset === 1) { |
| 753 | + start = middle; |
| 754 | + break; |
| 755 | + } |
| 756 | + // Words after "middle" won't fit |
| 757 | + end = middle - 1; |
| 758 | + } else { |
| 759 | + // Words before "middle" will fit |
| 760 | + start = middle; |
| 761 | + } |
| 762 | + } while ( start < end ); |
| 763 | + // Check if we ended by moving end to the left of middle |
| 764 | + if ( end === middle - 1 ) { |
| 765 | + // A final measurement is required |
| 766 | + var charStart = this.boundaries[start]; |
| 767 | + ruler.innerHTML = this.getHtml( new es.Range( charOffset, charStart ) ); |
| 768 | + lineWidth = this.widthCache[charOffset + ':' + charStart] = ruler.clientWidth; |
| 769 | + } |
| 770 | + return { 'end': start, 'width': lineWidth }; |
| 771 | +}; |
| 772 | + |
| 773 | +/** |
| 774 | + * Gets the index of the boundary of the last character that fits inside the line |
| 775 | + * |
| 776 | + * Results are given as an object containing both an index and a width, the later of which can be |
| 777 | + * used to detect when the first character was too long to fit on a line. In such cases the result |
| 778 | + * will contain the index of the first character and it's width. |
| 779 | + * |
| 780 | + * @method |
| 781 | + * @param {es.Range} range Range of data within content model to try to fit |
| 782 | + * @param {HTMLElement} ruler Element to take measurements with |
| 783 | + * @param {Integer} width Maximum width to allow the line to extend to |
| 784 | + * @returns {Integer} Last index within "text" that contains a character that fits |
| 785 | + */ |
| 786 | +es.ContentView.prototype.fitCharacters = function( range, ruler, width ) { |
| 787 | + var offset = range.start, |
| 788 | + start = range.start, |
| 789 | + end = range.end, |
| 790 | + middle, |
| 791 | + lineWidth, |
| 792 | + cacheKey; |
| 793 | + do { |
| 794 | + // Place "middle" directly in the center of "start" and "end" |
| 795 | + middle = Math.ceil( ( start + end ) / 2 ); |
| 796 | + // Measure and cache width of substring |
| 797 | + cacheKey = offset + ':' + middle; |
| 798 | + if ( cacheKey in this.widthCache ) { |
| 799 | + lineWidth = this.widthCache[cacheKey]; |
| 800 | + } else { |
| 801 | + // Fill the line with a portion of the text, escaped as HTML |
| 802 | + ruler.innerHTML = this.getHtml( new es.Range( offset, middle ) ); |
| 803 | + // Test for over/under using width of the rendered line |
| 804 | + this.widthCache[cacheKey] = lineWidth = ruler.clientWidth; |
| 805 | + } |
| 806 | + if ( lineWidth > width ) { |
| 807 | + // Detect impossible fit (the first character won't fit by itself) |
| 808 | + if (middle - offset === 1) { |
| 809 | + start = middle - 1; |
| 810 | + break; |
| 811 | + } |
| 812 | + // Words after "middle" won't fit |
| 813 | + end = middle - 1; |
| 814 | + } else { |
| 815 | + // Words before "middle" will fit |
| 816 | + start = middle; |
| 817 | + } |
| 818 | + } while ( start < end ); |
| 819 | + // Check if we ended by moving end to the left of middle |
| 820 | + if ( end === middle - 1 ) { |
| 821 | + // Try for cache hit |
| 822 | + cacheKey = offset + ':' + start; |
| 823 | + if ( cacheKey in this.widthCache ) { |
| 824 | + lineWidth = this.widthCache[cacheKey]; |
| 825 | + } else { |
| 826 | + // A final measurement is required |
| 827 | + ruler.innerHTML = this.getHtml( new es.Range( offset, start ) ); |
| 828 | + lineWidth = this.widthCache[cacheKey] = ruler.clientWidth; |
| 829 | + } |
| 830 | + } |
| 831 | + return { 'end': start, 'width': lineWidth }; |
| 832 | +}; |
| 833 | + |
| 834 | +/** |
| 835 | + * Gets an HTML rendering of a range of data within content model. |
| 836 | + * |
| 837 | + * @method |
| 838 | + * @param {es.Range} range Range of content to render |
| 839 | + * @param {String} Rendered HTML of data within content model |
| 840 | + */ |
| 841 | +es.ContentView.prototype.getHtml = function( range, options ) { |
| 842 | + if ( range ) { |
| 843 | + range.normalize(); |
| 844 | + } else { |
| 845 | + range = { 'start': 0, 'end': undefined }; |
| 846 | + } |
| 847 | + var data = this.contentCache.slice( range.start, range.end ), |
| 848 | + render = es.ContentView.renderAnnotation, |
| 849 | + htmlChars = es.ContentView.htmlCharacters; |
| 850 | + var out = '', |
| 851 | + left = '', |
| 852 | + right, |
| 853 | + leftPlain, |
| 854 | + rightPlain, |
| 855 | + stack = [], |
| 856 | + i, |
| 857 | + j; |
| 858 | + for ( i = 0; i < data.length; i++ ) { |
| 859 | + right = data[i]; |
| 860 | + leftPlain = typeof left === 'string'; |
| 861 | + rightPlain = typeof right === 'string'; |
| 862 | + if ( !leftPlain && rightPlain ) { |
| 863 | + // [formatted][plain] pair, close any annotations for left |
| 864 | + for ( j = 1; j < left.length; j++ ) { |
| 865 | + out += render( 'close', left[j], stack ); |
| 866 | + } |
| 867 | + } else if ( leftPlain && !rightPlain ) { |
| 868 | + // [plain][formatted] pair, open any annotations for right |
| 869 | + for ( j = 1; j < right.length; j++ ) { |
| 870 | + out += render( 'open', right[j], stack ); |
| 871 | + } |
| 872 | + } else if ( !leftPlain && !rightPlain ) { |
| 873 | + // [formatted][formatted] pair, open/close any differences |
| 874 | + for ( j = 1; j < left.length; j++ ) { |
| 875 | + if ( right.indexOf( left[j] ) === -1 ) { |
| 876 | + out += render( 'close', left[j], stack ); |
| 877 | + } |
| 878 | + } |
| 879 | + for ( j = 1; j < right.length; j++ ) { |
| 880 | + if ( left.indexOf( right[j] ) === -1 ) { |
| 881 | + out += render( 'open', right[j], stack ); |
| 882 | + } |
| 883 | + } |
| 884 | + } |
| 885 | + out += right[0] in htmlChars ? htmlChars[right[0]] : right[0]; |
| 886 | + left = right; |
| 887 | + } |
| 888 | + // Close all remaining tags at the end of the content |
| 889 | + if ( !rightPlain && right ) { |
| 890 | + for ( j = 1; j < right.length; j++ ) { |
| 891 | + out += render( 'close', right[j], stack ); |
| 892 | + } |
| 893 | + } |
| 894 | + return out; |
| 895 | +}; |
| 896 | + |
| 897 | +/* Inheritance */ |
| 898 | + |
| 899 | +es.extendClass( es.ContentView, es.EventEmitter ); |
Index: trunk/extensions/VisualEditor/modules/views/es.ListItemView.js |
— | — | @@ -0,0 +1,49 @@ |
| 2 | +/** |
| 3 | + * Creates an es.ListItemView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewLeafNode} |
| 8 | + * @param {es.ListItemModel} model List item model to view |
| 9 | + */ |
| 10 | +es.ListItemView = function( model ) { |
| 11 | + // Inheritance |
| 12 | + es.DocumentViewLeafNode.call( this, model ); |
| 13 | + |
| 14 | + // Properties |
| 15 | + this.$icon = $( '<div class="es-listItemView-icon"></div>' ).prependTo( this.$ ); |
| 16 | + |
| 17 | + // DOM Changes |
| 18 | + this.$.addClass( 'es-listItemView' ); |
| 19 | + |
| 20 | + // Events |
| 21 | + this.on( 'update', this.setClasses ); |
| 22 | + |
| 23 | + // Initialization |
| 24 | + this.setClasses(); |
| 25 | +}; |
| 26 | + |
| 27 | +es.ListItemView.prototype.setClasses = function() { |
| 28 | + var classes = this.$.attr( 'class' ), |
| 29 | + styles = this.model.getElementAttribute( 'styles' ); |
| 30 | + this.$ |
| 31 | + // Remove any existing level classes |
| 32 | + .attr( |
| 33 | + 'class', |
| 34 | + classes |
| 35 | + .replace( /es-listItemView-level[0-9]+/, '' ) |
| 36 | + .replace( /es-listItemView-(bullet|number)/, '' ) |
| 37 | + ) |
| 38 | + // Set the list style class from the style on top of the stack |
| 39 | + .addClass( 'es-listItemView-' + styles[styles.length - 1] ) |
| 40 | + // Set the list level class from the length of the stack |
| 41 | + .addClass( 'es-listItemView-level' + ( styles.length - 1 ) ); |
| 42 | +}; |
| 43 | + |
| 44 | +es.ListItemView.prototype.setNumber = function( number ) { |
| 45 | + this.$icon.text( number + '.' ); |
| 46 | +}; |
| 47 | + |
| 48 | +/* Inheritance */ |
| 49 | + |
| 50 | +es.extendClass( es.ListItemView, es.DocumentViewLeafNode ); |
Index: trunk/extensions/VisualEditor/modules/views/es.ListView.js |
— | — | @@ -0,0 +1,45 @@ |
| 2 | +/** |
| 3 | + * Creates an es.ListView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewBranchNode} |
| 8 | + * @param {es.ListModel} model List model to view |
| 9 | + */ |
| 10 | +es.ListView = function( model ) { |
| 11 | + // Inheritance |
| 12 | + es.DocumentViewBranchNode.call( this, model ); |
| 13 | + |
| 14 | + // DOM Changes |
| 15 | + this.$.addClass( 'es-listView' ); |
| 16 | + |
| 17 | + // Events |
| 18 | + this.on( 'update', this.enumerate ); |
| 19 | + |
| 20 | + // Initialization |
| 21 | + this.enumerate(); |
| 22 | +}; |
| 23 | + |
| 24 | +/** |
| 25 | + * Set the number labels of all ordered list items. |
| 26 | + * |
| 27 | + * @method |
| 28 | + */ |
| 29 | +es.ListView.prototype.enumerate = function() { |
| 30 | + var styles, |
| 31 | + levels = []; |
| 32 | + for ( var i = 0; i < this.children.length; i++ ) { |
| 33 | + styles = this.children[i].model.getElementAttribute( 'styles' ); |
| 34 | + levels = levels.slice( 0, styles.length ); |
| 35 | + if ( styles[styles.length - 1] === 'number' ) { |
| 36 | + if ( !levels[styles.length - 1] ) { |
| 37 | + levels[styles.length - 1] = 0; |
| 38 | + } |
| 39 | + this.children[i].setNumber( ++levels[styles.length - 1] ); |
| 40 | + } |
| 41 | + } |
| 42 | +}; |
| 43 | + |
| 44 | +/* Inheritance */ |
| 45 | + |
| 46 | +es.extendClass( es.ListView, es.DocumentViewBranchNode ); |
Index: trunk/extensions/VisualEditor/modules/views/es.DocumentView.js |
— | — | @@ -0,0 +1,35 @@ |
| 2 | +/** |
| 3 | + * Creates an es.DocumentView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewBranchNode} |
| 8 | + * @param {es.DocumentModel} documentModel Document model to view |
| 9 | + * @param {es.SurfaceView} surfaceView Surface view this view is a child of |
| 10 | + */ |
| 11 | +es.DocumentView = function( model, surfaceView ) { |
| 12 | + // Inheritance |
| 13 | + es.DocumentViewBranchNode.call( this, model ); |
| 14 | + |
| 15 | + // Properties |
| 16 | + this.surfaceView = surfaceView; |
| 17 | + |
| 18 | + // DOM Changes |
| 19 | + this.$.addClass( 'es-documentView' ); |
| 20 | +}; |
| 21 | + |
| 22 | +/** |
| 23 | + * Get the document offset of a position created from passed DOM event |
| 24 | + * |
| 25 | + * @method |
| 26 | + * @param e {Event} Event to create es.Position from |
| 27 | + * @returns {Integer} Document offset |
| 28 | + */ |
| 29 | +es.DocumentView.prototype.getOffsetFromEvent = function( e ) { |
| 30 | + var position = es.Position.newFromEventPagePosition( e ); |
| 31 | + return this.getOffsetFromRenderedPosition( position ); |
| 32 | +}; |
| 33 | + |
| 34 | +/* Inheritance */ |
| 35 | + |
| 36 | +es.extendClass( es.DocumentView, es.DocumentViewBranchNode ); |
Index: trunk/extensions/VisualEditor/modules/views/es.HeadingView.js |
— | — | @@ -0,0 +1,37 @@ |
| 2 | +/** |
| 3 | + * Creates an es.HeadingView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewLeafNode} |
| 8 | + * @param {es.HeadingModel} model Heading model to view |
| 9 | + */ |
| 10 | +es.HeadingView = function( model ) { |
| 11 | + // Inheritance |
| 12 | + es.DocumentViewLeafNode.call( this, model, $( '<h' + model.getElementAttribute( 'level' ) + '>') ); |
| 13 | + |
| 14 | + // DOM Changes |
| 15 | + this.$.addClass( 'es-headingView' ); |
| 16 | + |
| 17 | + // Events |
| 18 | + this.on( 'update', this.setClasses ); |
| 19 | + |
| 20 | + // Initialization |
| 21 | + this.setClasses(); |
| 22 | +}; |
| 23 | + |
| 24 | +/* Methods */ |
| 25 | + |
| 26 | +es.HeadingView.prototype.setClasses = function() { |
| 27 | + var classes = this.$.attr( 'class' ), |
| 28 | + level = this.model.getElementAttribute( 'level' ); |
| 29 | + this.$ |
| 30 | + // Remove any existing level classes |
| 31 | + .attr( 'class', classes.replace( /es-headingView-level[1-6]/, '' ) ) |
| 32 | + // Add a new level class |
| 33 | + .addClass( 'es-headingView-level' + level ); |
| 34 | +}; |
| 35 | + |
| 36 | +/* Inheritance */ |
| 37 | + |
| 38 | +es.extendClass( es.HeadingView, es.DocumentViewLeafNode ); |
Index: trunk/extensions/VisualEditor/modules/views/es.TableRowView.js |
— | — | @@ -0,0 +1,21 @@ |
| 2 | +/** |
| 3 | + * Creates an es.TableRowView object. |
| 4 | + * |
| 5 | + * @class |
| 6 | + * @constructor |
| 7 | + * @extends {es.DocumentViewBranchNode} |
| 8 | + * @param {es.TableRowModel} model Table row model to view |
| 9 | + */ |
| 10 | +es.TableRowView = function( model ) { |
| 11 | + // Inheritance |
| 12 | + es.DocumentViewBranchNode.call( this, model, $( '<tr>' ), true ); |
| 13 | + |
| 14 | + // DOM Changes |
| 15 | + this.$ |
| 16 | + .attr( 'style', model.getElementAttribute( 'html/style' ) ) |
| 17 | + .addClass( 'es-tableRowView' ); |
| 18 | +}; |
| 19 | + |
| 20 | +/* Inheritance */ |
| 21 | + |
| 22 | +es.extendClass( es.TableRowView, es.DocumentViewBranchNode ); |