plugin.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  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. /*jshint smarttabs:true, undef:true, unused:true, latedef:true, curly:true, bitwise:true */
  11. /*eslint no-labels:0, no-constant-condition: 0 */
  12. /*global tinymce:true */
  13. (function() {
  14. // Based on work developed by: James Padolsey http://james.padolsey.com
  15. // released under UNLICENSE that is compatible with LGPL
  16. // TODO: Handle contentEditable edgecase:
  17. // <p>text<span contentEditable="false">text<span contentEditable="true">text</span>text</span>text</p>
  18. function findAndReplaceDOMText(regex, node, replacementNode, captureGroup, schema) {
  19. var m, matches = [], text, count = 0, doc;
  20. var blockElementsMap, hiddenTextElementsMap, shortEndedElementsMap;
  21. doc = node.ownerDocument;
  22. blockElementsMap = schema.getBlockElements(); // H1-H6, P, TD etc
  23. hiddenTextElementsMap = schema.getWhiteSpaceElements(); // TEXTAREA, PRE, STYLE, SCRIPT
  24. shortEndedElementsMap = schema.getShortEndedElements(); // BR, IMG, INPUT
  25. function getMatchIndexes(m, captureGroup) {
  26. captureGroup = captureGroup || 0;
  27. if (!m[0]) {
  28. throw 'findAndReplaceDOMText cannot handle zero-length matches';
  29. }
  30. var index = m.index;
  31. if (captureGroup > 0) {
  32. var cg = m[captureGroup];
  33. if (!cg) {
  34. throw 'Invalid capture group';
  35. }
  36. index += m[0].indexOf(cg);
  37. m[0] = cg;
  38. }
  39. return [index, index + m[0].length, [m[0]]];
  40. }
  41. function getText(node) {
  42. var txt;
  43. if (node.nodeType === 3) {
  44. return node.data;
  45. }
  46. if (hiddenTextElementsMap[node.nodeName] && !blockElementsMap[node.nodeName]) {
  47. return '';
  48. }
  49. txt = '';
  50. if (blockElementsMap[node.nodeName] || shortEndedElementsMap[node.nodeName]) {
  51. txt += '\n';
  52. }
  53. if ((node = node.firstChild)) {
  54. do {
  55. txt += getText(node);
  56. } while ((node = node.nextSibling));
  57. }
  58. return txt;
  59. }
  60. function stepThroughMatches(node, matches, replaceFn) {
  61. var startNode, endNode, startNodeIndex,
  62. endNodeIndex, innerNodes = [], atIndex = 0, curNode = node,
  63. matchLocation = matches.shift(), matchIndex = 0;
  64. out: while (true) {
  65. if (blockElementsMap[curNode.nodeName] || shortEndedElementsMap[curNode.nodeName]) {
  66. atIndex++;
  67. }
  68. if (curNode.nodeType === 3) {
  69. if (!endNode && curNode.length + atIndex >= matchLocation[1]) {
  70. // We've found the ending
  71. endNode = curNode;
  72. endNodeIndex = matchLocation[1] - atIndex;
  73. } else if (startNode) {
  74. // Intersecting node
  75. innerNodes.push(curNode);
  76. }
  77. if (!startNode && curNode.length + atIndex > matchLocation[0]) {
  78. // We've found the match start
  79. startNode = curNode;
  80. startNodeIndex = matchLocation[0] - atIndex;
  81. }
  82. atIndex += curNode.length;
  83. }
  84. if (startNode && endNode) {
  85. curNode = replaceFn({
  86. startNode: startNode,
  87. startNodeIndex: startNodeIndex,
  88. endNode: endNode,
  89. endNodeIndex: endNodeIndex,
  90. innerNodes: innerNodes,
  91. match: matchLocation[2],
  92. matchIndex: matchIndex
  93. });
  94. // replaceFn has to return the node that replaced the endNode
  95. // and then we step back so we can continue from the end of the
  96. // match:
  97. atIndex -= (endNode.length - endNodeIndex);
  98. startNode = null;
  99. endNode = null;
  100. innerNodes = [];
  101. matchLocation = matches.shift();
  102. matchIndex++;
  103. if (!matchLocation) {
  104. break; // no more matches
  105. }
  106. } else if ((!hiddenTextElementsMap[curNode.nodeName] || blockElementsMap[curNode.nodeName]) && curNode.firstChild) {
  107. // Move down
  108. curNode = curNode.firstChild;
  109. continue;
  110. } else if (curNode.nextSibling) {
  111. // Move forward:
  112. curNode = curNode.nextSibling;
  113. continue;
  114. }
  115. // Move forward or up:
  116. while (true) {
  117. if (curNode.nextSibling) {
  118. curNode = curNode.nextSibling;
  119. break;
  120. } else if (curNode.parentNode !== node) {
  121. curNode = curNode.parentNode;
  122. } else {
  123. break out;
  124. }
  125. }
  126. }
  127. }
  128. /**
  129. * Generates the actual replaceFn which splits up text nodes
  130. * and inserts the replacement element.
  131. */
  132. function genReplacer(nodeName) {
  133. var makeReplacementNode;
  134. if (typeof nodeName != 'function') {
  135. var stencilNode = nodeName.nodeType ? nodeName : doc.createElement(nodeName);
  136. makeReplacementNode = function(fill, matchIndex) {
  137. var clone = stencilNode.cloneNode(false);
  138. clone.setAttribute('data-mce-index', matchIndex);
  139. if (fill) {
  140. clone.appendChild(doc.createTextNode(fill));
  141. }
  142. return clone;
  143. };
  144. } else {
  145. makeReplacementNode = nodeName;
  146. }
  147. return function(range) {
  148. var before, after, parentNode, startNode = range.startNode,
  149. endNode = range.endNode, matchIndex = range.matchIndex;
  150. if (startNode === endNode) {
  151. var node = startNode;
  152. parentNode = node.parentNode;
  153. if (range.startNodeIndex > 0) {
  154. // Add `before` text node (before the match)
  155. before = doc.createTextNode(node.data.substring(0, range.startNodeIndex));
  156. parentNode.insertBefore(before, node);
  157. }
  158. // Create the replacement node:
  159. var el = makeReplacementNode(range.match[0], matchIndex);
  160. parentNode.insertBefore(el, node);
  161. if (range.endNodeIndex < node.length) {
  162. // Add `after` text node (after the match)
  163. after = doc.createTextNode(node.data.substring(range.endNodeIndex));
  164. parentNode.insertBefore(after, node);
  165. }
  166. node.parentNode.removeChild(node);
  167. return el;
  168. } else {
  169. // Replace startNode -> [innerNodes...] -> endNode (in that order)
  170. before = doc.createTextNode(startNode.data.substring(0, range.startNodeIndex));
  171. after = doc.createTextNode(endNode.data.substring(range.endNodeIndex));
  172. var elA = makeReplacementNode(startNode.data.substring(range.startNodeIndex), matchIndex);
  173. var innerEls = [];
  174. for (var i = 0, l = range.innerNodes.length; i < l; ++i) {
  175. var innerNode = range.innerNodes[i];
  176. var innerEl = makeReplacementNode(innerNode.data, matchIndex);
  177. innerNode.parentNode.replaceChild(innerEl, innerNode);
  178. innerEls.push(innerEl);
  179. }
  180. var elB = makeReplacementNode(endNode.data.substring(0, range.endNodeIndex), matchIndex);
  181. parentNode = startNode.parentNode;
  182. parentNode.insertBefore(before, startNode);
  183. parentNode.insertBefore(elA, startNode);
  184. parentNode.removeChild(startNode);
  185. parentNode = endNode.parentNode;
  186. parentNode.insertBefore(elB, endNode);
  187. parentNode.insertBefore(after, endNode);
  188. parentNode.removeChild(endNode);
  189. return elB;
  190. }
  191. };
  192. }
  193. text = getText(node);
  194. if (!text) {
  195. return;
  196. }
  197. if (regex.global) {
  198. while ((m = regex.exec(text))) {
  199. matches.push(getMatchIndexes(m, captureGroup));
  200. }
  201. } else {
  202. m = text.match(regex);
  203. matches.push(getMatchIndexes(m, captureGroup));
  204. }
  205. if (matches.length) {
  206. count = matches.length;
  207. stepThroughMatches(node, matches, genReplacer(replacementNode));
  208. }
  209. return count;
  210. }
  211. function Plugin(editor) {
  212. var self = this, currentIndex = -1;
  213. function showDialog() {
  214. var last = {};
  215. function updateButtonStates() {
  216. win.statusbar.find('#next').disabled(!findSpansByIndex(currentIndex + 1).length);
  217. win.statusbar.find('#prev').disabled(!findSpansByIndex(currentIndex - 1).length);
  218. }
  219. function notFoundAlert() {
  220. tinymce.ui.MessageBox.alert('Could not find the specified string.', function() {
  221. win.find('#find')[0].focus();
  222. });
  223. }
  224. var win = tinymce.ui.Factory.create({
  225. type: 'window',
  226. layout: "flex",
  227. pack: "center",
  228. align: "center",
  229. onClose: function() {
  230. editor.focus();
  231. self.done();
  232. },
  233. onSubmit: function(e) {
  234. var count, caseState, text, wholeWord;
  235. e.preventDefault();
  236. caseState = win.find('#case').checked();
  237. wholeWord = win.find('#words').checked();
  238. text = win.find('#find').value();
  239. if (!text.length) {
  240. self.done(false);
  241. win.statusbar.items().slice(1).disabled(true);
  242. return;
  243. }
  244. if (last.text == text && last.caseState == caseState && last.wholeWord == wholeWord) {
  245. if (findSpansByIndex(currentIndex + 1).length === 0) {
  246. notFoundAlert();
  247. return;
  248. }
  249. self.next();
  250. updateButtonStates();
  251. return;
  252. }
  253. count = self.find(text, caseState, wholeWord);
  254. if (!count) {
  255. notFoundAlert();
  256. }
  257. win.statusbar.items().slice(1).disabled(count === 0);
  258. updateButtonStates();
  259. last = {
  260. text: text,
  261. caseState: caseState,
  262. wholeWord: wholeWord
  263. };
  264. },
  265. buttons: [
  266. {text: "Find", onclick: function() {
  267. win.submit();
  268. }},
  269. {text: "Replace", disabled: true, onclick: function() {
  270. if (!self.replace(win.find('#replace').value())) {
  271. win.statusbar.items().slice(1).disabled(true);
  272. currentIndex = -1;
  273. last = {};
  274. }
  275. }},
  276. {text: "Replace all", disabled: true, onclick: function() {
  277. self.replace(win.find('#replace').value(), true, true);
  278. win.statusbar.items().slice(1).disabled(true);
  279. last = {};
  280. }},
  281. {type: "spacer", flex: 1},
  282. {text: "Prev", name: 'prev', disabled: true, onclick: function() {
  283. self.prev();
  284. updateButtonStates();
  285. }},
  286. {text: "Next", name: 'next', disabled: true, onclick: function() {
  287. self.next();
  288. updateButtonStates();
  289. }}
  290. ],
  291. title: "Find and replace",
  292. items: {
  293. type: "form",
  294. padding: 20,
  295. labelGap: 30,
  296. spacing: 10,
  297. items: [
  298. {type: 'textbox', name: 'find', size: 40, label: 'Find', value: editor.selection.getNode().src},
  299. {type: 'textbox', name: 'replace', size: 40, label: 'Replace with'},
  300. {type: 'checkbox', name: 'case', text: 'Match case', label: ' '},
  301. {type: 'checkbox', name: 'words', text: 'Whole words', label: ' '}
  302. ]
  303. }
  304. }).renderTo().reflow();
  305. }
  306. self.init = function(ed) {
  307. ed.addMenuItem('searchreplace', {
  308. text: 'Find and replace',
  309. shortcut: 'Ctrl+F',
  310. onclick: showDialog,
  311. separator: 'before',
  312. context: 'edit'
  313. });
  314. ed.addButton('searchreplace', {
  315. tooltip: 'Find and replace',
  316. shortcut: 'Ctrl+F',
  317. onclick: showDialog
  318. });
  319. ed.addCommand("SearchReplace", showDialog);
  320. ed.shortcuts.add('Ctrl+F', '', showDialog);
  321. };
  322. function getElmIndex(elm) {
  323. var value = elm.getAttribute('data-mce-index');
  324. if (typeof(value) == "number") {
  325. return "" + value;
  326. }
  327. return value;
  328. }
  329. function markAllMatches(regex) {
  330. var node, marker;
  331. marker = editor.dom.create('span', {
  332. "data-mce-bogus": 1
  333. });
  334. marker.className = 'mce-match-marker'; // IE 7 adds class="mce-match-marker" and class=mce-match-marker
  335. node = editor.getBody();
  336. self.done(false);
  337. return findAndReplaceDOMText(regex, node, marker, false, editor.schema);
  338. }
  339. function unwrap(node) {
  340. var parentNode = node.parentNode;
  341. if (node.firstChild) {
  342. parentNode.insertBefore(node.firstChild, node);
  343. }
  344. node.parentNode.removeChild(node);
  345. }
  346. function findSpansByIndex(index) {
  347. var nodes, spans = [];
  348. nodes = tinymce.toArray(editor.getBody().getElementsByTagName('span'));
  349. if (nodes.length) {
  350. for (var i = 0; i < nodes.length; i++) {
  351. var nodeIndex = getElmIndex(nodes[i]);
  352. if (nodeIndex === null || !nodeIndex.length) {
  353. continue;
  354. }
  355. if (nodeIndex === index.toString()) {
  356. spans.push(nodes[i]);
  357. }
  358. }
  359. }
  360. return spans;
  361. }
  362. function moveSelection(forward) {
  363. var testIndex = currentIndex, dom = editor.dom;
  364. forward = forward !== false;
  365. if (forward) {
  366. testIndex++;
  367. } else {
  368. testIndex--;
  369. }
  370. dom.removeClass(findSpansByIndex(currentIndex), 'mce-match-marker-selected');
  371. var spans = findSpansByIndex(testIndex);
  372. if (spans.length) {
  373. dom.addClass(findSpansByIndex(testIndex), 'mce-match-marker-selected');
  374. editor.selection.scrollIntoView(spans[0]);
  375. return testIndex;
  376. }
  377. return -1;
  378. }
  379. function removeNode(node) {
  380. node.parentNode.removeChild(node);
  381. }
  382. self.find = function(text, matchCase, wholeWord) {
  383. text = text.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
  384. text = wholeWord ? '\\b' + text + '\\b' : text;
  385. var count = markAllMatches(new RegExp(text, matchCase ? 'g' : 'gi'));
  386. if (count) {
  387. currentIndex = -1;
  388. currentIndex = moveSelection(true);
  389. }
  390. return count;
  391. };
  392. self.next = function() {
  393. var index = moveSelection(true);
  394. if (index !== -1) {
  395. currentIndex = index;
  396. }
  397. };
  398. self.prev = function() {
  399. var index = moveSelection(false);
  400. if (index !== -1) {
  401. currentIndex = index;
  402. }
  403. };
  404. self.replace = function(text, forward, all) {
  405. var i, nodes, node, matchIndex, currentMatchIndex, nextIndex = currentIndex, hasMore;
  406. forward = forward !== false;
  407. node = editor.getBody();
  408. nodes = tinymce.toArray(node.getElementsByTagName('span'));
  409. for (i = 0; i < nodes.length; i++) {
  410. var nodeIndex = getElmIndex(nodes[i]);
  411. if (nodeIndex === null || !nodeIndex.length) {
  412. continue;
  413. }
  414. matchIndex = currentMatchIndex = parseInt(nodeIndex, 10);
  415. if (all || matchIndex === currentIndex) {
  416. if (text.length) {
  417. nodes[i].firstChild.nodeValue = text;
  418. unwrap(nodes[i]);
  419. } else {
  420. removeNode(nodes[i]);
  421. }
  422. while (nodes[++i]) {
  423. matchIndex = getElmIndex(nodes[i]);
  424. if (nodeIndex === null || !nodeIndex.length) {
  425. continue;
  426. }
  427. if (matchIndex === currentMatchIndex) {
  428. removeNode(nodes[i]);
  429. } else {
  430. i--;
  431. break;
  432. }
  433. }
  434. if (forward) {
  435. nextIndex--;
  436. }
  437. } else if (currentMatchIndex > currentIndex) {
  438. nodes[i].setAttribute('data-mce-index', currentMatchIndex - 1);
  439. }
  440. }
  441. editor.undoManager.add();
  442. currentIndex = nextIndex;
  443. if (forward) {
  444. hasMore = findSpansByIndex(nextIndex + 1).length > 0;
  445. self.next();
  446. } else {
  447. hasMore = findSpansByIndex(nextIndex - 1).length > 0;
  448. self.prev();
  449. }
  450. return !all && hasMore;
  451. };
  452. self.done = function(keepEditorSelection) {
  453. var i, nodes, startContainer, endContainer;
  454. nodes = tinymce.toArray(editor.getBody().getElementsByTagName('span'));
  455. for (i = 0; i < nodes.length; i++) {
  456. var nodeIndex = getElmIndex(nodes[i]);
  457. if (nodeIndex !== null && nodeIndex.length) {
  458. if (nodeIndex === currentIndex.toString()) {
  459. if (!startContainer) {
  460. startContainer = nodes[i].firstChild;
  461. }
  462. endContainer = nodes[i].firstChild;
  463. }
  464. unwrap(nodes[i]);
  465. }
  466. }
  467. if (startContainer && endContainer) {
  468. var rng = editor.dom.createRng();
  469. rng.setStart(startContainer, 0);
  470. rng.setEnd(endContainer, endContainer.data.length);
  471. if (keepEditorSelection !== false) {
  472. editor.selection.setRng(rng);
  473. }
  474. return rng;
  475. }
  476. };
  477. }
  478. tinymce.PluginManager.add('searchreplace', Plugin);
  479. })();