/** * Creates an es.ContentView object. * * A content view flows text into a DOM element and provides methods to get information about the * rendered output. HTML serialized specifically for rendering into and editing surface. * * Rendering occurs automatically when content is modified, by responding to "update" events from * the model. Rendering is iterative and interruptable to reduce user feedback latency. * * TODO: Cleanup code and comments * * @class * @constructor * @param {jQuery} $container Element to render into * @param {es.ModelNode} model Model to produce view for * @property {jQuery} $ * @property {es.ContentModel} model * @property {Array} boundaries * @property {Array} lines * @property {Integer} width * @property {RegExp} bondaryTest * @property {Object} widthCache * @property {Object} renderState * @property {Object} contentCache */ es.ContentView = function( $container, model ) { // Inheritance es.EventEmitter.call( this ); // Properties this.$ = $container; this.model = model; this.boundaries = []; this.lines = []; this.width = null; this.boundaryTest = /([ \-\t\r\n\f])/g; this.widthCache = {}; this.renderState = {}; this.contentCache = null; if ( model ) { // Events var _this = this; this.model.on( 'update', function( offset ) { _this.scanBoundaries(); _this.render( offset || 0 ); } ); // DOM Changes this.$ranges = $( '
' ); this.$rangeStart = $( '' ); this.$rangeFill = $( '' ); this.$rangeEnd = $( '' ); this.$.prepend( this.$ranges.append( this.$rangeStart, this.$rangeFill, this.$rangeEnd ) ); // Initialization this.scanBoundaries(); } }; /* Static Members */ /** * List of annotation rendering implementations. * * Each supported annotation renderer must have an open and close property, each either a string or * a function which accepts a data argument. * * @static * @member */ es.ContentView.annotationRenderers = { 'object/template': { 'open': function( data ) { return '' + data.html; }, 'close': '' }, 'object/hook': { 'open': function( data ) { return '' + data.html; }, 'close': '' }, 'textStyle/bold': { 'open': '', 'close': '' }, 'textStyle/italic': { 'open': '', 'close': '' }, 'textStyle/strong': { 'open': '', 'close': '' }, 'textStyle/emphasize': { 'open': '', 'close': '' }, 'textStyle/big': { 'open': '', 'close': '' }, 'textStyle/small': { 'open': '', 'close': '' }, 'textStyle/superScript': { 'open': '', 'close': '' }, 'textStyle/subScript': { 'open': '', 'close': '' }, 'link/external': { 'open': function( data ) { return ''; }, 'close': '' }, 'link/internal': { 'open': function( data ) { return ''; }, 'close': '' } }; /** * Mapping of character and HTML entities or renderings. * * @static * @member */ es.ContentView.htmlCharacters = { '&': '&', '<': '<', '>': '>', '\'': ''', '"': '"', '\n': '¶', '\t': '⇾', ' ': ' ' }; /* Static Methods */ /** * Gets a rendered opening or closing of an annotation. * * Tag nesting is handled using a stack, which keeps track of what is currently open. A common stack * argument should be used while rendering content. * * @static * @method * @param {String} bias Which side of the annotation to render, either "open" or "close" * @param {Object} annotation Annotation to render * @param {Array} stack List of currently open annotations * @returns {String} Rendered annotation */ es.ContentView.renderAnnotation = function( bias, annotation, stack ) { var renderers = es.ContentView.annotationRenderers, type = annotation.type, out = ''; if ( type in renderers ) { if ( bias === 'open' ) { // Add annotation to the top of the stack stack.push( annotation ); // Open annotation out += typeof renderers[type].open === 'function' ? renderers[type].open( annotation.data ) : renderers[type].open; } else { if ( stack[stack.length - 1] === annotation ) { // Remove annotation from top of the stack stack.pop(); // Close annotation out += typeof renderers[type].close === 'function' ? renderers[type].close( annotation.data ) : renderers[type].close; } else { // Find the annotation in the stack var depth = stack.indexOf( annotation ), i; if ( depth === -1 ) { throw 'Invalid stack error. An element is missing from the stack.'; } // Close each already opened annotation for ( i = stack.length - 1; i >= depth + 1; i-- ) { out += typeof renderers[stack[i].type].close === 'function' ? renderers[stack[i].type].close( stack[i].data ) : renderers[stack[i].type].close; } // Close the buried annotation out += typeof renderers[type].close === 'function' ? renderers[type].close( annotation.data ) : renderers[type].close; // Re-open each previously opened annotation for ( i = depth + 1; i < stack.length; i++ ) { out += typeof renderers[stack[i].type].open === 'function' ? renderers[stack[i].type].open( stack[i].data ) : renderers[stack[i].type].open; } // Remove the annotation from the middle of the stack stack.splice( depth, 1 ); } } } return out; }; /* Methods */ /** * Draws selection around a given range of content. * * @method * @param {es.Range} range Range to draw selection around */ es.ContentView.prototype.drawSelection = function( range ) { if ( typeof range === 'undefined' ) { range = new es.Range( 0, this.model.getContentLength() ); } else { range.normalize(); } var fromLineIndex = this.getRenderedLineIndexFromOffset( range.start ), toLineIndex = this.getRenderedLineIndexFromOffset( range.end ), fromPosition = this.getRenderedPositionFromOffset( range.start ), toPosition = this.getRenderedPositionFromOffset( range.end ); if ( fromLineIndex === toLineIndex ) { // Single line selection this.$rangeStart.css( { 'top': fromPosition.top, 'left': fromPosition.left, 'width': toPosition.left - fromPosition.left, 'height': fromPosition.bottom - fromPosition.top } ).show(); this.$rangeFill.hide(); this.$rangeEnd.hide(); } else { // Multiple line selection var contentWidth = this.$.width(); this.$rangeStart.css( { 'top': fromPosition.top, 'left': fromPosition.left, 'width': contentWidth - fromPosition.left, 'height': fromPosition.bottom - fromPosition.top } ).show(); this.$rangeEnd.css( { 'top': toPosition.top, 'left': 0, 'width': toPosition.left, 'height': toPosition.bottom - toPosition.top } ).show(); if ( fromLineIndex + 1 < toLineIndex ) { this.$rangeFill.css( { 'top': fromPosition.bottom, 'left': 0, 'width': contentWidth, 'height': toPosition.top - fromPosition.bottom } ).show(); } else { this.$rangeFill.hide(); } } }; /** * Clears selection if any was drawn. * * @method */ es.ContentView.prototype.clearSelection = function() { this.$rangeStart.hide(); this.$rangeFill.hide(); this.$rangeEnd.hide(); }; /** * Gets the index of the rendered line a given offset is within. * * Offsets that are out of range will always return the index of the last line. * * @method * @param {Integer} offset Offset to get line for * @returns {Integer} Index of rendered lin offset is within */ es.ContentView.prototype.getRenderedLineIndexFromOffset = function( offset ) { for ( var i = 0; i < this.lines.length; i++ ) { if ( this.lines[i].range.containsOffset( offset ) ) { return i; } } return this.lines.length - 1; }; /* * Gets the index of the rendered line closest to a given position. * * If the position is above the first line, the offset will always be 0, and if the position is * below the last line the offset will always be the content length. All other vertical * positions will fall inside of one of the lines. * * @method * @returns {Integer} Index of rendered line closest to position */ es.ContentView.prototype.getRenderedLineIndexFromPosition = function( position ) { var lineCount = this.lines.length; // Positions above the first line always jump to the first offset if ( !lineCount || position.top < 0 ) { return 0; } // Find which line the position is inside of var i = 0, top = 0; while ( i < lineCount ) { top += this.lines[i].height; if ( position.top < top ) { break; } i++; } // Positions below the last line always jump to the last offset if ( i === lineCount ) { return i - 1; } return i; }; /** * Gets the range of the rendered line a given offset is within. * * Offsets that are out of range will always return the range of the last line. * * @method * @param {Integer} offset Offset to get line for * @returns {es.Range} Range of line offset is within */ es.ContentView.prototype.getRenderedLineRangeFromOffset = function( offset ) { for ( var i = 0; i < this.lines.length; i++ ) { if ( this.lines[i].range.containsOffset( offset ) ) { return this.lines[i].range; } } return this.lines[this.lines.length - 1].range; }; /** * Gets offset within content model closest to of a given position. * * Position is assumed to be local to the container the text is being flowed in. * * @method * @param {Object} position Position to find offset for * @param {Integer} position.left Horizontal position in pixels * @param {Integer} position.top Vertical position in pixels * @returns {Integer} Offset within content model nearest the given coordinates */ es.ContentView.prototype.getOffsetFromRenderedPosition = function( position ) { // Empty content model shortcut if ( this.model.getContentLength() === 0 ) { return 0; } // Localize position position.subtract( es.Position.newFromElementPagePosition( this.$ ) ); // Get the line object nearest the position var line = this.lines[this.getRenderedLineIndexFromPosition( position )]; /* * Offset finding * * Now that we know which line we are on, we can just use the "fitCharacters" method to get the * last offset before "position.left". * * TODO: The offset needs to be chosen based on nearest offset to the cursor, not offset before * the cursor. */ var $ruler = $( '' ).appendTo( this.$ ), ruler = $ruler[0], fit = this.fitCharacters( line.range, ruler, position.left ), center; ruler.innerHTML = this.getHtml( new es.Range( line.range.start, fit.end ) ); if ( fit.end < this.model.getContentLength() ) { var left = ruler.clientWidth; ruler.innerHTML = this.getHtml( new es.Range( line.range.start, fit.end + 1 ) ); center = Math.round( left + ( ( ruler.clientWidth - left ) / 2 ) ); } else { center = ruler.clientWidth; } $ruler.remove(); // Reset RegExp object's state this.boundaryTest.lastIndex = 0; return Math.min( // If the position is right of the center of the character it's on top of, increment offset fit.end + ( position.left >= center ? 1 : 0 ), // Don't allow the value to be higher than the end line.range.end ); }; /** * Gets position coordinates of a given offset. * * Offsets are boundaries between plain or annotated characters within content model. Results are * given in left, top and bottom positions, which could be used to draw a cursor, highlighting, etc. * * @method * @param {Integer} offset Offset within content model * @returns {Object} Object containing left, top and bottom properties, each positions in pixels as * well as a line index */ es.ContentView.prototype.getRenderedPositionFromOffset = function( offset, leftBias ) { /* * Range validation * * Rather than clamping the range, which can hide errors, exceptions will be thrown if offset is * less than 0 or greater than the length of the content model. */ if ( offset < 0 ) { throw 'Out of range error. Offset is expected to be greater than or equal to 0.'; } else if ( offset > this.model.getContentLength() ) { throw 'Out of range error. Offset is expected to be less than or equal to text length.'; } /* * Line finding * * It's possible that a more efficient method could be used here, but the number of lines to be * iterated through will rarely be over 100, so it's unlikely that any significant gains will be * had. Plus, as long as we are iterating over each line, we can also sum up the top and bottom * positions, which is a nice benefit of this method. */ var line, lineCount = this.lines.length, lineIndex = 0, position = new es.Position(); while ( lineIndex < lineCount ) { line = this.lines[lineIndex]; if ( line.range.containsOffset( offset ) || ( leftBias && line.range.end === offset ) ) { position.bottom = position.top + line.height; break; } position.top += line.height; lineIndex++; } /* * Virtual n+1 position * * To allow access to position information of the right side of the last character on the last * line, a virtual n+1 position is supported. Offsets beyond this virtual position will cause * an exception to be thrown. */ if ( lineIndex === lineCount ) { position.bottom = position.top; position.top -= line.height; } /* * Offset measuring * * Since the left position will be zero for the first character in the line, so we can skip * measuring for those cases. */ if ( line.range.start < offset ) { var $ruler = $( '' ).appendTo( this.$ ), ruler = $ruler[0]; ruler.innerHTML = this.getHtml( new es.Range( line.range.start, offset ) ); position.left = ruler.clientWidth; $ruler.remove(); } return position; }; /** * Updates the word boundary cache, which is used for word fitting. * * @method */ es.ContentView.prototype.scanBoundaries = function() { /* * Word boundary scan * * To perform binary-search on words, rather than characters, we need to collect word boundary * offsets into an array. The offset of the right side of the breaking character is stored, so * the gaps between stored offsets always include the breaking character at the end. * * To avoid encoding the same words as HTML over and over while fitting text to lines, we also * build a list of HTML escaped strings for each gap between the offsets stored in the * "boundaries" array. Slices of the "words" array can be joined, producing the escaped HTML of * the words. */ // Get and cache a copy of all content, the make a plain-text version of the cached content var data = this.contentCache = this.model.getContent(), text = ''; for ( var i = 0, length = data.length; i < length; i++ ) { text += typeof data[i] === 'string' ? data[i] : data[i][0]; } // Purge "boundaries" and "words" arrays this.boundaries = [0]; // Reset RegExp object's state this.boundaryTest.lastIndex = 0; // Iterate over each word+boundary sequence, capturing offsets and encoding text as we go var match, end; while ( ( match = this.boundaryTest.exec( text ) ) ) { // Include the boundary character in the range end = match.index + 1; // Store the boundary offset this.boundaries.push( end ); } // If the last character is not a boundary character, we need to append the final range to the // "boundaries" and "words" arrays if ( end < text.length || this.boundaries.length === 1 ) { this.boundaries.push( text.length ); } }; /** * Renders a batch of lines and then yields execution before rendering another batch. * * In cases where a single word is too long to fit on a line, the word will be "virtually" wrapped, * causing them to be fragmented. Word fragments are rendered on their own lines, except for their * remainder, which is combined with whatever proceeding words can fit on the same line. * * @method * @param {Integer} limit Maximum number of iterations to render before yeilding */ es.ContentView.prototype.renderIteration = function( limit ) { var rs = this.renderState, iteration = 0, fractional = false, lineStart = this.boundaries[rs.wordOffset], lineEnd, wordFit = null, charOffset = 0, charFit = null, wordCount = this.boundaries.length; while ( ++iteration <= limit && rs.wordOffset < wordCount - 1 ) { wordFit = this.fitWords( new es.Range( rs.wordOffset, wordCount - 1 ), rs.ruler, rs.width ); fractional = false; if ( wordFit.width > rs.width ) { // The first word didn't fit, we need to split it up charOffset = lineStart; var lineOffset = rs.wordOffset; rs.wordOffset++; lineEnd = this.boundaries[rs.wordOffset]; do { charFit = this.fitCharacters( new es.Range( charOffset, lineEnd ), rs.ruler, rs.width ); // If we were able to get the rest of the characters on the line OK if ( charFit.end === lineEnd) { // Try to fit more words on the line wordFit = this.fitWords( new es.Range( rs.wordOffset, wordCount - 1 ), rs.ruler, rs.width - charFit.width ); if ( wordFit.end > rs.wordOffset ) { lineOffset = rs.wordOffset; rs.wordOffset = wordFit.end; charFit.end = lineEnd = this.boundaries[rs.wordOffset]; } } this.appendLine( new es.Range( charOffset, charFit.end ), lineOffset, fractional ); // Move on to another line charOffset = charFit.end; // Mark the next line as fractional fractional = true; } while ( charOffset < lineEnd ); } else { lineEnd = this.boundaries[wordFit.end]; this.appendLine( new es.Range( lineStart, lineEnd ), rs.wordOffset, fractional ); rs.wordOffset = wordFit.end; } lineStart = lineEnd; } // Only perform on actual last iteration if ( rs.wordOffset >= wordCount - 1 ) { // Cleanup rs.$ruler.remove(); this.lines = rs.lines; this.$.find( '.es-contentView-line[line-index=' + ( this.lines.length - 1 ) + ']' ) .nextAll() .remove(); rs.timeout = undefined; this.emit( 'update' ); } else { rs.ruler.innerHTML = ''; var that = this; rs.timeout = setTimeout( function() { that.renderIteration( 3 ); }, 0 ); } }; /** * Renders text into a series of HTML elements, each a single line of wrapped text. * * The offset parameter can be used to reduce the amount of work involved in re-rendering the same * text, but will be automatically ignored if the text or width of the container has changed. * * Rendering happens asynchronously, and yields execution between iterations. Iterative rendering * provides the JavaScript engine an ability to process events between rendering batches of lines, * allowing rendering to be interrupted and restarted if changes to content model are happening before * rendering of all lines is complete. * * @method * @param {Integer} [offset] Offset to re-render from, if possible */ es.ContentView.prototype.render = function( offset ) { var rs = this.renderState; // Check if rendering is currently underway if ( rs.timeout !== undefined ) { // Cancel the active rendering process clearTimeout( rs.timeout ); // Cleanup rs.$ruler.remove(); } // Clear caches that were specific to the previous render this.widthCache = {}; // In case of empty content model we still want to display empty with non-breaking space inside // This is very important for lists if(this.model.getContentLength() === 0) { var $line = $( '