4 * Released under LGPL License.
5 * Copyright (c) 1999-2015 Ephox Corp. All rights reserved
7 * License: http://www.tinymce.com/license
8 * Contributing: http://www.tinymce.com/contributing
11 /*global tinymce:true */
12 /*eslint consistent-this:0 */
14 tinymce.PluginManager.add('lists', function(editor) {
17 function isChildOfBody(elm) {
18 return editor.$.contains(editor.getBody(), elm);
22 return node && node.nodeName == 'BR';
25 function isListNode(node) {
26 return node && (/^(OL|UL|DL)$/).test(node.nodeName) && isChildOfBody(node);
29 function isListItemNode(node) {
30 return node && /^(LI|DT|DD)$/.test(node.nodeName);
33 function isFirstChild(node) {
34 return node.parentNode.firstChild == node;
37 function isLastChild(node) {
38 return node.parentNode.lastChild == node;
41 function isTextBlock(node) {
42 return node && !!editor.schema.getTextBlockElements()[node.nodeName];
45 function isEditorBody(elm) {
46 return elm === editor.getBody();
49 function isTextNode(node) {
50 return node && node.nodeType === 3;
53 function getNormalizedEndPoint(container, offset) {
54 var node = tinymce.dom.RangeUtils.getNode(container, offset);
56 if (isListItemNode(container) && isTextNode(node)) {
57 var textNodeOffset = offset >= container.childNodes.length ? node.data.length : 0;
58 return {container: node, offset: textNodeOffset};
61 return {container: container, offset: offset};
64 function normalizeRange(rng) {
65 var outRng = rng.cloneRange();
67 var rangeStart = getNormalizedEndPoint(rng.startContainer, rng.startOffset);
68 outRng.setStart(rangeStart.container, rangeStart.offset);
70 var rangeEnd = getNormalizedEndPoint(rng.endContainer, rng.endOffset);
71 outRng.setEnd(rangeEnd.container, rangeEnd.offset);
76 editor.on('init', function() {
77 var dom = editor.dom, selection = editor.selection;
79 function isEmpty(elm, keepBookmarks) {
80 var empty = dom.isEmpty(elm);
82 if (keepBookmarks && dom.select('span[data-mce-type=bookmark]').length > 0) {
90 * Returns a range bookmark. This will convert indexed bookmarks into temporary span elements with
91 * index 0 so that they can be restored properly after the DOM has been modified. Text bookmarks will not have spans
92 * added to them since they can be restored after a dom operation.
94 * So this: <p><b>|</b><b>|</b></p>
95 * becomes: <p><b><span data-mce-type="bookmark">|</span></b><b data-mce-type="bookmark">|</span></b></p>
97 * @param {DOMRange} rng DOM Range to get bookmark on.
98 * @return {Object} Bookmark object.
100 function createBookmark(rng) {
103 function setupEndPoint(start) {
104 var offsetNode, container, offset;
106 container = rng[start ? 'startContainer' : 'endContainer'];
107 offset = rng[start ? 'startOffset' : 'endOffset'];
109 if (container.nodeType == 1) {
110 offsetNode = dom.create('span', {'data-mce-type': 'bookmark'});
112 if (container.hasChildNodes()) {
113 offset = Math.min(offset, container.childNodes.length - 1);
116 container.insertBefore(offsetNode, container.childNodes[offset]);
118 dom.insertAfter(offsetNode, container.childNodes[offset]);
121 container.appendChild(offsetNode);
124 container = offsetNode;
128 bookmark[start ? 'startContainer' : 'endContainer'] = container;
129 bookmark[start ? 'startOffset' : 'endOffset'] = offset;
134 if (!rng.collapsed) {
142 * Moves the selection to the current bookmark and removes any selection container wrappers.
144 * @param {Object} bookmark Bookmark object to move selection to.
146 function moveToBookmark(bookmark) {
147 function restoreEndPoint(start) {
148 var container, offset, node;
150 function nodeIndex(container) {
151 var node = container.parentNode.firstChild, idx = 0;
154 if (node == container) {
158 // Skip data-mce-type=bookmark nodes
159 if (node.nodeType != 1 || node.getAttribute('data-mce-type') != 'bookmark') {
163 node = node.nextSibling;
169 container = node = bookmark[start ? 'startContainer' : 'endContainer'];
170 offset = bookmark[start ? 'startOffset' : 'endOffset'];
176 if (container.nodeType == 1) {
177 offset = nodeIndex(container);
178 container = container.parentNode;
182 bookmark[start ? 'startContainer' : 'endContainer'] = container;
183 bookmark[start ? 'startOffset' : 'endOffset'] = offset;
186 restoreEndPoint(true);
189 var rng = dom.createRng();
191 rng.setStart(bookmark.startContainer, bookmark.startOffset);
193 if (bookmark.endContainer) {
194 rng.setEnd(bookmark.endContainer, bookmark.endOffset);
197 selection.setRng(normalizeRange(rng));
200 function createNewTextBlock(contentNode, blockName) {
201 var node, textBlock, fragment = dom.createFragment(), hasContentNode;
202 var blockElements = editor.schema.getBlockElements();
204 if (editor.settings.forced_root_block) {
205 blockName = blockName || editor.settings.forced_root_block;
209 textBlock = dom.create(blockName);
211 if (textBlock.tagName === editor.settings.forced_root_block) {
212 dom.setAttribs(textBlock, editor.settings.forced_root_block_attrs);
215 fragment.appendChild(textBlock);
219 while ((node = contentNode.firstChild)) {
220 var nodeName = node.nodeName;
222 if (!hasContentNode && (nodeName != 'SPAN' || node.getAttribute('data-mce-type') != 'bookmark')) {
223 hasContentNode = true;
226 if (blockElements[nodeName]) {
227 fragment.appendChild(node);
232 textBlock = dom.create(blockName);
233 fragment.appendChild(textBlock);
236 textBlock.appendChild(node);
238 fragment.appendChild(node);
244 if (!editor.settings.forced_root_block) {
245 fragment.appendChild(dom.create('br'));
247 // BR is needed in empty blocks on non IE browsers
248 if (!hasContentNode && (!tinymce.Env.ie || tinymce.Env.ie > 10)) {
249 textBlock.appendChild(dom.create('br', {'data-mce-bogus': '1'}));
256 function getSelectedListItems() {
257 return tinymce.grep(selection.getSelectedBlocks(), function(block) {
258 return isListItemNode(block);
262 function splitList(ul, li, newBlock) {
263 var tmpRng, fragment, bookmarks, node;
265 function removeAndKeepBookmarks(targetNode) {
266 tinymce.each(bookmarks, function(node) {
267 targetNode.parentNode.insertBefore(node, li.parentNode);
270 dom.remove(targetNode);
273 bookmarks = dom.select('span[data-mce-type="bookmark"]', ul);
274 newBlock = newBlock || createNewTextBlock(li);
275 tmpRng = dom.createRng();
276 tmpRng.setStartAfter(li);
277 tmpRng.setEndAfter(ul);
278 fragment = tmpRng.extractContents();
280 for (node = fragment.firstChild; node; node = node.firstChild) {
281 if (node.nodeName == 'LI' && dom.isEmpty(node)) {
287 if (!dom.isEmpty(fragment)) {
288 dom.insertAfter(fragment, ul);
291 dom.insertAfter(newBlock, ul);
293 if (isEmpty(li.parentNode)) {
294 removeAndKeepBookmarks(li.parentNode);
304 var shouldMerge = function (listBlock, sibling) {
305 var targetStyle = editor.dom.getStyle(listBlock, 'list-style-type', true);
306 var style = editor.dom.getStyle(sibling, 'list-style-type', true);
307 return targetStyle === style;
310 function mergeWithAdjacentLists(listBlock) {
313 sibling = listBlock.nextSibling;
314 if (sibling && isListNode(sibling) && sibling.nodeName == listBlock.nodeName && shouldMerge(listBlock, sibling)) {
315 while ((node = sibling.firstChild)) {
316 listBlock.appendChild(node);
322 sibling = listBlock.previousSibling;
323 if (sibling && isListNode(sibling) && sibling.nodeName == listBlock.nodeName && shouldMerge(listBlock, sibling)) {
324 while ((node = sibling.firstChild)) {
325 listBlock.insertBefore(node, listBlock.firstChild);
332 function normalizeLists(element) {
333 tinymce.each(tinymce.grep(dom.select('ol,ul', element)), normalizeList);
336 function normalizeList(ul) {
337 var sibling, parentNode = ul.parentNode;
339 // Move UL/OL to previous LI if it's the only child of a LI
340 if (parentNode.nodeName == 'LI' && parentNode.firstChild == ul) {
341 sibling = parentNode.previousSibling;
342 if (sibling && sibling.nodeName == 'LI') {
343 sibling.appendChild(ul);
345 if (isEmpty(parentNode)) {
346 dom.remove(parentNode);
349 dom.setStyle(parentNode, 'listStyleType', 'none');
353 // Append OL/UL to previous LI if it's in a parent OL/UL i.e. old HTML4
354 if (isListNode(parentNode)) {
355 sibling = parentNode.previousSibling;
356 if (sibling && sibling.nodeName == 'LI') {
357 sibling.appendChild(ul);
362 function outdent(li) {
363 var ul = li.parentNode, ulParent = ul.parentNode, newBlock;
365 function removeEmptyLi(li) {
371 if (isEditorBody(ul)) {
375 if (li.nodeName == 'DD') {
376 dom.rename(li, 'DT');
380 if (isFirstChild(li) && isLastChild(li)) {
381 if (ulParent.nodeName == "LI") {
382 dom.insertAfter(li, ulParent);
383 removeEmptyLi(ulParent);
385 } else if (isListNode(ulParent)) {
386 dom.remove(ul, true);
388 ulParent.insertBefore(createNewTextBlock(li), ul);
393 } else if (isFirstChild(li)) {
394 if (ulParent.nodeName == "LI") {
395 dom.insertAfter(li, ulParent);
397 removeEmptyLi(ulParent);
398 } else if (isListNode(ulParent)) {
399 ulParent.insertBefore(li, ul);
401 ulParent.insertBefore(createNewTextBlock(li), ul);
406 } else if (isLastChild(li)) {
407 if (ulParent.nodeName == "LI") {
408 dom.insertAfter(li, ulParent);
409 } else if (isListNode(ulParent)) {
410 dom.insertAfter(li, ul);
412 dom.insertAfter(createNewTextBlock(li), ul);
419 if (ulParent.nodeName == 'LI') {
421 newBlock = createNewTextBlock(li, 'LI');
422 } else if (isListNode(ulParent)) {
423 newBlock = createNewTextBlock(li, 'LI');
425 newBlock = createNewTextBlock(li);
428 splitList(ul, li, newBlock);
429 normalizeLists(ul.parentNode);
434 function indent(li) {
435 var sibling, newList, listStyle;
437 function mergeLists(from, to) {
440 if (isListNode(from)) {
441 while ((node = li.lastChild.firstChild)) {
442 to.appendChild(node);
449 if (li.nodeName == 'DT') {
450 dom.rename(li, 'DD');
454 sibling = li.previousSibling;
456 if (sibling && isListNode(sibling)) {
457 sibling.appendChild(li);
461 if (sibling && sibling.nodeName == 'LI' && isListNode(sibling.lastChild)) {
462 sibling.lastChild.appendChild(li);
463 mergeLists(li.lastChild, sibling.lastChild);
467 sibling = li.nextSibling;
469 if (sibling && isListNode(sibling)) {
470 sibling.insertBefore(li, sibling.firstChild);
474 /*if (sibling && sibling.nodeName == 'LI' && isListNode(li.lastChild)) {
478 sibling = li.previousSibling;
479 if (sibling && sibling.nodeName == 'LI') {
480 newList = dom.create(li.parentNode.nodeName);
481 listStyle = dom.getStyle(li.parentNode, 'listStyleType');
483 dom.setStyle(newList, 'listStyleType', listStyle);
485 sibling.appendChild(newList);
486 newList.appendChild(li);
487 mergeLists(li.lastChild, newList);
494 function indentSelection() {
495 var listElements = getSelectedListItems();
497 if (listElements.length) {
498 var bookmark = createBookmark(selection.getRng(true));
500 for (var i = 0; i < listElements.length; i++) {
501 if (!indent(listElements[i]) && i === 0) {
506 moveToBookmark(bookmark);
507 editor.nodeChanged();
513 function outdentSelection() {
514 var listElements = getSelectedListItems();
516 if (listElements.length) {
517 var bookmark = createBookmark(selection.getRng(true));
518 var i, y, root = editor.getBody();
520 i = listElements.length;
522 var node = listElements[i].parentNode;
524 while (node && node != root) {
525 y = listElements.length;
527 if (listElements[y] === node) {
528 listElements.splice(i, 1);
533 node = node.parentNode;
537 for (i = 0; i < listElements.length; i++) {
538 if (!outdent(listElements[i]) && i === 0) {
543 moveToBookmark(bookmark);
544 editor.nodeChanged();
550 function applyList(listName, detail) {
551 var rng = selection.getRng(true), bookmark, listItemName = 'LI';
553 if (dom.getContentEditable(selection.getNode()) === "false") {
557 listName = listName.toUpperCase();
559 if (listName == 'DL') {
563 function getSelectedTextBlocks() {
564 var textBlocks = [], root = editor.getBody();
566 function getEndPointNode(start) {
567 var container, offset;
569 container = rng[start ? 'startContainer' : 'endContainer'];
570 offset = rng[start ? 'startOffset' : 'endOffset'];
572 // Resolve node index
573 if (container.nodeType == 1) {
574 container = container.childNodes[Math.min(offset, container.childNodes.length - 1)] || container;
577 while (container.parentNode != root) {
578 if (isTextBlock(container)) {
582 if (/^(TD|TH)$/.test(container.parentNode.nodeName)) {
586 container = container.parentNode;
592 var startNode = getEndPointNode(true);
593 var endNode = getEndPointNode();
594 var block, siblings = [];
596 for (var node = startNode; node; node = node.nextSibling) {
599 if (node == endNode) {
604 tinymce.each(siblings, function(node) {
605 if (isTextBlock(node)) {
606 textBlocks.push(node);
611 if (dom.isBlock(node) || isBr(node)) {
620 var nextSibling = node.nextSibling;
621 if (tinymce.dom.BookmarkManager.isBookmarkNode(node)) {
622 if (isTextBlock(nextSibling) || (!nextSibling && node.parentNode == root)) {
629 block = dom.create('p');
630 node.parentNode.insertBefore(block, node);
631 textBlocks.push(block);
634 block.appendChild(node);
640 bookmark = createBookmark(rng);
642 tinymce.each(getSelectedTextBlocks(), function(block) {
643 var listBlock, sibling;
645 var hasCompatibleStyle = function (sib) {
646 var sibStyle = dom.getStyle(sib, 'list-style-type');
647 var detailStyle = detail ? detail['list-style-type'] : '';
649 detailStyle = detailStyle === null ? '' : detailStyle;
651 return sibStyle === detailStyle;
654 sibling = block.previousSibling;
655 if (sibling && isListNode(sibling) && sibling.nodeName == listName && hasCompatibleStyle(sibling)) {
657 block = dom.rename(block, listItemName);
658 sibling.appendChild(block);
660 listBlock = dom.create(listName);
661 block.parentNode.insertBefore(listBlock, block);
662 listBlock.appendChild(block);
663 block = dom.rename(block, listItemName);
666 updateListStyle(listBlock, detail);
667 mergeWithAdjacentLists(listBlock);
670 moveToBookmark(bookmark);
673 var updateListStyle = function (el, detail) {
674 dom.setStyle(el, 'list-style-type', detail ? detail['list-style-type'] : null);
677 function removeList() {
678 var bookmark = createBookmark(selection.getRng(true)), root = editor.getBody();
680 tinymce.each(getSelectedListItems(), function(li) {
683 if (isEditorBody(li.parentNode)) {
692 for (node = li; node && node != root; node = node.parentNode) {
693 if (isListNode(node)) {
698 splitList(rootList, li);
699 normalizeLists(rootList.parentNode);
702 moveToBookmark(bookmark);
705 function toggleList(listName, detail) {
706 var parentList = dom.getParent(selection.getStart(), 'OL,UL,DL');
708 if (isEditorBody(parentList)) {
713 if (parentList.nodeName == listName) {
714 removeList(listName);
716 var bookmark = createBookmark(selection.getRng(true));
717 updateListStyle(parentList, detail);
718 mergeWithAdjacentLists(dom.rename(parentList, listName));
720 moveToBookmark(bookmark);
723 applyList(listName, detail);
727 function queryListCommandState(listName) {
729 var parentList = dom.getParent(editor.selection.getStart(), 'UL,OL,DL');
731 return parentList && parentList.nodeName == listName;
735 function isBogusBr(node) {
740 if (dom.isBlock(node.nextSibling) && !isBr(node.previousSibling)) {
747 function findNextCaretContainer(rng, isForward) {
748 var node = rng.startContainer, offset = rng.startOffset;
749 var nonEmptyBlocks, walker;
751 if (node.nodeType == 3 && (isForward ? offset < node.data.length : offset > 0)) {
755 nonEmptyBlocks = editor.schema.getNonEmptyElements();
756 if (node.nodeType == 1) {
757 node = tinymce.dom.RangeUtils.getNode(node, offset);
760 walker = new tinymce.dom.TreeWalker(node, editor.getBody());
762 // Delete at <li>|<br></li> then jump over the bogus br
764 if (isBogusBr(node)) {
769 while ((node = walker[isForward ? 'next' : 'prev2']())) {
770 if (node.nodeName == 'LI' && !node.hasChildNodes()) {
774 if (nonEmptyBlocks[node.nodeName]) {
778 if (node.nodeType == 3 && node.data.length > 0) {
784 function mergeLiElements(fromElm, toElm) {
785 var node, listNode, ul = fromElm.parentNode;
787 if (!isChildOfBody(fromElm) || !isChildOfBody(toElm)) {
791 if (isListNode(toElm.lastChild)) {
792 listNode = toElm.lastChild;
795 if (ul == toElm.lastChild) {
796 if (isBr(ul.previousSibling)) {
797 dom.remove(ul.previousSibling);
801 node = toElm.lastChild;
802 if (node && isBr(node) && fromElm.hasChildNodes()) {
806 if (isEmpty(toElm, true)) {
807 dom.$(toElm).empty();
810 if (!isEmpty(fromElm, true)) {
811 while ((node = fromElm.firstChild)) {
812 toElm.appendChild(node);
817 toElm.appendChild(listNode);
822 if (isEmpty(ul) && !isEditorBody(ul)) {
827 function backspaceDeleteCaret(isForward) {
828 var li = dom.getParent(selection.getStart(), 'LI'), ul, rng, otherLi;
832 if (isEditorBody(ul) && dom.isEmpty(ul)) {
836 rng = normalizeRange(selection.getRng(true));
837 otherLi = dom.getParent(findNextCaretContainer(rng, isForward), 'LI');
839 if (otherLi && otherLi != li) {
840 var bookmark = createBookmark(rng);
843 mergeLiElements(otherLi, li);
845 mergeLiElements(li, otherLi);
848 moveToBookmark(bookmark);
851 } else if (!otherLi) {
852 if (!isForward && removeList(ul.nodeName)) {
859 function backspaceDeleteRange() {
860 var startListParent = editor.dom.getParent(editor.selection.getStart(), 'LI,DT,DD');
862 if (startListParent || getSelectedListItems().length > 0) {
863 editor.undoManager.transact(function() {
864 editor.execCommand('Delete');
865 normalizeLists(editor.getBody());
874 self.backspaceDelete = function(isForward) {
875 return selection.isCollapsed() ? backspaceDeleteCaret(isForward) : backspaceDeleteRange();
878 editor.on('BeforeExecCommand', function(e) {
879 var cmd = e.command.toLowerCase(), isHandled;
881 if (cmd == "indent") {
882 if (indentSelection()) {
885 } else if (cmd == "outdent") {
886 if (outdentSelection()) {
892 editor.fire('ExecCommand', {command: e.command});
898 editor.addCommand('InsertUnorderedList', function(ui, detail) {
899 toggleList('UL', detail);
902 editor.addCommand('InsertOrderedList', function(ui, detail) {
903 toggleList('OL', detail);
906 editor.addCommand('InsertDefinitionList', function(ui, detail) {
907 toggleList('DL', detail);
910 editor.addQueryStateHandler('InsertUnorderedList', queryListCommandState('UL'));
911 editor.addQueryStateHandler('InsertOrderedList', queryListCommandState('OL'));
912 editor.addQueryStateHandler('InsertDefinitionList', queryListCommandState('DL'));
914 editor.on('keydown', function(e) {
915 // Check for tab but not ctrl/cmd+tab since it switches browser tabs
916 if (e.keyCode != 9 || tinymce.util.VK.metaKeyPressed(e)) {
920 if (editor.dom.getParent(editor.selection.getStart(), 'LI,DT,DD')) {
932 editor.addButton('indent', {
934 title: 'Increase indent',
936 onPostRender: function() {
939 editor.on('nodechange', function() {
940 var blocks = editor.selection.getSelectedBlocks();
943 for (var i = 0, l = blocks.length; !disable && i < l; i++) {
944 var tag = blocks[i].nodeName;
946 disable = (tag == 'LI' && isFirstChild(blocks[i]) || tag == 'UL' || tag == 'OL' || tag == 'DD');
949 ctrl.disabled(disable);
954 editor.on('keydown', function(e) {
955 if (e.keyCode == tinymce.util.VK.BACKSPACE) {
956 if (self.backspaceDelete()) {
959 } else if (e.keyCode == tinymce.util.VK.DELETE) {
960 if (self.backspaceDelete(true)) {