0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-02-16 03:58:20 -05:00
penpot/frontend/text-editor/editor/controllers/SelectionController.js

1740 lines
45 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 {
createInline,
createInlineFrom,
getInline,
getInlineLength,
isInline,
isInlineStart,
isInlineEnd,
setInlineStyles,
mergeInlines,
splitInline,
createEmptyInline,
} from "../content/dom/Inline.js";
import {
createEmptyParagraph,
isEmptyParagraph,
getParagraph,
isParagraph,
isParagraphStart,
isParagraphEnd,
setParagraphStyles,
splitParagraph,
splitParagraphAtNode,
mergeParagraphs,
fixParagraph,
} from "../content/dom/Paragraph.js";
import {
removeBackward,
removeForward,
replaceWith,
insertInto,
removeSlice,
} 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 { setRootStyles } from "../content/dom/Root.js";
import { SelectionDirection } from "./SelectionDirection.js";
import SafeGuard from "./SafeGuard.js";
const SAFE_GUARD = true;
const SAFE_GUARD_TIME = true;
/**
* Supported options for the SelectionController.
*
* @typedef {Object} SelectionControllerOptions
* @property {Object} [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 drafconst textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
createParagraph([createInline(new Text("Hello, "))]),
createEmptyParagraph(),
createParagraph([createInline(new Text("World!"))]),
]);
const root = textEditorMock.root;
const selection = document.getSelection();
const selectionController = new SelectionController(
textEditorMock,
selection
);
focus(
selection,
textEditorMock,
root.childNodes.item(2).firstChild.firstChild,
0
);
selectionController.mergeBackwardParagraph();
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.children.length).toBe(2);
expect(textEditorMock.root.dataset.itype).toBe("root");
expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement);
expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph");
expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf(
HTMLSpanElement
);
expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe(
"inline"
);
expect(textEditorMock.root.textContent).toBe("Hello, World!");
expect(textEditorMock.root.firstChild.textContent).toBe("Hello, ");
expect(textEditorMock.root.lastChild.textContent).toBe("World!");
t.js they were called blocks) and inlines.
*/
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;
/**
* CSSStyleDeclaration that we can mutate
* to handle style changes.
*
* @type {CSSStyleDeclaration}
*/
#currentStyle = null;
/**
* Element used to have a custom CSSStyleDeclaration
* that we can modify to handle style changes when the
* selection is changed.
*
* @type {HTMLDivElement}
*/
#inertElement = null;
/**
* @type {SelectionControllerDebug}
*/
#debug = null;
/**
* Command Mutations.
*
* @type {CommandMutations}
*/
#mutations = new CommandMutations();
/**
* Style defaults.
*
* @type {Object.<string, *>}
*/
#styleDefaults = null;
/**
* Fix for Chrome.
*/
#fixInsertCompositionText = false;
/**
* 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.#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 inline.
*
* @type {CSSStyleDeclaration}
*/
get currentStyle() {
return this.#currentStyle;
}
/**
* 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
*/
#applyStylesToCurrentStyle(element) {
for (let index = 0; index < element.style.length; index++) {
const styleName = element.style.item(index);
const styleValue = element.style.getPropertyValue(styleName);
this.#currentStyle.setProperty(styleName, styleValue);
}
}
/**
* Updates current styles based on the currently selected inline.
*
* @param {HTMLSpanElement} inline
* @returns {SelectionController}
*/
#updateCurrentStyle(inline) {
this.#applyDefaultStylesToCurrentStyle();
const root = inline.parentElement.parentElement;
this.#applyStylesToCurrentStyle(root);
const paragraph = inline.parentElement;
this.#applyStylesToCurrentStyle(paragraph);
this.#applyStylesToCurrentStyle(inline);
return this;
}
/**
* This is called on every `selectionchange` because it is dispatched
* only by the `document` object.
*
* @param {Event} e
*/
#onSelectionChange = (e) => {
// 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.#focusNode = this.#selection.focusNode;
focusNodeChanges = true;
}
this.#focusOffset = this.#selection.focusOffset;
if (this.#anchorNode !== this.#selection.anchorNode) {
this.#anchorNode = this.#selection.anchorNode;
anchorNodeChanges = true;
}
this.#anchorOffset = this.#selection.anchorOffset;
// 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 inline and dispatch an event
// to notify that the styles have changed.
if (focusNodeChanges) {
this.#notifyStyleChange();
}
if (this.#fixInsertCompositionText) {
this.#fixInsertCompositionText = false;
const lineBreak = fixParagraph(this.focusNode);
this.collapse(lineBreak, 0);
}
if (this.#debug) {
this.#debug.update(this);
}
};
/**
* Notifies that the styles have changed.
*/
#notifyStyleChange() {
const inline = this.focusInline;
if (inline) {
this.#updateCurrentStyle(inline);
this.dispatchEvent(
new CustomEvent("stylechange", {
detail: this.#currentStyle,
})
);
}
}
/**
* Setups
*/
#setup() {
// This element is not attached to the DOM
// so it doesn't trigger style or layout calculations.
// That's why it's called "inertElement".
this.#inertElement = document.createElement("div");
this.#currentStyle = this.#inertElement.style;
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);
}
/**
* 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.
*/
startMutation() {
this.#mutations.clear();
if (!this.#focusNode) return false;
return true;
}
/**
* Marks the end of a mutation.
*
* @returns
*/
endMutation() {
return this.#mutations;
}
/**
* Selects all content.
*/
selectAll() {
this.#selection.selectAllChildren(this.#textEditor.root);
return this;
}
/**
* Moves cursor to end.
*/
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 nodeOffset = (node.nodeType === Node.TEXT_NODE && offset >= node.nodeValue.length)
? node.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
);
}
}
}
/**
* 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 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;
}
/**
* Current or saved focus node.
*
* @type {Node}
*/
get focusNode() {
if (this.#savedSelection) {
return this.#savedSelection.focusNode;
}
if (!this.#focusNode)
console.trace("focusNode", this.#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;
}
/**
* Returns the paragraph in the focus node
* of the current selection.
*
* @type {HTMLElement|null}
*/
get focusParagraph() {
return getParagraph(this.focusNode);
}
/**
* Returns the inline in the focus node
* of the current selection.
*
* @type {HTMLElement|null}
*/
get focusInline() {
return getInline(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 inline in the anchor
* node of the current selection.
*
* @type {HTMLElement|null}
*/
get anchorInline() {
return getInline(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 inline of the current page.
*
* @type {HTMLElement|null}
*/
get startInline() {
const startContainer = this.startContainer;
if (!startContainer) return null;
return getInline(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);
}
/**
* Inline element of the `endContainer` of
* the current range.
*
* @type {HTMLElement|null}
*/
get endInline() {
const endContainer = this.endContainer;
if (!endContainer) return null;
return getInline(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 inline.
*
* @type {boolean}
*/
get isInlineFocus() {
return isInline(this.focusNode);
}
/**
* Is true if the current anchor node is a inline.
*
* @type {boolean}
*/
get isInlineAnchor() {
return isInline(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) ||
(isInline(this.focusNode) && isLineBreak(this.focusNode.firstChild))
);
}
/**
* 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
* inline elements.
*
* @type {boolean}
*/
get isMultiInline() {
return this.isMulti && this.focusInline !== this.anchorInline;
}
/**
* Indicates that the caret (only the caret)
* is at the start of an inline.
*
* @type {boolean}
*/
get isInlineStart() {
if (!this.isCollapsed) return false;
return isInlineStart(this.focusNode, this.focusOffset);
}
/**
* Indicates that the caret (only the caret)
* is at the end of an inline. This value doesn't
* matter when dealing with selections.
*
* @type {boolean}
*/
get isInlineEnd() {
if (!this.isCollapsed) return false;
return isInlineEnd(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);
}
/**
* Insert pasted fragment.
*
* @param {DocumentFragment} fragment
*/
insertPaste(fragment) {
const numParagraphs = fragment.children.length;
if (this.isParagraphStart) {
this.focusParagraph.before(fragment);
} else if (this.isParagraphEnd) {
this.focusParagraph.after(fragment);
} else {
const newParagraph = splitParagraph(
this.focusParagraph,
this.focusInline,
this.focusOffset
);
this.focusParagraph.after(fragment, newParagraph);
}
}
/**
* Replaces data with pasted fragment
*
* @param {DocumentFragment} fragment
*/
replaceWithPaste(fragment) {
const numParagraphs = fragment.children.length;
this.removeSelected();
this.insertPaste(fragment);
}
/**
* Replaces the current line break with text
*
* @param {string} text
*/
replaceLineBreak(text) {
const newText = new Text(text);
this.focusInline.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 inline = this.focusInline;
if (!inline) throw new Error("Cannot find inline");
const nextTextNode = this.#textNodeIterator.nextNode();
if (this.focusNode.nodeValue === "") {
this.focusNode.remove();
}
if (paragraph.childNodes.length === 1 && inline.childNodes.length === 0) {
const lineBreak = createLineBreak();
inline.appendChild(lineBreak);
return this.collapse(lineBreak, 0);
} else if (
paragraph.childNodes.length > 1 &&
inline.childNodes.length === 0
) {
inline.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 inline = this.focusInline;
if (!inline) throw new Error("Cannot find inline");
const previousTextNode = this.#textNodeIterator.previousNode();
if (this.focusNode.nodeValue === "") {
this.focusNode.remove();
}
if (paragraph.children.length === 1 && inline.childNodes.length === 0) {
const lineBreak = createLineBreak();
inline.appendChild(lineBreak);
return this.collapse(lineBreak, 0);
} else if (
paragraph.children.length > 1 &&
inline.childNodes.length === 0
) {
inline.remove();
return this.collapse(previousTextNode, getTextNodeLength(previousTextNode));
}
return this.collapse(this.focusNode, this.focusOffset - 1);
}
/**
* 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.focusInline);
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
);
} else if (this.isLineBreakFocus) {
const textNode = new Text(newText);
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 {
throw new Error('Unknown node type');
}
this.#mutations.update(this.focusInline);
return this.collapse(this.focusNode, startOffset + newText.length);
}
/**
* Replaces the selected inlines with new text.
*
* @param {string} newText
*/
replaceInlines(newText) {
const currentParagraph = this.focusParagraph;
// This is the special (and fast) case where we're
// removing everything inside a paragraph.
if (
this.startInline === currentParagraph.firstChild &&
this.startOffset === 0 &&
this.endInline === currentParagraph.lastChild &&
this.endOffset === currentParagraph.lastChild.textContent.length
) {
const newTextNode = new Text(newText);
currentParagraph.replaceChildren(
createInline(newTextNode, this.anchorInline.style)
);
return this.collapse(newTextNode, newTextNode.nodeValue.length);
}
this.removeSelected();
this.insertIntoFocus(newText);
/*
this.focusNode.nodeValue = insertInto(
this.focusNode.nodeValue,
this.focusOffset,
newText
);
*/
// FIXME: I'm not sure if we should merge inlines when they share the same styles.
// For example: if we have > 2 inlines and the start inline and the end inline
// share the same styles, maybe we should merge them?
// mergeInlines(startInline, endInline);
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.focusInline,
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 currentInline = this.focusInline;
this.removeSelected();
const newParagraph = splitParagraph(
currentParagraph,
currentInline,
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 previousInline =
previousParagraph.children.length > 1
? previousParagraph.lastElementChild
: previousParagraph.firstChild;
const previousOffset = isLineBreak(previousInline.firstChild)
? 0
: previousInline.firstChild.nodeValue.length;
this.#mutations.remove(paragraphToBeRemoved);
return this.collapse(previousInline.firstChild, previousOffset);
}
/**
* Merges the previous paragraph with the current paragraph.
*/
mergeBackwardParagraph() {
const currentParagraph = this.focusParagraph;
const previousParagraph = this.focusParagraph.previousElementSibling;
if (!previousParagraph) {
return;
}
let previousInline = previousParagraph.lastChild;
const previousOffset = getInlineLength(previousInline);
if (isEmptyParagraph(previousParagraph)) {
previousParagraph.replaceChildren(...currentParagraph.children);
previousInline = previousParagraph.firstChild;
currentParagraph.remove();
} else {
mergeParagraphs(previousParagraph, currentParagraph);
}
this.#mutations.remove(currentParagraph);
this.#mutations.update(previousParagraph);
return this.collapse(previousInline.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 nextInline = nextParagraph.firstChild;
const nextOffset = this.focusOffset;
this.#mutations.remove(paragraphToBeRemoved);
return this.collapse(nextInline.firstChild, nextOffset);
}
/**
* Cleans up all the affected paragraphs.
*
* @param {Set<HTMLDivElement>} affectedParagraphs
* @param {Set<HTMLSpanElement>} affectedInlines
*/
cleanUp(affectedParagraphs, affectedInlines) {
// Remove empty inlines
for (const inline of affectedInlines) {
if (inline.textContent === "") {
inline.remove();
this.#mutations.remove(inline);
}
}
// 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 affectedInlines = new Set();
const affectedParagraphs = new Set();
const startNode = getClosestTextNode(this.#range.startContainer);
const endNode = getClosestTextNode(this.#range.endContainer);
const startOffset = this.#range.startOffset;
const endOffset = this.#range.endOffset;
let previousNode = null;
let nextNode = null;
// This is the simplest case, when the startNode and the endNode
// are the same and they're a textNode.
if (startNode === endNode) {
this.#textNodeIterator.currentNode = startNode;
previousNode = this.#textNodeIterator.previousNode();
this.#textNodeIterator.currentNode = startNode;
nextNode = this.#textNodeIterator.nextNode();
const inline = getInline(startNode);
const paragraph = getParagraph(startNode);
affectedInlines.add(inline);
affectedParagraphs.add(paragraph);
const newNodeValue = removeSlice(
startNode.nodeValue,
startOffset,
endOffset
);
if (newNodeValue === "") {
const lineBreak = createLineBreak();
inline.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 startInline = getInline(startNode);
const startParagraph = getParagraph(startNode);
const endInline = getInline(endNode);
const endParagraph = getParagraph(endNode);
SafeGuard.start();
do {
SafeGuard.update();
const currentNode = this.#textNodeIterator.currentNode;
// We retrieve the inline and paragraph of the
// current node.
const inline = getInline(this.#textNodeIterator.currentNode);
const paragraph = getParagraph(this.#textNodeIterator.currentNode);
let shouldRemoveNodeCompletely = false;
if (this.#textNodeIterator.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 (this.#textNodeIterator.currentNode === endNode) {
if (isLineBreak(endNode)
|| (isTextNode(endNode)
&& endOffset === endNode.nodeValue.length)) {
// 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 (currentNode === endNode) {
break;
}
if (inline.childNodes.length === 0) {
inline.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 newEmptyInline = createEmptyInline(this.#currentStyle);
mergedParagraph.appendChild(newEmptyInline);
return this.collapse(newEmptyInline.firstChild, 0);
}
}
if (startInline.childNodes.length === 0 && endInline.childNodes.length > 0) {
startInline.remove();
return this.collapse(endNode, 0);
} else if (startInline.childNodes.length > 0 && endInline.childNodes.length === 0) {
endInline.remove();
return this.collapse(startNode, startOffset);
} else if (startInline.childNodes.length === 0 && endInline.childNodes.length === 0) {
const previousInline = startInline.previousElementSibling;
const nextInline = endInline.nextElementSibling;
startInline.remove();
endInline.remove();
if (previousInline) {
return this.collapse(previousInline.firstChild, previousInline.firstChild.nodeValue.length);
}
if (nextInline) {
return this.collapse(nextInline.firstChild, 0);
}
const newEmptyInline = createEmptyInline(this.#currentStyle);
startParagraph.appendChild(newEmptyInline);
return this.collapse(newEmptyInline.firstChild, 0);
}
return this.collapse(startNode, startOffset);
}
/**
* 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 completelly.
if (startOffset === 0 && endOffset === endNode.nodeValue.length) {
const paragraph = this.startParagraph;
const inline = this.startInline;
setParagraphStyles(paragraph, newStyles);
setInlineStyles(inline, newStyles);
// The styles are applied to a part of the node.
} else if (startOffset !== endOffset) {
const paragraph = this.startParagraph;
setParagraphStyles(paragraph, newStyles);
const inline = this.startInline;
const midText = startNode.splitText(startOffset);
const endText = midText.splitText(endOffset - startOffset);
const midInline = createInlineFrom(inline, midText, newStyles);
inline.after(midInline);
if (endText.length > 0) {
const endInline = createInline(endText, inline.style);
midInline.after(endInline);
}
// FIXME: This can change focus <-> anchor order.
this.setSelection(midText, 0, midText, midText.nodeValue.length);
// 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 inline = getInline(this.#textNodeIterator.currentNode);
// If we're at the start node and offset is greater than 0
// then we should split the inline and apply styles to that
// new inline.
if (
this.#textNodeIterator.currentNode === startNode &&
startOffset > 0
) {
const newInline = splitInline(inline, startOffset);
setInlineStyles(newInline, newStyles);
inline.after(newInline);
// 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)
) {
setInlineStyles(inline, newStyles);
// If we're at end node
} else if (
this.#textNodeIterator.currentNode === endNode &&
endOffset < endNode.nodeValue.length
) {
const newInline = splitInline(inline, endOffset);
setInlineStyles(inline, newStyles);
inline.after(newInline);
}
// 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;