fix: get rid of deprecated API in inline-tool-bold.ts

This commit is contained in:
JackUait 2025-11-10 16:54:40 +03:00
commit f26202e8d7
11 changed files with 825 additions and 93 deletions

View file

@ -14,7 +14,7 @@ import { defineConfig } from '@playwright/test';
*/
export default defineConfig({
testDir: 'test/playwright/tests',
timeout: 10_000,
timeout: 5_000,
expect: {
timeout: 5_000,
},

View file

@ -336,12 +336,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* Measuring execution time
*/
const measuringStart = window.performance.now();
let measuringEnd;
return Promise.resolve(extractedBlock)
.then((finishedExtraction) => {
/** measure promise execution */
measuringEnd = window.performance.now();
const measuringEnd = window.performance.now();
return {
id: this.id,
@ -367,13 +366,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* @returns {Promise<boolean>} valid
*/
public async validate(data: BlockToolData): Promise<boolean> {
let isValid = true;
if (this.toolInstance.validate instanceof Function) {
isValid = await this.toolInstance.validate(data);
return await this.toolInstance.validate(data);
}
return isValid;
return true;
}
/**
@ -770,9 +767,9 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* @returns {HTMLDivElement}
*/
private compose(): HTMLDivElement {
const wrapper = $.make('div', Block.CSS.wrapper) as HTMLDivElement,
contentNode = $.make('div', Block.CSS.content),
pluginsContent = this.toolInstance.render();
const wrapper = $.make('div', Block.CSS.wrapper) as HTMLDivElement;
const contentNode = $.make('div', Block.CSS.content);
const pluginsContent = this.toolInstance.render();
if (import.meta.env.MODE === 'test') {
wrapper.setAttribute('data-cy', 'block-wrapper');
@ -792,6 +789,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
// Handle async render: resolve the promise and update DOM when ready
pluginsContent.then((resolvedElement) => {
this.toolRenderedElement = resolvedElement;
this.addToolDataAttributes(resolvedElement);
contentNode.appendChild(resolvedElement);
}).catch((error) => {
_.log(`Tool render promise rejected: %o`, 'error', error);
@ -799,6 +797,7 @@ export default class Block extends EventsDispatcher<BlockEvents> {
} else {
// Handle synchronous render
this.toolRenderedElement = pluginsContent;
this.addToolDataAttributes(pluginsContent);
contentNode.appendChild(pluginsContent);
}
@ -811,24 +810,39 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* </tune1wrapper>
* </tune2wrapper>
*/
let wrappedContentNode: HTMLElement = contentNode;
[...this.tunesInstances.values(), ...this.defaultTunesInstances.values()]
.forEach((tune) => {
const wrappedContentNode: HTMLElement = [...this.tunesInstances.values(), ...this.defaultTunesInstances.values()]
.reduce((acc, tune) => {
if (_.isFunction(tune.wrap)) {
try {
wrappedContentNode = tune.wrap(wrappedContentNode);
return tune.wrap(acc);
} catch (e) {
_.log(`Tune ${tune.constructor.name} wrap method throws an Error %o`, 'warn', e);
return acc;
}
}
});
return acc;
}, contentNode);
wrapper.appendChild(wrappedContentNode);
return wrapper;
}
/**
* Add data attributes to tool-rendered element based on tool name
*
* @param element - The tool-rendered element
* @private
*/
private addToolDataAttributes(element: HTMLElement): void {
// Add data-block-tool attribute to identify the tool type used for the block
if (this.name === 'paragraph' && element.classList.contains('ce-paragraph')) {
element.setAttribute('data-block-tool', 'paragraph');
}
}
/**
* Instantiate Block Tunes
*
@ -925,13 +939,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
/**
* We won't fire a Block mutation event if mutation contain only nodes marked with 'data-mutation-free' attributes
*/
let shouldFireUpdate;
const shouldFireUpdate = (() => {
if (isManuallyDispatched || isInputEventHandler) {
return true;
}
if (isManuallyDispatched) {
shouldFireUpdate = true;
} else if (isInputEventHandler) {
shouldFireUpdate = true;
} else {
/**
* Update from 2023, Feb 17:
* Changed mutationsOrInputEvent.some() to mutationsOrInputEvent.every()
@ -948,24 +960,20 @@ export default class Block extends EventsDispatcher<BlockEvents> {
];
return changedNodes.some((node) => {
if (!$.isElement(node)) {
/**
* "characterData" mutation record has Text node as a target, so we need to get parent element to check it for mutation-free attribute
*/
const parentElement = node.parentElement;
const elementToCheck: Element | null = !$.isElement(node)
? node.parentElement ?? null
: node;
if (!parentElement) {
return false;
}
node = parentElement;
if (elementToCheck === null) {
return false;
}
return (node as HTMLElement).closest('[data-mutation-free="true"]') !== null;
return elementToCheck.closest('[data-mutation-free="true"]') !== null;
});
});
shouldFireUpdate = !everyRecordIsMutationFree;
}
return !everyRecordIsMutationFree;
})();
/**
* In case some mutation free elements are added or removed, do not trigger didMutated event

View file

@ -40,6 +40,8 @@ export default class BoldInlineTool implements InlineTool {
private static inputListenerRegistered = false;
private static markerSequence = 0;
private static readonly DATA_ATTR_COLLAPSED_LENGTH = 'data-bold-collapsed-length';
private static readonly DATA_ATTR_COLLAPSED_ACTIVE = 'data-bold-collapsed-active';
private static readonly DATA_ATTR_PREV_LENGTH = 'data-bold-prev-length';
private static readonly instances = new Set<BoldInlineTool>();
/**
@ -190,12 +192,14 @@ export default class BoldInlineTool implements InlineTool {
const range = selection.getRangeAt(0);
if (range.collapsed) {
this.toggleCollapsedSelection();
this.handleCollapsedToggle(selection);
return;
}
const shouldUnwrap = this.isRangeBold(range, { ignoreWhitespace: false });
// Check if selection is visually bold (ignoring whitespace) to match button state
// If visually bold, unwrap; otherwise wrap
const shouldUnwrap = this.isRangeBold(range, { ignoreWhitespace: true });
if (shouldUnwrap) {
this.unwrapBoldTags(range);
@ -204,6 +208,32 @@ export default class BoldInlineTool implements InlineTool {
}
}
/**
* Handle bold toggling for collapsed selections
*
* @param selection - Current selection
*/
private handleCollapsedToggle(selection: Selection): void {
if (!BoldInlineTool.tryNativeBold(selection)) {
this.toggleCollapsedSelection();
return;
}
BoldInlineTool.normalizeBoldTagsWithinEditor(selection);
BoldInlineTool.replaceNbspInBlock(selection);
const updatedRange = selection.rangeCount ? selection.getRangeAt(0) : null;
if (updatedRange) {
BoldInlineTool.exitCollapsedIfNeeded(selection, updatedRange);
}
BoldInlineTool.removeEmptyBoldElements(selection);
BoldInlineTool.moveCaretAfterBoundaryBold(selection);
this.notifySelectionChange();
}
/**
* Check if current selection is within a bold tag (<strong>)
*
@ -226,10 +256,14 @@ export default class BoldInlineTool implements InlineTool {
*/
private wrapWithBold(range: Range): void {
const html = this.getRangeHtmlWithoutBold(range);
document.execCommand('insertHTML', false, `<strong>${html}</strong>`);
const insertedRange = this.replaceRangeWithHtml(range, `<strong>${html}</strong>`);
const selection = window.getSelection();
if (selection && insertedRange) {
selection.removeAllRanges();
selection.addRange(insertedRange);
}
const boldElement = selection ? BoldInlineTool.findBoldElement(selection.focusNode) : null;
if (!boldElement) {
@ -252,17 +286,63 @@ export default class BoldInlineTool implements InlineTool {
* @param range - The Range object containing the selection to unwrap
*/
private unwrapBoldTags(range: Range): void {
const html = this.getRangeHtmlWithoutBold(range);
const boldAncestors = this.collectBoldAncestors(range);
const markerId = `bold-marker-${BoldInlineTool.markerSequence++}`;
const selection = window.getSelection();
document.execCommand('insertHTML', false, `<span data-bold-marker="${markerId}-start"></span>${html}<span data-bold-marker="${markerId}-end"></span>`);
if (!selection) {
return;
}
const restoredRange = this.restoreSelectionFromMarkers(markerId);
const marker = document.createElement('span');
const fragment = range.extractContents();
this.replaceNbspWithinRange(restoredRange);
BoldInlineTool.normalizeBoldTagsWithinEditor(window.getSelection());
BoldInlineTool.replaceNbspInBlock(window.getSelection());
marker.dataset.boldMarker = `unwrap-${BoldInlineTool.markerSequence++}`;
marker.appendChild(fragment);
this.removeNestedBold(marker);
range.insertNode(marker);
const markerRange = document.createRange();
markerRange.selectNodeContents(marker);
selection.removeAllRanges();
selection.addRange(markerRange);
for (;;) {
const currentBold = BoldInlineTool.findBoldElement(marker);
if (!currentBold) {
break;
}
this.moveMarkerOutOfBold(marker, currentBold);
}
const firstChild = marker.firstChild;
const lastChild = marker.lastChild;
this.unwrapElement(marker);
const finalRange = firstChild && lastChild ? (() => {
const newRange = document.createRange();
newRange.setStartBefore(firstChild);
newRange.setEndAfter(lastChild);
selection.removeAllRanges();
selection.addRange(newRange);
return newRange;
})() : undefined;
if (!finalRange) {
selection.removeAllRanges();
}
this.replaceNbspWithinRange(finalRange);
BoldInlineTool.normalizeBoldTagsWithinEditor(selection);
BoldInlineTool.replaceNbspInBlock(selection);
BoldInlineTool.removeEmptyBoldElements(selection);
boldAncestors.forEach((element) => {
if (BoldInlineTool.isElementEmpty(element)) {
@ -273,6 +353,81 @@ export default class BoldInlineTool implements InlineTool {
this.notifySelectionChange();
}
/**
* Replace the current range contents with provided HTML snippet
*
* @param range - Range to replace
* @param html - HTML string to insert
* @returns range spanning inserted content
*/
private replaceRangeWithHtml(range: Range, html: string): Range | undefined {
const fragment = BoldInlineTool.createFragmentFromHtml(html);
const firstInserted = fragment.firstChild ?? null;
const lastInserted = fragment.lastChild ?? null;
range.deleteContents();
if (!firstInserted || !lastInserted) {
return;
}
range.insertNode(fragment);
const newRange = document.createRange();
newRange.setStartBefore(firstInserted);
newRange.setEndAfter(lastInserted);
return newRange;
}
/**
* Move a temporary marker element outside of a bold ancestor while preserving content order
*
* @param marker - Marker element wrapping the selection contents
* @param boldElement - Bold ancestor containing the marker
*/
private moveMarkerOutOfBold(marker: HTMLElement, boldElement: HTMLElement): void {
const parent = boldElement.parentNode;
if (!parent) {
return;
}
const isOnlyChild = boldElement.childNodes.length === 1 && boldElement.firstChild === marker;
if (isOnlyChild) {
boldElement.replaceWith(marker);
return;
}
const isFirstChild = boldElement.firstChild === marker;
if (isFirstChild) {
parent.insertBefore(marker, boldElement);
return;
}
const isLastChild = boldElement.lastChild === marker;
if (isLastChild) {
parent.insertBefore(marker, boldElement.nextSibling);
return;
}
const trailingClone = boldElement.cloneNode(false) as HTMLElement;
while (marker.nextSibling) {
trailingClone.appendChild(marker.nextSibling);
}
parent.insertBefore(trailingClone, boldElement.nextSibling);
parent.insertBefore(marker, trailingClone);
}
/**
* Select all contents of an element
*
@ -437,23 +592,85 @@ export default class BoldInlineTool implements InlineTool {
if (boldElement) {
const caretRange = BoldInlineTool.exitCollapsedBold(selection, boldElement);
const storedRange = caretRange?.cloneRange();
document.execCommand('bold');
if (storedRange) {
if (caretRange) {
selection.removeAllRanges();
selection.addRange(storedRange);
selection.addRange(caretRange);
}
} else {
document.execCommand('bold');
const newRange = this.startCollapsedBold(range);
if (newRange) {
selection.removeAllRanges();
selection.addRange(newRange);
}
}
BoldInlineTool.normalizeBoldTagsWithinEditor(selection);
BoldInlineTool.replaceNbspInBlock(selection);
BoldInlineTool.removeEmptyBoldElements(selection);
this.notifySelectionChange();
}
/**
* Insert a bold wrapper at the caret so newly typed text becomes bold
*
* @param range - Current collapsed range
*/
private startCollapsedBold(range: Range): Range | undefined {
if (!range.collapsed) {
return;
}
const strong = document.createElement('strong');
const textNode = document.createTextNode('');
strong.appendChild(textNode);
strong.setAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE, 'true');
const container = range.startContainer;
const offset = range.startOffset;
if (container.nodeType === Node.TEXT_NODE) {
const text = container as Text;
const parent = text.parentNode;
if (!parent) {
return;
}
const content = text.textContent ?? '';
const before = content.slice(0, offset);
const after = content.slice(offset);
text.textContent = before;
const afterNode = after.length ? document.createTextNode(after) : null;
if (afterNode) {
parent.insertBefore(afterNode, text.nextSibling);
}
parent.insertBefore(strong, afterNode ?? text.nextSibling);
strong.setAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH, before.length.toString());
} else if (container.nodeType === Node.ELEMENT_NODE) {
const element = container as Element;
const referenceNode = element.childNodes[offset] ?? null;
element.insertBefore(strong, referenceNode);
strong.setAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH, '0');
} else {
return;
}
const newRange = document.createRange();
newRange.setStart(textNode, 0);
newRange.collapse(true);
return newRange;
}
/**
* Check if an element is empty (has no text content)
*
@ -502,7 +719,15 @@ export default class BoldInlineTool implements InlineTool {
return;
}
button.classList.toggle('ce-popover-item--active', this.isSelectionVisuallyBold(selection));
const isActive = this.isSelectionVisuallyBold(selection);
button.classList.toggle('ce-popover-item--active', isActive);
if (isActive) {
button.setAttribute('data-popover-item-active', 'true');
} else {
button.removeAttribute('data-popover-item-active');
}
}
/**
@ -649,7 +874,7 @@ export default class BoldInlineTool implements InlineTool {
}
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
const block = element?.closest('.ce-paragraph');
const block = element?.closest('[data-block-tool="paragraph"]');
if (!block) {
return;
@ -662,6 +887,332 @@ export default class BoldInlineTool implements InlineTool {
}
}
/**
* Remove empty bold elements within the current block
*
* @param selection - The current selection to determine the block context
*/
private static removeEmptyBoldElements(selection: Selection | null): void {
const node = selection?.anchorNode ?? selection?.focusNode;
if (!node) {
return;
}
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
const block = element?.closest('[data-block-tool="paragraph"]');
if (!block) {
return;
}
const focusNode = selection?.focusNode ?? null;
block.querySelectorAll('strong').forEach((strong) => {
if ((strong.textContent ?? '').length === 0 && !BoldInlineTool.isNodeWithin(focusNode, strong)) {
strong.remove();
}
});
}
/**
* Ensure collapsed bold placeholders absorb newly typed text
*
* @param selection - The current selection to determine the editor context
*/
private static synchronizeCollapsedBold(selection: Selection | null): void {
const node = selection?.anchorNode ?? selection?.focusNode;
const element = node && node.nodeType === Node.ELEMENT_NODE ? node as Element : node?.parentElement;
const root = element?.closest(`.${SelectionUtils.CSS.editorWrapper}`) ?? element?.ownerDocument;
if (!root) {
return;
}
const selector = `strong[${BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE}="true"]`;
root.querySelectorAll<HTMLElement>(selector).forEach((boldElement) => {
const prevLengthAttr = boldElement.getAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH);
const prevNode = boldElement.previousSibling;
if (!prevLengthAttr || !prevNode || prevNode.nodeType !== Node.TEXT_NODE) {
return;
}
const prevLength = Number(prevLengthAttr);
if (!Number.isFinite(prevLength)) {
return;
}
const prevTextNode = prevNode as Text;
const prevText = prevTextNode.textContent ?? '';
if (prevText.length <= prevLength) {
return;
}
const preserved = prevText.slice(0, prevLength);
const extra = prevText.slice(prevLength);
prevTextNode.textContent = preserved;
const boldTextNode = boldElement.firstChild instanceof Text
? boldElement.firstChild as Text
: boldElement.appendChild(document.createTextNode('')) as Text;
boldTextNode.textContent = (boldTextNode.textContent ?? '') + extra;
if (selection?.isCollapsed && BoldInlineTool.isNodeWithin(selection.focusNode, prevTextNode)) {
const newRange = document.createRange();
const caretOffset = boldTextNode.textContent?.length ?? 0;
newRange.setStart(boldTextNode, caretOffset);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
});
}
/**
* Ensure caret is positioned after boundary bold elements when toggling collapsed selections
*
* @param selection - Current selection
*/
private static moveCaretAfterBoundaryBold(selection: Selection): void {
if (!selection.rangeCount) {
return;
}
const range = selection.getRangeAt(0);
if (!range.collapsed) {
return;
}
if (BoldInlineTool.moveCaretFromElementContainer(selection, range)) {
return;
}
BoldInlineTool.moveCaretFromTextContainer(selection, range);
}
/**
* Locate a bold element adjacent to a collapsed range
*
* @param range - Range to inspect
*/
private static getAdjacentBold(range: Range): HTMLElement | null {
const container = range.startContainer;
if (container.nodeType === Node.TEXT_NODE) {
const textNode = container as Text;
const textLength = textNode.textContent?.length ?? 0;
const previous = textNode.previousSibling;
if (range.startOffset === 0 && BoldInlineTool.isBoldElement(previous)) {
return previous as HTMLElement;
}
if (range.startOffset !== textLength) {
return null;
}
const next = textNode.nextSibling;
return BoldInlineTool.isBoldElement(next) ? next as HTMLElement : null;
}
if (container.nodeType === Node.ELEMENT_NODE) {
const element = container as Element;
const previous = range.startOffset > 0 ? element.childNodes[range.startOffset - 1] ?? null : null;
if (BoldInlineTool.isBoldElement(previous)) {
return previous as HTMLElement;
}
const next = element.childNodes[range.startOffset] ?? null;
return BoldInlineTool.isBoldElement(next) ? next as HTMLElement : null;
}
return null;
}
/**
* Exit collapsed bold state when caret no longer resides within bold content
*
* @param selection - Current selection
* @param range - Collapsed range after toggling bold
*/
private static exitCollapsedIfNeeded(selection: Selection, range: Range): void {
const insideBold = Boolean(BoldInlineTool.findBoldElement(range.startContainer));
if (insideBold) {
return;
}
const boundaryBold = BoldInlineTool.getBoundaryBold(range) ?? BoldInlineTool.getAdjacentBold(range);
if (!boundaryBold) {
return;
}
const caretRange = BoldInlineTool.exitCollapsedBold(selection, boundaryBold);
if (!caretRange) {
return;
}
selection.removeAllRanges();
selection.addRange(caretRange);
}
/**
* Adjust caret when selection container is an element adjacent to bold content
*
* @param selection - Current selection
* @param range - Collapsed range to inspect
* @returns true when caret position was updated
*/
private static moveCaretFromElementContainer(selection: Selection, range: Range): boolean {
if (range.startContainer.nodeType !== Node.ELEMENT_NODE) {
return false;
}
const element = range.startContainer as Element;
const beforeNode = range.startOffset > 0 ? element.childNodes[range.startOffset - 1] ?? null : null;
if (BoldInlineTool.isBoldElement(beforeNode)) {
const textNode = BoldInlineTool.ensureFollowingTextNode(beforeNode as Element, beforeNode.nextSibling);
if (textNode) {
BoldInlineTool.setCaret(selection, textNode, 0);
return true;
}
}
const nextNode = element.childNodes[range.startOffset] ?? null;
if (!BoldInlineTool.isBoldElement(nextNode)) {
return false;
}
const textNode = BoldInlineTool.ensureFollowingTextNode(nextNode as Element, nextNode.nextSibling);
if (textNode) {
BoldInlineTool.setCaret(selection, textNode, 0);
return true;
}
BoldInlineTool.setCaretAfterNode(selection, nextNode);
return true;
}
/**
* Adjust caret when selection container is a text node adjacent to bold content
*
* @param selection - Current selection
* @param range - Collapsed range to inspect
*/
private static moveCaretFromTextContainer(selection: Selection, range: Range): void {
if (range.startContainer.nodeType !== Node.TEXT_NODE) {
return;
}
const textNode = range.startContainer as Text;
const boldElement = BoldInlineTool.findBoldElement(textNode);
if (!boldElement || range.startOffset !== (textNode.textContent?.length ?? 0)) {
return;
}
const textNodeAfter = BoldInlineTool.ensureFollowingTextNode(boldElement, boldElement.nextSibling);
if (textNodeAfter) {
BoldInlineTool.setCaret(selection, textNodeAfter, 0);
return;
}
BoldInlineTool.setCaretAfterNode(selection, boldElement);
}
/**
* Determine whether a node is a bold element (<strong>/<b>)
*
* @param node - Node to inspect
*/
private static isBoldElement(node: Node | null): node is Element {
return Boolean(node && node.nodeType === Node.ELEMENT_NODE && BoldInlineTool.isBoldTag(node as Element));
}
/**
* Place caret at the provided offset within a text node
*
* @param selection - Current selection
* @param node - Target text node
* @param offset - Offset within the text node
*/
private static setCaret(selection: Selection, node: Text, offset: number): void {
const newRange = document.createRange();
newRange.setStart(node, offset);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
/**
* Position caret immediately after the provided node
*
* @param selection - Current selection
* @param node - Reference node
*/
private static setCaretAfterNode(selection: Selection, node: Node | null): void {
if (!node) {
return;
}
const newRange = document.createRange();
newRange.setStartAfter(node);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
/**
* Ensure there is a text node immediately following a bold element to accept new input
*
* @param boldElement - Bold element after which text should be inserted
* @param referenceNode - Node that currently follows the bold element
*/
private static ensureFollowingTextNode(boldElement: Element, referenceNode: Node | null): Text | null {
const parent = boldElement.parentNode;
if (!parent) {
return null;
}
if (referenceNode && referenceNode.nodeType === Node.TEXT_NODE) {
return referenceNode as Text;
}
const textNode = document.createTextNode('');
parent.insertBefore(textNode, referenceNode);
return textNode;
}
/**
* Enforce length limits on collapsed bold elements
*
@ -719,7 +1270,7 @@ export default class BoldInlineTool implements InlineTool {
return null;
})();
const prevLengthAttr = boldEl.getAttribute('data-bold-prev-length');
const prevLengthAttr = boldEl.getAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH);
const shouldRemovePrevLength = (() => {
if (!prevLengthAttr) {
@ -751,7 +1302,7 @@ export default class BoldInlineTool implements InlineTool {
})();
if (shouldRemovePrevLength) {
boldEl.removeAttribute('data-bold-prev-length');
boldEl.removeAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH);
}
if (selection?.isCollapsed && newTextNodeAfterSplit && BoldInlineTool.isNodeWithin(selection.focusNode, boldEl)) {
@ -790,6 +1341,7 @@ export default class BoldInlineTool implements InlineTool {
*/
private static handleGlobalSelectionChange(): void {
BoldInlineTool.enforceCollapsedBoldLengths(window.getSelection());
BoldInlineTool.synchronizeCollapsedBold(window.getSelection());
}
/**
@ -797,6 +1349,28 @@ export default class BoldInlineTool implements InlineTool {
*/
private static handleGlobalInput(): void {
BoldInlineTool.enforceCollapsedBoldLengths(window.getSelection());
BoldInlineTool.synchronizeCollapsedBold(window.getSelection());
}
/**
* Attempt to toggle bold via the browser's native command
*
* @param selection - Current selection
*/
private static tryNativeBold(selection: Selection): boolean {
if (typeof document === 'undefined' || typeof document.execCommand !== 'function') {
return false;
}
if (!BoldInlineTool.isSelectionInsideEditor(selection)) {
return false;
}
try {
return document.execCommand('bold');
} catch (error) {
return false;
}
}
/**
@ -826,6 +1400,7 @@ export default class BoldInlineTool implements InlineTool {
}
boldElement.setAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH, (boldElement.textContent?.length ?? 0).toString());
boldElement.removeAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE);
const parent = boldElement.parentNode;
@ -972,6 +1547,19 @@ export default class BoldInlineTool implements InlineTool {
return container.innerHTML;
}
/**
* Convert an HTML snippet to a document fragment
*
* @param html - HTML string to convert
*/
private static createFragmentFromHtml(html: string): DocumentFragment {
const template = document.createElement('template');
template.innerHTML = html;
return template.content;
}
/**
* Collect all bold ancestor elements within a range
*

View file

@ -24,3 +24,8 @@ export const css = {
iconChevronRight: className('icon', 'chevron-right'),
wobbleAnimation: bem('wobble')(),
};
/**
* Data attribute name for active state
*/
export const DATA_ATTRIBUTE_ACTIVE = 'data-popover-item-active';

View file

@ -6,7 +6,7 @@ import type {
PopoverItemType
} from '@/types/utils/popover/popover-item';
import { PopoverItem } from '../popover-item';
import { css } from './popover-item-default.const';
import { css, DATA_ATTRIBUTE_ACTIVE } from './popover-item-default.const';
/**
* Represents single popover item node
@ -111,7 +111,19 @@ export class PopoverItemDefault extends PopoverItem {
* @param isActive - true if item should strictly should become active
*/
public toggleActive(isActive?: boolean): void {
this.nodes.root?.classList.toggle(css.active, isActive);
if (this.nodes.root === null) {
return;
}
const shouldBeActive = isActive !== undefined ? isActive : !this.nodes.root.classList.contains(css.active);
this.nodes.root.classList.toggle(css.active, isActive);
if (shouldBeActive) {
this.nodes.root.setAttribute(DATA_ATTRIBUTE_ACTIVE, 'true');
} else {
this.nodes.root.removeAttribute(DATA_ATTRIBUTE_ACTIVE);
}
}
/**
@ -181,6 +193,7 @@ export class PopoverItemDefault extends PopoverItem {
if (this.isActive) {
el.classList.add(css.active);
el.setAttribute(DATA_ATTRIBUTE_ACTIVE, 'true');
}
if (params.isDisabled) {

View file

@ -117,6 +117,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
*/
public show(): void {
this.nodes.popover.classList.add(css.popoverOpened);
this.nodes.popover.setAttribute('data-popover-opened', 'true');
if (this.search !== undefined) {
this.search.focus();
@ -129,6 +130,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
public hide(): void {
this.nodes.popover.classList.remove(css.popoverOpened);
this.nodes.popover.classList.remove(css.popoverOpenTop);
this.nodes.popover.removeAttribute('data-popover-opened');
this.itemsDefault.forEach(item => item.reset());
@ -157,6 +159,10 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
public activateItemByName(name: string): void {
const foundItem = this.items.find(item => item.name === name);
if (foundItem === undefined) {
return;
}
this.handleItemClick(foundItem);
}
@ -203,7 +209,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
* @param item - item to handle click of
*/
protected handleItemClick(item: PopoverItem): void {
if ('isDisabled' in item && item.isDisabled) {
if (item instanceof PopoverItemDefault && item.isDisabled) {
return;
}
@ -226,7 +232,7 @@ export abstract class PopoverAbstract<Nodes extends PopoverNodes = PopoverNodes>
this.toggleItemActivenessIfNeeded(item);
if (item.closeOnActivate) {
if (item.closeOnActivate === true) {
this.hide();
this.emit(PopoverEvent.ClosedOnActivate);

View file

@ -13,13 +13,14 @@ import Chainable = Cypress.Chainable;
/**
* Create a wrapper and initialize the new instance of editor.js
* Then return the instance
*
* @param editorConfig - config to pass to the editor
* @returns EditorJS - created instance
*/
Cypress.Commands.add('createEditor', (editorConfig: EditorConfig = {}): Chainable<EditorJS> => {
return cy.window()
.then((window) => {
return new Promise((resolve: (instance: EditorJS) => void) => {
return new Promise((resolve: (instance: EditorJS) => void, reject: (error: unknown) => void) => {
const editorContainer = window.document.createElement('div');
editorContainer.setAttribute('id', 'editorjs');
@ -32,6 +33,8 @@ Cypress.Commands.add('createEditor', (editorConfig: EditorConfig = {}): Chainabl
editorInstance.isReady.then(() => {
resolve(editorInstance);
}).catch((error) => {
reject(error);
});
});
});
@ -42,6 +45,7 @@ Cypress.Commands.add('createEditor', (editorConfig: EditorConfig = {}): Chainabl
*
* Usage
* cy.get('div').paste({'text/plain': 'Text', 'text/html': '<b>Text</b>'})
*
* @param data - map with MIME type as a key and data as value
*/
Cypress.Commands.add('paste', {
@ -69,7 +73,7 @@ Cypress.Commands.add('paste', {
* cy.get('div').copy().then(data => {})
*/
Cypress.Commands.add('copy', {
prevSubject: ['element'],
prevSubject: [ 'element' ],
}, (subject) => {
const clipboardData: {[type: string]: any} = {};
@ -95,7 +99,7 @@ Cypress.Commands.add('copy', {
* Usage:
* cy.get('div').cut().then(data => {})
*/
Cypress.Commands.add('cut', { prevSubject: ['element'] }, (subject) => {
Cypress.Commands.add('cut', { prevSubject: [ 'element' ] }, (subject) => {
const clipboardData: {[type: string]: any} = {};
const copyEvent = Object.assign(new Event('cut', {
@ -116,6 +120,7 @@ Cypress.Commands.add('cut', { prevSubject: ['element'] }, (subject) => {
/**
* Calls EditorJS API render method
*
* @param data data to render
*/
Cypress.Commands.add('render', { prevSubject: true }, (subject: EditorJS, data: OutputData) => {
@ -134,6 +139,7 @@ Cypress.Commands.add('render', { prevSubject: true }, (subject: EditorJS, data:
* cy.get('[data-cy=editorjs]')
* .find('.ce-paragraph')
* .selectText('block te')
*
* @param text - text to select
*/
Cypress.Commands.add('selectText', {
@ -162,6 +168,7 @@ Cypress.Commands.add('selectText', {
* cy.get('[data-cy=editorjs]')
* .find('.ce-paragraph')
* .selectTextByOffset([0, 5])
*
* @param offset - offset to select
*/
Cypress.Commands.add('selectTextByOffset', {
@ -189,6 +196,7 @@ Cypress.Commands.add('selectTextByOffset', {
* cy.get('[data-cy=editorjs]')
* .find('.ce-paragraph')
* .getLineWrapPositions()
*
* @returns number[] - array of line wrap positions
*/
Cypress.Commands.add('getLineWrapPositions', {
@ -245,6 +253,7 @@ Cypress.Commands.add('keydown', {
* so real-world and Cypress behaviour were different.
*
* To make it work we need to trigger Cypress event with "eventConstructor: 'KeyboardEvent'",
*
* @see https://github.com/cypress-io/cypress/issues/5650
* @see https://github.com/cypress-io/cypress/pull/8305/files
*/
@ -259,12 +268,14 @@ Cypress.Commands.add('keydown', {
/**
* Extract content of pseudo element
*
* @example cy.get('element').getPseudoElementContent('::before').should('eq', 'my-test-string')
*/
Cypress.Commands.add('getPseudoElementContent', {
prevSubject: ['element'],
prevSubject: [ 'element' ],
}, (subject, pseudoElement: string) => {
const win = subject[0].ownerDocument.defaultView;
if (!win) {
throw new Error('defaultView is null');
}

View file

@ -4,6 +4,7 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type EditorJS from '@/types';
import type { OutputData } from '@/types';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
@ -11,7 +12,7 @@ const TEST_PAGE_URL = pathToFileURL(
const HOLDER_ID = 'editorjs';
const EDITOR_SELECTOR = '[data-cy=editorjs]';
const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} .ce-paragraph`;
const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} [data-block-tool="paragraph"]`;
const INLINE_TOOLBAR_SELECTOR = `${EDITOR_SELECTOR} [data-cy=inline-toolbar]`;
/**
@ -102,6 +103,10 @@ const selectText = async (locator: Locator, text: string): Promise<void> => {
};
test.describe('Inline Tool Bold', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');
@ -153,8 +158,8 @@ test.describe('Inline Tool Bold', () => {
doc.dispatchEvent(new Event('selectionchange'));
});
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} .ce-popover--opened`)).toHaveCount(1);
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`)).toHaveClass(/ce-popover-item--active/);
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-popover-opened="true"]`)).toHaveCount(1);
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`)).toHaveAttribute('data-popover-item-active', 'true');
});
test('detects bold state within a single word', async ({ page }) => {
@ -171,7 +176,7 @@ test.describe('Inline Tool Bold', () => {
await selectText(paragraph, 'bold');
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`)).toHaveClass(/ce-popover-item--active/);
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`)).toHaveAttribute('data-popover-item-active', 'true');
});
test('does not detect bold state in normal text', async ({ page }) => {
@ -188,7 +193,7 @@ test.describe('Inline Tool Bold', () => {
await selectText(paragraph, 'normal');
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`)).not.toHaveClass(/ce-popover-item--active/);
await expect(page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`)).not.toHaveAttribute('data-popover-item-active', 'true');
});
test('toggles bold across multiple bold elements', async ({ page }) => {
@ -238,25 +243,22 @@ test.describe('Inline Tool Bold', () => {
doc.dispatchEvent(new Event('selectionchange'));
});
// Click bold button to toggle (should unwrap and then wrap together)
await page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`).click();
const boldButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`);
// Wait for the text to be wrapped in a single <strong> tag
await page.waitForFunction(
({ selector }) => {
const element = document.querySelector(selector);
// Verify bold button is active (since all text is visually bold)
await expect(boldButton).toHaveAttribute('data-popover-item-active', 'true');
return element && /<strong>first second<\/strong>/.test(element.innerHTML);
},
{
selector: PARAGRAPH_SELECTOR,
}
);
// Click bold button - should remove bold on first click (since selection is visually bold)
await boldButton.click();
// Verify that the text is now wrapped in a single <strong> tag
// Wait for the toolbar state to update (bold button should no longer be active)
await expect(boldButton).not.toHaveAttribute('data-popover-item-active', 'true');
// Verify that bold has been removed
const html = await paragraph.innerHTML();
expect(html).toMatch(/<strong>first second<\/strong>/);
expect(html).toBe('first second');
expect(html).not.toMatch(/<strong>/);
});
test('makes mixed selection (bold and normal text) bold', async ({ page }) => {
@ -344,11 +346,11 @@ test.describe('Inline Tool Bold', () => {
const boldButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`);
await expect(boldButton).toHaveClass(/ce-popover-item--active/);
await expect(boldButton).toHaveAttribute('data-popover-item-active', 'true');
await boldButton.click();
await expect(boldButton).not.toHaveClass(/ce-popover-item--active/);
await expect(boldButton).not.toHaveAttribute('data-popover-item-active', 'true');
const html = await paragraph.innerHTML();
@ -376,7 +378,7 @@ test.describe('Inline Tool Bold', () => {
await page.keyboard.press(`${modifierKey}+b`);
await expect(boldButton).toHaveClass(/ce-popover-item--active/);
await expect(boldButton).toHaveAttribute('data-popover-item-active', 'true');
let html = await paragraph.innerHTML();
@ -384,7 +386,7 @@ test.describe('Inline Tool Bold', () => {
await page.keyboard.press(`${modifierKey}+b`);
await expect(boldButton).not.toHaveClass(/ce-popover-item--active/);
await expect(boldButton).not.toHaveAttribute('data-popover-item-active', 'true');
html = await paragraph.innerHTML();
@ -462,7 +464,7 @@ test.describe('Inline Tool Bold', () => {
expect(paragraphBlock?.data.text).toMatch(/<strong>bold<\/strong> text/);
});
test('removes bold from selected word while keeping the rest bold', async ({ page }) => {
test('removes bold from selection within bold text', async ({ page }) => {
// Step 1: Create editor with "Some text"
await createEditorWithBlocks(page, [
{
@ -503,13 +505,13 @@ test.describe('Inline Tool Bold', () => {
await selectText(paragraph, 'Some');
// Verify bold button is active (since "Some" is bold)
await expect(boldButton).toHaveClass(/ce-popover-item--active/);
await expect(boldButton).toHaveAttribute('data-popover-item-active', 'true');
// Click to remove bold from "Some"
await boldButton.click();
// Wait for the toolbar state to update (bold button should no longer be active for "Some")
await expect(boldButton).not.toHaveClass(/ce-popover-item--active/);
await expect(boldButton).not.toHaveAttribute('data-popover-item-active', 'true');
// Step 4: Verify that "text" is still bold while "Some" is not
html = await paragraph.innerHTML();
@ -519,6 +521,90 @@ test.describe('Inline Tool Bold', () => {
// "Some" should not be wrapped in bold tags
expect(html).not.toMatch(/<strong>Some<\/strong>/);
});
test('removes bold from separately bolded words', async ({ page }) => {
// Step 1: Start with normal text "some text"
await createEditorWithBlocks(page, [
{
type: 'paragraph',
data: {
text: 'some text',
},
},
]);
const paragraph = page.locator(PARAGRAPH_SELECTOR).first();
const boldButton = page.locator(`${INLINE_TOOLBAR_SELECTOR} [data-item-name="bold"]`);
// Step 2: Make "some" bold
await selectText(paragraph, 'some');
await boldButton.click();
// Verify "some" is now bold
let html = await paragraph.innerHTML();
expect(html).toMatch(/<strong>some<\/strong> text/);
// Step 3: Make "text" bold (now we have <strong>some</strong> <strong>text</strong>)
await selectText(paragraph, 'text');
await boldButton.click();
// Verify both words are now bold with space between them
html = await paragraph.innerHTML();
expect(html).toMatch(/<strong>some<\/strong> <strong>text<\/strong>/);
// Step 4: Select the whole phrase including the space
await paragraph.evaluate((el) => {
const paragraphEl = el as HTMLElement;
const doc = paragraphEl.ownerDocument;
const range = doc.createRange();
const selection = doc.getSelection();
if (!selection) {
throw new Error('Selection not available');
}
const bolds = paragraphEl.querySelectorAll('strong');
const firstBold = bolds[0];
const secondBold = bolds[1];
if (!firstBold || !secondBold) {
throw new Error('Bold elements not found');
}
const firstBoldText = firstBold.firstChild;
const secondBoldText = secondBold.firstChild;
if (!firstBoldText || !secondBoldText) {
throw new Error('Text nodes not found');
}
// Select from start of first bold to end of second bold (including the space)
range.setStart(firstBoldText, 0);
range.setEnd(secondBoldText, secondBoldText.textContent?.length ?? 0);
selection.removeAllRanges();
selection.addRange(range);
doc.dispatchEvent(new Event('selectionchange'));
});
// Step 5: Verify the editor indicates the selection is bold (button is active)
await expect(boldButton).toHaveAttribute('data-popover-item-active', 'true');
// Step 6: Click bold button - should remove bold on first click (not wrap again)
await boldButton.click();
// Verify bold button is no longer active
await expect(boldButton).not.toHaveAttribute('data-popover-item-active', 'true');
// Verify that bold has been removed from both words on first click
html = await paragraph.innerHTML();
expect(html).toBe('some text');
expect(html).not.toMatch(/<strong>/);
});
});
declare global {

View file

@ -4,13 +4,14 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type EditorJS from '@/types';
import type { OutputData } from '@/types';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = '.ce-paragraph';
const PARAGRAPH_SELECTOR = '[data-block-tool="paragraph"]';
const INLINE_TOOLBAR_SELECTOR = '[data-interface=inline-toolbar]';
const LINK_BUTTON_SELECTOR = `${INLINE_TOOLBAR_SELECTOR} [data-item-name="link"] button`;
const LINK_INPUT_SELECTOR = `input[data-link-tool-input-opened]`;
@ -152,6 +153,10 @@ const submitLink = async (page: Page, url: string): Promise<void> => {
};
test.describe('Inline Tool Link', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');

View file

@ -4,13 +4,14 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type EditorJS from '../../../../../types';
import type { OutputData } from '../../../../../types';
import { ensureEditorBundleBuilt } from '../../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../../cypress/fixtures/test.html')
).href;
const EDITOR_SELECTOR = '[data-cy=editorjs]';
const BLOCK_SELECTOR = `${EDITOR_SELECTOR} div.ce-block`;
const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} .ce-paragraph`;
const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} [data-block-tool="paragraph"]`;
const TOOLBAR_SELECTOR = `${EDITOR_SELECTOR} .ce-toolbar`;
const HOLDER_ID = 'editorjs';
@ -393,6 +394,10 @@ const expectToolbarClosed = async (page: Page): Promise<void> => {
};
test.describe('Backspace keydown', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');

View file

@ -4,13 +4,14 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type EditorJS from '../../../../../types';
import type { OutputData } from '../../../../../types';
import { ensureEditorBundleBuilt } from '../../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const EDITOR_SELECTOR = '[data-cy=editorjs]';
const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} .ce-paragraph`;
const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} [data-block-tool="paragraph"]`;
const TOOL_WITH_TWO_INPUTS_SELECTOR = '[data-cy=tool-with-two-inputs] div[contenteditable=true]';
const CONTENTLESS_TOOL_SELECTOR = '[data-cy=contentless-tool]';
const REGULAR_INPUT_SELECTOR = '[data-cy=regular-input]';
@ -206,6 +207,10 @@ const addRegularInput = async (page: Page, position: 'before' | 'after'): Promis
};
test.describe('Tab keydown', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await page.waitForFunction(() => typeof window.EditorJS === 'function');