4 * Copyright, Moxiecode Systems AB
5 * Released under LGPL License.
7 * License: http://www.tinymce.com/license
8 * Contributing: http://www.tinymce.com/contributing
11 /*jshint loopfunc:true */
12 /*global tinymce:true */
14 tinymce.PluginManager.add('noneditable', function(editor) {
15 var TreeWalker = tinymce.dom.TreeWalker;
16 var externalName = 'contenteditable', internalName = 'data-mce-' + externalName;
17 var VK = tinymce.util.VK;
19 function handleContentEditableSelection() {
20 var dom = editor.dom, selection = editor.selection, caretContainerId = 'mce_noneditablecaret', invisibleChar = '\uFEFF';
22 // Returns the content editable state of a node "true/false" or null
23 function getContentEditable(node) {
26 // Ignore non elements
27 if (node.nodeType === 1) {
28 // Check for fake content editable
29 contentEditable = node.getAttribute(internalName);
30 if (contentEditable && contentEditable !== "inherit") {
31 return contentEditable;
34 // Check for real content editable
35 contentEditable = node.contentEditable;
36 if (contentEditable !== "inherit") {
37 return contentEditable;
44 // Returns the noneditable parent or null if there is a editable before it or if it wasn't found
45 function getNonEditableParent(node) {
49 state = getContentEditable(node);
51 return state === "false" ? node : null;
54 node = node.parentNode;
58 // Get caret container parent for the specified node
59 function getParentCaretContainer(node) {
61 if (node.id === caretContainerId) {
65 node = node.parentNode;
69 // Finds the first text node in the specified node
70 function findFirstTextNode(node) {
74 walker = new TreeWalker(node, node);
76 for (node = walker.current(); node; node = walker.next()) {
77 if (node.nodeType === 3) {
84 // Insert caret container before/after target or expand selection to include block
85 function insertCaretContainerOrExpandToBlock(target, before) {
86 var caretContainer, rng;
89 if (getContentEditable(target) === "false") {
90 if (dom.isBlock(target)) {
91 selection.select(target);
96 rng = dom.createRng();
98 if (getContentEditable(target) === "true") {
99 if (!target.firstChild) {
100 target.appendChild(editor.getDoc().createTextNode('\u00a0'));
103 target = target.firstChild;
108 caretContainer = dom.create('span', {
109 id: caretContainerId,
110 'data-mce-bogus': true,
111 style:'border: 1px solid red'
115 caretContainer = dom.create('span', {id: caretContainerId, 'data-mce-bogus': true}, invisibleChar);
118 target.parentNode.insertBefore(caretContainer, target);
120 dom.insertAfter(caretContainer, target);
123 rng.setStart(caretContainer.firstChild, 1);
125 selection.setRng(rng);
127 return caretContainer;
130 // Removes any caret container except the one we might be in
131 function removeCaretContainer(caretContainer) {
132 var rng, child, currentCaretContainer, lastContainer;
134 if (caretContainer) {
135 rng = selection.getRng(true);
136 rng.setStartBefore(caretContainer);
137 rng.setEndBefore(caretContainer);
139 child = findFirstTextNode(caretContainer);
140 if (child && child.nodeValue.charAt(0) == invisibleChar) {
141 child = child.deleteData(0, 1);
144 dom.remove(caretContainer, true);
146 selection.setRng(rng);
148 currentCaretContainer = getParentCaretContainer(selection.getStart());
149 while ((caretContainer = dom.get(caretContainerId)) && caretContainer !== lastContainer) {
150 if (currentCaretContainer !== caretContainer) {
151 child = findFirstTextNode(caretContainer);
152 if (child && child.nodeValue.charAt(0) == invisibleChar) {
153 child = child.deleteData(0, 1);
156 dom.remove(caretContainer, true);
159 lastContainer = caretContainer;
164 // Modifies the selection to include contentEditable false elements or insert caret containers
165 function moveSelection() {
166 var nonEditableStart, nonEditableEnd, isCollapsed, rng, element;
168 // Checks if there is any contents to the left/right side of caret returns the noneditable element or
169 // any editable element if it finds one inside
170 function hasSideContent(element, left) {
171 var container, offset, walker, node, len;
173 container = rng.startContainer;
174 offset = rng.startOffset;
176 // If endpoint is in middle of text node then expand to beginning/end of element
177 if (container.nodeType == 3) {
178 len = container.nodeValue.length;
179 if ((offset > 0 && offset < len) || (left ? offset == len : offset === 0)) {
183 // Can we resolve the node by index
184 if (offset < container.childNodes.length) {
185 // Browser represents caret position as the offset at the start of an element. When moving right
186 // this is the element we are moving into so we consider our container to be child node at offset-1
187 var pos = !left && offset > 0 ? offset-1 : offset;
188 container = container.childNodes[pos];
189 if (container.hasChildNodes()) {
190 container = container.firstChild;
193 // If not then the caret is at the last position in it's container and the caret container
194 // should be inserted after the noneditable element
195 return !left ? element : null;
199 // Walk left/right to look for contents
200 walker = new TreeWalker(container, element);
201 while ((node = walker[left ? 'prev' : 'next']())) {
202 if (node.nodeType === 3 && node.nodeValue.length > 0) {
204 } else if (getContentEditable(node) === "true") {
205 // Found contentEditable=true element return this one to we can move the caret inside it
213 // Remove any existing caret containers
214 removeCaretContainer();
216 // Get noneditable start/end elements
217 isCollapsed = selection.isCollapsed();
218 nonEditableStart = getNonEditableParent(selection.getStart());
219 nonEditableEnd = getNonEditableParent(selection.getEnd());
221 // Is any fo the range endpoints noneditable
222 if (nonEditableStart || nonEditableEnd) {
223 rng = selection.getRng(true);
225 // If it's a caret selection then look left/right to see if we need to move the caret out side or expand
227 nonEditableStart = nonEditableStart || nonEditableEnd;
229 if ((element = hasSideContent(nonEditableStart, true))) {
230 // We have no contents to the left of the caret then insert a caret container before the noneditable element
231 insertCaretContainerOrExpandToBlock(element, true);
232 } else if ((element = hasSideContent(nonEditableStart, false))) {
233 // We have no contents to the right of the caret then insert a caret container after the noneditable element
234 insertCaretContainerOrExpandToBlock(element, false);
236 // We are in the middle of a noneditable so expand to select it
237 selection.select(nonEditableStart);
240 rng = selection.getRng(true);
242 // Expand selection to include start non editable element
243 if (nonEditableStart) {
244 rng.setStartBefore(nonEditableStart);
247 // Expand selection to include end non editable element
248 if (nonEditableEnd) {
249 rng.setEndAfter(nonEditableEnd);
252 selection.setRng(rng);
257 function handleKey(e) {
258 var keyCode = e.keyCode, nonEditableParent, caretContainer, startElement, endElement;
260 function getNonEmptyTextNodeSibling(node, prev) {
261 while ((node = node[prev ? 'previousSibling' : 'nextSibling'])) {
262 if (node.nodeType !== 3 || node.nodeValue.length > 0) {
268 function positionCaretOnElement(element, start) {
269 selection.select(element);
270 selection.collapse(start);
273 function canDelete(backspace) {
274 var rng, container, offset, nonEditableParent;
276 function removeNodeIfNotParent(node) {
277 var parent = container;
280 if (parent === node) {
284 parent = parent.parentNode;
291 function isNextPrevTreeNodeNonEditable() {
292 var node, walker, nonEmptyElements = editor.schema.getNonEmptyElements();
294 walker = new tinymce.dom.TreeWalker(container, editor.getBody());
295 while ((node = (backspace ? walker.prev() : walker.next()))) {
296 // Found IMG/INPUT etc
297 if (nonEmptyElements[node.nodeName.toLowerCase()]) {
301 // Found text node with contents
302 if (node.nodeType === 3 && tinymce.trim(node.nodeValue).length > 0) {
306 // Found non editable node
307 if (getContentEditable(node) === "false") {
308 removeNodeIfNotParent(node);
313 // Check if the content node is within a non editable parent
314 if (getNonEditableParent(node)) {
321 if (selection.isCollapsed()) {
322 rng = selection.getRng(true);
323 container = rng.startContainer;
324 offset = rng.startOffset;
325 container = getParentCaretContainer(container) || container;
327 // Is in noneditable parent
328 if ((nonEditableParent = getNonEditableParent(container))) {
329 removeNodeIfNotParent(nonEditableParent);
333 // Check if the caret is in the middle of a text node
334 if (container.nodeType == 3 && (backspace ? offset > 0 : offset < container.nodeValue.length)) {
338 // Resolve container index
339 if (container.nodeType == 1) {
340 container = container.childNodes[offset] || container;
343 // Check if previous or next tree node is non editable then block the event
344 if (isNextPrevTreeNodeNonEditable()) {
352 startElement = selection.getStart();
353 endElement = selection.getEnd();
355 // Disable all key presses in contentEditable=false except delete or backspace
356 nonEditableParent = getNonEditableParent(startElement) || getNonEditableParent(endElement);
357 if (nonEditableParent && (keyCode < 112 || keyCode > 124) && keyCode != VK.DELETE && keyCode != VK.BACKSPACE) {
358 // Is Ctrl+c, Ctrl+v or Ctrl+x then use default browser behavior
359 if ((tinymce.isMac ? e.metaKey : e.ctrlKey) && (keyCode == 67 || keyCode == 88 || keyCode == 86)) {
365 // Arrow left/right select the element and collapse left/right
366 if (keyCode == VK.LEFT || keyCode == VK.RIGHT) {
367 var left = keyCode == VK.LEFT;
368 // If a block element find previous or next element to position the caret
369 if (editor.dom.isBlock(nonEditableParent)) {
370 var targetElement = left ? nonEditableParent.previousSibling : nonEditableParent.nextSibling;
371 var walker = new TreeWalker(targetElement, targetElement);
372 var caretElement = left ? walker.prev() : walker.next();
373 positionCaretOnElement(caretElement, !left);
375 positionCaretOnElement(nonEditableParent, left);
379 // Is arrow left/right, backspace or delete
380 if (keyCode == VK.LEFT || keyCode == VK.RIGHT || keyCode == VK.BACKSPACE || keyCode == VK.DELETE) {
381 caretContainer = getParentCaretContainer(startElement);
382 if (caretContainer) {
383 // Arrow left or backspace
384 if (keyCode == VK.LEFT || keyCode == VK.BACKSPACE) {
385 nonEditableParent = getNonEmptyTextNodeSibling(caretContainer, true);
387 if (nonEditableParent && getContentEditable(nonEditableParent) === "false") {
390 if (keyCode == VK.LEFT) {
391 positionCaretOnElement(nonEditableParent, true);
393 dom.remove(nonEditableParent);
397 removeCaretContainer(caretContainer);
401 // Arrow right or delete
402 if (keyCode == VK.RIGHT || keyCode == VK.DELETE) {
403 nonEditableParent = getNonEmptyTextNodeSibling(caretContainer);
405 if (nonEditableParent && getContentEditable(nonEditableParent) === "false") {
408 if (keyCode == VK.RIGHT) {
409 positionCaretOnElement(nonEditableParent, false);
411 dom.remove(nonEditableParent);
415 removeCaretContainer(caretContainer);
420 if ((keyCode == VK.BACKSPACE || keyCode == VK.DELETE) && !canDelete(keyCode == VK.BACKSPACE)) {
428 editor.on('mousedown', function(e) {
429 var node = editor.selection.getNode();
431 if (getContentEditable(node) === "false" && node == e.target) {
432 // Expand selection on mouse down we can't block the default event since it's used for drag/drop
437 editor.on('mouseup keyup', moveSelection);
438 editor.on('keydown', handleKey);
441 var editClass, nonEditClass, nonEditableRegExps;
443 // Converts configured regexps to noneditable span items
444 function convertRegExpsToNonEditable(e) {
445 var i = nonEditableRegExps.length, content = e.content, cls = tinymce.trim(nonEditClass);
447 // Don't replace the variables when raw is used for example on undo/redo
448 if (e.format == "raw") {
453 content = content.replace(nonEditableRegExps[i], function(match) {
454 var args = arguments, index = args[args.length - 2];
456 // Is value inside an attribute then don't replace
457 if (index > 0 && content.charAt(index - 1) == '"') {
462 '<span class="' + cls + '" data-mce-content="' + editor.dom.encode(args[0]) + '">' +
463 editor.dom.encode(typeof(args[1]) === "string" ? args[1] : args[0]) + '</span>'
471 editClass = " " + tinymce.trim(editor.getParam("noneditable_editable_class", "mceEditable")) + " ";
472 nonEditClass = " " + tinymce.trim(editor.getParam("noneditable_noneditable_class", "mceNonEditable")) + " ";
474 // Setup noneditable regexps array
475 nonEditableRegExps = editor.getParam("noneditable_regexp");
476 if (nonEditableRegExps && !nonEditableRegExps.length) {
477 nonEditableRegExps = [nonEditableRegExps];
480 editor.on('PreInit', function() {
481 handleContentEditableSelection();
483 if (nonEditableRegExps) {
484 editor.on('BeforeSetContent', convertRegExpsToNonEditable);
487 // Apply contentEditable true/false on elements with the noneditable/editable classes
488 editor.parser.addAttributeFilter('class', function(nodes) {
489 var i = nodes.length, className, node;
493 className = " " + node.attr("class") + " ";
495 if (className.indexOf(editClass) !== -1) {
496 node.attr(internalName, "true");
497 } else if (className.indexOf(nonEditClass) !== -1) {
498 node.attr(internalName, "false");
503 // Remove internal name
504 editor.serializer.addAttributeFilter(internalName, function(nodes) {
505 var i = nodes.length, node;
510 if (nonEditableRegExps && node.attr('data-mce-content')) {
514 node.value = node.attr('data-mce-content');
516 node.attr(externalName, null);
517 node.attr(internalName, null);
522 // Convert external name into internal name
523 editor.parser.addAttributeFilter(externalName, function(nodes) {
524 var i = nodes.length, node;
528 node.attr(internalName, node.attr(externalName));
529 node.attr(externalName, null);