/** * plugin.js * * Released under LGPL License. * Copyright (c) 1999-2015 Ephox Corp. All rights reserved * * License: http://www.tinymce.com/license * Contributing: http://www.tinymce.com/contributing */ /*jshint smarttabs:true, undef:true, unused:true, latedef:true, curly:true, bitwise:true */ /*eslint no-labels:0, no-constant-condition: 0 */ /*global tinymce:true */ (function() { function isContentEditableFalse(node) { return node && node.nodeType == 1 && node.contentEditable === "false"; } // Based on work developed by: James Padolsey http://james.padolsey.com // released under UNLICENSE that is compatible with LGPL // TODO: Handle contentEditable edgecase: //

texttexttexttexttext

function findAndReplaceDOMText(regex, node, replacementNode, captureGroup, schema) { var m, matches = [], text, count = 0, doc; var blockElementsMap, hiddenTextElementsMap, shortEndedElementsMap; doc = node.ownerDocument; blockElementsMap = schema.getBlockElements(); // H1-H6, P, TD etc hiddenTextElementsMap = schema.getWhiteSpaceElements(); // TEXTAREA, PRE, STYLE, SCRIPT shortEndedElementsMap = schema.getShortEndedElements(); // BR, IMG, INPUT function getMatchIndexes(m, captureGroup) { captureGroup = captureGroup || 0; if (!m[0]) { throw 'findAndReplaceDOMText cannot handle zero-length matches'; } var index = m.index; if (captureGroup > 0) { var cg = m[captureGroup]; if (!cg) { throw 'Invalid capture group'; } index += m[0].indexOf(cg); m[0] = cg; } return [index, index + m[0].length, [m[0]]]; } function getText(node) { var txt; if (node.nodeType === 3) { return node.data; } if (hiddenTextElementsMap[node.nodeName] && !blockElementsMap[node.nodeName]) { return ''; } txt = ''; if (isContentEditableFalse(node)) { return '\n'; } if (blockElementsMap[node.nodeName] || shortEndedElementsMap[node.nodeName]) { txt += '\n'; } if ((node = node.firstChild)) { do { txt += getText(node); } while ((node = node.nextSibling)); } return txt; } function stepThroughMatches(node, matches, replaceFn) { var startNode, endNode, startNodeIndex, endNodeIndex, innerNodes = [], atIndex = 0, curNode = node, matchLocation = matches.shift(), matchIndex = 0; out: while (true) { if (blockElementsMap[curNode.nodeName] || shortEndedElementsMap[curNode.nodeName] || isContentEditableFalse(curNode)) { atIndex++; } if (curNode.nodeType === 3) { if (!endNode && curNode.length + atIndex >= matchLocation[1]) { // We've found the ending endNode = curNode; endNodeIndex = matchLocation[1] - atIndex; } else if (startNode) { // Intersecting node innerNodes.push(curNode); } if (!startNode && curNode.length + atIndex > matchLocation[0]) { // We've found the match start startNode = curNode; startNodeIndex = matchLocation[0] - atIndex; } atIndex += curNode.length; } if (startNode && endNode) { curNode = replaceFn({ startNode: startNode, startNodeIndex: startNodeIndex, endNode: endNode, endNodeIndex: endNodeIndex, innerNodes: innerNodes, match: matchLocation[2], matchIndex: matchIndex }); // replaceFn has to return the node that replaced the endNode // and then we step back so we can continue from the end of the // match: atIndex -= (endNode.length - endNodeIndex); startNode = null; endNode = null; innerNodes = []; matchLocation = matches.shift(); matchIndex++; if (!matchLocation) { break; // no more matches } } else if ((!hiddenTextElementsMap[curNode.nodeName] || blockElementsMap[curNode.nodeName]) && curNode.firstChild) { if (!isContentEditableFalse(curNode)) { // Move down curNode = curNode.firstChild; continue; } } else if (curNode.nextSibling) { // Move forward: curNode = curNode.nextSibling; continue; } // Move forward or up: while (true) { if (curNode.nextSibling) { curNode = curNode.nextSibling; break; } else if (curNode.parentNode !== node) { curNode = curNode.parentNode; } else { break out; } } } } /** * Generates the actual replaceFn which splits up text nodes * and inserts the replacement element. */ function genReplacer(nodeName) { var makeReplacementNode; if (typeof nodeName != 'function') { var stencilNode = nodeName.nodeType ? nodeName : doc.createElement(nodeName); makeReplacementNode = function(fill, matchIndex) { var clone = stencilNode.cloneNode(false); clone.setAttribute('data-mce-index', matchIndex); if (fill) { clone.appendChild(doc.createTextNode(fill)); } return clone; }; } else { makeReplacementNode = nodeName; } return function(range) { var before, after, parentNode, startNode = range.startNode, endNode = range.endNode, matchIndex = range.matchIndex; if (startNode === endNode) { var node = startNode; parentNode = node.parentNode; if (range.startNodeIndex > 0) { // Add `before` text node (before the match) before = doc.createTextNode(node.data.substring(0, range.startNodeIndex)); parentNode.insertBefore(before, node); } // Create the replacement node: var el = makeReplacementNode(range.match[0], matchIndex); parentNode.insertBefore(el, node); if (range.endNodeIndex < node.length) { // Add `after` text node (after the match) after = doc.createTextNode(node.data.substring(range.endNodeIndex)); parentNode.insertBefore(after, node); } node.parentNode.removeChild(node); return el; } // Replace startNode -> [innerNodes...] -> endNode (in that order) before = doc.createTextNode(startNode.data.substring(0, range.startNodeIndex)); after = doc.createTextNode(endNode.data.substring(range.endNodeIndex)); var elA = makeReplacementNode(startNode.data.substring(range.startNodeIndex), matchIndex); var innerEls = []; for (var i = 0, l = range.innerNodes.length; i < l; ++i) { var innerNode = range.innerNodes[i]; var innerEl = makeReplacementNode(innerNode.data, matchIndex); innerNode.parentNode.replaceChild(innerEl, innerNode); innerEls.push(innerEl); } var elB = makeReplacementNode(endNode.data.substring(0, range.endNodeIndex), matchIndex); parentNode = startNode.parentNode; parentNode.insertBefore(before, startNode); parentNode.insertBefore(elA, startNode); parentNode.removeChild(startNode); parentNode = endNode.parentNode; parentNode.insertBefore(elB, endNode); parentNode.insertBefore(after, endNode); parentNode.removeChild(endNode); return elB; }; } text = getText(node); if (!text) { return; } if (regex.global) { while ((m = regex.exec(text))) { matches.push(getMatchIndexes(m, captureGroup)); } } else { m = text.match(regex); matches.push(getMatchIndexes(m, captureGroup)); } if (matches.length) { count = matches.length; stepThroughMatches(node, matches, genReplacer(replacementNode)); } return count; } function Plugin(editor) { var self = this, currentIndex = -1; function showDialog() { var last = {}, selectedText; selectedText = tinymce.trim(editor.selection.getContent({format: 'text'})); function updateButtonStates() { win.statusbar.find('#next').disabled(!findSpansByIndex(currentIndex + 1).length); win.statusbar.find('#prev').disabled(!findSpansByIndex(currentIndex - 1).length); } function notFoundAlert() { editor.windowManager.alert('Could not find the specified string.', function() { win.find('#find')[0].focus(); }); } var win = editor.windowManager.open({ layout: "flex", pack: "center", align: "center", onClose: function() { editor.focus(); self.done(); }, onSubmit: function(e) { var count, caseState, text, wholeWord; e.preventDefault(); caseState = win.find('#case').checked(); wholeWord = win.find('#words').checked(); text = win.find('#find').value(); if (!text.length) { self.done(false); win.statusbar.items().slice(1).disabled(true); return; } if (last.text == text && last.caseState == caseState && last.wholeWord == wholeWord) { if (findSpansByIndex(currentIndex + 1).length === 0) { notFoundAlert(); return; } self.next(); updateButtonStates(); return; } count = self.find(text, caseState, wholeWord); if (!count) { notFoundAlert(); } win.statusbar.items().slice(1).disabled(count === 0); updateButtonStates(); last = { text: text, caseState: caseState, wholeWord: wholeWord }; }, buttons: [ {text: "Find", subtype: 'primary', onclick: function() { win.submit(); }}, {text: "Replace", disabled: true, onclick: function() { if (!self.replace(win.find('#replace').value())) { win.statusbar.items().slice(1).disabled(true); currentIndex = -1; last = {}; } }}, {text: "Replace all", disabled: true, onclick: function() { self.replace(win.find('#replace').value(), true, true); win.statusbar.items().slice(1).disabled(true); last = {}; }}, {type: "spacer", flex: 1}, {text: "Prev", name: 'prev', disabled: true, onclick: function() { self.prev(); updateButtonStates(); }}, {text: "Next", name: 'next', disabled: true, onclick: function() { self.next(); updateButtonStates(); }} ], title: "Find and replace", items: { type: "form", padding: 20, labelGap: 30, spacing: 10, items: [ {type: 'textbox', name: 'find', size: 40, label: 'Find', value: selectedText}, {type: 'textbox', name: 'replace', size: 40, label: 'Replace with'}, {type: 'checkbox', name: 'case', text: 'Match case', label: ' '}, {type: 'checkbox', name: 'words', text: 'Whole words', label: ' '} ] } }); } self.init = function(ed) { ed.addMenuItem('searchreplace', { text: 'Find and replace', shortcut: 'Meta+F', onclick: showDialog, separator: 'before', context: 'edit' }); ed.addButton('searchreplace', { tooltip: 'Find and replace', shortcut: 'Meta+F', onclick: showDialog }); ed.addCommand("SearchReplace", showDialog); ed.shortcuts.add('Meta+F', '', showDialog); }; function getElmIndex(elm) { var value = elm.getAttribute('data-mce-index'); if (typeof value == "number") { return "" + value; } return value; } function markAllMatches(regex) { var node, marker; marker = editor.dom.create('span', { "data-mce-bogus": 1 }); marker.className = 'mce-match-marker'; // IE 7 adds class="mce-match-marker" and class=mce-match-marker node = editor.getBody(); self.done(false); return findAndReplaceDOMText(regex, node, marker, false, editor.schema); } function unwrap(node) { var parentNode = node.parentNode; if (node.firstChild) { parentNode.insertBefore(node.firstChild, node); } node.parentNode.removeChild(node); } function findSpansByIndex(index) { var nodes, spans = []; nodes = tinymce.toArray(editor.getBody().getElementsByTagName('span')); if (nodes.length) { for (var i = 0; i < nodes.length; i++) { var nodeIndex = getElmIndex(nodes[i]); if (nodeIndex === null || !nodeIndex.length) { continue; } if (nodeIndex === index.toString()) { spans.push(nodes[i]); } } } return spans; } function moveSelection(forward) { var testIndex = currentIndex, dom = editor.dom; forward = forward !== false; if (forward) { testIndex++; } else { testIndex--; } dom.removeClass(findSpansByIndex(currentIndex), 'mce-match-marker-selected'); var spans = findSpansByIndex(testIndex); if (spans.length) { dom.addClass(findSpansByIndex(testIndex), 'mce-match-marker-selected'); editor.selection.scrollIntoView(spans[0]); return testIndex; } return -1; } function removeNode(node) { var dom = editor.dom, parent = node.parentNode; dom.remove(node); if (dom.isEmpty(parent)) { dom.remove(parent); } } self.find = function(text, matchCase, wholeWord) { text = text.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); text = wholeWord ? '\\b' + text + '\\b' : text; var count = markAllMatches(new RegExp(text, matchCase ? 'g' : 'gi')); if (count) { currentIndex = -1; currentIndex = moveSelection(true); } return count; }; self.next = function() { var index = moveSelection(true); if (index !== -1) { currentIndex = index; } }; self.prev = function() { var index = moveSelection(false); if (index !== -1) { currentIndex = index; } }; function isMatchSpan(node) { var matchIndex = getElmIndex(node); return matchIndex !== null && matchIndex.length > 0; } self.replace = function(text, forward, all) { var i, nodes, node, matchIndex, currentMatchIndex, nextIndex = currentIndex, hasMore; forward = forward !== false; node = editor.getBody(); nodes = tinymce.grep(tinymce.toArray(node.getElementsByTagName('span')), isMatchSpan); for (i = 0; i < nodes.length; i++) { var nodeIndex = getElmIndex(nodes[i]); matchIndex = currentMatchIndex = parseInt(nodeIndex, 10); if (all || matchIndex === currentIndex) { if (text.length) { nodes[i].firstChild.nodeValue = text; unwrap(nodes[i]); } else { removeNode(nodes[i]); } while (nodes[++i]) { matchIndex = parseInt(getElmIndex(nodes[i]), 10); if (matchIndex === currentMatchIndex) { removeNode(nodes[i]); } else { i--; break; } } if (forward) { nextIndex--; } } else if (currentMatchIndex > currentIndex) { nodes[i].setAttribute('data-mce-index', currentMatchIndex - 1); } } editor.undoManager.add(); currentIndex = nextIndex; if (forward) { hasMore = findSpansByIndex(nextIndex + 1).length > 0; self.next(); } else { hasMore = findSpansByIndex(nextIndex - 1).length > 0; self.prev(); } return !all && hasMore; }; self.done = function(keepEditorSelection) { var i, nodes, startContainer, endContainer; nodes = tinymce.toArray(editor.getBody().getElementsByTagName('span')); for (i = 0; i < nodes.length; i++) { var nodeIndex = getElmIndex(nodes[i]); if (nodeIndex !== null && nodeIndex.length) { if (nodeIndex === currentIndex.toString()) { if (!startContainer) { startContainer = nodes[i].firstChild; } endContainer = nodes[i].firstChild; } unwrap(nodes[i]); } } if (startContainer && endContainer) { var rng = editor.dom.createRng(); rng.setStart(startContainer, 0); rng.setEnd(endContainer, endContainer.data.length); if (keepEditorSelection !== false) { editor.selection.setRng(rng); } return rng; } }; } tinymce.PluginManager.add('searchreplace', Plugin); })();