1 /** |
|
2 * DomTextMatcher.js |
|
3 * |
|
4 * Copyright, Moxiecode Systems AB |
|
5 * Released under LGPL License. |
|
6 * |
|
7 * License: http://www.tinymce.com/license |
|
8 * Contributing: http://www.tinymce.com/contributing |
|
9 */ |
|
10 |
|
11 /*eslint no-labels:0, no-constant-condition: 0 */ |
|
12 |
|
13 /** |
|
14 * This class logic for filtering text and matching words. |
|
15 * |
|
16 * @class tinymce.spellcheckerplugin.TextFilter |
|
17 * @private |
|
18 */ |
|
19 define("tinymce/spellcheckerplugin/DomTextMatcher", [], function() { |
|
20 // Based on work developed by: James Padolsey http://james.padolsey.com |
|
21 // released under UNLICENSE that is compatible with LGPL |
|
22 // TODO: Handle contentEditable edgecase: |
|
23 // <p>text<span contentEditable="false">text<span contentEditable="true">text</span>text</span>text</p> |
|
24 return function(node, editor) { |
|
25 var m, matches = [], text, dom = editor.dom; |
|
26 var blockElementsMap, hiddenTextElementsMap, shortEndedElementsMap; |
|
27 |
|
28 blockElementsMap = editor.schema.getBlockElements(); // H1-H6, P, TD etc |
|
29 hiddenTextElementsMap = editor.schema.getWhiteSpaceElements(); // TEXTAREA, PRE, STYLE, SCRIPT |
|
30 shortEndedElementsMap = editor.schema.getShortEndedElements(); // BR, IMG, INPUT |
|
31 |
|
32 function createMatch(m, data) { |
|
33 if (!m[0]) { |
|
34 throw 'findAndReplaceDOMText cannot handle zero-length matches'; |
|
35 } |
|
36 |
|
37 return { |
|
38 start: m.index, |
|
39 end: m.index + m[0].length, |
|
40 text: m[0], |
|
41 data: data |
|
42 }; |
|
43 } |
|
44 |
|
45 function getText(node) { |
|
46 var txt; |
|
47 |
|
48 if (node.nodeType === 3) { |
|
49 return node.data; |
|
50 } |
|
51 |
|
52 if (hiddenTextElementsMap[node.nodeName] && !blockElementsMap[node.nodeName]) { |
|
53 return ''; |
|
54 } |
|
55 |
|
56 txt = ''; |
|
57 |
|
58 if (blockElementsMap[node.nodeName] || shortEndedElementsMap[node.nodeName]) { |
|
59 txt += '\n'; |
|
60 } |
|
61 |
|
62 if ((node = node.firstChild)) { |
|
63 do { |
|
64 txt += getText(node); |
|
65 } while ((node = node.nextSibling)); |
|
66 } |
|
67 |
|
68 return txt; |
|
69 } |
|
70 |
|
71 function stepThroughMatches(node, matches, replaceFn) { |
|
72 var startNode, endNode, startNodeIndex, |
|
73 endNodeIndex, innerNodes = [], atIndex = 0, curNode = node, |
|
74 matchLocation, matchIndex = 0; |
|
75 |
|
76 matches = matches.slice(0); |
|
77 matches.sort(function(a, b) { |
|
78 return a.start - b.start; |
|
79 }); |
|
80 |
|
81 matchLocation = matches.shift(); |
|
82 |
|
83 out: while (true) { |
|
84 if (blockElementsMap[curNode.nodeName] || shortEndedElementsMap[curNode.nodeName]) { |
|
85 atIndex++; |
|
86 } |
|
87 |
|
88 if (curNode.nodeType === 3) { |
|
89 if (!endNode && curNode.length + atIndex >= matchLocation.end) { |
|
90 // We've found the ending |
|
91 endNode = curNode; |
|
92 endNodeIndex = matchLocation.end - atIndex; |
|
93 } else if (startNode) { |
|
94 // Intersecting node |
|
95 innerNodes.push(curNode); |
|
96 } |
|
97 |
|
98 if (!startNode && curNode.length + atIndex > matchLocation.start) { |
|
99 // We've found the match start |
|
100 startNode = curNode; |
|
101 startNodeIndex = matchLocation.start - atIndex; |
|
102 } |
|
103 |
|
104 atIndex += curNode.length; |
|
105 } |
|
106 |
|
107 if (startNode && endNode) { |
|
108 curNode = replaceFn({ |
|
109 startNode: startNode, |
|
110 startNodeIndex: startNodeIndex, |
|
111 endNode: endNode, |
|
112 endNodeIndex: endNodeIndex, |
|
113 innerNodes: innerNodes, |
|
114 match: matchLocation.text, |
|
115 matchIndex: matchIndex |
|
116 }); |
|
117 |
|
118 // replaceFn has to return the node that replaced the endNode |
|
119 // and then we step back so we can continue from the end of the |
|
120 // match: |
|
121 atIndex -= (endNode.length - endNodeIndex); |
|
122 startNode = null; |
|
123 endNode = null; |
|
124 innerNodes = []; |
|
125 matchLocation = matches.shift(); |
|
126 matchIndex++; |
|
127 |
|
128 if (!matchLocation) { |
|
129 break; // no more matches |
|
130 } |
|
131 } else if ((!hiddenTextElementsMap[curNode.nodeName] || blockElementsMap[curNode.nodeName]) && curNode.firstChild) { |
|
132 // Move down |
|
133 curNode = curNode.firstChild; |
|
134 continue; |
|
135 } else if (curNode.nextSibling) { |
|
136 // Move forward: |
|
137 curNode = curNode.nextSibling; |
|
138 continue; |
|
139 } |
|
140 |
|
141 // Move forward or up: |
|
142 while (true) { |
|
143 if (curNode.nextSibling) { |
|
144 curNode = curNode.nextSibling; |
|
145 break; |
|
146 } else if (curNode.parentNode !== node) { |
|
147 curNode = curNode.parentNode; |
|
148 } else { |
|
149 break out; |
|
150 } |
|
151 } |
|
152 } |
|
153 } |
|
154 |
|
155 /** |
|
156 * Generates the actual replaceFn which splits up text nodes |
|
157 * and inserts the replacement element. |
|
158 */ |
|
159 function genReplacer(callback) { |
|
160 function makeReplacementNode(fill, matchIndex) { |
|
161 var match = matches[matchIndex]; |
|
162 |
|
163 if (!match.stencil) { |
|
164 match.stencil = callback(match); |
|
165 } |
|
166 |
|
167 var clone = match.stencil.cloneNode(false); |
|
168 clone.setAttribute('data-mce-index', matchIndex); |
|
169 |
|
170 if (fill) { |
|
171 clone.appendChild(dom.doc.createTextNode(fill)); |
|
172 } |
|
173 |
|
174 return clone; |
|
175 } |
|
176 |
|
177 return function(range) { |
|
178 var before, after, parentNode, startNode = range.startNode, |
|
179 endNode = range.endNode, matchIndex = range.matchIndex, |
|
180 doc = dom.doc; |
|
181 |
|
182 if (startNode === endNode) { |
|
183 var node = startNode; |
|
184 |
|
185 parentNode = node.parentNode; |
|
186 if (range.startNodeIndex > 0) { |
|
187 // Add "before" text node (before the match) |
|
188 before = doc.createTextNode(node.data.substring(0, range.startNodeIndex)); |
|
189 parentNode.insertBefore(before, node); |
|
190 } |
|
191 |
|
192 // Create the replacement node: |
|
193 var el = makeReplacementNode(range.match, matchIndex); |
|
194 parentNode.insertBefore(el, node); |
|
195 if (range.endNodeIndex < node.length) { |
|
196 // Add "after" text node (after the match) |
|
197 after = doc.createTextNode(node.data.substring(range.endNodeIndex)); |
|
198 parentNode.insertBefore(after, node); |
|
199 } |
|
200 |
|
201 node.parentNode.removeChild(node); |
|
202 |
|
203 return el; |
|
204 } else { |
|
205 // Replace startNode -> [innerNodes...] -> endNode (in that order) |
|
206 before = doc.createTextNode(startNode.data.substring(0, range.startNodeIndex)); |
|
207 after = doc.createTextNode(endNode.data.substring(range.endNodeIndex)); |
|
208 var elA = makeReplacementNode(startNode.data.substring(range.startNodeIndex), matchIndex); |
|
209 var innerEls = []; |
|
210 |
|
211 for (var i = 0, l = range.innerNodes.length; i < l; ++i) { |
|
212 var innerNode = range.innerNodes[i]; |
|
213 var innerEl = makeReplacementNode(innerNode.data, matchIndex); |
|
214 innerNode.parentNode.replaceChild(innerEl, innerNode); |
|
215 innerEls.push(innerEl); |
|
216 } |
|
217 |
|
218 var elB = makeReplacementNode(endNode.data.substring(0, range.endNodeIndex), matchIndex); |
|
219 |
|
220 parentNode = startNode.parentNode; |
|
221 parentNode.insertBefore(before, startNode); |
|
222 parentNode.insertBefore(elA, startNode); |
|
223 parentNode.removeChild(startNode); |
|
224 |
|
225 parentNode = endNode.parentNode; |
|
226 parentNode.insertBefore(elB, endNode); |
|
227 parentNode.insertBefore(after, endNode); |
|
228 parentNode.removeChild(endNode); |
|
229 |
|
230 return elB; |
|
231 } |
|
232 }; |
|
233 } |
|
234 |
|
235 function unwrapElement(element) { |
|
236 var parentNode = element.parentNode; |
|
237 parentNode.insertBefore(element.firstChild, element); |
|
238 element.parentNode.removeChild(element); |
|
239 } |
|
240 |
|
241 function getWrappersByIndex(index) { |
|
242 var elements = node.getElementsByTagName('*'), wrappers = []; |
|
243 |
|
244 index = typeof index == "number" ? "" + index : null; |
|
245 |
|
246 for (var i = 0; i < elements.length; i++) { |
|
247 var element = elements[i], dataIndex = element.getAttribute('data-mce-index'); |
|
248 |
|
249 if (dataIndex !== null && dataIndex.length) { |
|
250 if (dataIndex === index || index === null) { |
|
251 wrappers.push(element); |
|
252 } |
|
253 } |
|
254 } |
|
255 |
|
256 return wrappers; |
|
257 } |
|
258 |
|
259 /** |
|
260 * Returns the index of a specific match object or -1 if it isn't found. |
|
261 * |
|
262 * @param {Match} match Text match object. |
|
263 * @return {Number} Index of match or -1 if it isn't found. |
|
264 */ |
|
265 function indexOf(match) { |
|
266 var i = matches.length; |
|
267 while (i--) { |
|
268 if (matches[i] === match) { |
|
269 return i; |
|
270 } |
|
271 } |
|
272 |
|
273 return -1; |
|
274 } |
|
275 |
|
276 /** |
|
277 * Filters the matches. If the callback returns true it stays if not it gets removed. |
|
278 * |
|
279 * @param {Function} callback Callback to execute for each match. |
|
280 * @return {DomTextMatcher} Current DomTextMatcher instance. |
|
281 */ |
|
282 function filter(callback) { |
|
283 var filteredMatches = []; |
|
284 |
|
285 each(function(match, i) { |
|
286 if (callback(match, i)) { |
|
287 filteredMatches.push(match); |
|
288 } |
|
289 }); |
|
290 |
|
291 matches = filteredMatches; |
|
292 |
|
293 /*jshint validthis:true*/ |
|
294 return this; |
|
295 } |
|
296 |
|
297 /** |
|
298 * Executes the specified callback for each match. |
|
299 * |
|
300 * @param {Function} callback Callback to execute for each match. |
|
301 * @return {DomTextMatcher} Current DomTextMatcher instance. |
|
302 */ |
|
303 function each(callback) { |
|
304 for (var i = 0, l = matches.length; i < l; i++) { |
|
305 if (callback(matches[i], i) === false) { |
|
306 break; |
|
307 } |
|
308 } |
|
309 |
|
310 /*jshint validthis:true*/ |
|
311 return this; |
|
312 } |
|
313 |
|
314 /** |
|
315 * Wraps the current matches with nodes created by the specified callback. |
|
316 * Multiple clones of these matches might occur on matches that are on multiple nodex. |
|
317 * |
|
318 * @param {Function} callback Callback to execute in order to create elements for matches. |
|
319 * @return {DomTextMatcher} Current DomTextMatcher instance. |
|
320 */ |
|
321 function wrap(callback) { |
|
322 if (matches.length) { |
|
323 stepThroughMatches(node, matches, genReplacer(callback)); |
|
324 } |
|
325 |
|
326 /*jshint validthis:true*/ |
|
327 return this; |
|
328 } |
|
329 |
|
330 /** |
|
331 * Finds the specified regexp and adds them to the matches collection. |
|
332 * |
|
333 * @param {RegExp} regex Global regexp to search the current node by. |
|
334 * @param {Object} [data] Optional custom data element for the match. |
|
335 * @return {DomTextMatcher} Current DomTextMatcher instance. |
|
336 */ |
|
337 function find(regex, data) { |
|
338 if (text && regex.global) { |
|
339 while ((m = regex.exec(text))) { |
|
340 matches.push(createMatch(m, data)); |
|
341 } |
|
342 } |
|
343 |
|
344 return this; |
|
345 } |
|
346 |
|
347 /** |
|
348 * Unwraps the specified match object or all matches if unspecified. |
|
349 * |
|
350 * @param {Object} [match] Optional match object. |
|
351 * @return {DomTextMatcher} Current DomTextMatcher instance. |
|
352 */ |
|
353 function unwrap(match) { |
|
354 var i, elements = getWrappersByIndex(match ? indexOf(match) : null); |
|
355 |
|
356 i = elements.length; |
|
357 while (i--) { |
|
358 unwrapElement(elements[i]); |
|
359 } |
|
360 |
|
361 return this; |
|
362 } |
|
363 |
|
364 /** |
|
365 * Returns a match object by the specified DOM element. |
|
366 * |
|
367 * @param {DOMElement} element Element to return match object for. |
|
368 * @return {Object} Match object for the specified element. |
|
369 */ |
|
370 function matchFromElement(element) { |
|
371 return matches[element.getAttribute('data-mce-index')]; |
|
372 } |
|
373 |
|
374 /** |
|
375 * Returns a DOM element from the specified match element. This will be the first element if it's split |
|
376 * on multiple nodes. |
|
377 * |
|
378 * @param {Object} match Match element to get first element of. |
|
379 * @return {DOMElement} DOM element for the specified match object. |
|
380 */ |
|
381 function elementFromMatch(match) { |
|
382 return getWrappersByIndex(indexOf(match))[0]; |
|
383 } |
|
384 |
|
385 /** |
|
386 * Adds match the specified range for example a grammar line. |
|
387 * |
|
388 * @param {Number} start Start offset. |
|
389 * @param {Number} length Length of the text. |
|
390 * @param {Object} data Custom data object for match. |
|
391 * @return {DomTextMatcher} Current DomTextMatcher instance. |
|
392 */ |
|
393 function add(start, length, data) { |
|
394 matches.push({ |
|
395 start: start, |
|
396 end: start + length, |
|
397 text: text.substr(start, length), |
|
398 data: data |
|
399 }); |
|
400 |
|
401 return this; |
|
402 } |
|
403 |
|
404 /** |
|
405 * Returns a DOM range for the specified match. |
|
406 * |
|
407 * @param {Object} match Match object to get range for. |
|
408 * @return {DOMRange} DOM Range for the specified match. |
|
409 */ |
|
410 function rangeFromMatch(match) { |
|
411 var wrappers = getWrappersByIndex(indexOf(match)); |
|
412 |
|
413 var rng = editor.dom.createRng(); |
|
414 rng.setStartBefore(wrappers[0]); |
|
415 rng.setEndAfter(wrappers[wrappers.length - 1]); |
|
416 |
|
417 return rng; |
|
418 } |
|
419 |
|
420 /** |
|
421 * Replaces the specified match with the specified text. |
|
422 * |
|
423 * @param {Object} match Match object to replace. |
|
424 * @param {String} text Text to replace the match with. |
|
425 * @return {DOMRange} DOM range produced after the replace. |
|
426 */ |
|
427 function replace(match, text) { |
|
428 var rng = rangeFromMatch(match); |
|
429 |
|
430 rng.deleteContents(); |
|
431 |
|
432 if (text.length > 0) { |
|
433 rng.insertNode(editor.dom.doc.createTextNode(text)); |
|
434 } |
|
435 |
|
436 return rng; |
|
437 } |
|
438 |
|
439 /** |
|
440 * Resets the DomTextMatcher instance. This will remove any wrapped nodes and remove any matches. |
|
441 * |
|
442 * @return {[type]} [description] |
|
443 */ |
|
444 function reset() { |
|
445 matches.splice(0, matches.length); |
|
446 unwrap(); |
|
447 |
|
448 return this; |
|
449 } |
|
450 |
|
451 text = getText(node); |
|
452 |
|
453 return { |
|
454 text: text, |
|
455 matches: matches, |
|
456 each: each, |
|
457 filter: filter, |
|
458 reset: reset, |
|
459 matchFromElement: matchFromElement, |
|
460 elementFromMatch: elementFromMatch, |
|
461 find: find, |
|
462 add: add, |
|
463 wrap: wrap, |
|
464 unwrap: unwrap, |
|
465 replace: replace, |
|
466 rangeFromMatch: rangeFromMatch, |
|
467 indexOf: indexOf |
|
468 }; |
|
469 }; |
|
470 }); |
|