r94084 MediaWiki - Code Review archive

Repository:MediaWiki
Revision:r94083‎ | r94084 | r94085 >
Date:20:15, 8 August 2011
Author:tparscal
Status:deferred
Tags:
Comment:
Renamed TextFlow to Flow (it flows content in general, there won't be other flow objects)
Modified paths:
  • /trunk/parsers/wikidom/demos/es/index.html (modified) (history)
  • /trunk/parsers/wikidom/demos/es/index2.html (modified) (history)
  • /trunk/parsers/wikidom/lib/es/es.Flow.js (added) (history)
  • /trunk/parsers/wikidom/lib/es/es.ListBlockItem.js (modified) (history)
  • /trunk/parsers/wikidom/lib/es/es.ParagraphBlock.js (modified) (history)
  • /trunk/parsers/wikidom/lib/es/es.TextFlow.js (deleted) (history)

Diff [purge]

Index: trunk/parsers/wikidom/lib/es/es.TextFlow.js
@@ -1,544 +0,0 @@
2 -/**
3 - * Flowing text renderer.
4 - *
5 - * TODO: Cleanup code and comments
6 - *
7 - * @class
8 - * @constructor
9 - * @extends {es.EventEmitter}
10 - * @param $container {jQuery} Element to render into
11 - * @param content {es.Content} Initial content to render
12 - * @property $ {jQuery}
13 - * @property content {es.Content}
14 - * @property boundaries {Array}
15 - * @property lines {Array}
16 - * @property width {Integer}
17 - * @property bondaryTest {RegExp}
18 - * @property widthCache {Object}
19 - * @property renderState {Object}
20 - */
21 -es.TextFlow = function( $container, content ) {
22 - // Inheritance
23 - es.EventEmitter.call( this );
24 -
25 - // Members
26 - this.$ = $container;
27 - this.content = content || new es.Content();
28 - this.boundaries = [];
29 - this.lines = [];
30 - this.width = null;
31 - this.boundaryTest = /([ \-\t\r\n\f])/g;
32 - this.widthCache = {};
33 - this.renderState = {};
34 -
35 - // Events
36 - var flow = this;
37 - this.content.on( 'insert', function( args ) {
38 - flow.scanBoundaries();
39 - flow.render( args.offset );
40 - } );
41 - this.content.on( 'remove', function( args ) {
42 - flow.scanBoundaries();
43 - flow.render( args.start );
44 - } );
45 - this.content.on( 'annotate', function( args ) {
46 - flow.scanBoundaries();
47 - flow.render( args.start );
48 - } );
49 -
50 - // Initialization
51 - this.scanBoundaries();
52 -}
53 -
54 -/**
55 - * Gets offset within content closest to of a given position.
56 - *
57 - * Position is assumed to be local to the container the text is being flowed in.
58 - *
59 - * @param position {Object} Position to find offset for
60 - * @param position.left {Integer} Horizontal position in pixels
61 - * @param position.top {Integer} Vertical position in pixels
62 - * @return {Integer} Offset within content nearest the given coordinates
63 - */
64 -es.TextFlow.prototype.getOffset = function( position ) {
65 - /*
66 - * Line finding
67 - *
68 - * If the position is above the first line, the offset will always be 0, and if the position is
69 - * below the last line the offset will always be {this.content.length}. All other vertical
70 - * vertical positions will fall inside of one of the lines.
71 - */
72 - var lineCount = this.lines.length;
73 - // Positions above the first line always jump to the first offset
74 - if ( !lineCount || position.top < 0 ) {
75 - return 0;
76 - }
77 - // Find which line the position is inside of
78 - var i = 0,
79 - top = 0;
80 - while ( i < lineCount ) {
81 - top += this.lines[i].height;
82 - if ( position.top <= top ) {
83 - break;
84 - }
85 - i++;
86 - }
87 - // Positions below the last line always jump to the last offset
88 - if ( i == lineCount ) {
89 - return this.content.getLength();
90 - }
91 - // Alias current line object
92 - var line = this.lines[i];
93 -
94 - /*
95 - * Offset finding
96 - *
97 - * Now that we know which line we are on, we can just use the "fitCharacters" method to get the
98 - * last offset before "position.left".
99 - *
100 - * TODO: The offset needs to be chosen based on nearest offset to the cursor, not offset before
101 - * the cursor.
102 - */
103 - var $ruler = $( '<div class="editSurface-ruler"></div>' ).appendTo( this.$ ),
104 - ruler = $ruler[0],
105 - fit = this.fitCharacters( line.range, ruler, position.left );
106 - ruler.innerHTML = this.content.render( new es.Range( line.range.start, fit.end ) );
107 - var left = ruler.clientWidth;
108 - ruler.innerHTML = this.content.render( new es.Range( line.range.start, fit.end + 1 ) );
109 - var right = ruler.clientWidth;
110 - var center = Math.round( left + ( ( right - left ) / 2 ) );
111 - $ruler.remove();
112 - // Reset RegExp object's state
113 - this.boundaryTest.lastIndex = 0;
114 - return Math.min(
115 - // If the position is right of the center of the character it's on top of, increment offset
116 - fit.end + ( position.left >= center ? 1 : 0 ),
117 - // If the line ends in a non-boundary character, decrement offset
118 - line.range.end + ( this.boundaryTest.exec( line.text.substr( -1 ) ) ? -1 : 0 )
119 - );
120 -};
121 -
122 -/**
123 - * Gets position coordinates of a given offset.
124 - *
125 - * Offsets are boundaries between plain or annotated characters within content. Results are given in
126 - * left, top and bottom positions, which could be used to draw a cursor, highlighting, etc.
127 - *
128 - * @param offset {Integer} Offset within content
129 - * @return {Object} Object containing left, top and bottom properties, each positions in pixels
130 - */
131 -es.TextFlow.prototype.getPosition = function( offset ) {
132 - /*
133 - * Range validation
134 - *
135 - * Rather than clamping the range, which can hide errors, exceptions will be thrown if offset is
136 - * less than 0 or greater than the length of the content.
137 - */
138 - if ( offset < 0 ) {
139 - throw 'Out of range error. Offset is expected to be greater than or equal to 0.';
140 - } else if ( offset > this.content.getLength() ) {
141 - throw 'Out of range error. Offset is expected to be less than or equal to text length.';
142 - }
143 -
144 - /*
145 - * Line finding
146 - *
147 - * It's possible that a more efficient method could be used here, but the number of lines to be
148 - * iterated through will rarely be over 100, so it's unlikely that any significant gains will be
149 - * had. Plus, as long as we are iterating over each line, we can also sum up the top and bottom
150 - * positions, which is a nice benefit of this method.
151 - */
152 - var line,
153 - lineCount = this.lines.length,
154 - position = {
155 - 'left': 0,
156 - 'top': 0,
157 - 'bottom': 0,
158 - 'line': 0
159 - };
160 - while ( position.line < lineCount ) {
161 - line = this.lines[position.line];
162 - if ( line.range.containsOffset( offset ) ) {
163 - position.bottom = position.top + line.height;
164 - break;
165 - }
166 - position.top += line.height;
167 - position.line++;
168 - }
169 -
170 - /*
171 - * Virtual n+1 position
172 - *
173 - * To allow access to position information of the right side of the last character on the last
174 - * line, a virtual n+1 position is supported. Offsets beyond this virtual position will cause
175 - * an exception to be thrown.
176 - */
177 - if ( position.line === lineCount ) {
178 - position.line--;
179 - position.bottom = position.top;
180 - position.top -= line.height;
181 - }
182 -
183 - /*
184 - * Offset measuring
185 - *
186 - * Since the left position will be zero for the first character in the line, so we can skip
187 - * measuring for those cases.
188 - */
189 - if ( line.range.start < offset ) {
190 - var $ruler = $( '<div class="editSurface-ruler"></div>' ).appendTo( this.$ ),
191 - ruler = $ruler[0];
192 - ruler.innerHTML = this.content.render( new es.Range( line.range.start, offset ) );
193 - position.left = ruler.clientWidth;
194 - $ruler.remove();
195 - }
196 -
197 - return position;
198 -};
199 -
200 -/**
201 - * Updates the word boundary cache, which is used for word fitting.
202 - */
203 -es.TextFlow.prototype.scanBoundaries = function() {
204 - /*
205 - * Word boundary scan
206 - *
207 - * To perform binary-search on words, rather than characters, we need to collect word boundary
208 - * offsets into an array. The offset of the right side of the breaking character is stored, so
209 - * the gaps between stored offsets always include the breaking character at the end.
210 - *
211 - * To avoid encoding the same words as HTML over and over while fitting text to lines, we also
212 - * build a list of HTML escaped strings for each gap between the offsets stored in the
213 - * "boundaries" array. Slices of the "words" array can be joined, producing the escaped HTML of
214 - * the words.
215 - */
216 - var text = this.content.getText();
217 - // Purge "boundaries" and "words" arrays
218 - this.boundaries = [0];
219 - // Reset RegExp object's state
220 - this.boundaryTest.lastIndex = 0;
221 - // Iterate over each word+boundary sequence, capturing offsets and encoding text as we go
222 - var match,
223 - end;
224 - while ( match = this.boundaryTest.exec( text ) ) {
225 - // Include the boundary character in the range
226 - end = match.index + 1;
227 - // Store the boundary offset
228 - this.boundaries.push( end );
229 - }
230 - // If the last character is not a boundary character, we need to append the final range to the
231 - // "boundaries" and "words" arrays
232 - if ( end < text.length || this.boundaries.length === 1 ) {
233 - this.boundaries.push( text.length );
234 - }
235 -};
236 -
237 -/**
238 - * Renders a batch of lines and then yields execution before rendering another batch.
239 - *
240 - * In cases where a single word is too long to fit on a line, the word will be "virtually" wrapped,
241 - * causing them to be fragmented. Word fragments are rendered on their own lines, except for their
242 - * remainder, which is combined with whatever proceeding words can fit on the same line.
243 - */
244 -es.TextFlow.prototype.renderIteration = function( limit ) {
245 - var rs = this.renderState,
246 - iteration = 0,
247 - fractional = false,
248 - lineStart = this.boundaries[rs.wordOffset],
249 - lineEnd,
250 - wordFit = null,
251 - charOffset = 0,
252 - charFit = null,
253 - wordCount = this.boundaries.length;
254 - while ( ++iteration <= limit && rs.wordOffset < wordCount - 1 ) {
255 - wordFit = this.fitWords( new es.Range( rs.wordOffset, wordCount - 1 ), rs.ruler, rs.width );
256 - fractional = false;
257 - if ( wordFit.width > rs.width ) {
258 - // The first word didn't fit, we need to split it up
259 - charOffset = lineStart;
260 - var lineOffset = rs.wordOffset;
261 - rs.wordOffset++;
262 - lineEnd = this.boundaries[rs.wordOffset];
263 - do {
264 - charFit = this.fitCharacters(
265 - new es.Range( charOffset, lineEnd ), rs.ruler, rs.width
266 - );
267 - // If we were able to get the rest of the characters on the line OK
268 - if ( charFit.end === lineEnd) {
269 - // Try to fit more words on the line
270 - wordFit = this.fitWords(
271 - new es.Range( rs.wordOffset, wordCount - 1 ),
272 - rs.ruler,
273 - rs.width - charFit.width
274 - );
275 - if ( wordFit.end > rs.wordOffset ) {
276 - lineOffset = rs.wordOffset;
277 - rs.wordOffset = wordFit.end;
278 - charFit.end = lineEnd = this.boundaries[rs.wordOffset];
279 - }
280 - }
281 - this.appendLine( new es.Range( charOffset, charFit.end ), lineOffset, fractional );
282 - // Move on to another line
283 - charOffset = charFit.end;
284 - // Mark the next line as fractional
285 - fractional = true;
286 - } while ( charOffset < lineEnd );
287 - } else {
288 - lineEnd = this.boundaries[wordFit.end];
289 - this.appendLine( new es.Range( lineStart, lineEnd ), rs.wordOffset, fractional );
290 - rs.wordOffset = wordFit.end;
291 - }
292 - lineStart = lineEnd;
293 - }
294 - // Only perform on actual last iteration
295 - if ( rs.wordOffset >= wordCount - 1 ) {
296 - // Cleanup
297 - rs.$ruler.remove();
298 - this.lines = rs.lines;
299 - this.$.find( '.editSurface-line[line-index=' + ( this.lines.length - 1 ) + ']' )
300 - .nextAll()
301 - .remove();
302 - rs.timeout = undefined;
303 - this.emit( 'render' );
304 - } else {
305 - rs.ruler.innerHTML = '';
306 - var flow = this;
307 - rs.timeout = setTimeout( function() {
308 - flow.renderIteration( 3 );
309 - }, 0 );
310 - }
311 -};
312 -
313 -/**
314 - * Renders text into a series of HTML elements, each a single line of wrapped text.
315 - *
316 - * The offset parameter can be used to reduce the amount of work involved in re-rendering the same
317 - * text, but will be automatically ignored if the text or width of the container has changed.
318 - *
319 - * Rendering happens asynchronously, and yields execution between iterations. Iterative rendering
320 - * provides the JavaScript engine an ability to process events between rendering batches of lines,
321 - * allowing rendering to be interrupted and restarted if changes to content are happening before
322 - * rendering of all lines is complete.
323 - *
324 - * @param offset {Integer} Offset to re-render from, if possible (not yet implemented)
325 - */
326 -es.TextFlow.prototype.render = function( offset ) {
327 - var rs = this.renderState;
328 -
329 - // Check if rendering is currently underway
330 - if ( rs.timeout !== undefined ) {
331 - // Cancel the active rendering process
332 - clearTimeout( rs.timeout );
333 - // Cleanup
334 - rs.$ruler.remove();
335 - }
336 -
337 - // Clear caches that were specific to the previous render
338 - this.widthCache = {};
339 -
340 - /*
341 - * Container measurement
342 - *
343 - * To get an accurate measurement of the inside of the container, without having to deal with
344 - * inconsistencies between browsers and box models, we can just create an element inside the
345 - * container and measure it.
346 - */
347 - rs.$ruler = $( '<div>&nbsp;</div>' ).appendTo( this.$ );
348 - rs.width = rs.$ruler.innerWidth();
349 - rs.ruler = rs.$ruler.addClass('editSurface-ruler')[0];
350 -
351 - // Ignore offset optimization if the width has changed or the text has never been flowed before
352 - if (this.width !== rs.width) {
353 - offset = undefined;
354 - }
355 - this.width = rs.width;
356 -
357 - // Reset the render state
358 - if ( offset ) {
359 - var gap,
360 - currentLine = this.lines.length - 1;
361 - for ( var i = this.lines.length - 1; i >= 0; i-- ) {
362 - var line = this.lines[i];
363 - if ( line.range.start < offset && line.range.end > offset ) {
364 - currentLine = i;
365 - }
366 - if ( ( line.range.end < offset && !line.fractional ) || i === 0 ) {
367 - rs.lines = this.lines.slice( 0, i );
368 - rs.wordOffset = line.wordOffset;
369 - gap = currentLine - i;
370 - break;
371 - }
372 - }
373 - this.renderIteration( 2 + gap );
374 - } else {
375 - rs.lines = [];
376 - rs.wordOffset = 0;
377 - this.renderIteration( 3 );
378 - }
379 -};
380 -
381 -/**
382 - * Adds a line containing a given range of text to the end of the DOM and the "lines" array.
383 - *
384 - * @param range {es.Range} Range of content to append
385 - * @param start {Integer} Beginning of text range for line
386 - * @param end {Integer} Ending of text range for line
387 - * @param wordOffset {Integer} Index within this.words which the line begins with
388 - * @param fractional {Boolean} If the line begins in the middle of a word
389 - */
390 -es.TextFlow.prototype.appendLine = function( range, wordOffset, fractional ) {
391 - var rs = this.renderState,
392 - lineCount = rs.lines.length;
393 - $line = this.$.children( '[line-index=' + lineCount + ']' );
394 - if ( !$line.length ) {
395 - $line = $( '<div class="editSurface-line" line-index="' + lineCount + '"></div>' );
396 - this.$.append( $line );
397 - }
398 - $line[0].innerHTML = this.content.render( range );
399 - // Collect line information
400 - rs.lines.push({
401 - 'text': this.content.getText( range ),
402 - 'range': range,
403 - 'width': $line.outerWidth(),
404 - 'height': $line.outerHeight(),
405 - 'wordOffset': wordOffset,
406 - 'fractional': fractional
407 - });
408 - // Disable links within content
409 - $line.find( '.editSurface-format-object a' )
410 - .mousedown( function( e ) {
411 - e.preventDefault();
412 - } )
413 - .click( function( e ) {
414 - e.preventDefault();
415 - } );
416 -};
417 -
418 -/**
419 - * Gets the index of the boundary of last word that fits inside the line
420 - *
421 - * The "words" and "boundaries" arrays provide linear access to the offsets around non-breakable
422 - * areas within the text. Using these, we can perform a binary-search for the best fit of words
423 - * within a line, just as we would with characters.
424 - *
425 - * Results are given as an object containing both an index and a width, the later of which can be
426 - * used to detect when the first word was too long to fit on a line. In such cases the result will
427 - * contain the index of the boundary of the first word and it's width.
428 - *
429 - * TODO: Because limit is most likely given as "words.length", it may be possible to improve the
430 - * efficiency of this code by making a best guess and working from there, rather than always
431 - * starting with [offset .. limit], which usually results in reducing the end position in all but
432 - * the last line, and in most cases more than 3 times, before changing directions.
433 - *
434 - * @param range {es.Range} Range of content to try to fit
435 - * @param ruler {HTMLElement} Element to take measurements with
436 - * @param width {Integer} Maximum width to allow the line to extend to
437 - * @return {Integer} Last index within "words" that contains a word that fits
438 - */
439 -es.TextFlow.prototype.fitWords = function( range, ruler, width ) {
440 - var offset = range.start,
441 - start = range.start,
442 - end = range.end,
443 - charOffset = this.boundaries[offset],
444 - middle,
445 - lineWidth,
446 - cacheKey;
447 - do {
448 - // Place "middle" directly in the center of "start" and "end"
449 - middle = Math.ceil( ( start + end ) / 2 );
450 - charMiddle = this.boundaries[middle];
451 -
452 - // Measure and cache width of substring
453 - cacheKey = charOffset + ':' + charMiddle;
454 - // Prepare the line for measurement using pre-escaped HTML
455 - ruler.innerHTML = this.content.render( new es.Range( charOffset, charMiddle ) );
456 - // Test for over/under using width of the rendered line
457 - this.widthCache[cacheKey] = lineWidth = ruler.clientWidth;
458 -
459 - // Test for over/under using width of the rendered line
460 - if ( lineWidth > width ) {
461 - // Detect impossible fit (the first word won't fit by itself)
462 - if (middle - offset === 1) {
463 - start = middle;
464 - break;
465 - }
466 - // Words after "middle" won't fit
467 - end = middle - 1;
468 - } else {
469 - // Words before "middle" will fit
470 - start = middle;
471 - }
472 - } while ( start < end );
473 - // Check if we ended by moving end to the left of middle
474 - if ( end === middle - 1 ) {
475 - // A final measurement is required
476 - var charStart = this.boundaries[start];
477 - ruler.innerHTML = this.content.render( new es.Range( charOffset, charStart ) );
478 - lineWidth = this.widthCache[charOffset + ':' + charStart] = ruler.clientWidth;
479 - }
480 - return { 'end': start, 'width': lineWidth };
481 -};
482 -
483 -/**
484 - * Gets the index of the boundary of the last character that fits inside the line
485 - *
486 - * Results are given as an object containing both an index and a width, the later of which can be
487 - * used to detect when the first character was too long to fit on a line. In such cases the result
488 - * will contain the index of the first character and it's width.
489 - *
490 - * @param range {es.Range} Range of content to try to fit
491 - * @param ruler {HTMLElement} Element to take measurements with
492 - * @param width {Integer} Maximum width to allow the line to extend to
493 - * @return {Integer} Last index within "text" that contains a character that fits
494 - */
495 -es.TextFlow.prototype.fitCharacters = function( range, ruler, width ) {
496 - var offset = range.start,
497 - start = range.start,
498 - end = range.end,
499 - middle,
500 - lineWidth,
501 - cacheKey;
502 - do {
503 - // Place "middle" directly in the center of "start" and "end"
504 - middle = Math.ceil( ( start + end ) / 2 );
505 -
506 - // Measure and cache width of substring
507 - cacheKey = offset + ':' + middle;
508 - if ( cacheKey in this.widthCache ) {
509 - lineWidth = this.widthCache[cacheKey];
510 - } else {
511 - // Fill the line with a portion of the text, escaped as HTML
512 - ruler.innerHTML = this.content.render( new es.Range( offset, middle ) );
513 - // Test for over/under using width of the rendered line
514 - this.widthCache[cacheKey] = lineWidth = ruler.clientWidth;
515 - }
516 -
517 - if ( lineWidth > width ) {
518 - // Detect impossible fit (the first character won't fit by itself)
519 - if (middle - offset === 1) {
520 - start = middle - 1;
521 - break;
522 - }
523 - // Words after "middle" won't fit
524 - end = middle - 1;
525 - } else {
526 - // Words before "middle" will fit
527 - start = middle;
528 - }
529 - } while ( start < end );
530 - // Check if we ended by moving end to the left of middle
531 - if ( end === middle - 1 ) {
532 - // Try for cache hit
533 - cacheKey = offset + ':' + start;
534 - if ( cacheKey in this.widthCache ) {
535 - lineWidth = this.widthCache[cacheKey];
536 - } else {
537 - // A final measurement is required
538 - ruler.innerHTML = this.content.render( new es.Range( offset, start ) );
539 - lineWidth = this.widthCache[cacheKey] = ruler.clientWidth;
540 - }
541 - }
542 - return { 'end': start, 'width': lineWidth };
543 -};
544 -
545 -es.extend( es.TextFlow, es.EventEmitter );
Index: trunk/parsers/wikidom/lib/es/es.ListBlockItem.js
@@ -11,7 +11,7 @@
1212 * @property lists {Array} List of item sub-lists
1313 * @property $line {jQuery} Line element
1414 * @property $content {jQuery} Content element
15 - * @property flow {es.TextFlow} Text flow object for content
 15+ * @property flow {es.Flow} Text flow object for content
1616 */
1717 es.ListBlockItem = function( content, lists ) {
1818 es.EventEmitter.call( this );
@@ -20,14 +20,14 @@
2121 this.content = content || new es.Content();
2222 this.$content = $( '<div class="editSurface-list-content"></div>' );
2323 this.$.prepend( this.$content );
24 - this.flow = new es.TextFlow( this.$content, this.content );
 24+ this.flow = new es.Flow( this.$content, this.content );
2525
2626 /*
2727 this.content = content || new es.Content();
2828 this.$line = $( '<div class="editSurface-list-line"></div>' );
2929 this.$content = $( '<div class="editSurface-list-content"></div>' );
3030 this.$.prepend( this.$line.append( this.$content ) );
31 - this.flow = new es.TextFlow( this.$content, this.content );
 31+ this.flow = new es.Flow( this.$content, this.content );
3232 */
3333
3434 var listBlockItem = this;
Index: trunk/parsers/wikidom/lib/es/es.Flow.js
@@ -0,0 +1,544 @@
 2+/**
 3+ * Flowing text renderer.
 4+ *
 5+ * TODO: Cleanup code and comments
 6+ *
 7+ * @class
 8+ * @constructor
 9+ * @extends {es.EventEmitter}
 10+ * @param $container {jQuery} Element to render into
 11+ * @param content {es.Content} Initial content to render
 12+ * @property $ {jQuery}
 13+ * @property content {es.Content}
 14+ * @property boundaries {Array}
 15+ * @property lines {Array}
 16+ * @property width {Integer}
 17+ * @property bondaryTest {RegExp}
 18+ * @property widthCache {Object}
 19+ * @property renderState {Object}
 20+ */
 21+es.Flow = function( $container, content ) {
 22+ // Inheritance
 23+ es.EventEmitter.call( this );
 24+
 25+ // Members
 26+ this.$ = $container;
 27+ this.content = content || new es.Content();
 28+ this.boundaries = [];
 29+ this.lines = [];
 30+ this.width = null;
 31+ this.boundaryTest = /([ \-\t\r\n\f])/g;
 32+ this.widthCache = {};
 33+ this.renderState = {};
 34+
 35+ // Events
 36+ var flow = this;
 37+ this.content.on( 'insert', function( args ) {
 38+ flow.scanBoundaries();
 39+ flow.render( args.offset );
 40+ } );
 41+ this.content.on( 'remove', function( args ) {
 42+ flow.scanBoundaries();
 43+ flow.render( args.start );
 44+ } );
 45+ this.content.on( 'annotate', function( args ) {
 46+ flow.scanBoundaries();
 47+ flow.render( args.start );
 48+ } );
 49+
 50+ // Initialization
 51+ this.scanBoundaries();
 52+}
 53+
 54+/**
 55+ * Gets offset within content closest to of a given position.
 56+ *
 57+ * Position is assumed to be local to the container the text is being flowed in.
 58+ *
 59+ * @param position {Object} Position to find offset for
 60+ * @param position.left {Integer} Horizontal position in pixels
 61+ * @param position.top {Integer} Vertical position in pixels
 62+ * @return {Integer} Offset within content nearest the given coordinates
 63+ */
 64+es.Flow.prototype.getOffset = function( position ) {
 65+ /*
 66+ * Line finding
 67+ *
 68+ * If the position is above the first line, the offset will always be 0, and if the position is
 69+ * below the last line the offset will always be {this.content.length}. All other vertical
 70+ * vertical positions will fall inside of one of the lines.
 71+ */
 72+ var lineCount = this.lines.length;
 73+ // Positions above the first line always jump to the first offset
 74+ if ( !lineCount || position.top < 0 ) {
 75+ return 0;
 76+ }
 77+ // Find which line the position is inside of
 78+ var i = 0,
 79+ top = 0;
 80+ while ( i < lineCount ) {
 81+ top += this.lines[i].height;
 82+ if ( position.top <= top ) {
 83+ break;
 84+ }
 85+ i++;
 86+ }
 87+ // Positions below the last line always jump to the last offset
 88+ if ( i == lineCount ) {
 89+ return this.content.getLength();
 90+ }
 91+ // Alias current line object
 92+ var line = this.lines[i];
 93+
 94+ /*
 95+ * Offset finding
 96+ *
 97+ * Now that we know which line we are on, we can just use the "fitCharacters" method to get the
 98+ * last offset before "position.left".
 99+ *
 100+ * TODO: The offset needs to be chosen based on nearest offset to the cursor, not offset before
 101+ * the cursor.
 102+ */
 103+ var $ruler = $( '<div class="editSurface-ruler"></div>' ).appendTo( this.$ ),
 104+ ruler = $ruler[0],
 105+ fit = this.fitCharacters( line.range, ruler, position.left );
 106+ ruler.innerHTML = this.content.render( new es.Range( line.range.start, fit.end ) );
 107+ var left = ruler.clientWidth;
 108+ ruler.innerHTML = this.content.render( new es.Range( line.range.start, fit.end + 1 ) );
 109+ var right = ruler.clientWidth;
 110+ var center = Math.round( left + ( ( right - left ) / 2 ) );
 111+ $ruler.remove();
 112+ // Reset RegExp object's state
 113+ this.boundaryTest.lastIndex = 0;
 114+ return Math.min(
 115+ // If the position is right of the center of the character it's on top of, increment offset
 116+ fit.end + ( position.left >= center ? 1 : 0 ),
 117+ // If the line ends in a non-boundary character, decrement offset
 118+ line.range.end + ( this.boundaryTest.exec( line.text.substr( -1 ) ) ? -1 : 0 )
 119+ );
 120+};
 121+
 122+/**
 123+ * Gets position coordinates of a given offset.
 124+ *
 125+ * Offsets are boundaries between plain or annotated characters within content. Results are given in
 126+ * left, top and bottom positions, which could be used to draw a cursor, highlighting, etc.
 127+ *
 128+ * @param offset {Integer} Offset within content
 129+ * @return {Object} Object containing left, top and bottom properties, each positions in pixels
 130+ */
 131+es.Flow.prototype.getPosition = function( offset ) {
 132+ /*
 133+ * Range validation
 134+ *
 135+ * Rather than clamping the range, which can hide errors, exceptions will be thrown if offset is
 136+ * less than 0 or greater than the length of the content.
 137+ */
 138+ if ( offset < 0 ) {
 139+ throw 'Out of range error. Offset is expected to be greater than or equal to 0.';
 140+ } else if ( offset > this.content.getLength() ) {
 141+ throw 'Out of range error. Offset is expected to be less than or equal to text length.';
 142+ }
 143+
 144+ /*
 145+ * Line finding
 146+ *
 147+ * It's possible that a more efficient method could be used here, but the number of lines to be
 148+ * iterated through will rarely be over 100, so it's unlikely that any significant gains will be
 149+ * had. Plus, as long as we are iterating over each line, we can also sum up the top and bottom
 150+ * positions, which is a nice benefit of this method.
 151+ */
 152+ var line,
 153+ lineCount = this.lines.length,
 154+ position = {
 155+ 'left': 0,
 156+ 'top': 0,
 157+ 'bottom': 0,
 158+ 'line': 0
 159+ };
 160+ while ( position.line < lineCount ) {
 161+ line = this.lines[position.line];
 162+ if ( line.range.containsOffset( offset ) ) {
 163+ position.bottom = position.top + line.height;
 164+ break;
 165+ }
 166+ position.top += line.height;
 167+ position.line++;
 168+ }
 169+
 170+ /*
 171+ * Virtual n+1 position
 172+ *
 173+ * To allow access to position information of the right side of the last character on the last
 174+ * line, a virtual n+1 position is supported. Offsets beyond this virtual position will cause
 175+ * an exception to be thrown.
 176+ */
 177+ if ( position.line === lineCount ) {
 178+ position.line--;
 179+ position.bottom = position.top;
 180+ position.top -= line.height;
 181+ }
 182+
 183+ /*
 184+ * Offset measuring
 185+ *
 186+ * Since the left position will be zero for the first character in the line, so we can skip
 187+ * measuring for those cases.
 188+ */
 189+ if ( line.range.start < offset ) {
 190+ var $ruler = $( '<div class="editSurface-ruler"></div>' ).appendTo( this.$ ),
 191+ ruler = $ruler[0];
 192+ ruler.innerHTML = this.content.render( new es.Range( line.range.start, offset ) );
 193+ position.left = ruler.clientWidth;
 194+ $ruler.remove();
 195+ }
 196+
 197+ return position;
 198+};
 199+
 200+/**
 201+ * Updates the word boundary cache, which is used for word fitting.
 202+ */
 203+es.Flow.prototype.scanBoundaries = function() {
 204+ /*
 205+ * Word boundary scan
 206+ *
 207+ * To perform binary-search on words, rather than characters, we need to collect word boundary
 208+ * offsets into an array. The offset of the right side of the breaking character is stored, so
 209+ * the gaps between stored offsets always include the breaking character at the end.
 210+ *
 211+ * To avoid encoding the same words as HTML over and over while fitting text to lines, we also
 212+ * build a list of HTML escaped strings for each gap between the offsets stored in the
 213+ * "boundaries" array. Slices of the "words" array can be joined, producing the escaped HTML of
 214+ * the words.
 215+ */
 216+ var text = this.content.getText();
 217+ // Purge "boundaries" and "words" arrays
 218+ this.boundaries = [0];
 219+ // Reset RegExp object's state
 220+ this.boundaryTest.lastIndex = 0;
 221+ // Iterate over each word+boundary sequence, capturing offsets and encoding text as we go
 222+ var match,
 223+ end;
 224+ while ( match = this.boundaryTest.exec( text ) ) {
 225+ // Include the boundary character in the range
 226+ end = match.index + 1;
 227+ // Store the boundary offset
 228+ this.boundaries.push( end );
 229+ }
 230+ // If the last character is not a boundary character, we need to append the final range to the
 231+ // "boundaries" and "words" arrays
 232+ if ( end < text.length || this.boundaries.length === 1 ) {
 233+ this.boundaries.push( text.length );
 234+ }
 235+};
 236+
 237+/**
 238+ * Renders a batch of lines and then yields execution before rendering another batch.
 239+ *
 240+ * In cases where a single word is too long to fit on a line, the word will be "virtually" wrapped,
 241+ * causing them to be fragmented. Word fragments are rendered on their own lines, except for their
 242+ * remainder, which is combined with whatever proceeding words can fit on the same line.
 243+ */
 244+es.Flow.prototype.renderIteration = function( limit ) {
 245+ var rs = this.renderState,
 246+ iteration = 0,
 247+ fractional = false,
 248+ lineStart = this.boundaries[rs.wordOffset],
 249+ lineEnd,
 250+ wordFit = null,
 251+ charOffset = 0,
 252+ charFit = null,
 253+ wordCount = this.boundaries.length;
 254+ while ( ++iteration <= limit && rs.wordOffset < wordCount - 1 ) {
 255+ wordFit = this.fitWords( new es.Range( rs.wordOffset, wordCount - 1 ), rs.ruler, rs.width );
 256+ fractional = false;
 257+ if ( wordFit.width > rs.width ) {
 258+ // The first word didn't fit, we need to split it up
 259+ charOffset = lineStart;
 260+ var lineOffset = rs.wordOffset;
 261+ rs.wordOffset++;
 262+ lineEnd = this.boundaries[rs.wordOffset];
 263+ do {
 264+ charFit = this.fitCharacters(
 265+ new es.Range( charOffset, lineEnd ), rs.ruler, rs.width
 266+ );
 267+ // If we were able to get the rest of the characters on the line OK
 268+ if ( charFit.end === lineEnd) {
 269+ // Try to fit more words on the line
 270+ wordFit = this.fitWords(
 271+ new es.Range( rs.wordOffset, wordCount - 1 ),
 272+ rs.ruler,
 273+ rs.width - charFit.width
 274+ );
 275+ if ( wordFit.end > rs.wordOffset ) {
 276+ lineOffset = rs.wordOffset;
 277+ rs.wordOffset = wordFit.end;
 278+ charFit.end = lineEnd = this.boundaries[rs.wordOffset];
 279+ }
 280+ }
 281+ this.appendLine( new es.Range( charOffset, charFit.end ), lineOffset, fractional );
 282+ // Move on to another line
 283+ charOffset = charFit.end;
 284+ // Mark the next line as fractional
 285+ fractional = true;
 286+ } while ( charOffset < lineEnd );
 287+ } else {
 288+ lineEnd = this.boundaries[wordFit.end];
 289+ this.appendLine( new es.Range( lineStart, lineEnd ), rs.wordOffset, fractional );
 290+ rs.wordOffset = wordFit.end;
 291+ }
 292+ lineStart = lineEnd;
 293+ }
 294+ // Only perform on actual last iteration
 295+ if ( rs.wordOffset >= wordCount - 1 ) {
 296+ // Cleanup
 297+ rs.$ruler.remove();
 298+ this.lines = rs.lines;
 299+ this.$.find( '.editSurface-line[line-index=' + ( this.lines.length - 1 ) + ']' )
 300+ .nextAll()
 301+ .remove();
 302+ rs.timeout = undefined;
 303+ this.emit( 'render' );
 304+ } else {
 305+ rs.ruler.innerHTML = '';
 306+ var flow = this;
 307+ rs.timeout = setTimeout( function() {
 308+ flow.renderIteration( 3 );
 309+ }, 0 );
 310+ }
 311+};
 312+
 313+/**
 314+ * Renders text into a series of HTML elements, each a single line of wrapped text.
 315+ *
 316+ * The offset parameter can be used to reduce the amount of work involved in re-rendering the same
 317+ * text, but will be automatically ignored if the text or width of the container has changed.
 318+ *
 319+ * Rendering happens asynchronously, and yields execution between iterations. Iterative rendering
 320+ * provides the JavaScript engine an ability to process events between rendering batches of lines,
 321+ * allowing rendering to be interrupted and restarted if changes to content are happening before
 322+ * rendering of all lines is complete.
 323+ *
 324+ * @param offset {Integer} Offset to re-render from, if possible (not yet implemented)
 325+ */
 326+es.Flow.prototype.render = function( offset ) {
 327+ var rs = this.renderState;
 328+
 329+ // Check if rendering is currently underway
 330+ if ( rs.timeout !== undefined ) {
 331+ // Cancel the active rendering process
 332+ clearTimeout( rs.timeout );
 333+ // Cleanup
 334+ rs.$ruler.remove();
 335+ }
 336+
 337+ // Clear caches that were specific to the previous render
 338+ this.widthCache = {};
 339+
 340+ /*
 341+ * Container measurement
 342+ *
 343+ * To get an accurate measurement of the inside of the container, without having to deal with
 344+ * inconsistencies between browsers and box models, we can just create an element inside the
 345+ * container and measure it.
 346+ */
 347+ rs.$ruler = $( '<div>&nbsp;</div>' ).appendTo( this.$ );
 348+ rs.width = rs.$ruler.innerWidth();
 349+ rs.ruler = rs.$ruler.addClass('editSurface-ruler')[0];
 350+
 351+ // Ignore offset optimization if the width has changed or the text has never been flowed before
 352+ if (this.width !== rs.width) {
 353+ offset = undefined;
 354+ }
 355+ this.width = rs.width;
 356+
 357+ // Reset the render state
 358+ if ( offset ) {
 359+ var gap,
 360+ currentLine = this.lines.length - 1;
 361+ for ( var i = this.lines.length - 1; i >= 0; i-- ) {
 362+ var line = this.lines[i];
 363+ if ( line.range.start < offset && line.range.end > offset ) {
 364+ currentLine = i;
 365+ }
 366+ if ( ( line.range.end < offset && !line.fractional ) || i === 0 ) {
 367+ rs.lines = this.lines.slice( 0, i );
 368+ rs.wordOffset = line.wordOffset;
 369+ gap = currentLine - i;
 370+ break;
 371+ }
 372+ }
 373+ this.renderIteration( 2 + gap );
 374+ } else {
 375+ rs.lines = [];
 376+ rs.wordOffset = 0;
 377+ this.renderIteration( 3 );
 378+ }
 379+};
 380+
 381+/**
 382+ * Adds a line containing a given range of text to the end of the DOM and the "lines" array.
 383+ *
 384+ * @param range {es.Range} Range of content to append
 385+ * @param start {Integer} Beginning of text range for line
 386+ * @param end {Integer} Ending of text range for line
 387+ * @param wordOffset {Integer} Index within this.words which the line begins with
 388+ * @param fractional {Boolean} If the line begins in the middle of a word
 389+ */
 390+es.Flow.prototype.appendLine = function( range, wordOffset, fractional ) {
 391+ var rs = this.renderState,
 392+ lineCount = rs.lines.length;
 393+ $line = this.$.children( '[line-index=' + lineCount + ']' );
 394+ if ( !$line.length ) {
 395+ $line = $( '<div class="editSurface-line" line-index="' + lineCount + '"></div>' );
 396+ this.$.append( $line );
 397+ }
 398+ $line[0].innerHTML = this.content.render( range );
 399+ // Collect line information
 400+ rs.lines.push({
 401+ 'text': this.content.getText( range ),
 402+ 'range': range,
 403+ 'width': $line.outerWidth(),
 404+ 'height': $line.outerHeight(),
 405+ 'wordOffset': wordOffset,
 406+ 'fractional': fractional
 407+ });
 408+ // Disable links within content
 409+ $line.find( '.editSurface-format-object a' )
 410+ .mousedown( function( e ) {
 411+ e.preventDefault();
 412+ } )
 413+ .click( function( e ) {
 414+ e.preventDefault();
 415+ } );
 416+};
 417+
 418+/**
 419+ * Gets the index of the boundary of last word that fits inside the line
 420+ *
 421+ * The "words" and "boundaries" arrays provide linear access to the offsets around non-breakable
 422+ * areas within the text. Using these, we can perform a binary-search for the best fit of words
 423+ * within a line, just as we would with characters.
 424+ *
 425+ * Results are given as an object containing both an index and a width, the later of which can be
 426+ * used to detect when the first word was too long to fit on a line. In such cases the result will
 427+ * contain the index of the boundary of the first word and it's width.
 428+ *
 429+ * TODO: Because limit is most likely given as "words.length", it may be possible to improve the
 430+ * efficiency of this code by making a best guess and working from there, rather than always
 431+ * starting with [offset .. limit], which usually results in reducing the end position in all but
 432+ * the last line, and in most cases more than 3 times, before changing directions.
 433+ *
 434+ * @param range {es.Range} Range of content to try to fit
 435+ * @param ruler {HTMLElement} Element to take measurements with
 436+ * @param width {Integer} Maximum width to allow the line to extend to
 437+ * @return {Integer} Last index within "words" that contains a word that fits
 438+ */
 439+es.Flow.prototype.fitWords = function( range, ruler, width ) {
 440+ var offset = range.start,
 441+ start = range.start,
 442+ end = range.end,
 443+ charOffset = this.boundaries[offset],
 444+ middle,
 445+ lineWidth,
 446+ cacheKey;
 447+ do {
 448+ // Place "middle" directly in the center of "start" and "end"
 449+ middle = Math.ceil( ( start + end ) / 2 );
 450+ charMiddle = this.boundaries[middle];
 451+
 452+ // Measure and cache width of substring
 453+ cacheKey = charOffset + ':' + charMiddle;
 454+ // Prepare the line for measurement using pre-escaped HTML
 455+ ruler.innerHTML = this.content.render( new es.Range( charOffset, charMiddle ) );
 456+ // Test for over/under using width of the rendered line
 457+ this.widthCache[cacheKey] = lineWidth = ruler.clientWidth;
 458+
 459+ // Test for over/under using width of the rendered line
 460+ if ( lineWidth > width ) {
 461+ // Detect impossible fit (the first word won't fit by itself)
 462+ if (middle - offset === 1) {
 463+ start = middle;
 464+ break;
 465+ }
 466+ // Words after "middle" won't fit
 467+ end = middle - 1;
 468+ } else {
 469+ // Words before "middle" will fit
 470+ start = middle;
 471+ }
 472+ } while ( start < end );
 473+ // Check if we ended by moving end to the left of middle
 474+ if ( end === middle - 1 ) {
 475+ // A final measurement is required
 476+ var charStart = this.boundaries[start];
 477+ ruler.innerHTML = this.content.render( new es.Range( charOffset, charStart ) );
 478+ lineWidth = this.widthCache[charOffset + ':' + charStart] = ruler.clientWidth;
 479+ }
 480+ return { 'end': start, 'width': lineWidth };
 481+};
 482+
 483+/**
 484+ * Gets the index of the boundary of the last character that fits inside the line
 485+ *
 486+ * Results are given as an object containing both an index and a width, the later of which can be
 487+ * used to detect when the first character was too long to fit on a line. In such cases the result
 488+ * will contain the index of the first character and it's width.
 489+ *
 490+ * @param range {es.Range} Range of content to try to fit
 491+ * @param ruler {HTMLElement} Element to take measurements with
 492+ * @param width {Integer} Maximum width to allow the line to extend to
 493+ * @return {Integer} Last index within "text" that contains a character that fits
 494+ */
 495+es.Flow.prototype.fitCharacters = function( range, ruler, width ) {
 496+ var offset = range.start,
 497+ start = range.start,
 498+ end = range.end,
 499+ middle,
 500+ lineWidth,
 501+ cacheKey;
 502+ do {
 503+ // Place "middle" directly in the center of "start" and "end"
 504+ middle = Math.ceil( ( start + end ) / 2 );
 505+
 506+ // Measure and cache width of substring
 507+ cacheKey = offset + ':' + middle;
 508+ if ( cacheKey in this.widthCache ) {
 509+ lineWidth = this.widthCache[cacheKey];
 510+ } else {
 511+ // Fill the line with a portion of the text, escaped as HTML
 512+ ruler.innerHTML = this.content.render( new es.Range( offset, middle ) );
 513+ // Test for over/under using width of the rendered line
 514+ this.widthCache[cacheKey] = lineWidth = ruler.clientWidth;
 515+ }
 516+
 517+ if ( lineWidth > width ) {
 518+ // Detect impossible fit (the first character won't fit by itself)
 519+ if (middle - offset === 1) {
 520+ start = middle - 1;
 521+ break;
 522+ }
 523+ // Words after "middle" won't fit
 524+ end = middle - 1;
 525+ } else {
 526+ // Words before "middle" will fit
 527+ start = middle;
 528+ }
 529+ } while ( start < end );
 530+ // Check if we ended by moving end to the left of middle
 531+ if ( end === middle - 1 ) {
 532+ // Try for cache hit
 533+ cacheKey = offset + ':' + start;
 534+ if ( cacheKey in this.widthCache ) {
 535+ lineWidth = this.widthCache[cacheKey];
 536+ } else {
 537+ // A final measurement is required
 538+ ruler.innerHTML = this.content.render( new es.Range( offset, start ) );
 539+ lineWidth = this.widthCache[cacheKey] = ruler.clientWidth;
 540+ }
 541+ }
 542+ return { 'end': start, 'width': lineWidth };
 543+};
 544+
 545+es.extend( es.Flow, es.EventEmitter );
Property changes on: trunk/parsers/wikidom/lib/es/es.Flow.js
___________________________________________________________________
Added: svn:eol-style
1546 + native
Added: svn:mime-type
2547 + text/plain
Index: trunk/parsers/wikidom/lib/es/es.ParagraphBlock.js
@@ -7,14 +7,14 @@
88 * @param content {es.Content} Paragraph content
99 * @property content {es.Content} Paragraph content
1010 * @property $ {jQuery} Container element
11 - * @property flow {es.TextFlow} Text flow object
 11+ * @property flow {es.Flow} Text flow object
1212 */
1313 es.ParagraphBlock = function( content ) {
1414 es.Block.call( this );
1515 this.content = content || new es.Content();
1616 this.$ = $( '<div class="editSurface-block editSurface-paragraph"></div>' )
1717 .data( 'block', this );
18 - this.flow = new es.TextFlow( this.$, this.content );
 18+ this.flow = new es.Flow( this.$, this.content );
1919 var block = this;
2020 this.flow.on( 'render', function() {
2121 block.emit( 'update' );
Index: trunk/parsers/wikidom/demos/es/index.html
@@ -63,7 +63,7 @@
6464 <script type="text/javascript" src="../../lib/es/es.Block.js"></script>
6565 <script type="text/javascript" src="../../lib/es/es.Document.js"></script>
6666 <script type="text/javascript" src="../../lib/es/es.Surface.js"></script>
67 - <script type="text/javascript" src="../../lib/es/es.TextFlow.js"></script>
 67+ <script type="text/javascript" src="../../lib/es/es.Flow.js"></script>
6868 <script type="text/javascript" src="../../lib/es/es.ParagraphBlock.js"></script>
6969 <script type="text/javascript" src="../../lib/es/es.ListBlockList.js"></script>
7070 <script type="text/javascript" src="../../lib/es/es.ListBlockItem.js"></script>
Index: trunk/parsers/wikidom/demos/es/index2.html
@@ -91,7 +91,7 @@
9292 <script type="text/javascript" src="../../lib/es/es.Block.js"></script>
9393 <script type="text/javascript" src="../../lib/es/es.Document.js"></script>
9494 <script type="text/javascript" src="../../lib/es/es.Surface.js"></script>
95 - <script type="text/javascript" src="../../lib/es/es.TextFlow.js"></script>
 95+ <script type="text/javascript" src="../../lib/es/es.Flow.js"></script>
9696 <script type="text/javascript" src="../../lib/es/es.ParagraphBlock.js"></script>
9797 <script type="text/javascript" src="../../lib/es/es.ListBlockList.js"></script>
9898 <script type="text/javascript" src="../../lib/es/es.ListBlockItem.js"></script>

Status & tagging log