|
1 /** |
|
2 * plugin.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 /*jshint smarttabs:true, undef:true, unused:true, latedef:true, curly:true, bitwise:true */ |
|
12 /*eslint no-labels:0, no-constant-condition: 0 */ |
|
13 /*global tinymce:true */ |
|
14 |
|
15 (function() { |
|
16 // Based on work developed by: James Padolsey http://james.padolsey.com |
|
17 // released under UNLICENSE that is compatible with LGPL |
|
18 // TODO: Handle contentEditable edgecase: |
|
19 // <p>text<span contentEditable="false">text<span contentEditable="true">text</span>text</span>text</p> |
|
20 function findAndReplaceDOMText(regex, node, replacementNode, captureGroup, schema) { |
|
21 var m, matches = [], text, count = 0, doc; |
|
22 var blockElementsMap, hiddenTextElementsMap, shortEndedElementsMap; |
|
23 |
|
24 doc = node.ownerDocument; |
|
25 blockElementsMap = schema.getBlockElements(); // H1-H6, P, TD etc |
|
26 hiddenTextElementsMap = schema.getWhiteSpaceElements(); // TEXTAREA, PRE, STYLE, SCRIPT |
|
27 shortEndedElementsMap = schema.getShortEndedElements(); // BR, IMG, INPUT |
|
28 |
|
29 function getMatchIndexes(m, captureGroup) { |
|
30 captureGroup = captureGroup || 0; |
|
31 |
|
32 if (!m[0]) { |
|
33 throw 'findAndReplaceDOMText cannot handle zero-length matches'; |
|
34 } |
|
35 |
|
36 var index = m.index; |
|
37 |
|
38 if (captureGroup > 0) { |
|
39 var cg = m[captureGroup]; |
|
40 |
|
41 if (!cg) { |
|
42 throw 'Invalid capture group'; |
|
43 } |
|
44 |
|
45 index += m[0].indexOf(cg); |
|
46 m[0] = cg; |
|
47 } |
|
48 |
|
49 return [index, index + m[0].length, [m[0]]]; |
|
50 } |
|
51 |
|
52 function getText(node) { |
|
53 var txt; |
|
54 |
|
55 if (node.nodeType === 3) { |
|
56 return node.data; |
|
57 } |
|
58 |
|
59 if (hiddenTextElementsMap[node.nodeName] && !blockElementsMap[node.nodeName]) { |
|
60 return ''; |
|
61 } |
|
62 |
|
63 txt = ''; |
|
64 |
|
65 if (blockElementsMap[node.nodeName] || shortEndedElementsMap[node.nodeName]) { |
|
66 txt += '\n'; |
|
67 } |
|
68 |
|
69 if ((node = node.firstChild)) { |
|
70 do { |
|
71 txt += getText(node); |
|
72 } while ((node = node.nextSibling)); |
|
73 } |
|
74 |
|
75 return txt; |
|
76 } |
|
77 |
|
78 function stepThroughMatches(node, matches, replaceFn) { |
|
79 var startNode, endNode, startNodeIndex, |
|
80 endNodeIndex, innerNodes = [], atIndex = 0, curNode = node, |
|
81 matchLocation = matches.shift(), matchIndex = 0; |
|
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[1]) { |
|
90 // We've found the ending |
|
91 endNode = curNode; |
|
92 endNodeIndex = matchLocation[1] - atIndex; |
|
93 } else if (startNode) { |
|
94 // Intersecting node |
|
95 innerNodes.push(curNode); |
|
96 } |
|
97 |
|
98 if (!startNode && curNode.length + atIndex > matchLocation[0]) { |
|
99 // We've found the match start |
|
100 startNode = curNode; |
|
101 startNodeIndex = matchLocation[0] - 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[2], |
|
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(nodeName) { |
|
160 var makeReplacementNode; |
|
161 |
|
162 if (typeof nodeName != 'function') { |
|
163 var stencilNode = nodeName.nodeType ? nodeName : doc.createElement(nodeName); |
|
164 |
|
165 makeReplacementNode = function(fill, matchIndex) { |
|
166 var clone = stencilNode.cloneNode(false); |
|
167 |
|
168 clone.setAttribute('data-mce-index', matchIndex); |
|
169 |
|
170 if (fill) { |
|
171 clone.appendChild(doc.createTextNode(fill)); |
|
172 } |
|
173 |
|
174 return clone; |
|
175 }; |
|
176 } else { |
|
177 makeReplacementNode = nodeName; |
|
178 } |
|
179 |
|
180 return function(range) { |
|
181 var before, after, parentNode, startNode = range.startNode, |
|
182 endNode = range.endNode, matchIndex = range.matchIndex; |
|
183 |
|
184 if (startNode === endNode) { |
|
185 var node = startNode; |
|
186 |
|
187 parentNode = node.parentNode; |
|
188 if (range.startNodeIndex > 0) { |
|
189 // Add `before` text node (before the match) |
|
190 before = doc.createTextNode(node.data.substring(0, range.startNodeIndex)); |
|
191 parentNode.insertBefore(before, node); |
|
192 } |
|
193 |
|
194 // Create the replacement node: |
|
195 var el = makeReplacementNode(range.match[0], matchIndex); |
|
196 parentNode.insertBefore(el, node); |
|
197 if (range.endNodeIndex < node.length) { |
|
198 // Add `after` text node (after the match) |
|
199 after = doc.createTextNode(node.data.substring(range.endNodeIndex)); |
|
200 parentNode.insertBefore(after, node); |
|
201 } |
|
202 |
|
203 node.parentNode.removeChild(node); |
|
204 |
|
205 return el; |
|
206 } else { |
|
207 // Replace startNode -> [innerNodes...] -> endNode (in that order) |
|
208 before = doc.createTextNode(startNode.data.substring(0, range.startNodeIndex)); |
|
209 after = doc.createTextNode(endNode.data.substring(range.endNodeIndex)); |
|
210 var elA = makeReplacementNode(startNode.data.substring(range.startNodeIndex), matchIndex); |
|
211 var innerEls = []; |
|
212 |
|
213 for (var i = 0, l = range.innerNodes.length; i < l; ++i) { |
|
214 var innerNode = range.innerNodes[i]; |
|
215 var innerEl = makeReplacementNode(innerNode.data, matchIndex); |
|
216 innerNode.parentNode.replaceChild(innerEl, innerNode); |
|
217 innerEls.push(innerEl); |
|
218 } |
|
219 |
|
220 var elB = makeReplacementNode(endNode.data.substring(0, range.endNodeIndex), matchIndex); |
|
221 |
|
222 parentNode = startNode.parentNode; |
|
223 parentNode.insertBefore(before, startNode); |
|
224 parentNode.insertBefore(elA, startNode); |
|
225 parentNode.removeChild(startNode); |
|
226 |
|
227 parentNode = endNode.parentNode; |
|
228 parentNode.insertBefore(elB, endNode); |
|
229 parentNode.insertBefore(after, endNode); |
|
230 parentNode.removeChild(endNode); |
|
231 |
|
232 return elB; |
|
233 } |
|
234 }; |
|
235 } |
|
236 |
|
237 text = getText(node); |
|
238 if (!text) { |
|
239 return; |
|
240 } |
|
241 |
|
242 if (regex.global) { |
|
243 while ((m = regex.exec(text))) { |
|
244 matches.push(getMatchIndexes(m, captureGroup)); |
|
245 } |
|
246 } else { |
|
247 m = text.match(regex); |
|
248 matches.push(getMatchIndexes(m, captureGroup)); |
|
249 } |
|
250 |
|
251 if (matches.length) { |
|
252 count = matches.length; |
|
253 stepThroughMatches(node, matches, genReplacer(replacementNode)); |
|
254 } |
|
255 |
|
256 return count; |
|
257 } |
|
258 |
|
259 function Plugin(editor) { |
|
260 var self = this, currentIndex = -1; |
|
261 |
|
262 function showDialog() { |
|
263 var last = {}; |
|
264 |
|
265 function updateButtonStates() { |
|
266 win.statusbar.find('#next').disabled(!findSpansByIndex(currentIndex + 1).length); |
|
267 win.statusbar.find('#prev').disabled(!findSpansByIndex(currentIndex - 1).length); |
|
268 } |
|
269 |
|
270 function notFoundAlert() { |
|
271 tinymce.ui.MessageBox.alert('Could not find the specified string.', function() { |
|
272 win.find('#find')[0].focus(); |
|
273 }); |
|
274 } |
|
275 |
|
276 var win = tinymce.ui.Factory.create({ |
|
277 type: 'window', |
|
278 layout: "flex", |
|
279 pack: "center", |
|
280 align: "center", |
|
281 onClose: function() { |
|
282 editor.focus(); |
|
283 self.done(); |
|
284 }, |
|
285 onSubmit: function(e) { |
|
286 var count, caseState, text, wholeWord; |
|
287 |
|
288 e.preventDefault(); |
|
289 |
|
290 caseState = win.find('#case').checked(); |
|
291 wholeWord = win.find('#words').checked(); |
|
292 |
|
293 text = win.find('#find').value(); |
|
294 if (!text.length) { |
|
295 self.done(false); |
|
296 win.statusbar.items().slice(1).disabled(true); |
|
297 return; |
|
298 } |
|
299 |
|
300 if (last.text == text && last.caseState == caseState && last.wholeWord == wholeWord) { |
|
301 if (findSpansByIndex(currentIndex + 1).length === 0) { |
|
302 notFoundAlert(); |
|
303 return; |
|
304 } |
|
305 |
|
306 self.next(); |
|
307 updateButtonStates(); |
|
308 return; |
|
309 } |
|
310 |
|
311 count = self.find(text, caseState, wholeWord); |
|
312 if (!count) { |
|
313 notFoundAlert(); |
|
314 } |
|
315 |
|
316 win.statusbar.items().slice(1).disabled(count === 0); |
|
317 updateButtonStates(); |
|
318 |
|
319 last = { |
|
320 text: text, |
|
321 caseState: caseState, |
|
322 wholeWord: wholeWord |
|
323 }; |
|
324 }, |
|
325 buttons: [ |
|
326 {text: "Find", onclick: function() { |
|
327 win.submit(); |
|
328 }}, |
|
329 {text: "Replace", disabled: true, onclick: function() { |
|
330 if (!self.replace(win.find('#replace').value())) { |
|
331 win.statusbar.items().slice(1).disabled(true); |
|
332 currentIndex = -1; |
|
333 last = {}; |
|
334 } |
|
335 }}, |
|
336 {text: "Replace all", disabled: true, onclick: function() { |
|
337 self.replace(win.find('#replace').value(), true, true); |
|
338 win.statusbar.items().slice(1).disabled(true); |
|
339 last = {}; |
|
340 }}, |
|
341 {type: "spacer", flex: 1}, |
|
342 {text: "Prev", name: 'prev', disabled: true, onclick: function() { |
|
343 self.prev(); |
|
344 updateButtonStates(); |
|
345 }}, |
|
346 {text: "Next", name: 'next', disabled: true, onclick: function() { |
|
347 self.next(); |
|
348 updateButtonStates(); |
|
349 }} |
|
350 ], |
|
351 title: "Find and replace", |
|
352 items: { |
|
353 type: "form", |
|
354 padding: 20, |
|
355 labelGap: 30, |
|
356 spacing: 10, |
|
357 items: [ |
|
358 {type: 'textbox', name: 'find', size: 40, label: 'Find', value: editor.selection.getNode().src}, |
|
359 {type: 'textbox', name: 'replace', size: 40, label: 'Replace with'}, |
|
360 {type: 'checkbox', name: 'case', text: 'Match case', label: ' '}, |
|
361 {type: 'checkbox', name: 'words', text: 'Whole words', label: ' '} |
|
362 ] |
|
363 } |
|
364 }).renderTo().reflow(); |
|
365 } |
|
366 |
|
367 self.init = function(ed) { |
|
368 ed.addMenuItem('searchreplace', { |
|
369 text: 'Find and replace', |
|
370 shortcut: 'Meta+F', |
|
371 onclick: showDialog, |
|
372 separator: 'before', |
|
373 context: 'edit' |
|
374 }); |
|
375 |
|
376 ed.addButton('searchreplace', { |
|
377 tooltip: 'Find and replace', |
|
378 shortcut: 'Meta+F', |
|
379 onclick: showDialog |
|
380 }); |
|
381 |
|
382 ed.addCommand("SearchReplace", showDialog); |
|
383 ed.shortcuts.add('Meta+F', '', showDialog); |
|
384 }; |
|
385 |
|
386 function getElmIndex(elm) { |
|
387 var value = elm.getAttribute('data-mce-index'); |
|
388 |
|
389 if (typeof value == "number") { |
|
390 return "" + value; |
|
391 } |
|
392 |
|
393 return value; |
|
394 } |
|
395 |
|
396 function markAllMatches(regex) { |
|
397 var node, marker; |
|
398 |
|
399 marker = editor.dom.create('span', { |
|
400 "data-mce-bogus": 1 |
|
401 }); |
|
402 |
|
403 marker.className = 'mce-match-marker'; // IE 7 adds class="mce-match-marker" and class=mce-match-marker |
|
404 node = editor.getBody(); |
|
405 |
|
406 self.done(false); |
|
407 |
|
408 return findAndReplaceDOMText(regex, node, marker, false, editor.schema); |
|
409 } |
|
410 |
|
411 function unwrap(node) { |
|
412 var parentNode = node.parentNode; |
|
413 |
|
414 if (node.firstChild) { |
|
415 parentNode.insertBefore(node.firstChild, node); |
|
416 } |
|
417 |
|
418 node.parentNode.removeChild(node); |
|
419 } |
|
420 |
|
421 function findSpansByIndex(index) { |
|
422 var nodes, spans = []; |
|
423 |
|
424 nodes = tinymce.toArray(editor.getBody().getElementsByTagName('span')); |
|
425 if (nodes.length) { |
|
426 for (var i = 0; i < nodes.length; i++) { |
|
427 var nodeIndex = getElmIndex(nodes[i]); |
|
428 |
|
429 if (nodeIndex === null || !nodeIndex.length) { |
|
430 continue; |
|
431 } |
|
432 |
|
433 if (nodeIndex === index.toString()) { |
|
434 spans.push(nodes[i]); |
|
435 } |
|
436 } |
|
437 } |
|
438 |
|
439 return spans; |
|
440 } |
|
441 |
|
442 function moveSelection(forward) { |
|
443 var testIndex = currentIndex, dom = editor.dom; |
|
444 |
|
445 forward = forward !== false; |
|
446 |
|
447 if (forward) { |
|
448 testIndex++; |
|
449 } else { |
|
450 testIndex--; |
|
451 } |
|
452 |
|
453 dom.removeClass(findSpansByIndex(currentIndex), 'mce-match-marker-selected'); |
|
454 |
|
455 var spans = findSpansByIndex(testIndex); |
|
456 if (spans.length) { |
|
457 dom.addClass(findSpansByIndex(testIndex), 'mce-match-marker-selected'); |
|
458 editor.selection.scrollIntoView(spans[0]); |
|
459 return testIndex; |
|
460 } |
|
461 |
|
462 return -1; |
|
463 } |
|
464 |
|
465 function removeNode(node) { |
|
466 node.parentNode.removeChild(node); |
|
467 } |
|
468 |
|
469 self.find = function(text, matchCase, wholeWord) { |
|
470 text = text.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); |
|
471 text = wholeWord ? '\\b' + text + '\\b' : text; |
|
472 |
|
473 var count = markAllMatches(new RegExp(text, matchCase ? 'g' : 'gi')); |
|
474 |
|
475 if (count) { |
|
476 currentIndex = -1; |
|
477 currentIndex = moveSelection(true); |
|
478 } |
|
479 |
|
480 return count; |
|
481 }; |
|
482 |
|
483 self.next = function() { |
|
484 var index = moveSelection(true); |
|
485 |
|
486 if (index !== -1) { |
|
487 currentIndex = index; |
|
488 } |
|
489 }; |
|
490 |
|
491 self.prev = function() { |
|
492 var index = moveSelection(false); |
|
493 |
|
494 if (index !== -1) { |
|
495 currentIndex = index; |
|
496 } |
|
497 }; |
|
498 |
|
499 self.replace = function(text, forward, all) { |
|
500 var i, nodes, node, matchIndex, currentMatchIndex, nextIndex = currentIndex, hasMore; |
|
501 |
|
502 forward = forward !== false; |
|
503 |
|
504 node = editor.getBody(); |
|
505 nodes = tinymce.toArray(node.getElementsByTagName('span')); |
|
506 for (i = 0; i < nodes.length; i++) { |
|
507 var nodeIndex = getElmIndex(nodes[i]); |
|
508 |
|
509 if (nodeIndex === null || !nodeIndex.length) { |
|
510 continue; |
|
511 } |
|
512 |
|
513 matchIndex = currentMatchIndex = parseInt(nodeIndex, 10); |
|
514 if (all || matchIndex === currentIndex) { |
|
515 if (text.length) { |
|
516 nodes[i].firstChild.nodeValue = text; |
|
517 unwrap(nodes[i]); |
|
518 } else { |
|
519 removeNode(nodes[i]); |
|
520 } |
|
521 |
|
522 while (nodes[++i]) { |
|
523 matchIndex = getElmIndex(nodes[i]); |
|
524 |
|
525 if (nodeIndex === null || !nodeIndex.length) { |
|
526 continue; |
|
527 } |
|
528 |
|
529 if (matchIndex === currentMatchIndex) { |
|
530 removeNode(nodes[i]); |
|
531 } else { |
|
532 i--; |
|
533 break; |
|
534 } |
|
535 } |
|
536 |
|
537 if (forward) { |
|
538 nextIndex--; |
|
539 } |
|
540 } else if (currentMatchIndex > currentIndex) { |
|
541 nodes[i].setAttribute('data-mce-index', currentMatchIndex - 1); |
|
542 } |
|
543 } |
|
544 |
|
545 editor.undoManager.add(); |
|
546 currentIndex = nextIndex; |
|
547 |
|
548 if (forward) { |
|
549 hasMore = findSpansByIndex(nextIndex + 1).length > 0; |
|
550 self.next(); |
|
551 } else { |
|
552 hasMore = findSpansByIndex(nextIndex - 1).length > 0; |
|
553 self.prev(); |
|
554 } |
|
555 |
|
556 return !all && hasMore; |
|
557 }; |
|
558 |
|
559 self.done = function(keepEditorSelection) { |
|
560 var i, nodes, startContainer, endContainer; |
|
561 |
|
562 nodes = tinymce.toArray(editor.getBody().getElementsByTagName('span')); |
|
563 for (i = 0; i < nodes.length; i++) { |
|
564 var nodeIndex = getElmIndex(nodes[i]); |
|
565 |
|
566 if (nodeIndex !== null && nodeIndex.length) { |
|
567 if (nodeIndex === currentIndex.toString()) { |
|
568 if (!startContainer) { |
|
569 startContainer = nodes[i].firstChild; |
|
570 } |
|
571 |
|
572 endContainer = nodes[i].firstChild; |
|
573 } |
|
574 |
|
575 unwrap(nodes[i]); |
|
576 } |
|
577 } |
|
578 |
|
579 if (startContainer && endContainer) { |
|
580 var rng = editor.dom.createRng(); |
|
581 rng.setStart(startContainer, 0); |
|
582 rng.setEnd(endContainer, endContainer.data.length); |
|
583 |
|
584 if (keepEditorSelection !== false) { |
|
585 editor.selection.setRng(rng); |
|
586 } |
|
587 |
|
588 return rng; |
|
589 } |
|
590 }; |
|
591 } |
|
592 |
|
593 tinymce.PluginManager.add('searchreplace', Plugin); |
|
594 })(); |