503 lines
20 KiB
JavaScript
503 lines
20 KiB
JavaScript
/**
|
|
* DOM range helpers for applying bold, italic, underline, and alignment inside
|
|
* the active contenteditable selection.
|
|
*/
|
|
import {
|
|
getTextNodesInRange,
|
|
isNodeBold,
|
|
isNodeItalic,
|
|
isNodeUnderlined,
|
|
} from '../../utils/inline-style-state.js';
|
|
|
|
function hasAncestorTag(textNode, tagNames, stopAt) {
|
|
let element = textNode.parentElement;
|
|
while (element && element !== stopAt) {
|
|
if (tagNames.includes(element.tagName.toLowerCase())) return true;
|
|
element = element.parentElement;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isFullyWrapped(range, tagNames, editTarget) {
|
|
if (range.collapsed) return false;
|
|
const nodes = getTextNodesInRange(range);
|
|
return nodes.length > 0 && nodes.every(node => hasAncestorTag(node, tagNames, editTarget));
|
|
}
|
|
|
|
/**
|
|
* Wraps a range in an element, with optional inline styles.
|
|
* @param {Range} range
|
|
* @param {string} tagName
|
|
* @param {object} [styleProperties]
|
|
* @returns {HTMLElement}
|
|
*/
|
|
export function wrapRange(range, tagName, styleProperties) {
|
|
const wrapper = document.createElement(tagName);
|
|
if (styleProperties) Object.assign(wrapper.style, styleProperties);
|
|
try {
|
|
range.surroundContents(wrapper);
|
|
} catch {
|
|
wrapper.appendChild(range.extractContents());
|
|
range.insertNode(wrapper);
|
|
}
|
|
if (!styleProperties) {
|
|
wrapper.querySelectorAll(tagName).forEach(element => element.replaceWith(...element.childNodes));
|
|
wrapper.normalize();
|
|
}
|
|
return wrapper;
|
|
}
|
|
|
|
function restoreSelectionOver(firstNode, lastNode) {
|
|
if (!firstNode || !lastNode) return;
|
|
try {
|
|
const newRange = document.createRange();
|
|
if (firstNode.nodeType === Node.TEXT_NODE) {
|
|
newRange.setStart(firstNode, 0);
|
|
} else {
|
|
newRange.setStartBefore(firstNode);
|
|
}
|
|
if (lastNode.nodeType === Node.TEXT_NODE) {
|
|
newRange.setEnd(lastNode, lastNode.length);
|
|
} else {
|
|
newRange.setEndAfter(lastNode);
|
|
}
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(newRange);
|
|
} catch { /* ignore invalid range errors */ }
|
|
}
|
|
|
|
function ancestorDepthWithin(element, editTarget) {
|
|
let depth = 0;
|
|
let current = element;
|
|
while (current && current !== editTarget) {
|
|
depth += 1;
|
|
current = current.parentElement;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
/**
|
|
* Removes a single inline wrapper element over the portion covered by `range`,
|
|
* keeping the parts outside the range wrapped and preserving every descendant
|
|
* (e.g. a nested `<strong>`) of the in-range portion. Splitting the element when
|
|
* the selection only partially covers it.
|
|
* @param {HTMLElement} element
|
|
* @param {Range} range
|
|
*/
|
|
function unwrapElementOverRange(element, range) {
|
|
const elementContents = document.createRange();
|
|
elementContents.selectNodeContents(element);
|
|
|
|
const selectionStartsInside = range.compareBoundaryPoints(Range.START_TO_START, elementContents) > 0;
|
|
const selectionEndsInside = range.compareBoundaryPoints(Range.END_TO_END, elementContents) < 0;
|
|
|
|
if (selectionEndsInside) {
|
|
const afterRange = document.createRange();
|
|
afterRange.setStart(range.endContainer, range.endOffset);
|
|
afterRange.setEnd(elementContents.endContainer, elementContents.endOffset);
|
|
const afterContent = afterRange.extractContents();
|
|
if (afterContent.textContent !== '') {
|
|
const afterClone = element.cloneNode(false);
|
|
afterClone.appendChild(afterContent);
|
|
element.after(afterClone);
|
|
}
|
|
}
|
|
|
|
if (selectionStartsInside) {
|
|
const beforeRange = document.createRange();
|
|
beforeRange.setStart(elementContents.startContainer, elementContents.startOffset);
|
|
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
const beforeContent = beforeRange.extractContents();
|
|
if (beforeContent.textContent !== '') {
|
|
const beforeClone = element.cloneNode(false);
|
|
beforeClone.appendChild(beforeContent);
|
|
element.before(beforeClone);
|
|
}
|
|
}
|
|
|
|
element.replaceWith(...element.childNodes);
|
|
}
|
|
|
|
function unwrapTagsInRange(range, tagNames, editTarget) {
|
|
const tagSet = tagNames.map(tag => tag.toLowerCase());
|
|
const textNodes = getTextNodesInRange(range);
|
|
const firstTextNode = textNodes[0] ?? null;
|
|
const lastTextNode = textNodes[textNodes.length - 1] ?? null;
|
|
|
|
const targetElements = [];
|
|
for (const textNode of textNodes) {
|
|
let element = textNode.parentElement;
|
|
while (element && element !== editTarget) {
|
|
if (tagSet.includes(element.tagName?.toLowerCase()) && !targetElements.includes(element)) {
|
|
targetElements.push(element);
|
|
}
|
|
element = element.parentElement;
|
|
}
|
|
}
|
|
|
|
// Unwrap the deepest wrappers first so re-parenting never shifts the
|
|
// boundaries of a shallower wrapper still waiting to be processed.
|
|
targetElements.sort((a, b) => ancestorDepthWithin(b, editTarget) - ancestorDepthWithin(a, editTarget));
|
|
for (const element of targetElements) {
|
|
unwrapElementOverRange(element, range);
|
|
}
|
|
|
|
restoreSelectionOver(firstTextNode, lastTextNode);
|
|
editTarget.normalize();
|
|
}
|
|
|
|
/**
|
|
* Returns the user's active text selection inside the edit target.
|
|
* When the caret is collapsed (no highlighted text), the entire element's
|
|
* contents are selected so that style actions apply to all text.
|
|
* @param {HTMLElement} editTarget
|
|
* @returns {Range|null} Selection range inside the edit target.
|
|
*/
|
|
export function getActiveRange(editTarget) {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return null;
|
|
|
|
if (selection.isCollapsed) {
|
|
if (!editTarget.contains(selection.anchorNode)) return null;
|
|
const range = document.createRange();
|
|
range.selectNodeContents(editTarget);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
return range;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
if (!editTarget.contains(range.commonAncestorContainer)) return null;
|
|
return range;
|
|
}
|
|
|
|
/** Tailwind utility classes cleared when toggling bold off on an edit target. */
|
|
const TAILWIND_BOLD_CLASSES = ['font-bold', 'font-semibold', 'font-extrabold', 'font-black'];
|
|
|
|
/** Tailwind utility classes cleared when toggling italic off on an edit target. */
|
|
const TAILWIND_ITALIC_CLASSES = ['italic'];
|
|
|
|
function isFullyBold(range, editTarget) {
|
|
if (range.collapsed) return false;
|
|
const nodes = getTextNodesInRange(range);
|
|
return nodes.length > 0 && nodes.every((node) => isNodeBold(node, editTarget));
|
|
}
|
|
|
|
function isFullyItalic(range, editTarget) {
|
|
if (range.collapsed) return false;
|
|
const nodes = getTextNodesInRange(range);
|
|
return nodes.length > 0 && nodes.every((node) => isNodeItalic(node, editTarget));
|
|
}
|
|
|
|
/**
|
|
* Toggles bold on the current selection within the edit target.
|
|
* @param {HTMLElement} editTarget
|
|
*/
|
|
export function applyBold(editTarget) {
|
|
const range = getActiveRange(editTarget);
|
|
if (!range) return;
|
|
if (isFullyBold(range, editTarget)) {
|
|
unwrapTagsInRange(range, ['strong', 'b'], editTarget);
|
|
TAILWIND_BOLD_CLASSES.forEach(className => editTarget.classList.remove(className));
|
|
if (editTarget.style.fontWeight) editTarget.style.removeProperty('font-weight');
|
|
const selection = window.getSelection();
|
|
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
|
|
const selectionRange = selection.getRangeAt(0);
|
|
if (isFullyBold(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontWeight: 'normal' });
|
|
}
|
|
} else {
|
|
wrapRange(range, 'strong');
|
|
}
|
|
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
/**
|
|
* Toggles italic on the current selection within the edit target.
|
|
* @param {HTMLElement} editTarget
|
|
*/
|
|
export function applyItalic(editTarget) {
|
|
const range = getActiveRange(editTarget);
|
|
if (!range) return;
|
|
if (isFullyItalic(range, editTarget)) {
|
|
unwrapTagsInRange(range, ['em', 'i'], editTarget);
|
|
TAILWIND_ITALIC_CLASSES.forEach(className => editTarget.classList.remove(className));
|
|
if (editTarget.style.fontStyle) editTarget.style.removeProperty('font-style');
|
|
const selection = window.getSelection();
|
|
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
|
|
const selectionRange = selection.getRangeAt(0);
|
|
if (isFullyItalic(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontStyle: 'normal' });
|
|
}
|
|
} else {
|
|
wrapRange(range, 'em');
|
|
}
|
|
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
/** Tailwind utility classes cleared when toggling underline off on an edit target. */
|
|
const TAILWIND_UNDERLINE_CLASSES = ['underline'];
|
|
|
|
function isRangeFullyUnderlined(range, editTarget) {
|
|
if (range.collapsed) return false;
|
|
const nodes = getTextNodesInRange(range);
|
|
return nodes.length > 0 && nodes.every((node) => isNodeUnderlined(node, editTarget));
|
|
}
|
|
|
|
function removeExplicitUnderlineInRange(range, editTarget) {
|
|
getTextNodesInRange(range).forEach(node => {
|
|
let element = node.parentElement;
|
|
while (element && element !== editTarget) {
|
|
if (element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline')) {
|
|
element.style.removeProperty('text-decoration');
|
|
element.style.removeProperty('text-decoration-line');
|
|
}
|
|
element = element.parentElement;
|
|
}
|
|
});
|
|
}
|
|
|
|
function getContextualDecorationStyle(range) {
|
|
// When text sits between CSS-class underline siblings (after a split), inherit
|
|
// their full decoration style so re-applied underline visually matches.
|
|
const node = range.startContainer;
|
|
if (node.nodeType !== Node.TEXT_NODE) return null;
|
|
|
|
for (const sibling of [node.previousSibling, node.nextSibling]) {
|
|
if (!sibling || sibling.nodeType !== Node.ELEMENT_NODE) continue;
|
|
const computedStyle = getComputedStyle(sibling);
|
|
const textDecorationLine = computedStyle.textDecorationLine || computedStyle.textDecoration || '';
|
|
if (!textDecorationLine.includes('underline')) continue;
|
|
|
|
const style = {};
|
|
if (computedStyle.textDecorationColor) {
|
|
style.textDecorationColor = computedStyle.textDecorationColor;
|
|
}
|
|
if (computedStyle.textDecorationThickness && computedStyle.textDecorationThickness !== 'auto') {
|
|
style.textDecorationThickness = computedStyle.textDecorationThickness;
|
|
}
|
|
if (computedStyle.textDecorationStyle && computedStyle.textDecorationStyle !== 'solid') {
|
|
style.textDecorationStyle = computedStyle.textDecorationStyle;
|
|
}
|
|
return Object.keys(style).length > 0 ? style : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getEffectiveTextColor(range, editTarget) {
|
|
const nodes = getTextNodesInRange(range);
|
|
if (nodes.length === 0 && range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
nodes.push(range.startContainer);
|
|
}
|
|
if (nodes.length === 0) return null;
|
|
|
|
const containerColor = getComputedStyle(editTarget).color;
|
|
for (const textNode of nodes) {
|
|
let element = textNode.parentElement;
|
|
while (element && element !== editTarget) {
|
|
const inlineColor = element.style?.color;
|
|
if (inlineColor && inlineColor !== 'transparent') return inlineColor;
|
|
element = element.parentElement;
|
|
}
|
|
const parent = textNode.parentElement;
|
|
if (!parent) continue;
|
|
const computed = getComputedStyle(parent).color;
|
|
if (computed && computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)') {
|
|
return computed;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildUnderlineWrapStyle(range, editTarget) {
|
|
const style = getContextualDecorationStyle(range) ?? {};
|
|
const textColor = getEffectiveTextColor(range, editTarget);
|
|
if (textColor) {
|
|
style.textDecorationColor = textColor;
|
|
}
|
|
return Object.keys(style).length > 0 ? style : null;
|
|
}
|
|
|
|
/** Keeps `<u>` decoration color in sync when nested text color changes. */
|
|
export function syncUnderlineColorFromElement(element, editTarget) {
|
|
if (!element || !editTarget) return;
|
|
const inlineColor = element.style?.color;
|
|
const containerColor = getComputedStyle(editTarget).color;
|
|
const decorationColor = inlineColor
|
|
|| (() => {
|
|
const computed = getComputedStyle(element).color;
|
|
return computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)' ? computed : null;
|
|
})();
|
|
if (!decorationColor) return;
|
|
|
|
let node = element.parentElement;
|
|
while (node && node !== editTarget) {
|
|
if (node.tagName?.toLowerCase() === 'u') {
|
|
node.style.textDecorationColor = decorationColor;
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
}
|
|
|
|
function findCssUnderlineAncestor(node, editTarget) {
|
|
// Find the closest ancestor that sources underline via a CSS class, not a <u> tag
|
|
// or inline style — those are handled by other steps.
|
|
let element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
|
while (element && element !== editTarget) {
|
|
if (element.tagName?.toLowerCase() === 'u') { element = element.parentElement; continue; }
|
|
const hasInline = element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline');
|
|
if (hasInline) { element = element.parentElement; continue; }
|
|
const textDecorationLine = getComputedStyle(element).textDecorationLine || getComputedStyle(element).textDecoration || '';
|
|
if (textDecorationLine.includes('underline')) return element;
|
|
element = element.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function splitElementAroundRange(range, ancestor, editTarget) {
|
|
const fragment = range.extractContents();
|
|
const selectionFirst = fragment.firstChild;
|
|
const selectionLast = fragment.lastChild;
|
|
|
|
const parent = ancestor.parentNode;
|
|
const nextSibling = ancestor.nextSibling;
|
|
|
|
const afterRange = document.createRange();
|
|
afterRange.setStart(range.startContainer, range.startOffset);
|
|
if (ancestor.lastChild) afterRange.setEndAfter(ancestor.lastChild);
|
|
else afterRange.collapse(true);
|
|
const afterContent = ancestor.lastChild ? afterRange.extractContents() : null;
|
|
|
|
parent.insertBefore(fragment, nextSibling);
|
|
|
|
if (afterContent?.textContent) {
|
|
const afterClone = ancestor.cloneNode(false);
|
|
afterClone.appendChild(afterContent);
|
|
parent.insertBefore(afterClone, nextSibling);
|
|
}
|
|
|
|
if (!ancestor.textContent) ancestor.remove();
|
|
|
|
restoreSelectionOver(selectionFirst, selectionLast);
|
|
editTarget.normalize();
|
|
}
|
|
|
|
/**
|
|
* Toggles underline on the current selection within the edit target.
|
|
* @param {HTMLElement} editTarget
|
|
*/
|
|
export function applyUnderline(editTarget) {
|
|
const range = getActiveRange(editTarget);
|
|
if (!range) return;
|
|
|
|
if (isRangeFullyUnderlined(range, editTarget)) {
|
|
if (getTextNodesInRange(range).some(node => hasAncestorTag(node, ['u'], editTarget))) {
|
|
unwrapTagsInRange(range, ['u'], editTarget);
|
|
}
|
|
let selection = window.getSelection();
|
|
if (selection?.rangeCount > 0) removeExplicitUnderlineInRange(selection.getRangeAt(0), editTarget);
|
|
// Strip Tailwind underline classes from the editTarget itself
|
|
TAILWIND_UNDERLINE_CLASSES.forEach(className => editTarget.classList.remove(className));
|
|
selection = window.getSelection();
|
|
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
|
|
const selectionRange = selection.getRangeAt(0);
|
|
if (isRangeFullyUnderlined(selectionRange, editTarget)) {
|
|
const textNodes = getTextNodesInRange(selectionRange);
|
|
const cssAncestor = textNodes.length > 0 ? findCssUnderlineAncestor(textNodes[0], editTarget) : null;
|
|
if (cssAncestor) splitElementAroundRange(selectionRange, cssAncestor, editTarget);
|
|
else wrapRange(selectionRange, 'span', { textDecoration: 'none' });
|
|
}
|
|
}
|
|
} else {
|
|
const decorationStyle = buildUnderlineWrapStyle(range, editTarget);
|
|
wrapRange(range, 'u', decorationStyle ?? undefined);
|
|
}
|
|
|
|
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
/**
|
|
* Finds the nearest ancestor `<span>` that already carries `styleProperty` and whose
|
|
* text content exactly matches the selection. Reusing it (instead of nesting a
|
|
* new inner span) keeps in-between elements like `<u>` at the same font-size as
|
|
* the text, so e.g. underline thickness stays proportional.
|
|
* @param {Range} range
|
|
* @param {HTMLElement} editTarget
|
|
* @param {string} styleProperty
|
|
* @returns {HTMLElement|null}
|
|
*/
|
|
function findStyledAncestorWrappingRange(range, editTarget, styleProperty) {
|
|
let element = range.commonAncestorContainer;
|
|
if (element.nodeType === Node.TEXT_NODE) element = element.parentElement;
|
|
const selectionText = range.toString();
|
|
if (!selectionText) return null;
|
|
let match = null;
|
|
while (element && element !== editTarget) {
|
|
if (element.nodeType === Node.ELEMENT_NODE && element.tagName === 'SPAN' && element.style[styleProperty]) {
|
|
const elementRange = document.createRange();
|
|
elementRange.selectNodeContents(element);
|
|
if (elementRange.toString() === selectionText) match = element;
|
|
}
|
|
element = element.parentElement;
|
|
}
|
|
return match;
|
|
}
|
|
|
|
/**
|
|
* Wraps the active selection in a span carrying one inline style prop,
|
|
* stripping the same prop from descendants (and a redundant single-child
|
|
* parent span) so values never nest.
|
|
* @param {HTMLElement} editTarget
|
|
* @param {string} styleProperty - camelCase property (e.g. 'fontSize').
|
|
* @param {string} cssProperty - kebab-case property (e.g. 'font-size').
|
|
* @param {string} value
|
|
*/
|
|
export function wrapSelectionWithStyle(editTarget, styleProperty, cssProperty, value) {
|
|
const range = getActiveRange(editTarget);
|
|
if (!range) return;
|
|
const reuse = findStyledAncestorWrappingRange(range, editTarget, styleProperty);
|
|
if (reuse) {
|
|
reuse.style[styleProperty] = value;
|
|
if (styleProperty === 'fontSize') reuse.style.lineHeight = 'normal';
|
|
reuse.querySelectorAll('*').forEach(element => {
|
|
if (element.style[styleProperty]) {
|
|
element.style.removeProperty(cssProperty);
|
|
if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes);
|
|
}
|
|
});
|
|
reuse.normalize();
|
|
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
return;
|
|
}
|
|
|
|
const wrapperStyle = { [styleProperty]: value };
|
|
if (styleProperty === 'fontSize') wrapperStyle.lineHeight = 'normal';
|
|
const wrapper = wrapRange(range, 'span', wrapperStyle);
|
|
if (wrapper) {
|
|
wrapper.querySelectorAll('*').forEach(element => {
|
|
if (element.style[styleProperty]) {
|
|
element.style.removeProperty(cssProperty);
|
|
if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes);
|
|
}
|
|
});
|
|
const parentElement = wrapper.parentElement;
|
|
if (parentElement && parentElement !== editTarget && parentElement.tagName === 'SPAN' && parentElement.style[styleProperty] && parentElement.childNodes.length === 1) {
|
|
parentElement.style.removeProperty(cssProperty);
|
|
if (!parentElement.style.cssText.trim()) parentElement.replaceWith(...parentElement.childNodes);
|
|
}
|
|
wrapper.normalize();
|
|
}
|
|
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
/**
|
|
* Sets text-align on the edit target element.
|
|
* @param {HTMLElement} editTarget
|
|
* @param {'left'|'center'|'right'} alignment
|
|
*/
|
|
export function applyAlign(editTarget, alignment) {
|
|
const current = getComputedStyle(editTarget).textAlign;
|
|
if (current === alignment || (alignment === 'left' && (current === 'start' || current === 'left'))) return;
|
|
editTarget.style.textAlign = alignment;
|
|
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|