import { isBlock } from "./blocks"; import { CTGROUPS, CTYPES, ctypeToString } from "./content_types"; import { isInPre, isVisible, isWhitespace, whitespace } from "./dom_info"; import { PATH_END_REASONS, ancestors, closestElement, closestPath, createDOMPathGenerator, } from "./dom_traversal"; import { DIRECTIONS, leftPos, rightPos } from "./position"; const prepareUpdateLockedEditables = new Set(); /** * Any editor command is applied to a selection (collapsed or not). After the * command, the content type on the selection boundaries, in both direction, * should be preserved (some whitespace should disappear as went from collapsed * to non collapsed, or converted to   as went from non collapsed to * collapsed, there also
to remove/duplicate, etc). * * This function returns a callback which allows to do that after the command * has been done. * * Note: the method has been made generic enough to work with non-collapsed * selection but can be used for an unique cursor position. * * @param {HTMLElement} el * @param {number} offset * @param {...(HTMLElement|number)} args - argument 1 and 2 can be repeated for * multiple preparations with only one restore callback returned. Note: in * that case, the positions should be given in the document node order. * @param {Object} [options] * @param {boolean} [options.allowReenter = true] - if false, all calls to * prepareUpdate before this one gets restored will be ignored. * @param {string} [options.label = ] * @param {boolean} [options.debug = false] - if true, adds nicely formatted * console logs to help with debugging. * @returns {function} */ export function prepareUpdate(...args) { const closestRoot = args.length && ancestors(args[0]).find((ancestor) => ancestor.classList.contains("odoo-editor-editable")); const isPrepareUpdateLocked = closestRoot && prepareUpdateLockedEditables.has(closestRoot); const hash = (Math.random() + 1).toString(36).substring(7); const options = { allowReenter: true, label: hash, debug: false, ...(args.length && args[args.length - 1] instanceof Object ? args.pop() : {}), }; if (options.debug) { console.log( "%cPreparing%c update: " + options.label + (options.label === hash ? "" : ` (${hash})`) + "%c" + (isPrepareUpdateLocked ? " LOCKED" : ""), "color: cyan;", "color: white;", "color: red; font-weight: bold;" ); } if (isPrepareUpdateLocked) { return () => { if (options.debug) { console.log( "%cRestoring%c update: " + options.label + (options.label === hash ? "" : ` (${hash})`) + "%c LOCKED", "color: lightgreen;", "color: white;", "color: red; font-weight: bold;" ); } }; } if (!options.allowReenter && closestRoot) { prepareUpdateLockedEditables.add(closestRoot); } const positions = [...args]; // Check the state in each direction starting from each position. const restoreData = []; let el, offset; while (positions.length) { // Note: important to get the positions in reverse order to restore // right side before left side. offset = positions.pop(); el = positions.pop(); const left = getState(el, offset, DIRECTIONS.LEFT); const right = getState(el, offset, DIRECTIONS.RIGHT, left.cType); if (options.debug) { const editable = el && closestElement(el, ".odoo-editor-editable"); const oldEditableHTML = (editable && editable.innerHTML.replaceAll(" ", "_").replaceAll("\u200B", "ZWS")) || ""; left.oldEditableHTML = oldEditableHTML; right.oldEditableHTML = oldEditableHTML; } restoreData.push(left, right); } // Create the callback that will be able to restore the state in each // direction wherever the node in the opposite direction has landed. return function restoreStates() { if (options.debug) { console.log( "%cRestoring%c update: " + options.label + (options.label === hash ? "" : ` (${hash})`), "color: lightgreen;", "color: white;" ); } for (const data of restoreData) { restoreState(data, options.debug); } if (!options.allowReenter && closestRoot) { prepareUpdateLockedEditables.delete(closestRoot); } }; } export const leftLeafOnlyNotBlockPath = createDOMPathGenerator(DIRECTIONS.LEFT, { leafOnly: true, stopTraverseFunction: isBlock, stopFunction: isBlock, }); const rightLeafOnlyNotBlockPath = createDOMPathGenerator(DIRECTIONS.RIGHT, { leafOnly: true, stopTraverseFunction: isBlock, stopFunction: isBlock, }); /** * Retrieves the "state" from a given position looking at the given direction. * The "state" is the type of content. The functions also returns the first * meaninful node looking in the opposite direction = the first node we trust * will not disappear if a command is played in the given direction. * * Note: only work for in-between nodes positions. If the position is inside a * text node, first split it @see splitTextNode. * * @param {HTMLElement} el * @param {number} offset * @param {boolean} direction @see DIRECTIONS.LEFT @see DIRECTIONS.RIGHT * @param {CTYPES} [leftCType] * @returns {Object} */ export function getState(el, offset, direction, leftCType) { const leftDOMPath = leftLeafOnlyNotBlockPath; const rightDOMPath = rightLeafOnlyNotBlockPath; let domPath; let inverseDOMPath; const whitespaceAtStartRegex = new RegExp("^" + whitespace + "+"); const whitespaceAtEndRegex = new RegExp(whitespace + "+$"); const reasons = []; if (direction === DIRECTIONS.LEFT) { domPath = leftDOMPath(el, offset, reasons); inverseDOMPath = rightDOMPath(el, offset); } else { domPath = rightDOMPath(el, offset, reasons); inverseDOMPath = leftDOMPath(el, offset); } // TODO I think sometimes, the node we have to consider as the // anchor point to restore the state is not the first one of the inverse // path (like for example, empty text nodes that may disappear // after the command so we would not want to get those ones). const boundaryNode = inverseDOMPath.next().value; // We only traverse through deep inline nodes. If we cannot find a // meanfingful state between them, that means we hit a block. let cType = undefined; // Traverse the DOM in the given direction to check what type of content // there is. let lastSpace = null; for (const node of domPath) { if (node.nodeType === Node.TEXT_NODE) { const value = node.nodeValue; // If we hit a text node, the state depends on the path direction: // any space encountered backwards is a visible space if we hit // visible content afterwards. If going forward, spaces are only // visible if we have content backwards. if (direction === DIRECTIONS.LEFT) { if (!isWhitespace(value)) { if (lastSpace) { cType = CTYPES.SPACE; } else { const rightLeaf = rightLeafOnlyNotBlockPath(node).next().value; const hasContentRight = rightLeaf && !whitespaceAtStartRegex.test(rightLeaf.textContent); cType = !hasContentRight && whitespaceAtEndRegex.test(node.textContent) ? CTYPES.SPACE : CTYPES.CONTENT; } break; } if (value.length) { lastSpace = node; } } else { leftCType = leftCType || getState(el, offset, DIRECTIONS.LEFT).cType; if (whitespaceAtStartRegex.test(value)) { const leftLeaf = leftLeafOnlyNotBlockPath(node).next().value; const hasContentLeft = leftLeaf && !whitespaceAtEndRegex.test(leftLeaf.textContent); const rct = !isWhitespace(value) ? CTYPES.CONTENT : getState(...rightPos(node), DIRECTIONS.RIGHT).cType; cType = leftCType & CTYPES.CONTENT && rct & (CTYPES.CONTENT | CTYPES.BR) && !hasContentLeft ? CTYPES.SPACE : rct; break; } if (!isWhitespace(value)) { cType = CTYPES.CONTENT; break; } } } else if (node.nodeName === "BR") { cType = CTYPES.BR; break; } else if (isVisible(node)) { // E.g. an image cType = CTYPES.CONTENT; break; } } if (cType === undefined) { cType = reasons.includes(PATH_END_REASONS.BLOCK_HIT) ? CTYPES.BLOCK_OUTSIDE : CTYPES.BLOCK_INSIDE; } return { node: boundaryNode, direction: direction, cType: cType, // Short for contentType }; } const priorityRestoreStateRules = [ // Each entry is a list of two objects, with each key being optional (the // more key-value pairs, the bigger the priority). // {direction: ..., cType1: ..., cType2: ...} // -> // {spaceVisibility: (false|true), brVisibility: (false|true)} [ // Replace a space by   when it was not collapsed before and now is // collapsed (one-letter word removal for example). { cType1: CTYPES.CONTENT, cType2: CTYPES.SPACE | CTGROUPS.BLOCK }, { spaceVisibility: true }, ], [ // Replace a space by   when it was content before and now it is // a BR. { direction: DIRECTIONS.LEFT, cType1: CTGROUPS.INLINE, cType2: CTGROUPS.BR }, { spaceVisibility: true }, ], [ // Replace a space by   when it was content before and now it is // a BR (removal of last character before a BR for example). { direction: DIRECTIONS.RIGHT, cType1: CTGROUPS.CONTENT, cType2: CTGROUPS.BR }, { spaceVisibility: true }, ], [ // Replace a space by   when it was visible thanks to a BR which // is now gone. { direction: DIRECTIONS.RIGHT, cType1: CTGROUPS.BR, cType2: CTYPES.SPACE | CTGROUPS.BLOCK }, { spaceVisibility: true }, ], [ // Remove all collapsed spaces when a space is removed. { cType1: CTYPES.SPACE }, { spaceVisibility: false }, ], [ // Remove spaces once the preceeding BR is removed { direction: DIRECTIONS.LEFT, cType1: CTGROUPS.BR }, { spaceVisibility: false }, ], [ // Remove space before block once content is put after it (otherwise it // would become visible). { cType1: CTGROUPS.BLOCK, cType2: CTGROUPS.INLINE | CTGROUPS.BR }, { spaceVisibility: false }, ], [ // Duplicate a BR once the content afterwards disappears { direction: DIRECTIONS.RIGHT, cType1: CTGROUPS.INLINE, cType2: CTGROUPS.BLOCK }, { brVisibility: true }, ], [ // Remove a BR at the end of a block once inline content is put after // it (otherwise it would act as a line break). { direction: DIRECTIONS.RIGHT, cType1: CTGROUPS.BLOCK, cType2: CTGROUPS.INLINE | CTGROUPS.BR, }, { brVisibility: false }, ], [ // Remove a BR once the BR that preceeds it is now replaced by // content (or if it was a BR at the start of a block which now is // a trailing BR). { direction: DIRECTIONS.LEFT, cType1: CTGROUPS.BR | CTGROUPS.BLOCK, cType2: CTGROUPS.INLINE, }, { brVisibility: false, extraBRRemovalCondition: (brNode) => isFakeLineBreak(brNode) }, ], ]; function restoreStateRuleHashCode(direction, cType1, cType2) { return `${direction}-${cType1}-${cType2}`; } const allRestoreStateRules = (function () { const map = new Map(); const keys = ["direction", "cType1", "cType2"]; for (const direction of Object.values(DIRECTIONS)) { for (const cType1 of Object.values(CTYPES)) { for (const cType2 of Object.values(CTYPES)) { const rule = { direction: direction, cType1: cType1, cType2: cType2 }; // Search for the rules which match whatever their priority const matchedRules = []; for (const entry of priorityRestoreStateRules) { let priority = 0; for (const key of keys) { const entryKeyValue = entry[0][key]; if (entryKeyValue !== undefined) { if ( typeof entryKeyValue === "boolean" ? rule[key] === entryKeyValue : rule[key] & entryKeyValue ) { priority++; } else { priority = -1; break; } } } if (priority >= 0) { matchedRules.push([priority, entry[1]]); } } // Create the final rule by merging found rules by order of // priority const finalRule = {}; for (let p = 0; p <= keys.length; p++) { for (const entry of matchedRules) { if (entry[0] === p) { Object.assign(finalRule, entry[1]); } } } // Create an unique identifier for the set of values // direction - state 1 - state2 to add the rule in the map const hashCode = restoreStateRuleHashCode(direction, cType1, cType2); map.set(hashCode, finalRule); } } } return map; })(); /** * Restores the given state starting before the given while looking in the given * direction. * * @param {Object} prevStateData @see getState * @param {boolean} debug=false - if true, adds nicely formatted * console logs to help with debugging. * @returns {Object|undefined} the rule that was applied to restore the state, * if any, for testing purposes. */ export function restoreState(prevStateData, debug = false) { const { node, direction, cType: cType1, oldEditableHTML } = prevStateData; if (!node || !node.parentNode) { // FIXME sometimes we want to restore the state starting from a node // which has been removed by another restoreState call... Not sure if // it is a problem or not, to investigate. return; } const [el, offset] = direction === DIRECTIONS.LEFT ? leftPos(node) : rightPos(node); const { cType: cType2 } = getState(el, offset, direction); /** * Knowing the old state data and the new state data, we know if we have to * do something or not, and what to do. */ const ruleHashCode = restoreStateRuleHashCode(direction, cType1, cType2); const rule = allRestoreStateRules.get(ruleHashCode); if (debug) { const editable = closestElement(node, ".odoo-editor-editable"); console.log( "%c" + node.textContent.replaceAll(" ", "_").replaceAll("\u200B", "ZWS") + "\n" + "%c" + (direction === DIRECTIONS.LEFT ? "left" : "right") + "\n" + "%c" + ctypeToString(cType1) + "\n" + "%c" + ctypeToString(cType2) + "\n" + "%c" + "BEFORE: " + (oldEditableHTML || "(unavailable)") + "\n" + "%c" + "AFTER: " + (editable ? editable.innerHTML.replaceAll(" ", "_").replaceAll("\u200B", "ZWS") : "(unavailable)") + "\n", "color: white; display: block; width: 100%;", "color: " + (direction === DIRECTIONS.LEFT ? "magenta" : "lightgreen") + "; display: block; width: 100%;", "color: pink; display: block; width: 100%;", "color: lightblue; display: block; width: 100%;", "color: white; display: block; width: 100%;", "color: white; display: block; width: 100%;", rule ); } if (Object.values(rule).filter((x) => x !== undefined).length) { const inverseDirection = direction === DIRECTIONS.LEFT ? DIRECTIONS.RIGHT : DIRECTIONS.LEFT; enforceWhitespace(el, offset, inverseDirection, rule); } return rule; } /** * Returns whether or not the given node is a BR element which does not really * act as a line break, but as a placeholder for the cursor or to make some left * element (like a space) visible. * @todo @phoenix this depends on state, so hard to move it to dom_info * * @param {HTMLBRElement} brEl * @returns {boolean} */ export function isFakeLineBreak(brEl) { return !(getState(...rightPos(brEl), DIRECTIONS.RIGHT).cType & (CTYPES.CONTENT | CTGROUPS.BR)); } /** * Enforces the whitespace and BR visibility in the given direction starting * from the given position. * * @param {HTMLElement} el * @param {number} offset * @param {number} direction @see DIRECTIONS.LEFT @see DIRECTIONS.RIGHT * @param {Object} rule * @param {boolean} [rule.spaceVisibility] * @param {boolean} [rule.brVisibility] */ export function enforceWhitespace(el, offset, direction, rule) { const document = el.ownerDocument; let domPath, whitespaceAtEdgeRegex; if (direction === DIRECTIONS.LEFT) { domPath = leftLeafOnlyNotBlockPath(el, offset); whitespaceAtEdgeRegex = new RegExp(whitespace + "+$"); } else { domPath = rightLeafOnlyNotBlockPath(el, offset); whitespaceAtEdgeRegex = new RegExp("^" + whitespace + "+"); } const invisibleSpaceTextNodes = []; let foundVisibleSpaceTextNode = null; for (const node of domPath) { if (node.nodeName === "BR") { if (rule.brVisibility === undefined) { break; } if (rule.brVisibility) { node.before(document.createElement("br")); } else { if (!rule.extraBRRemovalCondition || rule.extraBRRemovalCondition(node)) { node.remove(); } } break; } else if (node.nodeType === Node.TEXT_NODE && !isInPre(node)) { if (whitespaceAtEdgeRegex.test(node.nodeValue)) { // If we hit spaces going in the direction, either they are in a // visible text node and we have to change the visibility of // those spaces, or it is in an invisible text node. In that // last case, we either remove the spaces if there are spaces in // a visible text node going further in the direction or we // change the visiblity or those spaces. if (!isWhitespace(node)) { foundVisibleSpaceTextNode = node; break; } else { invisibleSpaceTextNodes.push(node); } } else if (!isWhitespace(node)) { break; } } else { break; } } if (rule.spaceVisibility === undefined) { return; } if (!rule.spaceVisibility) { for (const node of invisibleSpaceTextNodes) { // Empty and not remove to not mess with offset-based positions in // commands implementation, also remove non-block empty parents. node.nodeValue = ""; const ancestorPath = closestPath(node.parentNode); let toRemove = null; for (const pNode of ancestorPath) { if (toRemove) { toRemove.remove(); } if (pNode.childNodes.length === 1 && !isBlock(pNode)) { pNode.after(node); toRemove = pNode; } else { break; } } } } const spaceNode = foundVisibleSpaceTextNode || invisibleSpaceTextNodes[0]; if (spaceNode) { let spaceVisibility = rule.spaceVisibility; // In case we are asked to replace the space by a  , disobey and // do the opposite if that space is currently not visible // TODO I'd like this to not be needed, it feels wrong... if ( spaceVisibility && !foundVisibleSpaceTextNode && getState(...rightPos(spaceNode), DIRECTIONS.RIGHT).cType & CTGROUPS.BLOCK && getState(...leftPos(spaceNode), DIRECTIONS.LEFT).cType !== CTYPES.CONTENT ) { spaceVisibility = false; } spaceNode.nodeValue = spaceNode.nodeValue.replace( whitespaceAtEdgeRegex, spaceVisibility ? "\u00A0" : "" ); } }