|
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 loopfunc:true */ |
|
12 /*eslint no-loop-func:0 */ |
|
13 /*global tinymce:true */ |
|
14 |
|
15 tinymce.PluginManager.add('noneditable', function(editor) { |
|
16 var TreeWalker = tinymce.dom.TreeWalker; |
|
17 var externalName = 'contenteditable', internalName = 'data-mce-' + externalName; |
|
18 var VK = tinymce.util.VK; |
|
19 |
|
20 // Returns the content editable state of a node "true/false" or null |
|
21 function getContentEditable(node) { |
|
22 var contentEditable; |
|
23 |
|
24 // Ignore non elements |
|
25 if (node.nodeType === 1) { |
|
26 // Check for fake content editable |
|
27 contentEditable = node.getAttribute(internalName); |
|
28 if (contentEditable && contentEditable !== "inherit") { |
|
29 return contentEditable; |
|
30 } |
|
31 |
|
32 // Check for real content editable |
|
33 contentEditable = node.contentEditable; |
|
34 if (contentEditable !== "inherit") { |
|
35 return contentEditable; |
|
36 } |
|
37 } |
|
38 |
|
39 return null; |
|
40 } |
|
41 |
|
42 // Returns the noneditable parent or null if there is a editable before it or if it wasn't found |
|
43 function getNonEditableParent(node) { |
|
44 var state; |
|
45 |
|
46 while (node) { |
|
47 state = getContentEditable(node); |
|
48 if (state) { |
|
49 return state === "false" ? node : null; |
|
50 } |
|
51 |
|
52 node = node.parentNode; |
|
53 } |
|
54 } |
|
55 |
|
56 function handleContentEditableSelection() { |
|
57 var dom = editor.dom, selection = editor.selection, caretContainerId = 'mce_noneditablecaret', invisibleChar = '\uFEFF'; |
|
58 |
|
59 // Get caret container parent for the specified node |
|
60 function getParentCaretContainer(node) { |
|
61 while (node) { |
|
62 if (node.id === caretContainerId) { |
|
63 return node; |
|
64 } |
|
65 |
|
66 node = node.parentNode; |
|
67 } |
|
68 } |
|
69 |
|
70 // Finds the first text node in the specified node |
|
71 function findFirstTextNode(node) { |
|
72 var walker; |
|
73 |
|
74 if (node) { |
|
75 walker = new TreeWalker(node, node); |
|
76 |
|
77 for (node = walker.current(); node; node = walker.next()) { |
|
78 if (node.nodeType === 3) { |
|
79 return node; |
|
80 } |
|
81 } |
|
82 } |
|
83 } |
|
84 |
|
85 // Insert caret container before/after target or expand selection to include block |
|
86 function insertCaretContainerOrExpandToBlock(target, before) { |
|
87 var caretContainer, rng; |
|
88 |
|
89 // Select block |
|
90 if (getContentEditable(target) === "false") { |
|
91 if (dom.isBlock(target)) { |
|
92 selection.select(target); |
|
93 return; |
|
94 } |
|
95 } |
|
96 |
|
97 rng = dom.createRng(); |
|
98 |
|
99 if (getContentEditable(target) === "true") { |
|
100 if (!target.firstChild) { |
|
101 target.appendChild(editor.getDoc().createTextNode('\u00a0')); |
|
102 } |
|
103 |
|
104 target = target.firstChild; |
|
105 before = true; |
|
106 } |
|
107 |
|
108 /* |
|
109 caretContainer = dom.create('span', { |
|
110 id: caretContainerId, |
|
111 'data-mce-bogus': true, |
|
112 style:'border: 1px solid red' |
|
113 }, invisibleChar); |
|
114 */ |
|
115 |
|
116 caretContainer = dom.create('span', {id: caretContainerId, 'data-mce-bogus': true}, invisibleChar); |
|
117 |
|
118 if (before) { |
|
119 target.parentNode.insertBefore(caretContainer, target); |
|
120 } else { |
|
121 dom.insertAfter(caretContainer, target); |
|
122 } |
|
123 |
|
124 rng.setStart(caretContainer.firstChild, 1); |
|
125 rng.collapse(true); |
|
126 selection.setRng(rng); |
|
127 |
|
128 return caretContainer; |
|
129 } |
|
130 |
|
131 // Removes any caret container |
|
132 function removeCaretContainer(caretContainer) { |
|
133 var rng, child, lastContainer; |
|
134 |
|
135 if (caretContainer) { |
|
136 rng = selection.getRng(true); |
|
137 rng.setStartBefore(caretContainer); |
|
138 rng.setEndBefore(caretContainer); |
|
139 |
|
140 child = findFirstTextNode(caretContainer); |
|
141 if (child && child.nodeValue.charAt(0) == invisibleChar) { |
|
142 child = child.deleteData(0, 1); |
|
143 } |
|
144 |
|
145 dom.remove(caretContainer, true); |
|
146 |
|
147 selection.setRng(rng); |
|
148 } else { |
|
149 while ((caretContainer = dom.get(caretContainerId)) && caretContainer !== lastContainer) { |
|
150 child = findFirstTextNode(caretContainer); |
|
151 if (child && child.nodeValue.charAt(0) == invisibleChar) { |
|
152 child = child.deleteData(0, 1); |
|
153 } |
|
154 |
|
155 dom.remove(caretContainer, true); |
|
156 |
|
157 lastContainer = caretContainer; |
|
158 } |
|
159 } |
|
160 } |
|
161 |
|
162 // Modifies the selection to include contentEditable false elements or insert caret containers |
|
163 function moveSelection() { |
|
164 var nonEditableStart, nonEditableEnd, isCollapsed, rng, element; |
|
165 |
|
166 // Checks if there is any contents to the left/right side of caret returns the noneditable element or |
|
167 // any editable element if it finds one inside |
|
168 function hasSideContent(element, left) { |
|
169 var container, offset, walker, node, len; |
|
170 |
|
171 container = rng.startContainer; |
|
172 offset = rng.startOffset; |
|
173 |
|
174 // If endpoint is in middle of text node then expand to beginning/end of element |
|
175 if (container.nodeType == 3) { |
|
176 len = container.nodeValue.length; |
|
177 if ((offset > 0 && offset < len) || (left ? offset == len : offset === 0)) { |
|
178 return; |
|
179 } |
|
180 } else { |
|
181 // Can we resolve the node by index |
|
182 if (offset < container.childNodes.length) { |
|
183 // Browser represents caret position as the offset at the start of an element. When moving right |
|
184 // this is the element we are moving into so we consider our container to be child node at offset-1 |
|
185 var pos = !left && offset > 0 ? offset - 1 : offset; |
|
186 container = container.childNodes[pos]; |
|
187 if (container.hasChildNodes()) { |
|
188 container = container.firstChild; |
|
189 } |
|
190 } else { |
|
191 // If not then the caret is at the last position in it's container and the caret container |
|
192 // should be inserted after the noneditable element |
|
193 return !left ? element : null; |
|
194 } |
|
195 } |
|
196 |
|
197 // Walk left/right to look for contents |
|
198 walker = new TreeWalker(container, element); |
|
199 while ((node = walker[left ? 'prev' : 'next']())) { |
|
200 if (node.nodeType === 3 && node.nodeValue.length > 0) { |
|
201 return; |
|
202 } else if (getContentEditable(node) === "true") { |
|
203 // Found contentEditable=true element return this one to we can move the caret inside it |
|
204 return node; |
|
205 } |
|
206 } |
|
207 |
|
208 return element; |
|
209 } |
|
210 |
|
211 // Remove any existing caret containers |
|
212 removeCaretContainer(); |
|
213 |
|
214 // Get noneditable start/end elements |
|
215 isCollapsed = selection.isCollapsed(); |
|
216 nonEditableStart = getNonEditableParent(selection.getStart()); |
|
217 nonEditableEnd = getNonEditableParent(selection.getEnd()); |
|
218 |
|
219 // Is any fo the range endpoints noneditable |
|
220 if (nonEditableStart || nonEditableEnd) { |
|
221 rng = selection.getRng(true); |
|
222 |
|
223 // If it's a caret selection then look left/right to see if we need to move the caret out side or expand |
|
224 if (isCollapsed) { |
|
225 nonEditableStart = nonEditableStart || nonEditableEnd; |
|
226 |
|
227 if ((element = hasSideContent(nonEditableStart, true))) { |
|
228 // We have no contents to the left of the caret then insert a caret container before the noneditable element |
|
229 insertCaretContainerOrExpandToBlock(element, true); |
|
230 } else if ((element = hasSideContent(nonEditableStart, false))) { |
|
231 // We have no contents to the right of the caret then insert a caret container after the noneditable element |
|
232 insertCaretContainerOrExpandToBlock(element, false); |
|
233 } else { |
|
234 // We are in the middle of a noneditable so expand to select it |
|
235 selection.select(nonEditableStart); |
|
236 } |
|
237 } else { |
|
238 rng = selection.getRng(true); |
|
239 |
|
240 // Expand selection to include start non editable element |
|
241 if (nonEditableStart) { |
|
242 rng.setStartBefore(nonEditableStart); |
|
243 } |
|
244 |
|
245 // Expand selection to include end non editable element |
|
246 if (nonEditableEnd) { |
|
247 rng.setEndAfter(nonEditableEnd); |
|
248 } |
|
249 |
|
250 selection.setRng(rng); |
|
251 } |
|
252 } |
|
253 } |
|
254 |
|
255 function handleKey(e) { |
|
256 var keyCode = e.keyCode, nonEditableParent, caretContainer, startElement, endElement; |
|
257 |
|
258 function getNonEmptyTextNodeSibling(node, prev) { |
|
259 while ((node = node[prev ? 'previousSibling' : 'nextSibling'])) { |
|
260 if (node.nodeType !== 3 || node.nodeValue.length > 0) { |
|
261 return node; |
|
262 } |
|
263 } |
|
264 } |
|
265 |
|
266 function positionCaretOnElement(element, start) { |
|
267 selection.select(element); |
|
268 selection.collapse(start); |
|
269 } |
|
270 |
|
271 function canDelete(backspace) { |
|
272 var rng, container, offset, nonEditableParent; |
|
273 |
|
274 function removeNodeIfNotParent(node) { |
|
275 var parent = container; |
|
276 |
|
277 while (parent) { |
|
278 if (parent === node) { |
|
279 return; |
|
280 } |
|
281 |
|
282 parent = parent.parentNode; |
|
283 } |
|
284 |
|
285 dom.remove(node); |
|
286 moveSelection(); |
|
287 } |
|
288 |
|
289 function isNextPrevTreeNodeNonEditable() { |
|
290 var node, walker, nonEmptyElements = editor.schema.getNonEmptyElements(); |
|
291 |
|
292 walker = new tinymce.dom.TreeWalker(container, editor.getBody()); |
|
293 while ((node = (backspace ? walker.prev() : walker.next()))) { |
|
294 // Found IMG/INPUT etc |
|
295 if (nonEmptyElements[node.nodeName.toLowerCase()]) { |
|
296 break; |
|
297 } |
|
298 |
|
299 // Found text node with contents |
|
300 if (node.nodeType === 3 && tinymce.trim(node.nodeValue).length > 0) { |
|
301 break; |
|
302 } |
|
303 |
|
304 // Found non editable node |
|
305 if (getContentEditable(node) === "false") { |
|
306 removeNodeIfNotParent(node); |
|
307 return true; |
|
308 } |
|
309 } |
|
310 |
|
311 // Check if the content node is within a non editable parent |
|
312 if (getNonEditableParent(node)) { |
|
313 return true; |
|
314 } |
|
315 |
|
316 return false; |
|
317 } |
|
318 |
|
319 if (selection.isCollapsed()) { |
|
320 rng = selection.getRng(true); |
|
321 container = rng.startContainer; |
|
322 offset = rng.startOffset; |
|
323 container = getParentCaretContainer(container) || container; |
|
324 |
|
325 // Is in noneditable parent |
|
326 if ((nonEditableParent = getNonEditableParent(container))) { |
|
327 removeNodeIfNotParent(nonEditableParent); |
|
328 return false; |
|
329 } |
|
330 |
|
331 // Check if the caret is in the middle of a text node |
|
332 if (container.nodeType == 3 && (backspace ? offset > 0 : offset < container.nodeValue.length)) { |
|
333 return true; |
|
334 } |
|
335 |
|
336 // Resolve container index |
|
337 if (container.nodeType == 1) { |
|
338 container = container.childNodes[offset] || container; |
|
339 } |
|
340 |
|
341 // Check if previous or next tree node is non editable then block the event |
|
342 if (isNextPrevTreeNodeNonEditable()) { |
|
343 return false; |
|
344 } |
|
345 } |
|
346 |
|
347 return true; |
|
348 } |
|
349 |
|
350 moveSelection(); |
|
351 |
|
352 startElement = selection.getStart(); |
|
353 endElement = selection.getEnd(); |
|
354 |
|
355 // Disable all key presses in contentEditable=false except delete or backspace |
|
356 nonEditableParent = getNonEditableParent(startElement) || getNonEditableParent(endElement); |
|
357 var currentNode = editor.selection.getNode(); |
|
358 |
|
359 var isDirectionKey = keyCode == VK.LEFT || keyCode == VK.RIGHT || keyCode == VK.UP || keyCode == VK.DOWN; |
|
360 var left = keyCode == VK.LEFT || keyCode == VK.UP; |
|
361 |
|
362 if (nonEditableParent && (keyCode < 112 || keyCode > 124) && keyCode != VK.DELETE && keyCode != VK.BACKSPACE) { |
|
363 |
|
364 // Is Ctrl+c, Ctrl+v or Ctrl+x then use default browser behavior |
|
365 if ((tinymce.isMac ? e.metaKey : e.ctrlKey) && (keyCode == 67 || keyCode == 88 || keyCode == 86)) { |
|
366 return; |
|
367 } |
|
368 |
|
369 e.preventDefault(); |
|
370 |
|
371 // Arrow left/right select the element and collapse left/right |
|
372 if (isDirectionKey) { |
|
373 |
|
374 // If a block element find previous or next element to position the caret |
|
375 if (editor.dom.isBlock(nonEditableParent)) { |
|
376 var targetElement = left ? nonEditableParent.previousSibling : nonEditableParent.nextSibling; |
|
377 |
|
378 // Handling for edge-cases: |
|
379 // - two nonEditables in a row -> no way to get between them |
|
380 // - nonEditable as the first/last element -> no way to get before/behind it |
|
381 if (!targetElement || targetElement && getContentEditable(targetElement) === 'false') { |
|
382 var p = dom.create('p', null, ' '); |
|
383 p.className = 'mceTmpParagraph'; |
|
384 |
|
385 var insertElement = left ? nonEditableParent : targetElement; |
|
386 |
|
387 if (insertElement && insertElement.parentNode) { |
|
388 insertElement.parentNode.insertBefore(p, insertElement); |
|
389 } else if (!targetElement && !left) { |
|
390 nonEditableParent.parentNode.appendChild(p); |
|
391 } |
|
392 |
|
393 targetElement = p; |
|
394 } |
|
395 |
|
396 var walker = new TreeWalker(targetElement, targetElement); |
|
397 var caretElement = left ? walker.prev() : walker.next(); |
|
398 |
|
399 positionCaretOnElement(caretElement, !left); |
|
400 } else { |
|
401 positionCaretOnElement(nonEditableParent, left); |
|
402 } |
|
403 } |
|
404 } else { |
|
405 // Is arrow left/right, backspace or delete |
|
406 if (isDirectionKey || keyCode == VK.BACKSPACE || keyCode == VK.DELETE) { |
|
407 caretContainer = getParentCaretContainer(startElement); |
|
408 |
|
409 if (caretContainer) { |
|
410 // Arrow left or backspace |
|
411 if (keyCode == VK.LEFT || keyCode == VK.BACKSPACE) { |
|
412 nonEditableParent = getNonEmptyTextNodeSibling(caretContainer, true); |
|
413 |
|
414 if (nonEditableParent && getContentEditable(nonEditableParent) === "false") { |
|
415 e.preventDefault(); |
|
416 |
|
417 if (keyCode == VK.LEFT) { |
|
418 positionCaretOnElement(nonEditableParent, true); |
|
419 } else { |
|
420 dom.remove(nonEditableParent); |
|
421 return; |
|
422 } |
|
423 } else { |
|
424 removeCaretContainer(caretContainer); |
|
425 } |
|
426 } |
|
427 |
|
428 // Arrow right or delete |
|
429 if (keyCode == VK.RIGHT || keyCode == VK.DELETE) { |
|
430 nonEditableParent = getNonEmptyTextNodeSibling(caretContainer, true); |
|
431 |
|
432 if (nonEditableParent && getContentEditable(nonEditableParent) === "false") { |
|
433 e.preventDefault(); |
|
434 |
|
435 if (keyCode == VK.RIGHT) { |
|
436 positionCaretOnElement(nonEditableParent, false); |
|
437 } else { |
|
438 dom.remove(nonEditableParent); |
|
439 return; |
|
440 } |
|
441 } else { |
|
442 removeCaretContainer(caretContainer); |
|
443 } |
|
444 } |
|
445 } else { |
|
446 |
|
447 if (isDirectionKey) { |
|
448 // Removal of separator paragraphs between two nonEditables |
|
449 // and before/after a nonEditable as the first/last element |
|
450 if (currentNode && currentNode.className.indexOf('mceTmpParagraph') !== -1 && |
|
451 currentNode[left ? 'previousSibling' : 'nextSibling']) { |
|
452 var jumpTarget = currentNode[left ? 'previousSibling' : 'nextSibling']; |
|
453 |
|
454 // current node is still empty and a separator -> remove it |
|
455 // else: remove the separator class, as it now includes content |
|
456 if (currentNode.innerHTML === ' ' || currentNode.innerHTML === '' || currentNode.innerHTML === ' ') { |
|
457 dom.remove(currentNode); |
|
458 } else { |
|
459 currentNode.className = currentNode.className.replace('mceTmpParagraph', ''); |
|
460 } |
|
461 |
|
462 positionCaretOnElement(jumpTarget, !left); |
|
463 } |
|
464 } |
|
465 |
|
466 var rng = selection.getRng(true); |
|
467 var container = rng.endContainer; |
|
468 |
|
469 // FIX: If end of node is selected, check wether next sibling is nonEditable to correctly remove it |
|
470 // (else would break for more complex nonEditables, their content would get moved to the current node) |
|
471 if (dom.isBlock(container) && dom.isBlock(container.nextSibling) && rng.endOffset == 1 && keyCode == VK.DELETE) { |
|
472 nonEditableParent = getNonEditableParent(container.nextSibling); |
|
473 } |
|
474 |
|
475 // correctly remove block-level nonEditable domNode on delete/backspace |
|
476 if (nonEditableParent && (keyCode == VK.DELETE || keyCode == VK.BACKSPACE) && dom.isBlock(nonEditableParent)) { |
|
477 e.preventDefault(); |
|
478 dom.remove(nonEditableParent); |
|
479 return; |
|
480 } |
|
481 } |
|
482 |
|
483 if ((keyCode == VK.BACKSPACE || keyCode == VK.DELETE) && !canDelete(keyCode == VK.BACKSPACE)) { |
|
484 e.preventDefault(); |
|
485 return false; |
|
486 } |
|
487 } |
|
488 } |
|
489 } |
|
490 |
|
491 editor.on('mousedown', function(e) { |
|
492 var node = editor.selection.getNode(); |
|
493 |
|
494 // Also remove separator lines when clicking on another node |
|
495 if (node && node.className.indexOf('mceTmpParagraph') !== -1 && node !== e.target) { |
|
496 // current node is still empty and a separator -> remove it |
|
497 // else: remove the separator class, as it now includes content |
|
498 if (node.innerHTML === ' ' || node.innerHTML === '' || node.innerHTML === ' ') { |
|
499 dom.remove(node); |
|
500 } else { |
|
501 node.className = node.className.replace('mceTmpParagraph', ''); |
|
502 } |
|
503 } |
|
504 |
|
505 if (getContentEditable(node) === "false" && node == e.target) { |
|
506 // Expand selection on mouse down we can't block the default event since it's used for drag/drop |
|
507 moveSelection(); |
|
508 } |
|
509 }); |
|
510 |
|
511 editor.on('mouseup', moveSelection); |
|
512 |
|
513 editor.on('keydown', handleKey); |
|
514 } |
|
515 |
|
516 var editClass, nonEditClass, nonEditableRegExps; |
|
517 |
|
518 // Converts configured regexps to noneditable span items |
|
519 function convertRegExpsToNonEditable(e) { |
|
520 var i = nonEditableRegExps.length, content = e.content, cls = tinymce.trim(nonEditClass); |
|
521 |
|
522 // Don't replace the variables when raw is used for example on undo/redo |
|
523 if (e.format == "raw") { |
|
524 return; |
|
525 } |
|
526 |
|
527 while (i--) { |
|
528 content = content.replace(nonEditableRegExps[i], function(match) { |
|
529 var args = arguments, index = args[args.length - 2]; |
|
530 |
|
531 // Is value inside an attribute then don't replace |
|
532 if (index > 0 && content.charAt(index - 1) == '"') { |
|
533 return match; |
|
534 } |
|
535 |
|
536 return ( |
|
537 '<span class="' + cls + '" data-mce-content="' + editor.dom.encode(args[0]) + '">' + |
|
538 editor.dom.encode(typeof args[1] === "string" ? args[1] : args[0]) + '</span>' |
|
539 ); |
|
540 }); |
|
541 } |
|
542 |
|
543 e.content = content; |
|
544 } |
|
545 |
|
546 editClass = " " + tinymce.trim(editor.getParam("noneditable_editable_class", "mceEditable")) + " "; |
|
547 nonEditClass = " " + tinymce.trim(editor.getParam("noneditable_noneditable_class", "mceNonEditable")) + " "; |
|
548 |
|
549 // Setup noneditable regexps array |
|
550 nonEditableRegExps = editor.getParam("noneditable_regexp"); |
|
551 if (nonEditableRegExps && !nonEditableRegExps.length) { |
|
552 nonEditableRegExps = [nonEditableRegExps]; |
|
553 } |
|
554 |
|
555 editor.on('PreInit', function() { |
|
556 handleContentEditableSelection(); |
|
557 |
|
558 if (nonEditableRegExps) { |
|
559 editor.on('BeforeSetContent', convertRegExpsToNonEditable); |
|
560 } |
|
561 |
|
562 // Apply contentEditable true/false on elements with the noneditable/editable classes |
|
563 editor.parser.addAttributeFilter('class', function(nodes) { |
|
564 var i = nodes.length, className, node; |
|
565 |
|
566 while (i--) { |
|
567 node = nodes[i]; |
|
568 className = " " + node.attr("class") + " "; |
|
569 |
|
570 if (className.indexOf(editClass) !== -1) { |
|
571 node.attr(internalName, "true"); |
|
572 } else if (className.indexOf(nonEditClass) !== -1) { |
|
573 node.attr(internalName, "false"); |
|
574 } |
|
575 } |
|
576 }); |
|
577 |
|
578 // Remove internal name |
|
579 editor.serializer.addAttributeFilter(internalName, function(nodes) { |
|
580 var i = nodes.length, node; |
|
581 |
|
582 while (i--) { |
|
583 node = nodes[i]; |
|
584 |
|
585 if (nonEditableRegExps && node.attr('data-mce-content')) { |
|
586 node.name = "#text"; |
|
587 node.type = 3; |
|
588 node.raw = true; |
|
589 node.value = node.attr('data-mce-content'); |
|
590 } else { |
|
591 node.attr(externalName, null); |
|
592 node.attr(internalName, null); |
|
593 } |
|
594 } |
|
595 }); |
|
596 |
|
597 // Convert external name into internal name |
|
598 editor.parser.addAttributeFilter(externalName, function(nodes) { |
|
599 var i = nodes.length, node; |
|
600 |
|
601 while (i--) { |
|
602 node = nodes[i]; |
|
603 node.attr(internalName, node.attr(externalName)); |
|
604 node.attr(externalName, null); |
|
605 } |
|
606 }); |
|
607 }); |
|
608 |
|
609 editor.on('drop', function(e) { |
|
610 if (getNonEditableParent(e.target)) { |
|
611 e.preventDefault(); |
|
612 } |
|
613 }); |
|
614 }); |