import { Arr, Obj, Optional, Optionals, Type } from '@ephox/katamari'; import { Css, PredicateFilter, SugarElement, SugarNode } from '@ephox/sugar'; import DOMUtils from '../api/dom/DOMUtils'; import DomTreeWalker from '../api/dom/TreeWalker'; import Editor from '../api/Editor'; import { SchemaMap } from '../api/html/Schema'; import * as Options from '../api/Options'; import { EditorEvent } from '../api/util/EventDispatcher'; import Tools from '../api/util/Tools'; import * as Bookmarks from '../bookmark/Bookmarks'; import * as CaretContainer from '../caret/CaretContainer'; import * as NodeType from '../dom/NodeType'; import { isCaretNode } from '../fmt/FormatContainer'; import * as NormalizeRange from '../selection/NormalizeRange'; import { isWhitespaceText } from '../text/Whitespace'; import * as Zwsp from '../text/Zwsp'; import * as InsertLi from './InsertLi'; import * as NewLineUtils from './NewLineUtils'; const trimZwsp = (fragment: DocumentFragment) => { Arr.each(PredicateFilter.descendants(SugarElement.fromDom(fragment), SugarNode.isText), (text) => { const rawNode = text.dom; rawNode.nodeValue = Zwsp.trim(rawNode.data); }); }; const isWithinNonEditableList = (editor: Editor, node: Node): boolean => { const parentList = editor.dom.getParent(node, 'ol,ul,dl'); return parentList !== null && editor.dom.getContentEditableParent(parentList) === 'false'; }; const isEmptyAnchor = (dom: DOMUtils, elm: Node): boolean => { return elm && elm.nodeName === 'A' && dom.isEmpty(elm); }; const emptyBlock = (elm: Element) => { elm.innerHTML = '
'; }; const containerAndSiblingName = (container: Node, nodeName: string) => { return container.nodeName === nodeName || (container.previousSibling && container.previousSibling.nodeName === nodeName); }; // Returns true if the block can be split into two blocks or not const canSplitBlock = (dom: DOMUtils, node: Node | null): node is Element => { return Type.isNonNullable(node) && dom.isBlock(node) && !/^(TD|TH|CAPTION|FORM)$/.test(node.nodeName) && !/^(fixed|absolute)/i.test(node.style.position) && dom.isEditable(node.parentNode) && dom.getContentEditable(node) !== 'false'; }; // Remove the first empty inline element of the block so this:

x

becomes this:

x

const trimInlineElementsOnLeftSideOfBlock = (dom: DOMUtils, nonEmptyElementsMap: SchemaMap, block: Element) => { const firstChilds = []; if (!block) { return; } // Find inner most first child ex:

*

let currentNode: Node | null = block; while ((currentNode = currentNode.firstChild)) { if (dom.isBlock(currentNode)) { return; } if (NodeType.isElement(currentNode) && !nonEmptyElementsMap[currentNode.nodeName.toLowerCase()]) { firstChilds.push(currentNode); } } let i = firstChilds.length; while (i--) { currentNode = firstChilds[i]; if (!currentNode.hasChildNodes() || (currentNode.firstChild === currentNode.lastChild && currentNode.firstChild?.nodeValue === '')) { dom.remove(currentNode); } else { if (isEmptyAnchor(dom, currentNode)) { dom.remove(currentNode); } } } }; const normalizeZwspOffset = (start: boolean, container: Node, offset: number) => { if (!NodeType.isText(container)) { return offset; } else if (start) { return offset === 1 && container.data.charAt(offset - 1) === Zwsp.ZWSP ? 0 : offset; } else { return offset === container.data.length - 1 && container.data.charAt(offset) === Zwsp.ZWSP ? container.data.length : offset; } }; const includeZwspInRange = (rng: Range) => { const newRng = rng.cloneRange(); newRng.setStart(rng.startContainer, normalizeZwspOffset(true, rng.startContainer, rng.startOffset)); newRng.setEnd(rng.endContainer, normalizeZwspOffset(false, rng.endContainer, rng.endOffset)); return newRng; }; // Trims any linebreaks at the beginning of node user for example when pressing enter in a PRE element const trimLeadingLineBreaks = (node: Node) => { let currentNode: Node | null = node; do { if (NodeType.isText(currentNode)) { currentNode.data = currentNode.data.replace(/^[\r\n]+/, ''); } currentNode = currentNode.firstChild; } while (currentNode); }; const applyAttributes = (editor: Editor, node: Element, forcedRootBlockAttrs: Record) => { const dom = editor.dom; // Merge and apply style attribute Optional.from(forcedRootBlockAttrs.style) .map(dom.parseStyle) .each((attrStyles) => { const currentStyles = Css.getAllRaw(SugarElement.fromDom(node)); const newStyles = { ...currentStyles, ...attrStyles }; dom.setStyles(node, newStyles); }); // Merge and apply class attribute const attrClassesOpt = Optional.from(forcedRootBlockAttrs.class).map((attrClasses) => attrClasses.split(/\s+/)); const currentClassesOpt = Optional.from(node.className).map((currentClasses) => Arr.filter(currentClasses.split(/\s+/), (clazz) => clazz !== '')); Optionals.lift2(attrClassesOpt, currentClassesOpt, (attrClasses, currentClasses) => { const filteredClasses = Arr.filter(currentClasses, (clazz) => !Arr.contains(attrClasses, clazz)); const newClasses = [ ...attrClasses, ...filteredClasses ]; dom.setAttrib(node, 'class', newClasses.join(' ')); }); // Apply any remaining forced root block attributes const appliedAttrs = [ 'style', 'class' ]; const remainingAttrs = Obj.filter(forcedRootBlockAttrs, (_, attrs) => !Arr.contains(appliedAttrs, attrs)); dom.setAttribs(node, remainingAttrs); }; const setForcedBlockAttrs = (editor: Editor, node: Element) => { const forcedRootBlockName = Options.getForcedRootBlock(editor); if (forcedRootBlockName.toLowerCase() === node.tagName.toLowerCase()) { const forcedRootBlockAttrs = Options.getForcedRootBlockAttrs(editor); applyAttributes(editor, node, forcedRootBlockAttrs); } }; // Wraps any text nodes or inline elements in the specified forced root block name const wrapSelfAndSiblingsInDefaultBlock = (editor: Editor, newBlockName: string, rng: Range, container: Node, offset: number) => { const dom = editor.dom; const editableRoot = NewLineUtils.getEditableRoot(dom, container) ?? dom.getRoot(); // Not in a block element or in a table cell or caption let parentBlock = dom.getParent(container, dom.isBlock); if (!parentBlock || !canSplitBlock(dom, parentBlock)) { parentBlock = parentBlock || editableRoot; if (!parentBlock.hasChildNodes()) { const newBlock = dom.create(newBlockName); setForcedBlockAttrs(editor, newBlock); parentBlock.appendChild(newBlock); rng.setStart(newBlock, 0); rng.setEnd(newBlock, 0); return newBlock; } // Find parent that is the first child of parentBlock let node: Node | null = container; while (node && node.parentNode !== parentBlock) { node = node.parentNode; } // Loop left to find start node start wrapping at let startNode: Node | undefined; while (node && !dom.isBlock(node)) { startNode = node; node = node.previousSibling; } const startNodeName = startNode?.parentElement?.nodeName; if (startNode && startNodeName && editor.schema.isValidChild(startNodeName, newBlockName.toLowerCase())) { // This should never be null since we check it above const startNodeParent = startNode.parentNode as Node; const newBlock = dom.create(newBlockName); setForcedBlockAttrs(editor, newBlock); startNodeParent.insertBefore(newBlock, startNode); // Start wrapping until we hit a block node = startNode; while (node && !dom.isBlock(node)) { const next: Node | null = node.nextSibling; newBlock.appendChild(node); node = next; } // Restore range to it's past location rng.setStart(container, offset); rng.setEnd(container, offset); } } return container; }; // Adds a BR at the end of blocks that only contains an IMG or INPUT since // these might be floated and then they won't expand the block const addBrToBlockIfNeeded = (dom: DOMUtils, block: Node) => { // IE will render the blocks correctly other browsers needs a BR block.normalize(); // Remove empty text nodes that got left behind by the extract // Check if the block is empty or contains a floated last child const lastChild = block.lastChild; if (!lastChild || NodeType.isElement(lastChild) && (/^(left|right)$/gi.test(dom.getStyle(lastChild, 'float', true)))) { dom.add(block, 'br'); } }; const shouldEndContainer = (editor: Editor, container: Node | null | undefined) => { const optionValue = Options.shouldEndContainerOnEmptyBlock(editor); if (Type.isNullable(container)) { return false; } else if (Type.isString(optionValue)) { return Arr.contains(Tools.explode(optionValue), container.nodeName.toLowerCase()); } else { return optionValue; } }; const insert = (editor: Editor, evt?: EditorEvent): void => { let container: Node; let offset: number; let parentBlockName: string; let containerBlock: Node | null; let isAfterLastNodeInContainer = false; const dom = editor.dom; const schema = editor.schema, nonEmptyElementsMap = schema.getNonEmptyElements(); const rng = editor.selection.getRng(); const newBlockName = Options.getForcedRootBlock(editor); // Creates a new block element by cloning the current one or creating a new one if the name is specified // This function will also copy any text formatting from the parent block and add it to the new one const createNewBlock = (name?: string): Element => { let node: Node | null = container; const textInlineElements = schema.getTextInlineElements(); let block: Element; if (name || parentBlockName === 'TABLE' || parentBlockName === 'HR') { block = dom.create(name || newBlockName); } else { block = parentBlock.cloneNode(false) as Element; } let caretNode = block; if (Options.shouldKeepStyles(editor) === false) { dom.setAttrib(block, 'style', null); // wipe out any styles that came over with the block dom.setAttrib(block, 'class', null); } else { // Clone any parent styles do { if (textInlineElements[node.nodeName]) { // Ignore caret or bookmark nodes when cloning if (isCaretNode(node) || Bookmarks.isBookmarkNode(node)) { continue; } const clonedNode = node.cloneNode(false) as Element; dom.setAttrib(clonedNode, 'id', ''); // Remove ID since it needs to be document unique if (block.hasChildNodes()) { clonedNode.appendChild(block.firstChild as Node); block.appendChild(clonedNode); } else { caretNode = clonedNode; block.appendChild(clonedNode); } } } while ((node = node.parentNode) && node !== editableRoot); } setForcedBlockAttrs(editor, block); emptyBlock(caretNode); return block; }; // Returns true/false if the caret is at the start/end of the parent block element const isCaretAtStartOrEndOfBlock = (start: boolean) => { const normalizedOffset = normalizeZwspOffset(start, container, offset); // Caret is in the middle of a text node like "a|b" if (NodeType.isText(container) && (start ? normalizedOffset > 0 : normalizedOffset < container.data.length)) { return false; } // If after the last element in block node edge case for #5091 if (container.parentNode === parentBlock && isAfterLastNodeInContainer && !start) { return true; } // If the caret if before the first element in parentBlock if (start && NodeType.isElement(container) && container === parentBlock.firstChild) { return true; } // Caret can be before/after a table or a hr if (containerAndSiblingName(container, 'TABLE') || containerAndSiblingName(container, 'HR')) { return (isAfterLastNodeInContainer && !start) || (!isAfterLastNodeInContainer && start); } // Walk the DOM and look for text nodes or non empty elements const walker = new DomTreeWalker(container, parentBlock); // If caret is in beginning or end of a text block then jump to the next/previous node if (NodeType.isText(container)) { if (start && normalizedOffset === 0) { walker.prev(); } else if (!start && normalizedOffset === container.data.length) { walker.next(); } } let node: Node | null | undefined; while ((node = walker.current())) { if (NodeType.isElement(node)) { // Ignore bogus elements if (!node.getAttribute('data-mce-bogus')) { // Keep empty elements like but not trailing br:s like

text|

const name = node.nodeName.toLowerCase(); if (nonEmptyElementsMap[name] && name !== 'br') { return false; } } } else if (NodeType.isText(node) && !isWhitespaceText(node.data)) { return false; } if (start) { walker.prev(); } else { walker.next(); } } return true; }; const insertNewBlockAfter = () => { let block: Element; // If the caret is at the end of a header we produce a P tag after it similar to Word unless we are in a hgroup if (/^(H[1-6]|PRE|FIGURE)$/.test(parentBlockName) && containerBlockName !== 'HGROUP') { block = createNewBlock(newBlockName); } else { block = createNewBlock(); } // Split the current container block element if enter is pressed inside an empty inner block element if (shouldEndContainer(editor, containerBlock) && canSplitBlock(dom, containerBlock) && dom.isEmpty(parentBlock)) { // Split container block for example a BLOCKQUOTE at the current blockParent location for example a P block = dom.split(containerBlock, parentBlock) as Element; } else { dom.insertAfter(block, parentBlock); } NewLineUtils.moveToCaretPosition(editor, block); return block; }; // Setup range items and newBlockName NormalizeRange.normalize(dom, rng).each((normRng) => { rng.setStart(normRng.startContainer, normRng.startOffset); rng.setEnd(normRng.endContainer, normRng.endOffset); }); container = rng.startContainer; offset = rng.startOffset; const shiftKey = !!(evt && evt.shiftKey); const ctrlKey = !!(evt && evt.ctrlKey); // Resolve node index if (NodeType.isElement(container) && container.hasChildNodes()) { isAfterLastNodeInContainer = offset > container.childNodes.length - 1; container = container.childNodes[Math.min(offset, container.childNodes.length - 1)] || container; if (isAfterLastNodeInContainer && NodeType.isText(container)) { offset = container.data.length; } else { offset = 0; } } // Get editable root node, normally the body element but sometimes a div or span const editableRoot = NewLineUtils.getEditableRoot(dom, container); // If there is no editable root then enter is done inside a contentEditable false element if (!editableRoot || isWithinNonEditableList(editor, container)) { return; } // Wrap the current node and it's sibling in a default block if it's needed. // for example this text|text2 will become this

text|text2

// This won't happen if root blocks are disabled or the shiftKey is pressed if (!shiftKey) { container = wrapSelfAndSiblingsInDefaultBlock(editor, newBlockName, rng, container, offset); } // Find parent block and setup empty block paddings let parentBlock: HTMLElement = dom.getParent(container, dom.isBlock) || dom.getRoot(); containerBlock = Type.isNonNullable(parentBlock?.parentNode) ? dom.getParent(parentBlock.parentNode, dom.isBlock) : null; // Setup block names parentBlockName = parentBlock ? parentBlock.nodeName.toUpperCase() : ''; // IE < 9 & HTML5 const containerBlockName = containerBlock ? containerBlock.nodeName.toUpperCase() : ''; // IE < 9 & HTML5 // Enter inside block contained within a LI then split or insert before/after LI if (containerBlockName === 'LI' && !ctrlKey) { const liBlock = containerBlock as HTMLLIElement; parentBlock = liBlock; containerBlock = liBlock.parentNode; parentBlockName = containerBlockName; } // Handle enter in list item if (/^(LI|DT|DD)$/.test(parentBlockName) && NodeType.isElement(containerBlock)) { // Handle enter inside an empty list item if (dom.isEmpty(parentBlock)) { InsertLi.insert(editor, createNewBlock, containerBlock, parentBlock, newBlockName); return; } } // Never split the body or blocks that we can't split like noneditable host elements if (parentBlock === editor.getBody() || !canSplitBlock(dom, parentBlock)) { return; } const parentBlockParent = parentBlock.parentNode; // Insert new block before/after the parent block depending on caret location let newBlock: Element; if (CaretContainer.isCaretContainerBlock(parentBlock)) { newBlock = CaretContainer.showCaretContainerBlock(parentBlock) as Element; if (dom.isEmpty(parentBlock)) { emptyBlock(parentBlock); } setForcedBlockAttrs(editor, newBlock); NewLineUtils.moveToCaretPosition(editor, newBlock); } else if (isCaretAtStartOrEndOfBlock(false)) { newBlock = insertNewBlockAfter(); } else if (isCaretAtStartOrEndOfBlock(true) && parentBlockParent) { // Insert new block before newBlock = parentBlockParent.insertBefore(createNewBlock(), parentBlock); NewLineUtils.moveToCaretPosition(editor, containerAndSiblingName(parentBlock, 'HR') ? newBlock : parentBlock); } else { // Extract after fragment and insert it after the current block const tmpRng = includeZwspInRange(rng).cloneRange(); tmpRng.setEndAfter(parentBlock); const fragment = tmpRng.extractContents(); trimZwsp(fragment); trimLeadingLineBreaks(fragment); newBlock = fragment.firstChild as Element; dom.insertAfter(fragment, parentBlock); trimInlineElementsOnLeftSideOfBlock(dom, nonEmptyElementsMap, newBlock); addBrToBlockIfNeeded(dom, parentBlock); if (dom.isEmpty(parentBlock)) { emptyBlock(parentBlock); } newBlock.normalize(); // New block might become empty if it's

a |

if (dom.isEmpty(newBlock)) { dom.remove(newBlock); insertNewBlockAfter(); } else { setForcedBlockAttrs(editor, newBlock); NewLineUtils.moveToCaretPosition(editor, newBlock); } } dom.setAttrib(newBlock, 'id', ''); // Remove ID since it needs to be document unique // Allow custom handling of new blocks editor.dispatch('NewBlock', { newBlock }); }; const fakeEventName = 'insertParagraph'; export const blockbreak = { insert, fakeEventName };