Files
penpot/frontend/text-editor/src/editor/controllers/SelectionController.js
2025-12-01 15:56:52 +01:00

2058 lines
55 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
*/
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,
} from "../content/dom/TextNode.js";
import TextNodeIterator from "../content/dom/TextNodeIterator.js";
import TextEditor from "../TextEditor.js";
import CommandMutations from "../commands/CommandMutations.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;
/**
* Command Mutations.
*
* @type {CommandMutations}
*/
#mutations = new CommandMutations();
/**
* 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);
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);
// 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.
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
const textSpan = textNode.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.
this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode);
}
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.#ranges.clear();
this.#ranges = null;
this.#range = null;
this.#selection = null;
this.#focusNode = null;
this.#anchorNode = null;
this.#mutations.dispose();
this.#mutations = 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) {
if (this.#savedSelection.anchorNode === this.#savedSelection.focusNode) {
this.#selection.setPosition(
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
} else {
this.#selection.setBaseAndExtent(
this.#savedSelection.anchorNode,
this.#savedSelection.anchorOffset,
this.#savedSelection.focusNode,
this.#savedSelection.focusOffset,
);
}
}
this.#savedSelection = null;
return true;
}
/**
* Marks the start of a mutation.
*
* Clears all the mutations kept in CommandMutations.
*
* @returns {boolean}
*/
startMutation() {
this.#mutations.clear();
if (!this.#focusNode) return false;
return true;
}
/**
* Marks the end of a mutation.
*
* @returns {CommandMutations}
*/
endMutation() {
return this.#mutations;
}
/**
* Selects all content.
*
* @returns {SelectionController}
*/
selectAll() {
if (this.#textEditor.isEmpty) {
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 range = document.createRange(); //Create a range (a range is a like the selection but invisible)
range.selectNodeContents(this.#textEditor.element);
range.collapse(false);
this.#selection.removeAllRanges();
this.#selection.addRange(range);
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;
if (anchorNode === focusNode) {
this.#focusNode = this.#anchorNode;
this.#focusOffset = this.#anchorOffset;
this.#selection.setPosition(anchorNode, anchorOffset);
} else {
this.#focusNode = focusNode;
this.#focusOffset = focusOffset;
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.nodeType === Node.TEXT_NODE;
}
/**
* Indicates that anchor node is a text node.
*
* @type {boolean}
*/
get isTextAnchor() {
return 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) {
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();
return this.collapse(nextTextNode, 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();
return this.collapse(
previousTextNode,
getTextNodeLength(previousTextNode),
);
}
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;
this.#mutations.update(this.focusTextSpan);
}
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();
return this.collapse(
previousTextNode,
getTextNodeLength(previousTextNode),
);
}
}
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,
);
this.#mutations.update(this.focusTextSpan);
return this.collapse(this.focusNode, this.focusOffset + newText.length);
}
/**
* Replaces the currently focus element
* with some text.
*
* @param {string} newText
*/
insertIntoFocus(newText) {
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);
}
this.#mutations.update(this.focusTextSpan);
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);
this.#mutations.update(currentParagraph);
this.#mutations.add(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);
this.#mutations.update(currentParagraph);
this.#mutations.add(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);
this.#mutations.update(currentParagraph);
this.#mutations.add(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);
this.#mutations.update(currentParagraph);
this.#mutations.add(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;
this.#mutations.remove(paragraphToBeRemoved);
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);
}
this.#mutations.remove(currentParagraph);
this.#mutations.update(previousParagraph);
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);
this.#mutations.update(currentParagraph);
this.#mutations.remove(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;
this.#mutations.remove(paragraphToBeRemoved);
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();
this.#mutations.remove(textSpan);
}
}
// Remove empty paragraphs.
for (const paragraph of affectedParagraphs) {
if (paragraph.children.length === 0) {
paragraph.remove();
this.#mutations.remove(paragraph);
}
}
}
/**
* 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);
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;
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 (currentNode === endNode) {
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;
}
this.#textNodeIterator.nextNode();
// Realizamos el borrado del nodo actual.
if (shouldRemoveNodeCompletely) {
currentNode.remove();
if (currentNode === startNode) {
continue;
}
if (textSpan.childNodes.length === 0) {
textSpan.remove();
}
if (paragraph !== startParagraph && paragraph.children.length === 0) {
paragraph.remove();
}
}
if (currentNode === endNode) {
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);
}
getRanges() {
let startNode = getClosestTextNode(this.#range.startContainer);
let endNode = getClosestTextNode(this.#range.endContainer);
let startOffset = this.#range.startOffset;
let endOffset = this.#range.startOffset + this.#range.toString().length;
return { startNode, endNode, startOffset, endOffset };
}
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);
const midTextSpan = createTextSpanFrom(textSpan, midText, newStyles);
textSpan.after(midTextSpan);
if (endText.length > 0) {
const endTextSpan = createTextSpan(endText, textSpan.style);
midTextSpan.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 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);
}
return this.#notifyStyleChange();
// If the startContainer and endContainer are different
// then we need to iterate through those nodes to apply
// the styles.
} else if (startNode !== endNode) {
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
) {
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
) {
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) return;
this.#textNodeIterator.nextNode();
} while (this.#textNodeIterator.currentNode);
}
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;