Index: trunk/parsers/wikidom/lib/es/es.ParagraphBlock.js |
— | — | @@ -6,12 +6,20 @@ |
7 | 7 | function ParagraphBlock( lines ) { |
8 | 8 | Block.call( this ); |
9 | 9 | this.lines = lines || []; |
10 | | - this.lineMetrics = []; |
11 | 10 | this.$ = $( '<div class="editSurface-block editSurface-paragraph"></div>' ) |
12 | 11 | .data( 'block', this ); |
13 | 12 | this.flow = new TextFlow( this.$ ); |
| 13 | + this.updateText(); |
14 | 14 | } |
15 | 15 | |
| 16 | +Block.prototype.updateText = function() { |
| 17 | + var text = []; |
| 18 | + for ( var i = 0; i < this.lines.length; i++ ) { |
| 19 | + text.push( this.lines[i].text ); |
| 20 | + } |
| 21 | + this.flow.setText( text.join( '\n' ) ); |
| 22 | +}; |
| 23 | + |
16 | 24 | /** |
17 | 25 | * Inserts content into a block at an offset. |
18 | 26 | * |
— | — | @@ -29,6 +37,7 @@ |
30 | 38 | } |
31 | 39 | lineOffset += line.text.length; |
32 | 40 | } |
| 41 | + this.updateText(); |
33 | 42 | }; |
34 | 43 | |
35 | 44 | /** |
— | — | @@ -74,6 +83,7 @@ |
75 | 84 | // Remove lines after "from" up to and including "to" |
76 | 85 | this.lines = this.lines.splice( from.index + 1, to.index - from.index ); |
77 | 86 | } |
| 87 | + this.updateText(); |
78 | 88 | }; |
79 | 89 | |
80 | 90 | /** |
— | — | @@ -82,11 +92,7 @@ |
83 | 93 | * @param $container {jQuery Selection} Container to render into |
84 | 94 | */ |
85 | 95 | Block.prototype.renderContent = function() { |
86 | | - var text = []; |
87 | | - for ( var i = 0; i < this.lines.length; i++ ) { |
88 | | - text.push( this.lines[i].text ); |
89 | | - } |
90 | | - this.lineMetrics = this.flow.render( text.join( '\n' ) ); |
| 96 | + this.flow.render(); |
91 | 97 | }; |
92 | 98 | |
93 | 99 | /** |
Index: trunk/parsers/wikidom/lib/es/es.Surface.css |
— | — | @@ -1,20 +1,24 @@ |
2 | 2 | body { |
3 | 3 | font-family: "Arial"; |
4 | | - font-size: 0.8em; |
| 4 | + font-size: 1em; |
5 | 5 | width: 100%; |
6 | 6 | margin: 0; |
7 | 7 | padding: 0; |
| 8 | + overflow-y: scroll; |
8 | 9 | } |
9 | 10 | |
10 | 11 | .editSurface-document { |
11 | | - /*border: solid 1px silver;*/ |
12 | | - background-color: lightgreen; |
| 12 | + border: solid 1px silver; |
| 13 | + background-color: #dddddd; |
13 | 14 | cursor: text; |
14 | | - width: 50%; |
| 15 | + margin-left: 12.5%; |
| 16 | + margin-top: 1em; |
| 17 | + width: 75%; |
15 | 18 | } |
16 | 19 | |
17 | 20 | .editSurface-paragraph { |
18 | | - /*margin: 2em;*/ |
| 21 | + background-color: #eeeeee; |
| 22 | + margin: 2em; |
19 | 23 | } |
20 | 24 | |
21 | 25 | .editSurface-line { |
— | — | @@ -23,8 +27,8 @@ |
24 | 28 | line-height: 1.5em; |
25 | 29 | cursor: text; |
26 | 30 | white-space: nowrap; |
27 | | - color: #eeeeee; |
28 | | - background-color: #333333; |
| 31 | + color: #000000; |
| 32 | + background-color: #ffffff; |
29 | 33 | } |
30 | 34 | |
31 | 35 | .editSurface-line.empty { |
Index: trunk/parsers/wikidom/lib/es/es.TextFlow.js |
— | — | @@ -4,18 +4,25 @@ |
5 | 5 | * @param $container {jQuery Selection} Element to render into |
6 | 6 | * @returns {TextFlow} |
7 | 7 | */ |
8 | | -function TextFlow( $container ) { |
| 8 | +function TextFlow( $container, text ) { |
9 | 9 | this.$ = $container; |
10 | | - this.lines = []; |
11 | 10 | this.boundaries = []; |
12 | 11 | this.words = []; |
| 12 | + this.lines = []; |
| 13 | + this.width = null; |
| 14 | + this.text = ''; |
| 15 | + if ( text !== undefined ) { |
| 16 | + this.setText( text ); |
| 17 | + } |
13 | 18 | } |
14 | 19 | |
15 | | -TextFlow.prototype.htmlEncode = function( text, trim ) { |
16 | | - if ( trim ) { |
17 | | - // Trailing whitespace |
18 | | - text = text.replace( / $/, '' ); |
19 | | - } |
| 20 | +/** |
| 21 | + * Encodes text as an HTML string. |
| 22 | + * |
| 23 | + * @param text {String} Text to escape |
| 24 | + * @return {String} HTML escaped text |
| 25 | + */ |
| 26 | +TextFlow.prototype.escape = function( text ) { |
20 | 27 | return text |
21 | 28 | // Tags |
22 | 29 | .replace( /&/g, '&' ) |
— | — | @@ -31,10 +38,11 @@ |
32 | 39 | }; |
33 | 40 | |
34 | 41 | /** |
35 | | - * Gets an offset within from x and y coordinates. |
| 42 | + * Gets offset within content closest to of a given position. |
36 | 43 | * |
37 | | - * @param x {Integer} |
38 | | - * @param y {Integer} |
| 44 | + * @param x {Integer} Horizontal position in pixels |
| 45 | + * @param y {Integer} Vertical position in pixels |
| 46 | + * @return {Integer} Offset within content nearest the given coordinates |
39 | 47 | */ |
40 | 48 | TextFlow.prototype.getOffset = function( x, y ) { |
41 | 49 | var line = 0, |
— | — | @@ -64,6 +72,15 @@ |
65 | 73 | return offset; |
66 | 74 | }; |
67 | 75 | |
| 76 | +/** |
| 77 | + * Gets position coordinates of a given offset. |
| 78 | + * |
| 79 | + * Offsets are boundaries between content. Results are given in left, top and bottom positions, |
| 80 | + * which could be used to draw a cursor, highlighting painting, etc. |
| 81 | + * |
| 82 | + * @param offset {Integer} Offset within content |
| 83 | + * @return {Object} Object containing left, top and bottom properties, each positions in pixels |
| 84 | + */ |
68 | 85 | TextFlow.prototype.getPosition = function( offset ) { |
69 | 86 | if ( offset < 0 ) { |
70 | 87 | throw 'Out of range error. Offset is expected to be greater than or equal to 0.'; |
— | — | @@ -119,7 +136,7 @@ |
120 | 137 | if ( lines[line].start < offset ) { |
121 | 138 | var $ruler = $( '<div class="editSurface-line"></div>' ).appendTo( this.$ ), |
122 | 139 | ruler = $ruler[0]; |
123 | | - ruler.innerHTML = this.htmlEncode( text.substring( lines[startLine].start, offset ) ); |
| 140 | + ruler.innerHTML = this.escape( text.substring( lines[startLine].start, offset ) ); |
124 | 141 | position.left = ruler.clientWidth; |
125 | 142 | $ruler.remove(); |
126 | 143 | } |
— | — | @@ -127,14 +144,66 @@ |
128 | 145 | return position; |
129 | 146 | }; |
130 | 147 | |
| 148 | +TextFlow.prototype.setText = function( text ) { |
| 149 | + // Don't reevaluate boundaries if the text hasn't actually changed |
| 150 | + if ( text === this.text ) { |
| 151 | + return; |
| 152 | + } |
| 153 | + this.text = text; |
| 154 | + |
| 155 | + /* |
| 156 | + * Word boundary scan |
| 157 | + * |
| 158 | + * To perform binary-search on words, rather than characters, we need to collect word boundary |
| 159 | + * offsets into an array. The offset of the right side of the breaking character is stored, so |
| 160 | + * the gaps between stored offsets always include the breaking character at the end. |
| 161 | + * |
| 162 | + * To avoid encoding the same words as HTML over and over while fitting text to lines, we also |
| 163 | + * build a list of HTML escaped strings for each gap between the offsets stored in the |
| 164 | + * "boundaries" array. Slices of the "words" array can be joined, producing the escaped HTML of |
| 165 | + * the words. |
| 166 | + */ |
| 167 | + // Purge "boundaries" and "words" arrays |
| 168 | + this.boundaries = []; |
| 169 | + this.words = []; |
| 170 | + // Iterate over each word+boundary sequence, capturing offsets and encoding text as we go |
| 171 | + var boundary = /([ \.\,\;\:\-\t\r\n\f])/g, |
| 172 | + match, |
| 173 | + start = 0, |
| 174 | + end; |
| 175 | + while ( match = boundary.exec( text ) ) { |
| 176 | + // Include the boundary character in the range |
| 177 | + end = match.index + 1; |
| 178 | + // Store the boundary offset |
| 179 | + this.boundaries.push( end ); |
| 180 | + // Store the word's escaped HTML |
| 181 | + this.words.push( this.escape( text.substring( start, end ) ) ); |
| 182 | + // Remember the previous match |
| 183 | + start = end; |
| 184 | + } |
| 185 | + // If the last character is not a boundary character, we need to append the final range to the |
| 186 | + // "boundaries" and "words" arrays |
| 187 | + if ( end < text.length ) { |
| 188 | + this.boundaries.push( text.length ); |
| 189 | + this.words.push( this.escape( text.substring( end, text.length ) ) ); |
| 190 | + } |
| 191 | + // Force re-flow |
| 192 | + this.width = null; |
| 193 | +}; |
| 194 | + |
131 | 195 | /** |
132 | 196 | * Renders text into a series of HTML elements, each a single line of wrapped text. |
133 | 197 | * |
134 | | - * TODO: Allow re-flowing from a given offset on to make re-flow faster when modifying the text |
| 198 | + * In cases where a single word is too long to fit on a line, the word will be "virtually" wrapped, |
| 199 | + * causing them to be fragmented. Word fragments are rendered on their own lines, except for their |
| 200 | + * remainder, which is combined with whatever proceeding words can fit on the same line. |
135 | 201 | * |
136 | | - * @param text {String} Text to render |
| 202 | + * The offset parameter can be used to reduce the amount of work involved in re-rendering the same |
| 203 | + * text, but will be automatically ignored if the text or width of the container has changed. |
| 204 | + * |
| 205 | + * @param offset {Integer} Offset to re-render from, if possible (not yet implemented) |
137 | 206 | */ |
138 | | -TextFlow.prototype.render = function( text ) { |
| 207 | +TextFlow.prototype.render = function( offset ) { |
139 | 208 | /* |
140 | 209 | * Container measurement |
141 | 210 | * |
— | — | @@ -146,107 +215,71 @@ |
147 | 216 | var width = $ruler.innerWidth() |
148 | 217 | $ruler.remove(); |
149 | 218 | |
150 | | - /* |
151 | | - * Word boundary scan |
152 | | - * |
153 | | - * To perform binary-search on words, rather than characters, we need to collect word boundary |
154 | | - * offsets into an array. This list of offsets always starts with 0 and ends with the length of |
155 | | - * the text, e.g. [0, ..., text.length]. The offset of the right side of the breaking character |
156 | | - * is stored, so the gaps between stored offsets always include the breaking character at the |
157 | | - * end. |
158 | | - * |
159 | | - * To avoid encoding the same words as HTML over and over while fitting text to lines, we also |
160 | | - * build a list of HTML encoded strings for each gap between the offsets stored in the |
161 | | - * "boundaries" array. Slices of the "words" array can be joined, producing the encoded HTML of |
162 | | - * the words. In the final pass, each line will get encoded 1 more time, to allow for whitespace |
163 | | - * trimming. |
164 | | - * |
165 | | - * Both "boundaries" and "words" data is kept around between renders. |
166 | | - */ |
167 | | - var boundaries = this.boundaries = [], |
168 | | - words = this.words = [], |
169 | | - boundary = /([ \.\,\;\:\-\t\r\n\f])/g, |
170 | | - match, |
171 | | - right, |
172 | | - left = 0; |
173 | | - while ( match = boundary.exec( text ) ) { |
174 | | - // Include the boundary character in the range |
175 | | - right = match.index + 1; |
176 | | - // Store the boundary offset |
177 | | - boundaries.push( right ); |
178 | | - // Store the word's encoded HTML |
179 | | - words.push( this.htmlEncode( text.substring( left, right ) ) ); |
180 | | - // Remember the previous match |
181 | | - left = right; |
| 219 | + // Ignore offset optimization if the width has changed or the text has never been flowed before |
| 220 | + if (this.width !== width) { |
| 221 | + offset = undefined; |
182 | 222 | } |
183 | | - // Ensure the "boundaries" array ends in a boundary, which may automatically happen if the text |
184 | | - // ends in a period, for instance, but may not in other cases |
185 | | - if ( right < text.length ) { |
186 | | - boundaries.push( text.length ); |
187 | | - words.push( this.htmlEncode( text.substring( right, text.length ) ) ); |
188 | | - } |
189 | 223 | |
190 | | - /* |
191 | | - * Line wrapping |
192 | | - * |
193 | | - * Now that we have linear access to the offsets around non-breakable areas within the text, we |
194 | | - * can perform a binary-search for the best fit of words within a line. Line data is kept around |
195 | | - * between renders. |
196 | | - * |
197 | | - * TODO: It may be possible to improve the efficiency of this code by making a best guess and |
198 | | - * working from there, rather than always starting with [i .. boundaries.length], which results |
199 | | - * in reducing the right position in all but the last line, and in most cases 2 or 3 times. |
200 | | - */ |
| 224 | + // TODO: Take offset into account and only work from there |
| 225 | + |
| 226 | + // Reset lines in the DOM and the "lines" array |
201 | 227 | this.$.empty(); |
202 | | - var lines = this.lines = [], |
203 | | - offset = 0, |
204 | | - subOffset, |
205 | | - start = 0, |
206 | | - end, |
207 | | - fit, |
208 | | - subFit, |
| 228 | + this.lines = []; |
| 229 | + // Iterate over each word that will fit in a line, appending them to the DOM as we go |
| 230 | + var wordOffset = 0, |
| 231 | + lineStart = 0, |
| 232 | + lineEnd, |
| 233 | + wordFit, |
| 234 | + charOffset, |
| 235 | + charFit, |
209 | 236 | $ruler = $( '<div class="editSurface-line"></div>' ).appendTo( this.$ ), |
210 | | - ruler = $ruler[0], |
211 | | - lineText, |
212 | | - $line; |
213 | | - while ( offset < boundaries.length ) { |
214 | | - fit = this.fitWords( words, offset, ruler, width ); |
215 | | - if ( fit.width > width ) { |
| 237 | + ruler = $ruler[0]; |
| 238 | + while ( wordOffset < this.boundaries.length ) { |
| 239 | + wordFit = this.fitWords( wordOffset, this.words.length, ruler, width ); |
| 240 | + if ( wordFit.width > width ) { |
216 | 241 | // The first word didn't fit, we need to split it up |
217 | | - subOffset = start; |
218 | | - offset++; |
219 | | - end = boundaries[offset]; |
| 242 | + charOffset = lineStart; |
| 243 | + wordOffset++; |
| 244 | + lineEnd = this.boundaries[wordOffset]; |
220 | 245 | do { |
221 | | - subFit = this.fitCharacters( text, subOffset, end, ruler, width ); |
| 246 | + charFit = this.fitCharacters( charOffset, lineEnd, ruler, width ); |
222 | 247 | // If we were able to get the rest of the characters on the line OK |
223 | | - if (subFit.end === end) { |
| 248 | + if (charFit.end === lineEnd) { |
224 | 249 | // Try to fit more words on the line |
225 | | - fit = this.fitWords( words, offset, ruler, width - subFit.width ); |
226 | | - if ( fit.end > offset ) { |
227 | | - offset = fit.end - 1; |
228 | | - subFit.end = end = boundaries[offset]; |
| 250 | + wordFit = this.fitWords( |
| 251 | + wordOffset, this.words.length, ruler, width - charFit.width |
| 252 | + ); |
| 253 | + if ( wordFit.end > wordOffset ) { |
| 254 | + wordOffset = wordFit.end - 1; |
| 255 | + charFit.end = lineEnd = this.boundaries[wordOffset]; |
229 | 256 | } |
230 | 257 | } |
231 | | - this.appendLine( text, subOffset, subFit.end ); |
| 258 | + this.appendLine( charOffset, charFit.end ); |
232 | 259 | // Move on to another line |
233 | | - subOffset = subFit.end; |
234 | | - } while (subOffset < end); |
| 260 | + charOffset = charFit.end; |
| 261 | + } while ( charOffset < lineEnd ); |
235 | 262 | } else { |
236 | | - offset = fit.end - 1; |
237 | | - end = boundaries[offset]; |
238 | | - this.appendLine( text, start, end ); |
| 263 | + wordOffset = wordFit.end - 1; |
| 264 | + lineEnd = this.boundaries[wordOffset]; |
| 265 | + this.appendLine( lineStart, lineEnd ); |
239 | 266 | } |
240 | | - start = end; |
241 | | - offset++; |
| 267 | + lineStart = lineEnd; |
| 268 | + wordOffset++; |
242 | 269 | } |
243 | 270 | // Cleanup |
244 | 271 | $ruler.remove(); |
245 | 272 | }; |
246 | 273 | |
247 | | -TextFlow.prototype.appendLine = function( text, start, end ) { |
248 | | - var lineText = text.substring( start, end ); |
| 274 | +/** |
| 275 | + * Adds a line containing a given range of text to the end of the DOM and the "lines" array. |
| 276 | + * |
| 277 | + * @param start {Integer} Beginning of text range for line |
| 278 | + * @param end {Integer} Ending of text range for line |
| 279 | + */ |
| 280 | +TextFlow.prototype.appendLine = function( start, end ) { |
| 281 | + var lineText = this.text.substring( start, end ); |
249 | 282 | $line = $( '<div class="editSurface-line" line-index="' |
250 | | - + this.lines.length + '">' + this.htmlEncode( lineText, true ) + '</div>' ) |
| 283 | + + this.lines.length + '">' + this.escape( lineText ) + '</div>' ) |
251 | 284 | .appendTo( this.$ ); |
252 | 285 | // Collect line information |
253 | 286 | this.lines.push({ |
— | — | @@ -259,25 +292,35 @@ |
260 | 293 | }; |
261 | 294 | |
262 | 295 | /** |
263 | | - * Gets the index of the last word that fits inside the line |
| 296 | + * Gets the index of the boundary of last word that fits inside the line |
264 | 297 | * |
265 | | - * @param words {Array} List of HTML encoded strings, each a word to be fit |
266 | | - * @param offset {Integer} Index within "words" to begin fitting from |
267 | | - * @param line {HTMLElement} Element to take measurements with |
| 298 | + * The "words" and "boundaries" arrays provide linear access to the offsets around non-breakable |
| 299 | + * areas within the text. Using these, we can perform a binary-search for the best fit of words |
| 300 | + * within a line, just as we would with characters. |
| 301 | + * |
| 302 | + * Results are given as an object containing both an index and a width, the later of which can be |
| 303 | + * used to detect when the first word was too long to fit on a line. In such cases the result will |
| 304 | + * contain the index of the boundary of the first word and it's width. |
| 305 | + * |
| 306 | + * TODO: Because limit is most likely given as "words.length", it may be possible to improve the |
| 307 | + * efficiency of this code by making a best guess and working from there, rather than always |
| 308 | + * starting with [offset .. limit], which usually results in reducing the end position in all but |
| 309 | + * the last line, and in most cases more than 3 times, before changing directions. |
| 310 | + * |
| 311 | + * @param start {Integer} Index within "words" to begin fitting from |
| 312 | + * @param end {Integer} Index within "words" to stop fitting to |
| 313 | + * @param ruler {HTMLElement} Element to take measurements with |
268 | 314 | * @param width {Integer} Maximum width to allow the line to extend to |
269 | 315 | * @return {Integer} Last index within "words" that contains a word that fits |
270 | | - * @return {Null} If not even the first word can fit |
271 | 316 | */ |
272 | | -TextFlow.prototype.fitWords = function( words, offset, ruler, width ) { |
273 | | - var start = offset, |
274 | | - end = words.length, |
275 | | - middle, |
276 | | - finalWidth; |
| 317 | +TextFlow.prototype.fitWords = function( start, end, ruler, width ) { |
| 318 | + var offset = start, |
| 319 | + middle; |
277 | 320 | do { |
278 | 321 | // Place "middle" directly in the center of "start" and "end" |
279 | 322 | middle = Math.ceil( ( start + end ) / 2 ); |
280 | | - // Prepare the line for measurement using pre-encoded HTML |
281 | | - ruler.innerHTML = words.slice( offset, middle ).join( '' ); |
| 323 | + // Prepare the line for measurement using pre-escaped HTML |
| 324 | + ruler.innerHTML = this.words.slice( offset, middle ).join( '' ); |
282 | 325 | // Test for over/under using width of the rendered line |
283 | 326 | if ( ruler.clientWidth > width ) { |
284 | 327 | // Detect impossible fit (the first word won't fit by itself) |
— | — | @@ -293,20 +336,31 @@ |
294 | 337 | } |
295 | 338 | } while ( start < end ); |
296 | 339 | // Final measurment |
297 | | - ruler.innerHTML = words.slice( offset, start ).join( '' ); |
| 340 | + ruler.innerHTML = this.words.slice( offset, start ).join( '' ); |
298 | 341 | return { 'end': start, 'width': ruler.clientWidth }; |
299 | 342 | }; |
300 | 343 | |
301 | | -TextFlow.prototype.fitCharacters = function( text, offset, limit, ruler, width ) { |
302 | | - var start = offset, |
303 | | - end = limit, |
304 | | - middle, |
305 | | - finalWidth; |
| 344 | +/** |
| 345 | + * Gets the index of the boundary of the last character that fits inside the line |
| 346 | + * |
| 347 | + * Results are given as an object containing both an index and a width, the later of which can be |
| 348 | + * used to detect when the first character was too long to fit on a line. In such cases the result |
| 349 | + * will contain the index of the first character and it's width. |
| 350 | + * |
| 351 | + * @param start {Integer} Index within "text" to begin fitting from |
| 352 | + * @param end {Integer} Index within "text" to stop fitting to |
| 353 | + * @param ruler {HTMLElement} Element to take measurements with |
| 354 | + * @param width {Integer} Maximum width to allow the line to extend to |
| 355 | + * @return {Integer} Last index within "text" that contains a character that fits |
| 356 | + */ |
| 357 | +TextFlow.prototype.fitCharacters = function( start, end, ruler, width ) { |
| 358 | + var offset = start, |
| 359 | + middle; |
306 | 360 | do { |
307 | 361 | // Place "middle" directly in the center of "start" and "end" |
308 | 362 | middle = Math.ceil( ( start + end ) / 2 ); |
309 | | - // Fill the line with a portion of the text, encoded as HTML |
310 | | - ruler.innerHTML = this.htmlEncode( text.substring( offset, middle ) ); |
| 363 | + // Fill the line with a portion of the text, escaped as HTML |
| 364 | + ruler.innerHTML = this.escape( this.text.substring( offset, middle ) ); |
311 | 365 | // Test for over/under using width of the rendered line |
312 | 366 | if ( ruler.clientWidth > width ) { |
313 | 367 | // Detect impossible fit (the first character won't fit by itself) |
— | — | @@ -322,6 +376,6 @@ |
323 | 377 | } |
324 | 378 | } while ( start < end ); |
325 | 379 | // Final measurement |
326 | | - ruler.innerHTML = this.htmlEncode( text.substring( offset, start ) ); |
| 380 | + ruler.innerHTML = this.escape( this.text.substring( offset, start ) ); |
327 | 381 | return { 'end': start, 'width': ruler.clientWidth }; |
328 | 382 | }; |
Index: trunk/parsers/wikidom/demos/es/index.html |
— | — | @@ -6,7 +6,6 @@ |
7 | 7 | <link rel="stylesheet" href="../../lib/es/es.Surface.css"> |
8 | 8 | </head> |
9 | 9 | <body> |
10 | | - <h1>EditSurface Demo</h1> |
11 | 10 | <div id="es"></div> |
12 | 11 | |
13 | 12 | <!-- EditSurface --> |