editor.js/src/components/selection.ts
JackUait c48898bb5b refactor: update inline tool interfaces and remove deprecated methods
- Refactored inline tool interfaces to use MenuConfig directly.
- Removed deprecated methods and properties from InlineTool and related types.
- Updated tests to reflect changes in inline tool handling and ensure proper functionality.
- Enhanced test coverage for inline tools, including link and italic tools.
- Cleaned up unused code and improved overall test structure.
2025-11-22 02:46:08 +03:00

745 lines
18 KiB
TypeScript

/**
* TextRange interface for IE9-
*/
import * as _ from './utils';
import $ from './dom';
interface TextRange {
boundingTop: number;
boundingLeft: number;
boundingBottom: number;
boundingRight: number;
boundingHeight: number;
boundingWidth: number;
}
/**
* Interface for object returned by document.selection in IE9-
*/
interface MSSelection {
createRange: () => TextRange;
type: string;
}
/**
* Extends Document interface for IE9-
*/
interface Document {
selection?: MSSelection;
}
/**
* Working with selection
*
* @typedef {SelectionUtils} SelectionUtils
*/
export default class SelectionUtils {
/**
* Selection instances
*
* @todo Check if this is still relevant
*/
public instance: Selection | null = null;
public selection: Selection | null = null;
/**
* This property can store SelectionUtils's range for restoring later
*
* @type {Range|null}
*/
public savedSelectionRange: Range | null = null;
/**
* Fake background is active
*
* @returns {boolean}
*/
public isFakeBackgroundEnabled = false;
/**
* Elements that currently imitate the selection highlight
*/
private fakeBackgroundElements: HTMLElement[] = [];
/**
* Editor styles
*
* @returns {{editorWrapper: string, editorZone: string}}
*/
public static get CSS(): { editorWrapper: string; editorZone: string } {
return {
editorWrapper: 'codex-editor',
editorZone: 'codex-editor__redactor',
};
}
/**
* Returns selected anchor
* {@link https://developer.mozilla.org/ru/docs/Web/API/Selection/anchorNode}
*
* @returns {Node|null}
*/
public static get anchorNode(): Node | null {
const selection = window.getSelection();
return selection ? selection.anchorNode : null;
}
/**
* Returns selected anchor element
*
* @returns {Element|null}
*/
public static get anchorElement(): Element | null {
const selection = window.getSelection();
if (!selection) {
return null;
}
const anchorNode = selection.anchorNode;
if (!anchorNode) {
return null;
}
if (!$.isElement(anchorNode)) {
return anchorNode.parentElement;
} else {
return anchorNode;
}
}
/**
* Returns selection offset according to the anchor node
* {@link https://developer.mozilla.org/ru/docs/Web/API/Selection/anchorOffset}
*
* @returns {number|null}
*/
public static get anchorOffset(): number | null {
const selection = window.getSelection();
return selection ? selection.anchorOffset : null;
}
/**
* Is current selection range collapsed
*
* @returns {boolean|null}
*/
public static get isCollapsed(): boolean | null {
const selection = window.getSelection();
return selection ? selection.isCollapsed : null;
}
/**
* Check current selection if it is at Editor's zone
*
* @returns {boolean}
*/
public static get isAtEditor(): boolean {
return this.isSelectionAtEditor(SelectionUtils.get());
}
/**
* Check if passed selection is at Editor's zone
*
* @param selection - Selection object to check
*/
public static isSelectionAtEditor(selection: Selection | null): boolean {
if (!selection) {
return false;
}
/**
* Something selected on document
*/
const initialNode = selection.anchorNode || selection.focusNode;
const selectedNode = initialNode && initialNode.nodeType === Node.TEXT_NODE
? initialNode.parentNode
: initialNode;
const editorZone = selectedNode && selectedNode instanceof Element
? selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`)
: null;
/**
* SelectionUtils is not out of Editor because Editor's wrapper was found
*/
return editorZone ? editorZone.nodeType === Node.ELEMENT_NODE : false;
}
/**
* Check if passed range at Editor zone
*
* @param range - range to check
*/
public static isRangeAtEditor(range: Range): boolean | void {
if (!range) {
return;
}
const selectedNode: Node | null =
range.startContainer && range.startContainer.nodeType === Node.TEXT_NODE
? range.startContainer.parentNode
: range.startContainer;
const editorZone =
selectedNode && selectedNode instanceof Element
? selectedNode.closest(`.${SelectionUtils.CSS.editorZone}`)
: null;
/**
* SelectionUtils is not out of Editor because Editor's wrapper was found
*/
return editorZone ? editorZone.nodeType === Node.ELEMENT_NODE : false;
}
/**
* Methods return boolean that true if selection exists on the page
*/
public static get isSelectionExists(): boolean {
const selection = SelectionUtils.get();
return !!selection?.anchorNode;
}
/**
* Return first range
*
* @returns {Range|null}
*/
public static get range(): Range | null {
return this.getRangeFromSelection(this.get());
}
/**
* Returns range from passed Selection object
*
* @param selection - Selection object to get Range from
*/
public static getRangeFromSelection(selection: Selection | null): Range | null {
return selection && selection.rangeCount ? selection.getRangeAt(0) : null;
}
/**
* Calculates position and size of selected text
*
* @returns {DOMRect}
*/
public static get rect(): DOMRect {
const ieSel: Selection | MSSelection | undefined | null = (document as Document).selection;
const rect = {
x: 0,
y: 0,
width: 0,
height: 0,
} as DOMRect;
if (ieSel && ieSel.type !== 'Control') {
const msSel = ieSel as MSSelection;
const range = msSel.createRange() as TextRange;
rect.x = range.boundingLeft;
rect.y = range.boundingTop;
rect.width = range.boundingWidth;
rect.height = range.boundingHeight;
return rect;
}
const sel = window.getSelection();
if (!sel) {
_.log('Method window.getSelection returned null', 'warn');
return rect;
}
if (sel.rangeCount === null || isNaN(sel.rangeCount)) {
_.log('Method SelectionUtils.rangeCount is not supported', 'warn');
return rect;
}
if (sel.rangeCount === 0) {
return rect;
}
const range = sel.getRangeAt(0).cloneRange() as Range;
const initialRect = range.getBoundingClientRect() as DOMRect;
// Fall back to inserting a temporary element
if (initialRect.x === 0 && initialRect.y === 0) {
const span = document.createElement('span');
// Ensure span has dimensions and position by
// adding a zero-width space character
span.appendChild(document.createTextNode('\u200b'));
range.insertNode(span);
const boundingRect = span.getBoundingClientRect() as DOMRect;
const spanParent = span.parentNode;
spanParent?.removeChild(span);
// Glue any broken text nodes back together
spanParent?.normalize();
return boundingRect;
}
return initialRect;
}
/**
* Returns selected text as String
*
* @returns {string}
*/
public static get text(): string {
const selection = window.getSelection();
return selection?.toString() ?? '';
}
/**
* Returns window SelectionUtils
* {@link https://developer.mozilla.org/ru/docs/Web/API/Window/getSelection}
*
* @returns {Selection}
*/
public static get(): Selection | null {
return window.getSelection();
}
/**
* Set focus to contenteditable or native input element
*
* @param element - element where to set focus
* @param offset - offset of cursor
*/
public static setCursor(element: HTMLElement, offset = 0): DOMRect {
const range = document.createRange();
const selection = window.getSelection();
const isNativeInput = $.isNativeInput(element);
/** if found deepest node is native input */
if (isNativeInput && !$.canSetCaret(element)) {
return element.getBoundingClientRect();
}
if (isNativeInput) {
const inputElement = element as HTMLInputElement | HTMLTextAreaElement;
inputElement.focus();
inputElement.selectionStart = offset;
inputElement.selectionEnd = offset;
return inputElement.getBoundingClientRect();
}
range.setStart(element, offset);
range.setEnd(element, offset);
if (!selection) {
return element.getBoundingClientRect();
}
selection.removeAllRanges();
selection.addRange(range);
return range.getBoundingClientRect();
}
/**
* Check if current range exists and belongs to container
*
* @param container - where range should be
*/
public static isRangeInsideContainer(container: HTMLElement): boolean {
const range = SelectionUtils.range;
if (range === null) {
return false;
}
return container.contains(range.startContainer);
}
/**
* Adds fake cursor to the current range
*/
public static addFakeCursor(): void {
const range = SelectionUtils.range;
if (range === null) {
return;
}
const fakeCursor = $.make('span', 'codex-editor__fake-cursor');
fakeCursor.dataset.mutationFree = 'true';
range.collapse();
range.insertNode(fakeCursor);
}
/**
* Check if passed element contains a fake cursor
*
* @param el - where to check
*/
public static isFakeCursorInsideContainer(el: HTMLElement): boolean {
return $.find(el, `.codex-editor__fake-cursor`) !== null;
}
/**
* Removes fake cursor from a container
*
* @param container - container to look for
*/
public static removeFakeCursor(container: HTMLElement = document.body): void {
const fakeCursor = $.find(container, `.codex-editor__fake-cursor`);
if (!fakeCursor) {
return;
}
fakeCursor.remove();
}
/**
* Removes fake background
*/
public removeFakeBackground(): void {
if (!this.fakeBackgroundElements.length) {
this.isFakeBackgroundEnabled = false;
return;
}
const firstElement = this.fakeBackgroundElements[0];
const lastElement = this.fakeBackgroundElements[this.fakeBackgroundElements.length - 1];
const firstChild = firstElement.firstChild;
const lastChild = lastElement.lastChild;
this.fakeBackgroundElements.forEach((element) => {
this.unwrapFakeBackground(element);
});
if (firstChild && lastChild) {
const newRange = document.createRange();
newRange.setStart(firstChild, 0);
newRange.setEnd(lastChild, lastChild.textContent?.length || 0);
this.savedSelectionRange = newRange;
}
this.fakeBackgroundElements = [];
this.isFakeBackgroundEnabled = false;
}
/**
* Sets fake background
*/
public setFakeBackground(): void {
this.removeFakeBackground();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (range.collapsed) {
return;
}
const textNodes = this.collectTextNodes(range);
if (textNodes.length === 0) {
return;
}
const anchorStartNode = range.startContainer;
const anchorStartOffset = range.startOffset;
const anchorEndNode = range.endContainer;
const anchorEndOffset = range.endOffset;
this.fakeBackgroundElements = [];
textNodes.forEach((textNode) => {
const segmentRange = document.createRange();
const isStartNode = textNode === anchorStartNode;
const isEndNode = textNode === anchorEndNode;
const startOffset = isStartNode ? anchorStartOffset : 0;
const nodeTextLength = textNode.textContent?.length ?? 0;
const endOffset = isEndNode ? anchorEndOffset : nodeTextLength;
if (startOffset === endOffset) {
return;
}
segmentRange.setStart(textNode, startOffset);
segmentRange.setEnd(textNode, endOffset);
const wrapper = this.wrapRangeWithFakeBackground(segmentRange);
if (wrapper) {
this.fakeBackgroundElements.push(wrapper);
}
});
if (!this.fakeBackgroundElements.length) {
return;
}
const visualRange = document.createRange();
visualRange.setStartBefore(this.fakeBackgroundElements[0]);
visualRange.setEndAfter(this.fakeBackgroundElements[this.fakeBackgroundElements.length - 1]);
selection.removeAllRanges();
selection.addRange(visualRange);
this.isFakeBackgroundEnabled = true;
}
/**
* Collects text nodes that intersect with the passed range
*
* @param range - selection range
*/
private collectTextNodes(range: Range): Text[] {
const nodes: Text[] = [];
const { commonAncestorContainer } = range;
if (commonAncestorContainer.nodeType === Node.TEXT_NODE) {
nodes.push(commonAncestorContainer as Text);
return nodes;
}
const walker = document.createTreeWalker(
commonAncestorContainer,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node: Node): number => {
if (!range.intersectsNode(node)) {
return NodeFilter.FILTER_REJECT;
}
return node.textContent && node.textContent.length > 0
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
}
);
while (walker.nextNode()) {
nodes.push(walker.currentNode as Text);
}
return nodes;
}
/**
* Wraps passed range (that belongs to the single text node) with fake background element
*
* @param range - range to wrap
*/
private wrapRangeWithFakeBackground(range: Range): HTMLElement | null {
if (range.collapsed) {
return null;
}
const wrapper = $.make('span', 'codex-editor__fake-background');
wrapper.dataset.fakeBackground = 'true';
wrapper.dataset.mutationFree = 'true';
wrapper.style.backgroundColor = '#a8d6ff';
wrapper.style.color = 'inherit';
wrapper.style.display = 'inline';
wrapper.style.padding = '0';
wrapper.style.margin = '0';
const contents = range.extractContents();
if (contents.childNodes.length === 0) {
return null;
}
wrapper.appendChild(contents);
range.insertNode(wrapper);
return wrapper;
}
/**
* Removes fake background wrapper
*
* @param element - wrapper element
*/
private unwrapFakeBackground(element: HTMLElement): void {
const parent = element.parentNode;
if (!parent) {
return;
}
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
}
/**
* Save SelectionUtils's range
*/
public save(): void {
this.savedSelectionRange = SelectionUtils.range;
}
/**
* Restore saved SelectionUtils's range
*/
public restore(): void {
if (!this.savedSelectionRange) {
return;
}
const sel = window.getSelection();
if (!sel) {
return;
}
sel.removeAllRanges();
sel.addRange(this.savedSelectionRange);
}
/**
* Clears saved selection
*/
public clearSaved(): void {
this.savedSelectionRange = null;
}
/**
* Collapse current selection
*/
public collapseToEnd(): void {
const sel = window.getSelection();
if (!sel || !sel.focusNode) {
return;
}
const range = document.createRange();
range.selectNodeContents(sel.focusNode);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
/**
* Looks ahead to find passed tag from current selection
*
* @param {string} tagName - tag to found
* @param {string} [className] - tag's class name
* @param {number} [searchDepth] - count of tags that can be included. For better performance.
* @returns {HTMLElement|null}
*/
public findParentTag(tagName: string, className?: string, searchDepth = 10): HTMLElement | null {
const selection = window.getSelection();
/**
* If selection is missing or no anchorNode or focusNode were found then return null
*/
if (!selection || !selection.anchorNode || !selection.focusNode) {
return null;
}
/**
* Define Nodes for start and end of selection
*/
const boundNodes = [
/** the Node in which the selection begins */
selection.anchorNode as HTMLElement,
/** the Node in which the selection ends */
selection.focusNode as HTMLElement,
];
/**
* Helper function to find parent tag starting from a given node
*
* @param {HTMLElement} startNode - node to start searching from
* @returns {HTMLElement | null}
*/
const findTagFromNode = (startNode: HTMLElement): HTMLElement | null => {
const searchUpTree = (node: HTMLElement, depth: number): HTMLElement | null => {
if (depth <= 0 || !node.parentNode) {
return null;
}
const parent = node.parentNode as HTMLElement;
const hasMatchingClass = !className || (parent.classList && parent.classList.contains(className));
const hasMatchingTag = parent.tagName === tagName;
if (hasMatchingTag && hasMatchingClass) {
return parent;
}
return searchUpTree(parent, depth - 1);
};
return searchUpTree(startNode, searchDepth);
};
/**
* For each selection parent Nodes we try to find target tag [with target class name]
*/
for (const node of boundNodes) {
const foundTag = findTagFromNode(node);
if (foundTag) {
return foundTag;
}
}
/**
* Return null if tag was not found
*/
return null;
}
/**
* Expands selection range to the passed parent node
*
* @param {HTMLElement} element - element which contents should be selected
*/
public expandToTag(element: HTMLElement): void {
const selection = window.getSelection();
if (!selection) {
return;
}
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(element);
selection.addRange(range);
}
}