mirror of
https://github.com/codex-team/editor.js
synced 2026-03-18 08:29:52 +01:00
fix: get rid of deprecated API in inline-tool-bold.ts
This commit is contained in:
parent
ec2865091a
commit
f26202e8d7
11 changed files with 825 additions and 93 deletions
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue