import { Arr, Fun, Obj, Optional, Strings } from '@ephox/katamari'; import { Attribute, Insert, Remove, SugarElement, SugarNode } from '@ephox/sugar'; import DomTreeWalker from '../api/dom/TreeWalker'; import Editor from '../api/Editor'; import CaretPosition from '../caret/CaretPosition'; import * as DeleteElement from '../delete/DeleteElement'; import * as NodeType from '../dom/NodeType'; import * as PaddingBr from '../dom/PaddingBr'; import * as SplitRange from '../selection/SplitRange'; import * as Zwsp from '../text/Zwsp'; import * as ExpandRange from './ExpandRange'; import { CARET_ID, getParentCaretContainer, isCaretNode } from './FormatContainer'; import { FormatVars } from './FormatTypes'; import * as FormatUtils from './FormatUtils'; import * as MatchFormat from './MatchFormat'; const ZWSP = Zwsp.ZWSP; const importNode = (ownerDocument: Document, node: Node) => { return ownerDocument.importNode(node, true); }; const getEmptyCaretContainers = (node: Node) => { const nodes: Element[] = []; let tempNode: Node | null = node; while (tempNode) { if ((NodeType.isText(tempNode) && tempNode.data !== ZWSP) || tempNode.childNodes.length > 1) { return []; } // Collect nodes if (NodeType.isElement(tempNode)) { nodes.push(tempNode); } tempNode = tempNode.firstChild; } return nodes; }; const isCaretContainerEmpty = (node: Node): boolean => { return getEmptyCaretContainers(node).length > 0; }; const findFirstTextNode = (node: Node | null): Text | null => { if (node) { const walker = new DomTreeWalker(node, node); for (let tempNode = walker.current(); tempNode; tempNode = walker.next()) { if (NodeType.isText(tempNode)) { return tempNode; } } } return null; }; const createCaretContainer = (fill: boolean) => { const caretContainer = SugarElement.fromTag('span'); Attribute.setAll(caretContainer, { // style: 'color:red', 'id': CARET_ID, 'data-mce-bogus': '1', 'data-mce-type': 'format-caret' }); if (fill) { Insert.append(caretContainer, SugarElement.fromText(ZWSP)); } return caretContainer; }; const trimZwspFromCaretContainer = (caretContainerNode: Node) => { const textNode = findFirstTextNode(caretContainerNode); if (textNode && textNode.data.charAt(0) === ZWSP) { textNode.deleteData(0, 1); } return textNode; }; const removeCaretContainerNode = (editor: Editor, node: Node, moveCaret: boolean = true) => { const dom = editor.dom, selection = editor.selection; if (isCaretContainerEmpty(node)) { DeleteElement.deleteElement(editor, false, SugarElement.fromDom(node), moveCaret); } else { const rng = selection.getRng(); const block = dom.getParent(node, dom.isBlock); // Store the current selection offsets const startContainer = rng.startContainer; const startOffset = rng.startOffset; const endContainer = rng.endContainer; const endOffset = rng.endOffset; const textNode = trimZwspFromCaretContainer(node); dom.remove(node, true); // Restore the selection after unwrapping the node and removing the zwsp if (startContainer === textNode && startOffset > 0) { rng.setStart(textNode, startOffset - 1); } if (endContainer === textNode && endOffset > 0) { rng.setEnd(textNode, endOffset - 1); } if (block && dom.isEmpty(block)) { PaddingBr.fillWithPaddingBr(SugarElement.fromDom(block)); } selection.setRng(rng); } }; // Removes the caret container for the specified node or all on the current document const removeCaretContainer = (editor: Editor, node: Node | null, moveCaret: boolean = true) => { const dom = editor.dom, selection = editor.selection; if (!node) { node = getParentCaretContainer(editor.getBody(), selection.getStart()); if (!node) { while ((node = dom.get(CARET_ID))) { removeCaretContainerNode(editor, node, false); } } } else { removeCaretContainerNode(editor, node, moveCaret); } }; const insertCaretContainerNode = (editor: Editor, caretContainer: Node, formatNode: Node) => { const dom = editor.dom; const block = dom.getParent(formatNode, Fun.curry(FormatUtils.isTextBlock, editor.schema)); if (block && dom.isEmpty(block)) { // Replace formatNode with caretContainer when removing format from empty block like

|

formatNode.parentNode?.replaceChild(caretContainer, formatNode); } else { PaddingBr.removeTrailingBr(SugarElement.fromDom(formatNode)); if (dom.isEmpty(formatNode)) { formatNode.parentNode?.replaceChild(caretContainer, formatNode); } else { dom.insertAfter(caretContainer, formatNode); } } }; const appendNode = (parentNode: Node, node: Node) => { parentNode.appendChild(node); return node; }; const insertFormatNodesIntoCaretContainer = (formatNodes: Node[], caretContainer: Node) => { const innerMostFormatNode = Arr.foldr(formatNodes, (parentNode, formatNode) => { return appendNode(parentNode, formatNode.cloneNode(false)); }, caretContainer); const doc = innerMostFormatNode.ownerDocument ?? document; return appendNode(innerMostFormatNode, doc.createTextNode(ZWSP)); }; const cleanFormatNode = (editor: Editor, caretContainer: Node, formatNode: Element, name: string, vars?: FormatVars, similar?: boolean): Optional => { const formatter = editor.formatter; const dom = editor.dom; // Find all formats present on the format node const validFormats = Arr.filter(Obj.keys(formatter.get()), (formatName) => formatName !== name && !Strings.contains(formatName, 'removeformat')); const matchedFormats = MatchFormat.matchAllOnNode(editor, formatNode, validFormats); // Filter out any matched formats that are 'visually' equivalent to the 'name' format since they are not unique formats on the node const uniqueFormats = Arr.filter(matchedFormats, (fmtName) => !FormatUtils.areSimilarFormats(editor, fmtName, name)); // If more than one format is present, then there's additional formats that should be retained. So clone the node, // remove the format and then return cleaned format node if (uniqueFormats.length > 0) { const clonedFormatNode = formatNode.cloneNode(false) as Element; dom.add(caretContainer, clonedFormatNode); formatter.remove(name, vars, clonedFormatNode, similar); dom.remove(clonedFormatNode); return Optional.some(clonedFormatNode); } else { return Optional.none(); } }; const applyCaretFormat = (editor: Editor, name: string, vars?: FormatVars): void => { let caretContainer: Node | null; const selection = editor.selection; const formatList = editor.formatter.get(name); if (!formatList) { return; } const selectionRng = selection.getRng(); let offset = selectionRng.startOffset; const container = selectionRng.startContainer; const text = container.nodeValue; caretContainer = getParentCaretContainer(editor.getBody(), selection.getStart()); // Expand to word if caret is in the middle of a text node and the char before/after is a alpha numeric character const wordcharRegex = /[^\s\u00a0\u00ad\u200b\ufeff]/; if (text && offset > 0 && offset < text.length && wordcharRegex.test(text.charAt(offset)) && wordcharRegex.test(text.charAt(offset - 1))) { // Get bookmark of caret position const bookmark = selection.getBookmark(); // Collapse bookmark range (WebKit) selectionRng.collapse(true); // Expand the range to the closest word and split it at those points let rng = ExpandRange.expandRng(editor.dom, selectionRng, formatList); rng = SplitRange.split(rng); // Apply the format to the range editor.formatter.apply(name, vars, rng); // Move selection back to caret position selection.moveToBookmark(bookmark); } else { let textNode = caretContainer ? findFirstTextNode(caretContainer) : null; if (!caretContainer || textNode?.data !== ZWSP) { // Need to import the node into the document on IE or we get a lovely WrongDocument exception caretContainer = importNode(editor.getDoc(), createCaretContainer(true).dom); textNode = caretContainer.firstChild as Text; selectionRng.insertNode(caretContainer); offset = 1; editor.formatter.apply(name, vars, caretContainer); } else { editor.formatter.apply(name, vars, caretContainer); } // Move selection to text node selection.setCursorLocation(textNode, offset); } }; const removeCaretFormat = (editor: Editor, name: string, vars?: FormatVars, similar?: boolean): void => { const dom = editor.dom; const selection = editor.selection; let hasContentAfter = false; const formatList = editor.formatter.get(name); if (!formatList) { return; } const rng = selection.getRng(); const container = rng.startContainer; const offset = rng.startOffset; let node: Node | null = container; if (NodeType.isText(container)) { if (offset !== container.data.length) { hasContentAfter = true; } node = node.parentNode; } const parents: Node[] = []; let formatNode: Element | undefined; while (node) { if (MatchFormat.matchNode(editor, node, name, vars, similar)) { formatNode = node as Element; break; } if (node.nextSibling) { hasContentAfter = true; } parents.push(node); node = node.parentNode; } // Node doesn't have the specified format if (!formatNode) { return; } // Is there contents after the caret then remove the format on the element if (hasContentAfter) { const bookmark = selection.getBookmark(); // Collapse bookmark range (WebKit) rng.collapse(true); // Expand the range to the closest word and split it at those points let expandedRng = ExpandRange.expandRng(dom, rng, formatList, true); expandedRng = SplitRange.split(expandedRng); // TODO: Figure out how on earth this works, as it shouldn't since remove format // definitely seems to require an actual Range editor.formatter.remove(name, vars, expandedRng as Range, similar); selection.moveToBookmark(bookmark); } else { const caretContainer = getParentCaretContainer(editor.getBody(), formatNode); const newCaretContainer = createCaretContainer(false).dom; insertCaretContainerNode(editor, newCaretContainer, caretContainer ?? formatNode); const cleanedFormatNode = cleanFormatNode(editor, newCaretContainer, formatNode, name, vars, similar); const caretTextNode = insertFormatNodesIntoCaretContainer(parents.concat(cleanedFormatNode.toArray()), newCaretContainer); if (caretContainer) { removeCaretContainerNode(editor, caretContainer, false); } selection.setCursorLocation(caretTextNode, 1); if (dom.isEmpty(formatNode)) { dom.remove(formatNode); } } }; const disableCaretContainer = (editor: Editor, keyCode: number) => { const selection = editor.selection, body = editor.getBody(); removeCaretContainer(editor, null, false); // Remove caret container if it's empty if ((keyCode === 8 || keyCode === 46) && selection.isCollapsed() && selection.getStart().innerHTML === ZWSP) { removeCaretContainer(editor, getParentCaretContainer(body, selection.getStart())); } // Remove caret container on keydown and it's left/right arrow keys if (keyCode === 37 || keyCode === 39) { removeCaretContainer(editor, getParentCaretContainer(body, selection.getStart())); } }; const setup = (editor: Editor): void => { editor.on('mouseup keydown', (e) => { disableCaretContainer(editor, e.keyCode); }); }; const createCaretFormat = (formatNodes: Node[]): { caretContainer: SugarElement; caretPosition: CaretPosition; } => { const caretContainer = createCaretContainer(false); const innerMost = insertFormatNodesIntoCaretContainer(formatNodes, caretContainer.dom); return { caretContainer, caretPosition: CaretPosition(innerMost, 0) }; }; const replaceWithCaretFormat = (targetNode: Node, formatNodes: Node[]): CaretPosition => { const { caretContainer, caretPosition } = createCaretFormat(formatNodes); Insert.before(SugarElement.fromDom(targetNode), caretContainer); Remove.remove(SugarElement.fromDom(targetNode)); return caretPosition; }; const createCaretFormatAtStart = (editor: Editor, formatNodes: Node[]): CaretPosition => { const { caretContainer, caretPosition } = createCaretFormat(formatNodes); editor.selection.getRng().insertNode(caretContainer.dom); return caretPosition; }; const isFormatElement = (editor: Editor, element: SugarElement): boolean => { const inlineElements = editor.schema.getTextInlineElements(); return Obj.has(inlineElements, SugarNode.name(element)) && !isCaretNode(element.dom) && !NodeType.isBogus(element.dom); }; const isEmptyCaretFormatElement = (element: SugarElement): boolean => { return isCaretNode(element.dom) && isCaretContainerEmpty(element.dom); }; export { setup, applyCaretFormat, removeCaretFormat, replaceWithCaretFormat, createCaretFormatAtStart, isFormatElement, isEmptyCaretFormatElement };