/** * plugin.js * * Copyright, Moxiecode Systems AB * Released under LGPL License. * * License: http://www.tinymce.com/license * Contributing: http://www.tinymce.com/contributing */ /*jshint loopfunc:true */ /*eslint no-loop-func:0 */ /*global tinymce:true */ tinymce.PluginManager.add('noneditable', function(editor) { var TreeWalker = tinymce.dom.TreeWalker; var externalName = 'contenteditable', internalName = 'data-mce-' + externalName; var VK = tinymce.util.VK; // Returns the content editable state of a node "true/false" or null function getContentEditable(node) { var contentEditable; // Ignore non elements if (node.nodeType === 1) { // Check for fake content editable contentEditable = node.getAttribute(internalName); if (contentEditable && contentEditable !== "inherit") { return contentEditable; } // Check for real content editable contentEditable = node.contentEditable; if (contentEditable !== "inherit") { return contentEditable; } } return null; } // Returns the noneditable parent or null if there is a editable before it or if it wasn't found function getNonEditableParent(node) { var state; while (node) { state = getContentEditable(node); if (state) { return state === "false" ? node : null; } node = node.parentNode; } } function handleContentEditableSelection() { var dom = editor.dom, selection = editor.selection, caretContainerId = 'mce_noneditablecaret', invisibleChar = '\uFEFF'; // Get caret container parent for the specified node function getParentCaretContainer(node) { while (node) { if (node.id === caretContainerId) { return node; } node = node.parentNode; } } // Finds the first text node in the specified node function findFirstTextNode(node) { var walker; if (node) { walker = new TreeWalker(node, node); for (node = walker.current(); node; node = walker.next()) { if (node.nodeType === 3) { return node; } } } } // Insert caret container before/after target or expand selection to include block function insertCaretContainerOrExpandToBlock(target, before) { var caretContainer, rng; // Select block if (getContentEditable(target) === "false") { if (dom.isBlock(target)) { selection.select(target); return; } } rng = dom.createRng(); if (getContentEditable(target) === "true") { if (!target.firstChild) { target.appendChild(editor.getDoc().createTextNode('\u00a0')); } target = target.firstChild; before = true; } /* caretContainer = dom.create('span', { id: caretContainerId, 'data-mce-bogus': true, style:'border: 1px solid red' }, invisibleChar); */ caretContainer = dom.create('span', {id: caretContainerId, 'data-mce-bogus': true}, invisibleChar); if (before) { target.parentNode.insertBefore(caretContainer, target); } else { dom.insertAfter(caretContainer, target); } rng.setStart(caretContainer.firstChild, 1); rng.collapse(true); selection.setRng(rng); return caretContainer; } // Removes any caret container function removeCaretContainer(caretContainer) { var rng, child, lastContainer; if (caretContainer) { rng = selection.getRng(true); rng.setStartBefore(caretContainer); rng.setEndBefore(caretContainer); child = findFirstTextNode(caretContainer); if (child && child.nodeValue.charAt(0) == invisibleChar) { child = child.deleteData(0, 1); } dom.remove(caretContainer, true); selection.setRng(rng); } else { while ((caretContainer = dom.get(caretContainerId)) && caretContainer !== lastContainer) { child = findFirstTextNode(caretContainer); if (child && child.nodeValue.charAt(0) == invisibleChar) { child = child.deleteData(0, 1); } dom.remove(caretContainer, true); lastContainer = caretContainer; } } } // Modifies the selection to include contentEditable false elements or insert caret containers function moveSelection() { var nonEditableStart, nonEditableEnd, isCollapsed, rng, element; // Checks if there is any contents to the left/right side of caret returns the noneditable element or // any editable element if it finds one inside function hasSideContent(element, left) { var container, offset, walker, node, len; container = rng.startContainer; offset = rng.startOffset; // If endpoint is in middle of text node then expand to beginning/end of element if (container.nodeType == 3) { len = container.nodeValue.length; if ((offset > 0 && offset < len) || (left ? offset == len : offset === 0)) { return; } } else { // Can we resolve the node by index if (offset < container.childNodes.length) { // Browser represents caret position as the offset at the start of an element. When moving right // this is the element we are moving into so we consider our container to be child node at offset-1 var pos = !left && offset > 0 ? offset - 1 : offset; container = container.childNodes[pos]; if (container.hasChildNodes()) { container = container.firstChild; } } else { // If not then the caret is at the last position in it's container and the caret container // should be inserted after the noneditable element return !left ? element : null; } } // Walk left/right to look for contents walker = new TreeWalker(container, element); while ((node = walker[left ? 'prev' : 'next']())) { if (node.nodeType === 3 && node.nodeValue.length > 0) { return; } else if (getContentEditable(node) === "true") { // Found contentEditable=true element return this one to we can move the caret inside it return node; } } return element; } // Remove any existing caret containers removeCaretContainer(); // Get noneditable start/end elements isCollapsed = selection.isCollapsed(); nonEditableStart = getNonEditableParent(selection.getStart()); nonEditableEnd = getNonEditableParent(selection.getEnd()); // Is any fo the range endpoints noneditable if (nonEditableStart || nonEditableEnd) { rng = selection.getRng(true); // If it's a caret selection then look left/right to see if we need to move the caret out side or expand if (isCollapsed) { nonEditableStart = nonEditableStart || nonEditableEnd; if ((element = hasSideContent(nonEditableStart, true))) { // We have no contents to the left of the caret then insert a caret container before the noneditable element insertCaretContainerOrExpandToBlock(element, true); } else if ((element = hasSideContent(nonEditableStart, false))) { // We have no contents to the right of the caret then insert a caret container after the noneditable element insertCaretContainerOrExpandToBlock(element, false); } else { // We are in the middle of a noneditable so expand to select it selection.select(nonEditableStart); } } else { rng = selection.getRng(true); // Expand selection to include start non editable element if (nonEditableStart) { rng.setStartBefore(nonEditableStart); } // Expand selection to include end non editable element if (nonEditableEnd) { rng.setEndAfter(nonEditableEnd); } selection.setRng(rng); } } } function handleKey(e) { var keyCode = e.keyCode, nonEditableParent, caretContainer, startElement, endElement; function getNonEmptyTextNodeSibling(node, prev) { while ((node = node[prev ? 'previousSibling' : 'nextSibling'])) { if (node.nodeType !== 3 || node.nodeValue.length > 0) { return node; } } } function positionCaretOnElement(element, start) { selection.select(element); selection.collapse(start); } function canDelete(backspace) { var rng, container, offset, nonEditableParent; function removeNodeIfNotParent(node) { var parent = container; while (parent) { if (parent === node) { return; } parent = parent.parentNode; } dom.remove(node); moveSelection(); } function isNextPrevTreeNodeNonEditable() { var node, walker, nonEmptyElements = editor.schema.getNonEmptyElements(); walker = new tinymce.dom.TreeWalker(container, editor.getBody()); while ((node = (backspace ? walker.prev() : walker.next()))) { // Found IMG/INPUT etc if (nonEmptyElements[node.nodeName.toLowerCase()]) { break; } // Found text node with contents if (node.nodeType === 3 && tinymce.trim(node.nodeValue).length > 0) { break; } // Found non editable node if (getContentEditable(node) === "false") { removeNodeIfNotParent(node); return true; } } // Check if the content node is within a non editable parent if (getNonEditableParent(node)) { return true; } return false; } if (selection.isCollapsed()) { rng = selection.getRng(true); container = rng.startContainer; offset = rng.startOffset; container = getParentCaretContainer(container) || container; // Is in noneditable parent if ((nonEditableParent = getNonEditableParent(container))) { removeNodeIfNotParent(nonEditableParent); return false; } // Check if the caret is in the middle of a text node if (container.nodeType == 3 && (backspace ? offset > 0 : offset < container.nodeValue.length)) { return true; } // Resolve container index if (container.nodeType == 1) { container = container.childNodes[offset] || container; } // Check if previous or next tree node is non editable then block the event if (isNextPrevTreeNodeNonEditable()) { return false; } } return true; } moveSelection(); startElement = selection.getStart(); endElement = selection.getEnd(); // Disable all key presses in contentEditable=false except delete or backspace nonEditableParent = getNonEditableParent(startElement) || getNonEditableParent(endElement); var currentNode = editor.selection.getNode(); var isDirectionKey = keyCode == VK.LEFT || keyCode == VK.RIGHT || keyCode == VK.UP || keyCode == VK.DOWN; var left = keyCode == VK.LEFT || keyCode == VK.UP; if (nonEditableParent && (keyCode < 112 || keyCode > 124) && keyCode != VK.DELETE && keyCode != VK.BACKSPACE) { // Is Ctrl+c, Ctrl+v or Ctrl+x then use default browser behavior if ((tinymce.isMac ? e.metaKey : e.ctrlKey) && (keyCode == 67 || keyCode == 88 || keyCode == 86)) { return; } e.preventDefault(); // Arrow left/right select the element and collapse left/right if (isDirectionKey) { // If a block element find previous or next element to position the caret if (editor.dom.isBlock(nonEditableParent)) { var targetElement = left ? nonEditableParent.previousSibling : nonEditableParent.nextSibling; // Handling for edge-cases: // - two nonEditables in a row -> no way to get between them // - nonEditable as the first/last element -> no way to get before/behind it if (!targetElement || targetElement && getContentEditable(targetElement) === 'false') { var p = dom.create('p', null, ' '); p.className = 'mceTmpParagraph'; var insertElement = left ? nonEditableParent : targetElement; if (insertElement && insertElement.parentNode) { insertElement.parentNode.insertBefore(p, insertElement); } else if (!targetElement && !left) { nonEditableParent.parentNode.appendChild(p); } targetElement = p; } var walker = new TreeWalker(targetElement, targetElement); var caretElement = left ? walker.prev() : walker.next(); positionCaretOnElement(caretElement, !left); } else { positionCaretOnElement(nonEditableParent, left); } } } else { // Is arrow left/right, backspace or delete if (isDirectionKey || keyCode == VK.BACKSPACE || keyCode == VK.DELETE) { caretContainer = getParentCaretContainer(startElement); if (caretContainer) { // Arrow left or backspace if (keyCode == VK.LEFT || keyCode == VK.BACKSPACE) { nonEditableParent = getNonEmptyTextNodeSibling(caretContainer, true); if (nonEditableParent && getContentEditable(nonEditableParent) === "false") { e.preventDefault(); if (keyCode == VK.LEFT) { positionCaretOnElement(nonEditableParent, true); } else { dom.remove(nonEditableParent); return; } } else { removeCaretContainer(caretContainer); } } // Arrow right or delete if (keyCode == VK.RIGHT || keyCode == VK.DELETE) { nonEditableParent = getNonEmptyTextNodeSibling(caretContainer, true); if (nonEditableParent && getContentEditable(nonEditableParent) === "false") { e.preventDefault(); if (keyCode == VK.RIGHT) { positionCaretOnElement(nonEditableParent, false); } else { dom.remove(nonEditableParent); return; } } else { removeCaretContainer(caretContainer); } } } else { if (isDirectionKey) { // Removal of separator paragraphs between two nonEditables // and before/after a nonEditable as the first/last element if (currentNode && currentNode.className.indexOf('mceTmpParagraph') !== -1 && currentNode[left ? 'previousSibling' : 'nextSibling']) { var jumpTarget = currentNode[left ? 'previousSibling' : 'nextSibling']; // current node is still empty and a separator -> remove it // else: remove the separator class, as it now includes content if (currentNode.innerHTML === ' ' || currentNode.innerHTML === '' || currentNode.innerHTML === ' ') { dom.remove(currentNode); } else { currentNode.className = currentNode.className.replace('mceTmpParagraph', ''); } positionCaretOnElement(jumpTarget, !left); } } var rng = selection.getRng(true); var container = rng.endContainer; // FIX: If end of node is selected, check wether next sibling is nonEditable to correctly remove it // (else would break for more complex nonEditables, their content would get moved to the current node) if (dom.isBlock(container) && dom.isBlock(container.nextSibling) && rng.endOffset == 1 && keyCode == VK.DELETE) { nonEditableParent = getNonEditableParent(container.nextSibling); } // correctly remove block-level nonEditable domNode on delete/backspace if (nonEditableParent && (keyCode == VK.DELETE || keyCode == VK.BACKSPACE) && dom.isBlock(nonEditableParent)) { e.preventDefault(); dom.remove(nonEditableParent); return; } } if ((keyCode == VK.BACKSPACE || keyCode == VK.DELETE) && !canDelete(keyCode == VK.BACKSPACE)) { e.preventDefault(); return false; } } } } editor.on('mousedown', function(e) { var node = editor.selection.getNode(); // Also remove separator lines when clicking on another node if (node && node.className.indexOf('mceTmpParagraph') !== -1 && node !== e.target) { // current node is still empty and a separator -> remove it // else: remove the separator class, as it now includes content if (node.innerHTML === ' ' || node.innerHTML === '' || node.innerHTML === ' ') { dom.remove(node); } else { node.className = node.className.replace('mceTmpParagraph', ''); } } if (getContentEditable(node) === "false" && node == e.target) { // Expand selection on mouse down we can't block the default event since it's used for drag/drop moveSelection(); } }); editor.on('mouseup', moveSelection); editor.on('keydown', handleKey); } var editClass, nonEditClass, nonEditableRegExps; // Converts configured regexps to noneditable span items function convertRegExpsToNonEditable(e) { var i = nonEditableRegExps.length, content = e.content, cls = tinymce.trim(nonEditClass); // Don't replace the variables when raw is used for example on undo/redo if (e.format == "raw") { return; } while (i--) { content = content.replace(nonEditableRegExps[i], function(match) { var args = arguments, index = args[args.length - 2]; // Is value inside an attribute then don't replace if (index > 0 && content.charAt(index - 1) == '"') { return match; } return ( '' + editor.dom.encode(typeof args[1] === "string" ? args[1] : args[0]) + '' ); }); } e.content = content; } editClass = " " + tinymce.trim(editor.getParam("noneditable_editable_class", "mceEditable")) + " "; nonEditClass = " " + tinymce.trim(editor.getParam("noneditable_noneditable_class", "mceNonEditable")) + " "; // Setup noneditable regexps array nonEditableRegExps = editor.getParam("noneditable_regexp"); if (nonEditableRegExps && !nonEditableRegExps.length) { nonEditableRegExps = [nonEditableRegExps]; } editor.on('PreInit', function() { handleContentEditableSelection(); if (nonEditableRegExps) { editor.on('BeforeSetContent', convertRegExpsToNonEditable); } // Apply contentEditable true/false on elements with the noneditable/editable classes editor.parser.addAttributeFilter('class', function(nodes) { var i = nodes.length, className, node; while (i--) { node = nodes[i]; className = " " + node.attr("class") + " "; if (className.indexOf(editClass) !== -1) { node.attr(internalName, "true"); } else if (className.indexOf(nonEditClass) !== -1) { node.attr(internalName, "false"); } } }); // Remove internal name editor.serializer.addAttributeFilter(internalName, function(nodes) { var i = nodes.length, node; while (i--) { node = nodes[i]; if (nonEditableRegExps && node.attr('data-mce-content')) { node.name = "#text"; node.type = 3; node.raw = true; node.value = node.attr('data-mce-content'); } else { node.attr(externalName, null); node.attr(internalName, null); } } }); // Convert external name into internal name editor.parser.addAttributeFilter(externalName, function(nodes) { var i = nodes.length, node; while (i--) { node = nodes[i]; node.attr(internalName, node.attr(externalName)); node.attr(externalName, null); } }); }); editor.on('drop', function(e) { if (getNonEditableParent(e.target)) { e.preventDefault(); } }); });