mirror of
https://github.com/codex-team/editor.js
synced 2026-03-15 15:15:47 +01:00
- 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.
745 lines
18 KiB
TypeScript
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);
|
|
}
|
|
}
|