penpot/frontend/text-editor/src/editor/controllers/SelectionController.js
Andrey Antukh 5f5e8bb8df 🐛 Fix crash when pasting into an empty or element-focused caret
Pasting text could throw "Unknown node type" and lose the paste. The
insertion paths assume the caret sits on a text node or a <br>, but the
browser can report it on a container element (the offset being a child
index, common in Firefox) or, for an empty text shape that was just
focused, on nothing at all: selectAll() returned early without ever
setting a selection.

Add resolveTextNodePosition(), which walks a (node, offset) pair down to
the addressed text node or line break and returns null instead of
throwing when it cannot. The selection controller normalizes the caret
with it before inserting text or a pasted fragment, and selectAll() now
collapses on the line break of an empty editor so the caret is always
usable.

Closes #11149

AI-assisted-by: longcat-2.0-free
2026-08-17 12:16:15 +02:00

2149 lines
60 KiB
JavaScript

/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC Sucursal en España SL
*/
import { createLineBreak, isLineBreak } from "../content/dom/LineBreak.js";
import {
createTextSpan,
createTextSpanFrom,
getTextSpan,
getTextSpanLength,
isTextSpan,
isTextSpanStart,
isTextSpanEnd,
setTextSpanStyles,
splitTextSpan,
createEmptyTextSpan,
createVoidTextSpan,
} from "../content/dom/TextSpan.js";
import {
createEmptyParagraph,
isEmptyParagraph,
getParagraph,
isParagraph,
isParagraphStart,
isParagraphEnd,
setParagraphStyles,
splitParagraph,
mergeParagraphs,
fixParagraph,
createParagraph,
} from "../content/dom/Paragraph.js";
import {
removeBackward,
removeForward,
replaceWith,
insertInto,
removeSlice,
findPreviousWordBoundary,
removeWordBackward,
} from "../content/Text.js";
import {
getTextNodeLength,
getClosestTextNode,
isTextNode,
resolveTextNodePosition,
} from "../content/dom/TextNode.js";
import TextNodeIterator from "../content/dom/TextNodeIterator.js";
import TextEditor from "../TextEditor.js";
import { isRoot, setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js";
import { SafeGuard } from "./SafeGuard.js";
import { sanitizeFontFamily } from "../content/dom/Style.js";
import StyleDeclaration from "./StyleDeclaration.js";
/**
* Supported options for the SelectionController.
*
* @typedef {Object} SelectionControllerOptions
* @property {SelectionControllerDebug} [debug] An object with references to DOM elements that will keep all the debugging values.
*/
/**
* SelectionController uses the same concepts used by the Selection API but extending it to support
* our own internal model based on paragraphs (in draft.js they were called blocks) and text spans.
*/
export class SelectionController extends EventTarget {
/**
* Reference to the text editor.
*
* @type {TextEditor}
*/
#textEditor = null;
/**
* Selection.
*
* @type {Selection}
*/
#selection = null;
/**
* Set of ranges (this should always have one)
*
* @type {Set<Range>}
*/
#ranges = new Set();
/**
* Current range (.rangeAt 0)
*
* @type {Range}
*/
#range = null;
/**
* @type {Node}
*/
#focusNode = null;
/**
* @type {number}
*/
#focusOffset = 0;
/**
* @type {Node}
*/
#anchorNode = null;
/**
* @type {number}
*/
#anchorOffset = 0;
/**
* Saved selection.
*
* @type {object}
*/
#savedSelection = null;
/**
* TextNodeIterator that allows us to move
* around the root element but only through
* <br> and #text nodes.
*
* @type {TextNodeIterator}
*/
#textNodeIterator = null;
/**
* StyleDeclaration that we can mutate
* to handle style changes.
*
* @type {StyleDeclaration}
*/
#currentStyle = new StyleDeclaration();
/**
* @type {SelectionControllerDebug}
*/
#debug = null;
/**
* Style defaults.
*
* @type {Object.<string, *>}
*/
#styleDefaults = null;
/**
* Fix for Chrome.
*/
#fixInsertCompositionText = false;
/**
* @type {TextEditorOptions}
*/
#options = {};
/**
* Constructor
*
* @param {TextEditor} textEditor
* @param {Selection} selection
* @param {SelectionControllerOptions} [options]
*/
constructor(textEditor, selection, options) {
super();
// FIXME: We can't check if it is an instanceof TextEditor
// because tests use TextEditorMock.
/*
if (!(textEditor instanceof TextEditor)) {
throw new TypeError("Invalid EventTarget");
}
*/
this.#options = options ?? {};
this.#debug = options?.debug;
this.#styleDefaults = options?.styleDefaults;
this.#selection = selection;
this.#textEditor = textEditor;
this.#textNodeIterator = new TextNodeIterator(this.#textEditor.element);
// Setups everything.
this.#setup();
}
/**
* Styles of the current text span.
*
* @type {CSSStyleDeclaration}
*/
get currentStyle() {
return this.#currentStyle;
}
/**
* Text editor options.
*
* @type {TextEditorOptions}
*/
get options() {
return this.#options;
}
/**
* Applies the default styles to the currentStyle
* CSSStyleDeclaration.
*/
#applyDefaultStylesToCurrentStyle() {
if (this.#styleDefaults) {
for (const [name, value] of Object.entries(this.#styleDefaults)) {
this.#currentStyle.setProperty(
name,
value + (name === "font-size" ? "px" : ""),
);
}
}
}
/**
* Applies some styles to the currentStyle
* CSSStyleDeclaration
*
* @param {HTMLElement} element
*/
#applyStylesFromElementToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
// Only merge fill styles from text spans.
if (!isTextSpan(element) && styleName === "--fills") {
continue;
}
let styleValue = element.style.getPropertyValue(styleName);
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
this.#currentStyle.setProperty(styleName, styleValue);
}
}
/**
* Applies some styles to the currentStyle
* CSSStyleDeclaration
*
* @param {HTMLElement} element
*/
#mergeStylesFromElementToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
let styleValue = element.style.getPropertyValue(styleName);
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
this.#currentStyle.mergeProperty(styleName, styleValue);
}
}
/**
* Updates current styles based on the currently selected text spans.
*
* @param {HTMLSpanElement} startNode
* @param {HTMLSpanElement} endNode
*/
#updateCurrentStyleFrom(startNode, endNode) {
this.#applyDefaultStylesToCurrentStyle();
const root = startNode.parentElement.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(root);
if (startNode === endNode) {
const paragraph = startNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
const textSpan = startNode.parentElement;
this.#applyStylesFromElementToCurrentStyle(textSpan);
} else {
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
const textNodesInRange = [
...this.#textNodeIterator.iterateFrom(startNode, endNode),
];
for (const textNode of textNodesInRange) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
// Empty trailing text runs (length 0) often carry paragraph fallback styles
// (e.g. font-size 0) and are not user-visible; merging them with real text
// yields false "mixed" in the sidebar. Skip empty nodes when the selection
// also includes non-empty text; if everything is empty, keep prior behavior.
const nonEmptyTextNodes = textNodesInRange.filter(
(textNode) => textNode.length > 0,
);
const spanMergeNodes =
nonEmptyTextNodes.length > 0 ? nonEmptyTextNodes : textNodesInRange;
if (spanMergeNodes.length > 0) {
const firstTextSpan = spanMergeNodes[0].parentElement;
this.#applyStylesFromElementToCurrentStyle(firstTextSpan);
for (let i = 1; i < spanMergeNodes.length; i++) {
const textSpan = spanMergeNodes[i].parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
}
}
}
return this;
}
/**
* Updates current styles based on the currently selected text span.
*
* @param {HTMLSpanElement} textSpan
* @returns {SelectionController}
*/
#updateCurrentStyle(textSpan) {
this.#applyDefaultStylesToCurrentStyle();
const root = textSpan.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(root);
const paragraph = textSpan.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
this.#applyStylesFromElementToCurrentStyle(textSpan);
return this;
}
#updateState() {
// If we're outside the contenteditable element, then
// we return.
if (!this.hasFocus) {
return;
}
let focusNodeChanges = false;
let anchorNodeChanges = false;
if (
this.#focusNode !== this.#selection.focusNode ||
this.#focusOffset !== this.#selection.focusOffset
) {
this.#focusNode = this.#selection.focusNode;
this.#focusOffset = this.#selection.focusOffset;
focusNodeChanges = true;
}
if (
this.#anchorNode !== this.#selection.anchorNode ||
this.#anchorOffset !== this.#selection.anchorOffset
) {
this.#anchorNode = this.#selection.anchorNode;
this.#anchorOffset = this.#selection.anchorOffset;
anchorNodeChanges = true;
}
// We need to handle multi selection from firefox
// and remove all the old ranges and just keep the
// last one added.
if (this.#selection.rangeCount > 1) {
for (let index = 0; index < this.#selection.rangeCount; index++) {
const range = this.#selection.getRangeAt(index);
if (this.#ranges.has(range)) {
this.#ranges.delete(range);
this.#selection.removeRange(range);
} else {
this.#ranges.add(range);
this.#range = range;
}
}
} else if (this.#selection.rangeCount > 0) {
const range = this.#selection.getRangeAt(0);
this.#range = range;
this.#ranges.clear();
this.#ranges.add(range);
} else {
this.#range = null;
this.#ranges.clear();
}
// If focus node changed, we need to retrieve all the
// styles of the current text span and dispatch an event
// to notify that the styles have changed.
if (focusNodeChanges || anchorNodeChanges) {
this.#notifyStyleChange();
}
if (this.#fixInsertCompositionText) {
this.#fixInsertCompositionText = false;
const lineBreak = fixParagraph(this.focusNode);
this.collapse(lineBreak, 0);
}
if (this.#debug) {
this.#debug.update(this);
}
}
/**
* This is called on every `selectionchange` because it is dispatched
* only by the `document` object.
*
* @param {Event} e
*/
#onSelectionChange = (_) => this.#updateState();
/**
* Notifies that the styles have changed.
*/
#notifyStyleChange() {
if (this.#selection.isCollapsed) {
// CARET
const textSpan =
this.focusTextSpan ??
this.#textEditor.root?.firstElementChild?.firstElementChild;
this.#updateCurrentStyle(textSpan);
} else {
// SELECTION.
// Use range boundaries normalized to text nodes, not anchor/focus.
// Firefox may set anchorNode on the paragraph element and focusNode on a
// text node for word selection; passing those to #updateCurrentStyleFrom
// breaks TextNodeIterator and yields wrong styles (e.g. default 14px).
const { startNode, endNode } = this.getRanges();
this.#updateCurrentStyleFrom(startNode, endNode);
}
this.dispatchEvent(
new CustomEvent("stylechange", {
detail: this.#currentStyle,
}),
);
}
/**
* Setups
*/
#setup() {
this.#applyDefaultStylesToCurrentStyle();
if (this.#selection.rangeCount > 0) {
const range = this.#selection.getRangeAt(0);
this.#range = range;
this.#ranges.add(range);
}
// If there are more than one range, we should remove
// them because this is a feature not supported by browsers
// like Safari and Chrome.
if (this.#selection.rangeCount > 1) {
for (let index = 1; index < this.#selection.rangeCount; index++) {
this.#selection.removeRange(index);
}
}
document.addEventListener("selectionchange", this.#onSelectionChange);
}
/**
* Disposes the current resources.
*/
dispose() {
document.removeEventListener("selectionchange", this.#onSelectionChange);
this.#textEditor = null;
this.#currentStyle = null;
this.#options = null;
this.#ranges.clear();
this.#ranges = null;
this.#range = null;
this.#selection = null;
this.#focusNode = null;
this.#anchorNode = null;
}
/**
* Returns a Range-like object.
*
* @returns {RangeLike}
*/
#getSavedRange() {
if (!this.#range) {
return {
collapsed: true,
commonAncestorContainer: null,
startContainer: null,
startOffset: 0,
endContainer: null,
endOffset: 0,
};
}
return {
collapsed: this.#range.collapsed,
commonAncestorContainer: this.#range.commonAncestorContainer,
startContainer: this.#range.startContainer,
startOffset: this.#range.startOffset,
endContainer: this.#range.endContainer,
endOffset: this.#range.endOffset,
};
}
/**
* Saves the current selection and returns the client rects.
*
* @returns {boolean}
*/
saveSelection() {
this.#savedSelection = {
isCollapsed: this.#selection.isCollapsed,
focusNode: this.#selection.focusNode,
focusOffset: this.#selection.focusOffset,
anchorNode: this.#selection.anchorNode,
anchorOffset: this.#selection.anchorOffset,
range: this.#getSavedRange(),
};
return true;
}
/**
* Restores a saved selection if there's any.
*
* @returns {boolean}
*/
restoreSelection() {
if (!this.#savedSelection) return false;
if (this.#savedSelection.anchorNode && this.#savedSelection.focusNode) {
this.#selection.setBaseAndExtent(
this.#savedSelection.anchorNode,
this.#savedSelection.anchorOffset,
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
}
this.#savedSelection = null;
return true;
}
/**
* Selects all content.
*
* @returns {SelectionController}
*/
selectAll() {
if (this.#textEditor.isEmpty) {
// There is nothing to select, but we still need a valid caret: leaving
// the selection untouched keeps `focusNode` null and makes any later
// insertion (typing, pasting) fail.
const lineBreak =
this.#textEditor.root?.firstElementChild?.firstElementChild?.firstChild;
if (lineBreak) {
this.collapse(lineBreak, 0);
}
return this;
}
// Find the first and last text nodes to create a proper text selection
// instead of selecting container elements
const root = this.#textEditor.root;
const firstParagraph = root.firstElementChild;
const lastParagraph = root.lastElementChild;
if (!firstParagraph || !lastParagraph) {
return this;
}
const firstTextSpan = firstParagraph.firstElementChild;
const lastTextSpan = lastParagraph.lastElementChild;
if (!firstTextSpan || !lastTextSpan) {
return this;
}
const firstTextNode = firstTextSpan.firstChild;
const lastTextNode = lastTextSpan.lastChild;
if (!firstTextNode || !lastTextNode) {
return this;
}
// Create a range from first text node to last text node
const range = document.createRange();
range.setStart(firstTextNode, 0);
range.setEnd(lastTextNode, lastTextNode.nodeValue?.length || 0);
this.#selection.removeAllRanges();
this.#selection.addRange(range);
this.#updateState();
return this;
}
/**
* Moves cursor to end.
*
* @returns {SelectionController}
*/
cursorToEnd() {
const root = this.#textEditor.root;
const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
range.setStart(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0);
range.setEnd(root.lastChild.firstChild.firstChild, root.lastChild.firstChild.firstChild?.nodeValue?.length ?? 0);
range.collapse(false);
this.#selection.removeAllRanges();
this.#selection.addRange(range);
this.#updateState();
return this;
}
/**
* Collapses a selection.
*
* @param {Node} node
* @param {number} offset
*/
collapse(node, offset) {
const nodeValue = node?.nodeValue ?? "";
const nodeOffset =
node.nodeType === Node.TEXT_NODE && offset >= nodeValue.length
? nodeValue.length
: offset;
return this.setSelection(node, nodeOffset, node, nodeOffset);
}
/**
* Sets base and extent.
*
* @param {Node} anchorNode
* @param {number} anchorOffset
* @param {Node} [focusNode=anchorNode]
* @param {number} [focusOffset=anchorOffset]
*/
setSelection(
anchorNode,
anchorOffset,
focusNode = anchorNode,
focusOffset = anchorOffset,
) {
if (!anchorNode.isConnected) {
throw new Error("Invalid anchorNode");
}
if (!focusNode.isConnected) {
throw new Error("Invalid focusNode");
}
if (this.#savedSelection) {
this.#savedSelection.isCollapsed =
focusNode === anchorNode && anchorOffset === focusOffset;
this.#savedSelection.focusNode = focusNode;
this.#savedSelection.focusOffset = focusOffset;
this.#savedSelection.anchorNode = anchorNode;
this.#savedSelection.anchorOffset = anchorOffset;
this.#savedSelection.range.collapsed = this.#savedSelection.isCollapsed;
const position = focusNode.compareDocumentPosition(anchorNode);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
this.#savedSelection.range.startContainer = focusNode;
this.#savedSelection.range.startOffset = focusOffset;
this.#savedSelection.range.endContainer = anchorNode;
this.#savedSelection.range.endOffset = anchorOffset;
} else {
this.#savedSelection.range.startContainer = anchorNode;
this.#savedSelection.range.startOffset = anchorOffset;
this.#savedSelection.range.endContainer = focusNode;
this.#savedSelection.range.endOffset = focusOffset;
}
} else {
this.#anchorNode = anchorNode;
this.#anchorOffset = anchorOffset;
this.#focusNode = focusNode;
this.#focusOffset = focusOffset;
// setPosition() collapses the selection to a single caret. We must only use it
// when anchorOffset === focusOffset. When both points are in the same node but
// offsets differ (e.g. selecting "hola" in "hola adios"), we need setBaseAndExtent()
// to preserve the range so we don't incorrectly collapse ranges and lose the selection.
if (anchorNode === focusNode && anchorOffset === focusOffset) {
this.#selection.setPosition(anchorNode, anchorOffset);
} else {
this.#selection.setBaseAndExtent(
anchorNode,
anchorOffset,
focusNode,
focusOffset,
);
}
}
}
/**
* Returns the current selection.
*
* @type {Selection}
*/
get selection() {
return this.#selection;
}
/**
* Returns the current range.
*
* @type {Range}
*/
get range() {
return this.#range;
}
/**
* Indicates the direction of the selection
*
* @type {SelectionDirection}
*/
get direction() {
if (this.isCollapsed) {
return SelectionDirection.NONE;
}
if (this.focusNode !== this.anchorNode) {
return this.startContainer === this.focusNode
? SelectionDirection.BACKWARD
: SelectionDirection.FORWARD;
}
return this.focusOffset < this.anchorOffset
? SelectionDirection.BACKWARD
: SelectionDirection.FORWARD;
}
/**
* Indicates that the editor element has the
* focus.
*
* @type {boolean}
*/
get hasFocus() {
return document.activeElement === this.#textEditor.element;
}
/**
* Returns true if the selection is collapsed (caret)
* or false otherwise.
*
* @type {boolean}
*/
get isCollapsed() {
if (this.#savedSelection) {
return this.#savedSelection.isCollapsed;
}
return this.#selection.isCollapsed;
}
/**
* Current or saved anchor node.
*
* @type {Node}
*/
get anchorNode() {
if (this.#savedSelection) {
return this.#savedSelection.anchorNode;
}
return this.#anchorNode;
}
/**
* Current or saved anchor offset.
*
* @type {number}
*/
get anchorOffset() {
if (this.#savedSelection) {
return this.#savedSelection.anchorOffset;
}
return this.#selection.anchorOffset;
}
/**
* Indicates that the caret is at the start of the node.
*
* @type {boolean}
*/
get anchorAtStart() {
return this.anchorOffset === 0;
}
/**
* Indicates that the caret is at the end of the node.
*
* @type {boolean}
*/
get anchorAtEnd() {
return this.anchorOffset === (this.anchorNode.nodeValue?.length ?? 0);
}
/**
* Current or saved focus node.
*
* @type {Node}
*/
get focusNode() {
if (this.#savedSelection) {
return this.#savedSelection.focusNode;
}
return this.#focusNode;
}
/**
* Current or saved focus offset.
*
* @type {number}
*/
get focusOffset() {
if (this.#savedSelection) {
return this.#savedSelection.focusOffset;
}
return this.#focusOffset;
}
/**
* Indicates that the caret is at the start of the node.
*
* @type {boolean}
*/
get focusAtStart() {
return this.focusOffset === 0;
}
/**
* Indicates that the caret is at the end of the node.
*
* @type {boolean}
*/
get focusAtEnd() {
return this.focusOffset === (this.focusNode.nodeValue?.length ?? 0);
}
/**
* Returns the paragraph in the focus node
* of the current selection.
*
* @type {HTMLElement|null}
*/
get focusParagraph() {
return getParagraph(this.focusNode);
}
/**
* Returns the text span in the focus node
* of the current selection.
*
* @type {HTMLElement|null}
*/
get focusTextSpan() {
return getTextSpan(this.focusNode);
}
/**
* Returns the current paragraph in the anchor
* node of the current selection.
*
* @type {HTMLElement|null}
*/
get anchorParagraph() {
return getParagraph(this.anchorNode);
}
/**
* Returns the current text span in the anchor
* node of the current selection.
*
* @type {HTMLElement|null}
*/
get anchorTextSpan() {
return getTextSpan(this.anchorNode);
}
/**
* Start container of the current range.
*/
get startContainer() {
if (this.#savedSelection) {
return this.#savedSelection?.range?.startContainer;
}
return this.#range?.startContainer;
}
/**
* `startOffset` of the current range.
*
* @type {number|null}
*/
get startOffset() {
if (this.#savedSelection) {
return this.#savedSelection?.range?.startOffset;
}
return this.#range?.startOffset;
}
/**
* Start paragraph of the current range.
*
* @type {HTMLElement|null}
*/
get startParagraph() {
const startContainer = this.startContainer;
if (!startContainer) return null;
return getParagraph(startContainer);
}
/**
* Start text span of the current page.
*
* @type {HTMLElement|null}
*/
get startTextSpan() {
const startContainer = this.startContainer;
if (!startContainer) return null;
return getTextSpan(startContainer);
}
/**
* End container of the current range.
*
* @type {Node}
*/
get endContainer() {
if (this.#savedSelection) {
return this.#savedSelection?.range?.endContainer;
}
return this.#range?.endContainer;
}
/**
* `endOffset` of the current range
*
* @type {HTMLElement|null}
*/
get endOffset() {
if (this.#savedSelection) {
return this.#savedSelection?.range?.endOffset;
}
return this.#range?.endOffset;
}
/**
* Paragraph element of the `endContainer` of
* the current range.
*
* @type {HTMLElement|null}
*/
get endParagraph() {
const endContainer = this.endContainer;
if (!endContainer) return null;
return getParagraph(endContainer);
}
/**
* TextSpan element of the `endContainer` of
* the current range.
*
* @type {HTMLElement|null}
*/
get endTextSpan() {
const endContainer = this.endContainer;
if (!endContainer) return null;
return getTextSpan(endContainer);
}
/**
* Returns true if the anchor node and the focus
* node are the same text nodes.
*
* @type {boolean}
*/
get isTextSame() {
return (
this.isTextFocus === this.isTextAnchor &&
this.focusNode === this.anchorNode
);
}
/**
* Indicates that focus node is a text node.
*
* @type {boolean}
*/
get isTextFocus() {
return this.focusNode != null && this.focusNode.nodeType === Node.TEXT_NODE;
}
/**
* Indicates that anchor node is a text node.
*
* @type {boolean}
*/
get isTextAnchor() {
return (
this.anchorNode != null && this.anchorNode.nodeType === Node.TEXT_NODE
);
}
/**
* Is true if the current focus node is a text span.
*
* @type {boolean}
*/
get isTextSpanFocus() {
return isTextSpan(this.focusNode);
}
/**
* Is true if the current anchor node is a text span.
*
* @type {boolean}
*/
get isTextSpanAnchor() {
return isTextSpan(this.anchorNode);
}
/**
* Is true if the current focus node is a paragraph.
*
* @type {boolean}
*/
get isParagraphFocus() {
return isParagraph(this.focusNode);
}
/**
* Is true if the current anchor node is a paragraph.
*
* @type {boolean}
*/
get isParagraphAnchor() {
return isParagraph(this.anchorNode);
}
/**
* Is true if the current focus node is a line break.
*
* @type {boolean}
*/
get isLineBreakFocus() {
return (
isLineBreak(this.focusNode) ||
(isTextSpan(this.focusNode) && isLineBreak(this.focusNode.firstChild))
);
}
/**
* Is true if the current focus node is a root.
*
* @type {boolean}
*/
get isRootFocus() {
return isRoot(this.focusNode);
}
/**
* Indicates that we have multiple nodes selected.
*
* @type {boolean}
*/
get isMulti() {
return this.focusNode !== this.anchorNode;
}
/**
* Indicates that we have selected multiple
* paragraph elements.
*
* @type {boolean}
*/
get isMultiParagraph() {
return this.isMulti && this.focusParagraph !== this.anchorParagraph;
}
/**
* Indicates that we have selected multiple
* text span elements.
*
* @type {boolean}
*/
get isMultiTextSpan() {
return this.isMulti && this.focusTextSpan !== this.anchorTextSpan;
}
/**
* Indicates that the caret (only the caret)
* is at the start of an text span.
*
* @type {boolean}
*/
get isTextSpanStart() {
if (!this.isCollapsed) return false;
return isTextSpanStart(this.focusNode, this.focusOffset);
}
/**
* Indicates that the caret (only the caret)
* is at the end of an text span. This value doesn't
* matter when dealing with selections.
*
* @type {boolean}
*/
get isTextSpanEnd() {
if (!this.isCollapsed) return false;
return isTextSpanEnd(this.focusNode, this.focusOffset);
}
/**
* Indicates that we're in the starting position of a paragraph.
*
* @type {boolean}
*/
get isParagraphStart() {
if (!this.isCollapsed) return false;
return isParagraphStart(this.focusNode, this.focusOffset);
}
/**
* Indicates that we're in the ending position of a paragraph.
*
* @type {boolean}
*/
get isParagraphEnd() {
if (!this.isCollapsed) return false;
return isParagraphEnd(this.focusNode, this.focusOffset);
}
#getFragmentTextSpanTextNode(fragment) {
if (isTextSpan(fragment.firstElementChild.lastChild)) {
return fragment.firstElementChild.firstElementChild.lastChild;
}
return fragment.firstElementChild.lastChild;
}
#getFragmentParagraphTextNode(fragment) {
return fragment.lastElementChild.lastElementChild.lastChild;
}
/**
* Insert pasted fragment.
*
* @param {DocumentFragment} fragment
*/
insertPaste(fragment) {
if (this.isCollapsed && !this.#normalizeFocus()) {
return;
}
const hasOnlyOneParagraph = fragment.children.length === 1;
const forceTextSpan =
fragment.firstElementChild?.dataset?.textSpan === "force";
if (hasOnlyOneParagraph && forceTextSpan) {
// first text span
const collapseNode = fragment.firstElementChild.firstElementChild;
if (this.isTextSpanStart) {
this.focusTextSpan.before(...fragment.firstElementChild.children);
} else if (this.isTextSpanEnd) {
this.focusTextSpan.after(...fragment.firstElementChild.children);
} else {
const newTextSpan = splitTextSpan(this.focusTextSpan, this.focusOffset);
this.focusTextSpan.after(
...fragment.firstElementChild.children,
newTextSpan,
);
}
// collapseNode could be a <br>, that's why we need to
// make `nodeValue` as optional.
return this.collapse(collapseNode, collapseNode?.nodeValue?.length || 0);
}
const collapseNode = this.#getFragmentParagraphTextNode(fragment);
if (this.isParagraphStart) {
const a = fragment.lastElementChild;
const b = this.focusParagraph;
this.focusParagraph.before(fragment);
mergeParagraphs(a, b);
} else if (this.isParagraphEnd) {
const a = this.focusParagraph;
const b = fragment.firstElementChild;
this.focusParagraph.after(fragment);
mergeParagraphs(a, b);
} else {
if (this.isTextSpanStart) {
this.focusTextSpan.before(...fragment.firstElementChild.children);
} else if (this.isTextSpanEnd) {
this.focusTextSpan.after(...fragment.firstElementChild.children);
} else {
const newTextSpan = splitTextSpan(this.focusTextSpan, this.focusOffset);
this.focusTextSpan.after(
...fragment.firstElementChild.children,
newTextSpan,
);
}
}
if (isLineBreak(collapseNode)) {
return this.collapse(collapseNode, 0);
}
return this.collapse(collapseNode, collapseNode.nodeValue?.length || 0);
}
/**
* Replaces data with pasted fragment
*
* @param {DocumentFragment} fragment
*/
replaceWithPaste(fragment) {
this.removeSelected();
this.insertPaste(fragment);
}
/**
* Replaces the current line break with text
*
* @param {string} text
*/
replaceLineBreak(text) {
const newText = new Text(text);
this.focusTextSpan.replaceChildren(newText);
this.collapse(newText, text.length);
}
/**
* Removes text forward from the current position.
*/
removeForwardText() {
this.#textNodeIterator.currentNode = this.focusNode;
const removedData = removeForward(
this.focusNode.nodeValue,
this.focusOffset,
);
if (this.focusNode.nodeValue !== removedData) {
this.focusNode.nodeValue = removedData;
}
const paragraph = this.focusParagraph;
if (!paragraph) throw new Error("Cannot find paragraph");
const textSpan = this.focusTextSpan;
if (!textSpan) throw new Error("Cannot find text span");
const nextTextNode = this.#textNodeIterator.nextNode();
if (this.focusNode.nodeValue === "") {
this.focusNode.remove();
}
if (paragraph.childNodes.length === 1 && textSpan.childNodes.length === 0) {
const lineBreak = createLineBreak();
textSpan.appendChild(lineBreak);
return this.collapse(lineBreak, 0);
} else if (
paragraph.childNodes.length > 1 &&
textSpan.childNodes.length === 0
) {
textSpan.remove();
// nextTextNode can be null when deleting the last text node in the last
// span of the paragraph; fall back to the last text node of the
// preceding sibling span so the cursor stays within the paragraph.
const forwardTarget = nextTextNode ?? paragraph.lastChild?.lastChild;
return this.collapse(forwardTarget, 0);
}
return this.collapse(this.focusNode, this.focusOffset);
}
/**
* Removes text backward from the current caret position.
*/
removeBackwardText() {
this.#textNodeIterator.currentNode = this.focusNode;
// Remove the character from the string.
const removedData = removeBackward(
this.focusNode.nodeValue,
this.focusOffset,
);
if (this.focusNode.nodeValue !== removedData) {
this.focusNode.nodeValue = removedData;
}
// If the focusNode has content we don't need to do
// anything else.
if (this.focusOffset - 1 > 0) {
return this.collapse(this.focusNode, this.focusOffset - 1);
}
const paragraph = this.focusParagraph;
if (!paragraph) throw new Error("Cannot find paragraph");
const textSpan = this.focusTextSpan;
if (!textSpan) throw new Error("Cannot find text span");
const previousTextNode = this.#textNodeIterator.previousNode();
if (this.focusNode.nodeValue === "") {
this.focusNode.remove();
}
if (paragraph.children.length === 1 && textSpan.childNodes.length === 0) {
const lineBreak = createLineBreak();
textSpan.appendChild(lineBreak);
return this.collapse(lineBreak, 0);
} else if (
paragraph.children.length > 1 &&
textSpan.childNodes.length === 0
) {
textSpan.remove();
// previousTextNode can be null when deleting the first text node in
// the paragraph (no preceding sibling text node exists). Fall back
// to the first text node of the now-first remaining span so the
// cursor stays within the paragraph.
const backwardTarget = previousTextNode ?? paragraph.firstChild?.firstChild;
return this.collapse(
backwardTarget,
getTextNodeLength(backwardTarget),
);
}
return this.collapse(this.focusNode, this.focusOffset - 1);
}
/**
* Removes word backward from the current caret position.
*/
removeWordBackward() {
if (!this.isCollapsed) {
return this.removeSelected();
}
this.#textNodeIterator.currentNode = this.focusNode;
const originalNodeValue = this.focusNode.nodeValue || "";
const wordStart = findPreviousWordBoundary(
originalNodeValue,
this.focusOffset,
);
// Start node
if (wordStart === this.focusOffset && this.focusOffset === 0) {
if (this.focusTextSpan.previousElementSibling) {
const prevTextSpan = this.focusTextSpan.previousElementSibling;
const prevTextNode = prevTextSpan.lastChild;
if (prevTextNode && prevTextNode.nodeType === Node.TEXT_NODE) {
this.collapse(prevTextNode, prevTextNode.nodeValue.length);
return this.removeWordBackward();
}
} else if (this.focusParagraph.previousElementSibling) {
// Move to previous paragraph
const prevParagraph = this.focusParagraph.previousElementSibling;
const prevTextSpan = prevParagraph.lastElementChild;
const prevTextNode = prevTextSpan?.lastChild;
if (prevTextNode && prevTextNode.nodeType === Node.TEXT_NODE) {
this.collapse(prevTextNode, prevTextNode.nodeValue.length);
return this.removeWordBackward();
} else {
return this.mergeBackwardParagraph();
}
}
return this;
}
const removedData = removeWordBackward(originalNodeValue, this.focusOffset);
if (this.focusNode.nodeValue !== removedData) {
this.focusNode.nodeValue = removedData;
}
const paragraph = this.focusParagraph;
if (!paragraph) throw new Error("Cannot find paragraph");
const textSpan = this.focusTextSpan;
if (!textSpan) throw new Error("Cannot find text span");
// If the text node is empty, handle cleanup
if (this.focusNode.nodeValue === "") {
const previousTextNode = this.#textNodeIterator.previousNode();
this.focusNode.remove();
if (paragraph.children.length === 1 && textSpan.childNodes.length === 0) {
const lineBreak = createLineBreak();
textSpan.appendChild(lineBreak);
return this.collapse(lineBreak, 0);
} else if (
paragraph.children.length > 1 &&
textSpan.childNodes.length === 0
) {
textSpan.remove();
// previousTextNode can be null when the deleted node was the first
// in the paragraph. Fall back to the first text node of the
// now-first remaining span.
const backwardTarget = previousTextNode ?? paragraph.firstChild?.firstChild;
return this.collapse(
backwardTarget,
getTextNodeLength(backwardTarget),
);
}
}
return this.collapse(this.focusNode, wordStart);
}
/**
* Inserts some text in the caret position.
*
* @param {string} newText
*/
insertText(newText) {
this.focusNode.nodeValue = insertInto(
this.focusNode.nodeValue,
this.focusOffset,
newText,
);
return this.collapse(this.focusNode, this.focusOffset + newText.length);
}
/**
* Moves the caret to an equivalent position on a text node or a line break.
*
* The browser can report the caret on a container element (with the offset
* being a child index) or, when the editor was focused without any content,
* on nothing at all. Both states break every insertion path, which expects
* the focus node to be a text node or a <br>.
*
* @returns {boolean} true when the focus is usable.
*/
#normalizeFocus() {
if (this.isTextFocus || this.isLineBreakFocus) {
return true;
}
const position =
resolveTextNodePosition(this.focusNode, this.focusOffset) ??
resolveTextNodePosition(this.#textEditor.root, 0);
if (!position?.node?.isConnected) {
return false;
}
this.collapse(position.node, position.offset);
return true;
}
/**
* Replaces the currently focus element
* with some text.
*
* @param {string} newText
*/
insertIntoFocus(newText) {
if (!this.#normalizeFocus()) {
return;
}
if (this.isTextFocus) {
this.focusNode.nodeValue = insertInto(
this.focusNode.nodeValue,
this.focusOffset,
newText,
);
this.collapse(this.focusNode, this.focusOffset + newText.length);
} else if (this.isLineBreakFocus) {
const textNode = new Text(newText);
// the focus node is a <span>.
if (isTextSpan(this.focusNode)) {
this.focusNode.firstElementChild.replaceWith(textNode);
// the focus node is a <br>.
} else {
this.focusNode.replaceWith(textNode);
}
this.collapse(textNode, newText.length);
} else {
throw new Error("Unknown node type");
}
}
/**
* Replaces currently selected text.
*
* @param {string} newText
*/
replaceText(newText) {
const startOffset = Math.min(this.anchorOffset, this.focusOffset);
const endOffset = Math.max(this.anchorOffset, this.focusOffset);
if (this.isTextFocus) {
this.focusNode.nodeValue = replaceWith(
this.focusNode.nodeValue,
startOffset,
endOffset,
newText,
);
} else if (this.isLineBreakFocus) {
this.focusNode.replaceWith(new Text(newText));
} else if (this.isRootFocus) {
const newTextNode = new Text(newText);
const newTextSpan = createTextSpan(newTextNode, this.#currentStyle);
const newParagraph = createParagraph([newTextSpan], this.#currentStyle);
this.focusNode.replaceChildren(newParagraph);
return this.collapse(newTextNode, newText.length + 1);
} else {
const newTextNode = new Text(newText);
const newTextSpan = createTextSpan(newTextNode, this.#currentStyle);
const newParagraph = createParagraph([newTextSpan], this.#currentStyle);
this.#textEditor.root.replaceChildren(newParagraph);
return this.collapse(newTextNode, newText.length + 1);
}
return this.collapse(this.focusNode, startOffset + newText.length);
}
/**
* Replaces the selected text spans with new text.
*
* @param {string} newText
*/
replaceTextSpans(newText) {
const currentParagraph = this.focusParagraph;
// This is the special (and fast) case where we're
// removing everything inside a paragraph.
if (
this.startTextSpan === currentParagraph.firstChild &&
this.startOffset === 0 &&
this.endTextSpan === currentParagraph.lastChild &&
this.endOffset === currentParagraph.lastChild.textContent.length
) {
const newTextNode = new Text(newText);
currentParagraph.replaceChildren(
createTextSpan(newTextNode, this.anchorTextSpan.style),
);
return this.collapse(newTextNode, newTextNode.nodeValue?.length || 0);
}
this.removeSelected();
this.insertIntoFocus(newText);
/*
this.focusNode.nodeValue = insertInto(
this.focusNode.nodeValue,
this.focusOffset,
newText
);
*/
// FIXME: I'm not sure if we should merge text spans when they share the same styles.
// For example: if we have > 2 text spans and the start text span and the end text span
// share the same styles, maybe we should merge them?
// mergeTextSpans(startTextSpan, endTextSpan);
return this.collapse(this.focusNode, this.focusOffset + newText.length);
}
/**
* Replaces paragraphs with text.
*
* @param {string} newText
*/
replaceParagraphs(newText) {
const currentParagraph = this.focusParagraph;
this.removeSelected();
this.insertIntoFocus(newText);
for (const child of currentParagraph.children) {
if (child.textContent === "") {
child.remove();
}
}
/*
this.focusNode.nodeValue = insertInto(
this.focusNode.nodeValue,
this.focusOffset,
newText
);
*/
}
/**
* Inserts a new paragraph after the current paragraph.
*/
insertParagraphAfter() {
const currentParagraph = this.focusParagraph;
const newParagraph = createEmptyParagraph(this.#currentStyle);
currentParagraph.after(newParagraph);
return this.collapse(newParagraph.firstChild.firstChild, 0);
}
/**
* Inserts a new paragraph before the current paragraph.
*/
insertParagraphBefore() {
const currentParagraph = this.focusParagraph;
const newParagraph = createEmptyParagraph(this.#currentStyle);
currentParagraph.before(newParagraph);
return this.collapse(currentParagraph.firstChild.firstChild, 0);
}
/**
* Splits the current paragraph.
*/
splitParagraph() {
const currentParagraph = this.focusParagraph;
const newParagraph = splitParagraph(
this.focusParagraph,
this.focusTextSpan,
this.#focusOffset,
);
this.focusParagraph.after(newParagraph);
return this.collapse(newParagraph.firstChild.firstChild, 0);
}
/**
* Inserts a new paragraph.
*/
insertParagraph() {
if (this.isParagraphEnd) {
return this.insertParagraphAfter();
} else if (this.isParagraphStart) {
return this.insertParagraphBefore();
}
return this.splitParagraph();
}
/**
* Replaces the currently selected content with
* a paragraph.
*/
replaceWithParagraph() {
const currentParagraph = this.focusParagraph;
const currentTextSpan = this.focusTextSpan;
this.removeSelected();
const newParagraph = splitParagraph(
currentParagraph,
currentTextSpan,
this.focusOffset,
);
currentParagraph.after(newParagraph);
// FIXME: Missing collapse?
}
/**
* Removes a paragraph in backward direction.
*/
removeBackwardParagraph() {
const previousParagraph = this.focusParagraph.previousElementSibling;
if (!previousParagraph) {
return;
}
const paragraphToBeRemoved = this.focusParagraph;
paragraphToBeRemoved.remove();
const previousTextSpan =
previousParagraph.children.length > 1
? previousParagraph.lastElementChild
: previousParagraph.firstChild;
const previousOffset = isLineBreak(previousTextSpan.firstChild)
? 0
: previousTextSpan.firstChild.nodeValue?.length || 0;
return this.collapse(previousTextSpan.firstChild, previousOffset);
}
/**
* Merges the previous paragraph with the current paragraph.
*/
mergeBackwardParagraph() {
const currentParagraph = this.focusParagraph;
const previousParagraph = this.focusParagraph.previousElementSibling;
if (!previousParagraph) {
return;
}
let previousTextSpan = previousParagraph.lastChild;
const previousOffset = getTextSpanLength(previousTextSpan);
if (isEmptyParagraph(previousParagraph)) {
previousParagraph.replaceChildren(...currentParagraph.children);
previousTextSpan = previousParagraph.firstChild;
currentParagraph.remove();
} else {
mergeParagraphs(previousParagraph, currentParagraph);
}
return this.collapse(previousTextSpan.firstChild, previousOffset);
}
/**
* Merges the next paragraph with the current paragraph.
*/
mergeForwardParagraph() {
const currentParagraph = this.focusParagraph;
const nextParagraph = this.focusParagraph.nextElementSibling;
if (!nextParagraph) {
return;
}
mergeParagraphs(this.focusParagraph, nextParagraph);
// FIXME: Missing collapse?
}
/**
* Removes the forward paragraph.
*/
removeForwardParagraph() {
const nextParagraph = this.focusParagraph.nextSibling;
if (!nextParagraph) {
return;
}
const paragraphToBeRemoved = this.focusParagraph;
paragraphToBeRemoved.remove();
const nextTextSpan = nextParagraph.firstChild;
const nextOffset = this.focusOffset;
return this.collapse(nextTextSpan.firstChild, nextOffset);
}
/**
* Cleans up all the affected paragraphs.
*
* @param {Set<HTMLDivElement>} affectedParagraphs
* @param {Set<HTMLSpanElement>} affectedTextSpans
*/
cleanUp(affectedParagraphs, affectedTextSpans) {
// Remove empty text spans
for (const textSpan of affectedTextSpans) {
if (textSpan.textContent === "") {
textSpan.remove();
}
}
// Remove empty paragraphs.
for (const paragraph of affectedParagraphs) {
if (paragraph.children.length === 0) {
paragraph.remove();
}
}
}
/**
* Removes the selected content.
*
* @param {RemoveSelectedOptions} [options]
*/
removeSelected(options) {
if (this.isCollapsed)
return;
const affectedTextSpans = new Set();
const affectedParagraphs = new Set();
let previousNode = null;
let nextNode = null;
let { startNode, endNode, startOffset, endOffset } = this.getRanges();
if (this.shouldHandleCompleteDeletion(startNode, endNode)) {
return this.handleCompleteContentDeletion();
}
// This is the simplest case, when the startNode and
// the endNode are the same and they're textNodes.
if (startNode === endNode) {
this.#textNodeIterator.currentNode = startNode;
previousNode = this.#textNodeIterator.previousNode();
this.#textNodeIterator.currentNode = startNode;
nextNode = this.#textNodeIterator.nextNode();
const textSpan = getTextSpan(startNode);
const paragraph = getParagraph(startNode);
affectedTextSpans.add(textSpan);
affectedParagraphs.add(paragraph);
const newNodeValue = removeSlice(
startNode.nodeValue,
startOffset,
endOffset,
);
if (newNodeValue === "") {
const lineBreak = createLineBreak();
textSpan.replaceChildren(lineBreak);
return this.collapse(lineBreak, 0);
}
startNode.nodeValue = newNodeValue;
return this.collapse(startNode, startOffset);
}
// If startNode and endNode are different,
// then we should process every text node from
// start to end.
// Select initial node.
this.#textNodeIterator.currentNode = startNode;
const startTextSpan = getTextSpan(startNode);
const startParagraph = getParagraph(startNode);
const endTextSpan = getTextSpan(endNode);
const endParagraph = getParagraph(endNode);
const safeGuard = new SafeGuard("removeSelected");
safeGuard.start();
do {
safeGuard.update();
const { currentNode } = this.#textNodeIterator;
// We retrieve the textSpan and paragraph of the
// current node.
const textSpan = getTextSpan(currentNode);
const paragraph = getParagraph(currentNode);
affectedTextSpans.add(textSpan);
affectedParagraphs.add(paragraph);
let shouldRemoveNodeCompletely = false;
const isEndNode = currentNode === endNode;
if (currentNode === startNode) {
if (startOffset === 0) {
// We should remove this node completely.
shouldRemoveNodeCompletely = true;
} else {
// We should remove this node partially.
currentNode.nodeValue = currentNode.nodeValue.slice(0, startOffset);
}
} else if (isEndNode) {
if (
isLineBreak(endNode) ||
(isTextNode(endNode) &&
endOffset >= (endNode.nodeValue?.length || 0))
) {
// We should remove this node completely.
shouldRemoveNodeCompletely = true;
} else {
// We should remove this node partially.
currentNode.nodeValue = currentNode.nodeValue.slice(endOffset);
}
} else {
// We should remove this node completely.
shouldRemoveNodeCompletely = true;
}
// We need to step to the next node before
// we remove them completely from the DOM tree
// because we need to iterate through parents
// and childrens.
this.#textNodeIterator.nextNode();
// We remove the current node.
if (shouldRemoveNodeCompletely) {
currentNode.remove();
if (currentNode === startNode) {
continue;
}
if (textSpan.childNodes.length === 0) {
textSpan.remove();
}
if (paragraph !== startParagraph
&& paragraph.children.length === 0) {
paragraph.remove();
}
}
// Break immediately after processing endNode, before advancing iterator
if (isEndNode) {
break;
}
} while (this.#textNodeIterator.currentNode);
if (startParagraph !== endParagraph) {
const mergedParagraph = mergeParagraphs(startParagraph, endParagraph);
if (mergedParagraph.children.length === 0) {
const newEmptyTextSpan = createEmptyTextSpan(this.#currentStyle);
mergedParagraph.appendChild(newEmptyTextSpan);
return this.collapse(newEmptyTextSpan.firstChild, 0);
}
}
if (
startTextSpan.childNodes.length === 0 &&
endTextSpan.childNodes.length > 0
) {
startTextSpan.remove();
return this.collapse(endNode, 0);
} else if (
startTextSpan.childNodes.length > 0 &&
endTextSpan.childNodes.length === 0
) {
endTextSpan.remove();
return this.collapse(startNode, startOffset);
} else if (
startTextSpan.childNodes.length === 0 &&
endTextSpan.childNodes.length === 0
) {
const previousTextSpan = startTextSpan.previousElementSibling;
const nextTextSpan = endTextSpan.nextElementSibling;
startTextSpan.remove();
endTextSpan.remove();
if (previousTextSpan) {
return this.collapse(
previousTextSpan.firstChild,
previousTextSpan.firstChild.nodeValue?.length || 0,
);
}
if (nextTextSpan) {
return this.collapse(nextTextSpan.firstChild, 0);
}
const newEmptyTextSpan = createEmptyTextSpan(this.#currentStyle);
startParagraph.appendChild(newEmptyTextSpan);
return this.collapse(newEmptyTextSpan.firstChild, 0);
}
return this.collapse(startNode, startOffset);
}
/**
* Returns an object with ranges.
*
* @returns {}
*/
getRanges() {
let startNode = getClosestTextNode(this.#range.startContainer);
let endNode = getClosestTextNode(this.#range.endContainer);
let startOffset = this.#range.startOffset;
let endOffset = this.#range.endOffset;
return { startNode, endNode, startOffset, endOffset };
}
/**
* Returns true if we should remove the complete root.
*
* @param {*} startNode
* @param {*} endNode
* @returns {boolean}
*/
shouldHandleCompleteDeletion(startNode, endNode) {
const root = this.#textEditor.root;
return (
(startNode &&
endNode &&
this.#range.toString() === (root.textContent || "") &&
root.textContent.length > 0) ||
!startNode ||
!endNode
);
}
/**
* Handles complete content deletion when all content is selected.
* @returns {SelectionController}
*/
handleCompleteContentDeletion() {
const root = this.#textEditor.root;
const firstParagraph = root.firstElementChild;
const newTextSpan = createEmptyTextSpan(this.#currentStyle);
if (!newTextSpan.firstChild) {
newTextSpan.appendChild(new Text(""));
}
if (firstParagraph) {
firstParagraph.replaceChildren(newTextSpan);
let currentParagraph = firstParagraph.nextElementSibling;
while (currentParagraph) {
const nextParagraph = currentParagraph.nextElementSibling;
currentParagraph.remove();
currentParagraph = nextParagraph;
}
} else {
const newParagraph = createEmptyParagraph();
newParagraph.appendChild(newTextSpan);
root.replaceChildren(newParagraph);
}
return this.collapse(newTextSpan.firstChild, 0);
}
/**
* Applies styles from the startNode to the endNode.
*
* @param {Node} startNode
* @param {number} startOffset
* @param {Node} endNode
* @param {number} endOffset
* @param {Object.<string,*>|CSSStyleDeclaration} newStyles
* @returns {void}
*/
#applyStylesTo(startNode, startOffset, endNode, endOffset, newStyles) {
// Applies the necessary styles to the root element.
const root = this.#textEditor.root;
setRootStyles(root, newStyles);
// If the startContainer and endContainer are the same
// node, then we can apply styles directly to that
// node.
if (startNode === endNode && startNode.nodeType === Node.TEXT_NODE) {
// The styles are applied to the node completely.
if (startOffset === 0 && endOffset === (endNode.nodeValue?.length || 0)) {
const paragraph = this.startParagraph;
const textSpan = this.startTextSpan;
setParagraphStyles(paragraph, newStyles);
setTextSpanStyles(textSpan, newStyles);
// The styles are applied to a part of the node.
} else if (startOffset !== endOffset) {
const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles);
const textSpan = this.startTextSpan;
const midText = startNode.splitText(startOffset);
const endText = midText.splitText(endOffset - startOffset);
// Only create text span if midText is not empty
if (midText.nodeValue && midText.nodeValue.length > 0) {
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
textSpan.after(midTextSpan);
if (endText.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style);
midTextSpan.after(endTextSpan);
}
} else {
// If midText is empty, just create endTextSpan if needed
if (endText.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style);
textSpan.after(endTextSpan);
}
}
// NOTE: This is necessary because sometimes
// text spans are splitted from the beginning
// to a mid offset and then the starting node
// remains empty.
if (textSpan.firstChild.nodeValue === "") {
textSpan.remove();
}
// FIXME: This can change focus <-> anchor order.
this.setSelection(midText, 0, midText, midText.nodeValue?.length || 0);
}
// the styles are applied to the current caret
else if (
this.startOffset === this.endOffset &&
this.endOffset === endNode.nodeValue?.length
) {
const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles);
const newTextSpan = createVoidTextSpan(newStyles);
this.endTextSpan.after(newTextSpan);
this.setSelection(newTextSpan.firstChild, 0, newTextSpan.firstChild, 0);
}
// The styles are applied to the paragraph
else
{
const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles);
// Apply styles to child text spans.
for (const textSpan of paragraph.children) {
setTextSpanStyles(textSpan, newStyles);
}
}
// If the startContainer and endContainer are different
// then we need to iterate through those nodes to apply
// the styles.
} else if (startNode !== endNode) {
const safeGuard = new SafeGuard("applyStylesTo");
safeGuard.start();
const expectedEndNode = getClosestTextNode(endNode);
this.#textNodeIterator.currentNode = getClosestTextNode(startNode);
do {
safeGuard.update();
const paragraph = getParagraph(this.#textNodeIterator.currentNode);
setParagraphStyles(paragraph, newStyles);
const textSpan = getTextSpan(this.#textNodeIterator.currentNode);
// If we're at the start node and offset is greater than 0
// then we should split the text span and apply styles to that
// new text span.
if (
this.#textNodeIterator.currentNode === startNode &&
startOffset > 0 &&
startOffset < (startNode.nodeValue?.length || 0)
) {
const newTextSpan = splitTextSpan(textSpan, startOffset);
setTextSpanStyles(newTextSpan, newStyles);
textSpan.after(newTextSpan);
// If we're at the start node and offset is equal to 0
// or current node is different to start node and
// different to end node or we're at the end node
// and the offset is equalto the node length
} else if (
(this.#textNodeIterator.currentNode === startNode &&
startOffset === 0) ||
(this.#textNodeIterator.currentNode !== startNode &&
this.#textNodeIterator.currentNode !== endNode) ||
(this.#textNodeIterator.currentNode === endNode &&
endOffset === endNode.nodeValue?.length)
) {
setTextSpanStyles(textSpan, newStyles);
// If we're at end node
} else if (
this.#textNodeIterator.currentNode === endNode &&
endOffset < endNode.nodeValue?.length &&
endOffset > 0
) {
const newTextSpan = splitTextSpan(textSpan, endOffset);
setTextSpanStyles(textSpan, newStyles);
textSpan.after(newTextSpan);
}
// We've reached the final node so we can return safely.
if (this.#textNodeIterator.currentNode === expectedEndNode)
break;
this.#textNodeIterator.nextNode();
} while (this.#textNodeIterator.currentNode);
} else {
// Empty paragraph uses a text span with <br> only (no text node). The
// selection is then on the line-break element, not a TEXT_NODE, so none
// of the branches above run — only setRootStyles applied. Paragraph
// styles (e.g. text-align) must still be applied before the user types.
const paragraph = this.startParagraph;
if (paragraph) {
setParagraphStyles(paragraph, newStyles);
for (const textSpan of paragraph.children) {
setTextSpanStyles(textSpan, newStyles);
}
}
}
return this.#notifyStyleChange();
}
/**
* Applies styles to selection
*
* @param {Object.<string, *>} newStyles
* @returns {void}
*/
applyStyles(newStyles) {
return this.#applyStylesTo(
this.startContainer,
this.startOffset,
this.endContainer,
this.endOffset,
newStyles,
);
}
/**
* BROWSER FIXES
*/
fixInsertCompositionText() {
this.#fixInsertCompositionText = true;
}
}
export default SelectionController;