import { closestBlock, isBlock } from "./blocks"; import { isShrunkBlock, isVisible, paragraphRelatedElements } from "./dom_info"; import { callbacksForCursorUpdate } from "./selection"; import { isEmptyBlock, isPhrasingContent } from "../utils/dom_info"; import { childNodes } from "./dom_traversal"; import { childNodeIndex, DIRECTIONS } from "./position"; /** @typedef {import("@html_editor/core/selection_plugin").Cursors} Cursors */ /** * Take a node and unwrap all of its block contents recursively. All blocks * (except for firstChilds) are preceded by a
in order to preserve the line * breaks. * * @param {Node} node */ export function makeContentsInline(node) { const document = node.ownerDocument; let childIndex = 0; for (const child of node.childNodes) { if (isBlock(child)) { if (childIndex && paragraphRelatedElements.includes(child.nodeName)) { child.before(document.createElement("br")); } for (const grandChild of child.childNodes) { child.before(grandChild); makeContentsInline(grandChild); } child.remove(); } childIndex += 1; } } /** * Wrap inline children nodes in Blocks, optionally updating cursors for * later selection restore. A paragraph is used for phrasing node, and a div * is used otherwise. * * @param {HTMLElement} element - block element * @param {Cursors} [cursors] */ export function wrapInlinesInBlocks(element, cursors = { update: () => {} }) { // Helpers to manipulate preserving selection. const wrapInBlock = (node, cursors) => { const block = isPhrasingContent(node) ? node.ownerDocument.createElement("P") : node.ownerDocument.createElement("DIV"); cursors.update(callbacksForCursorUpdate.before(node, block)); node.before(block); cursors.update(callbacksForCursorUpdate.append(block, node)); block.append(node); return block; }; const appendToCurrentBlock = (currentBlock, node, cursors) => { if (currentBlock.tagName === "P" && !isPhrasingContent(node)) { const block = document.createElement("DIV"); cursors.update(callbacksForCursorUpdate.before(currentBlock, block)); currentBlock.before(block); for (const child of [...currentBlock.childNodes]) { cursors.update(callbacksForCursorUpdate.append(block, child)); block.append(child); } cursors.update(callbacksForCursorUpdate.remove(currentBlock)); currentBlock.remove(); currentBlock = block; } cursors.update(callbacksForCursorUpdate.append(currentBlock, node)); currentBlock.append(node); return currentBlock; }; const removeNode = (node, cursors) => { cursors.update(callbacksForCursorUpdate.remove(node)); node.remove(); }; let currentBlock; let shouldBreakLine = true; for (const node of [...element.childNodes]) { if (isBlock(node)) { shouldBreakLine = true; } else if (!isVisible(node)) { removeNode(node, cursors); } else if (node.nodeName === "BR") { if (shouldBreakLine) { wrapInBlock(node, cursors); } else { // BR preceded by inline content: discard it and make sure // next inline goes in a new Block removeNode(node, cursors); shouldBreakLine = true; } } else if (shouldBreakLine) { currentBlock = wrapInBlock(node, cursors); shouldBreakLine = false; } else { currentBlock = appendToCurrentBlock(currentBlock, node, cursors); } } } export function unwrapContents(node) { const contents = childNodes(node); for (const child of contents) { node.parentNode.insertBefore(child, node); } node.parentNode.removeChild(node); return contents; } // @todo @phoenix // This utils seem to handle a particular case of LI element. // If only relevant to the list plugin, a specific util should be created // that plugin instead. export function setTagName(el, newTagName) { const document = el.ownerDocument; if (el.tagName === newTagName) { return el; } const newEl = document.createElement(newTagName); while (el.firstChild) { newEl.append(el.firstChild); } if (el.tagName === "LI") { el.append(newEl); } else { for (const attribute of el.attributes) { newEl.setAttribute(attribute.name, attribute.value); } el.parentNode.replaceChild(newEl, el); } return newEl; } /** * Removes the specified class names from the given element. If the element has * no more class names after removal, the "class" attribute is removed. * * @param {Element} element - The element from which to remove the class names. * @param {...string} classNames - The class names to be removed. */ export function removeClass(element, ...classNames) { element.classList.remove(...classNames); if (!element.classList.length) { element.removeAttribute("class"); } } /** * Add a BR in the given node if its closest ancestor block has nothing to make * it visible, and/or add a zero-width space in the given node if it's an empty * inline so the cursor can stay in it. * * @param {HTMLElement} el * @returns {Object} { br: the inserted
if any, * zws: the inserted zero-width space if any } */ export function fillEmpty(el) { const document = el.ownerDocument; const fillers = { ...fillShrunkPhrasingParent(el) }; if (!isBlock(el) && !isVisible(el) && !el.hasAttribute("data-oe-zws-empty-inline")) { const zws = document.createTextNode("\u200B"); el.appendChild(zws); el.setAttribute("data-oe-zws-empty-inline", ""); fillers.zws = zws; const previousSibling = el.previousSibling; if (previousSibling && previousSibling.nodeName === "BR") { previousSibling.remove(); } } return fillers; } /** * Add a BR in a shrunk phrasing parent to make it visible. * A shrunk block is assumed to be a phrasing parent, and the inserted *
must be wrapped in a paragraph by the caller if necessary. * * @param {HTMLElement} el * @returns {Object} { br: the inserted
if any } */ export function fillShrunkPhrasingParent(el) { const document = el.ownerDocument; const fillers = {}; const blockEl = closestBlock(el); if (isShrunkBlock(blockEl)) { const br = document.createElement("br"); blockEl.appendChild(br); fillers.br = br; } return fillers; } /** * Removes a trailing BR if it is unnecessary: * in a non-empty block, if the last childNode is a BR and its previous sibling * is not a BR, remove the BR. * * @param {HTMLElement} el * @returns {HTMLElement|undefined} the removed br, if any */ export function cleanTrailingBR(el) { const candidate = el?.lastChild; if ( candidate?.nodeName === "BR" && candidate.previousSibling?.nodeName !== "BR" && !isEmptyBlock(el) ) { candidate.remove(); return candidate; } } export function toggleClass(node, className) { node.classList.toggle(className); if (!node.className) { node.removeAttribute("class"); } } /** * Remove all occurrences of a character from a text node and optionally update * cursors for later selection restore. * * @param {Node} node text node * @param {String} char character to remove (string of length 1) * @param {Cursors} [cursors] */ export function cleanTextNode(node, char, cursors) { const removedIndexes = []; node.textContent = node.textContent.replaceAll(char, (_, offset) => { removedIndexes.push(offset); return ""; }); cursors?.update((cursor) => { if (cursor.node === node) { cursor.offset -= removedIndexes.filter((index) => cursor.offset > index).length; } }); } /** * Splits a text node in two parts. * If the split occurs at the beginning or the end, the text node stays * untouched and unsplit. If a split actually occurs, the original text node * still exists and become the right part of the split. * * Note: if split after or before whitespace, that whitespace may become * invisible, it is up to the caller to replace it by nbsp if needed. * * @param {Text} textNode * @param {number} offset * @param {boolean} originalNodeSide Whether the original node ends up on left * or right after the split * @returns {number} The parentOffset if the cursor was between the two text * node parts after the split. */ export function splitTextNode(textNode, offset, originalNodeSide = DIRECTIONS.RIGHT) { const document = textNode.ownerDocument; let parentOffset = childNodeIndex(textNode); if (offset > 0) { parentOffset++; if (offset < textNode.length) { const left = textNode.nodeValue.substring(0, offset); const right = textNode.nodeValue.substring(offset); if (originalNodeSide === DIRECTIONS.LEFT) { const newTextNode = document.createTextNode(right); textNode.after(newTextNode); textNode.nodeValue = left; } else { const newTextNode = document.createTextNode(left); textNode.before(newTextNode); textNode.nodeValue = right; } } } return parentOffset; }