281 lines
9.5 KiB
JavaScript
281 lines
9.5 KiB
JavaScript
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 <br> 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 <br> 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
|
|
* <br> must be wrapped in a paragraph by the caller if necessary.
|
|
*
|
|
* @param {HTMLElement} el
|
|
* @returns {Object} { br: the inserted <br> 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;
|
|
}
|