diff --git a/playwright.config.ts b/playwright.config.ts index c3d34d87..f4211efc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, }, diff --git a/src/components/block/index.ts b/src/components/block/index.ts index c1059d3c..61049091 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -336,12 +336,11 @@ export default class Block extends EventsDispatcher { * 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 { * @returns {Promise} valid */ public async validate(data: BlockToolData): Promise { - 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 { * @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 { // 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 { } else { // Handle synchronous render this.toolRenderedElement = pluginsContent; + this.addToolDataAttributes(pluginsContent); contentNode.appendChild(pluginsContent); } @@ -811,24 +810,39 @@ export default class Block extends EventsDispatcher { * * */ - 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 { /** * 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 { ]; 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 diff --git a/src/components/inline-tools/inline-tool-bold.ts b/src/components/inline-tools/inline-tool-bold.ts index 7735c610..d28accff 100644 --- a/src/components/inline-tools/inline-tool-bold.ts +++ b/src/components/inline-tools/inline-tool-bold.ts @@ -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(); /** @@ -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 () * @@ -226,10 +256,14 @@ export default class BoldInlineTool implements InlineTool { */ private wrapWithBold(range: Range): void { const html = this.getRangeHtmlWithoutBold(range); - - document.execCommand('insertHTML', false, `${html}`); - + const insertedRange = this.replaceRangeWithHtml(range, `${html}`); 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, `${html}`); + 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(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 (/) + * + * @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 * diff --git a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts index e5929b78..809b5cac 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts @@ -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'; diff --git a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts index 1daf3d13..430b7907 100644 --- a/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +++ b/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts @@ -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) { diff --git a/src/components/utils/popover/popover-abstract.ts b/src/components/utils/popover/popover-abstract.ts index f0453954..3119b82c 100644 --- a/src/components/utils/popover/popover-abstract.ts +++ b/src/components/utils/popover/popover-abstract.ts @@ -117,6 +117,7 @@ export abstract class PopoverAbstract */ 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 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 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 * @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 this.toggleItemActivenessIfNeeded(item); - if (item.closeOnActivate) { + if (item.closeOnActivate === true) { this.hide(); this.emit(PopoverEvent.ClosedOnActivate); diff --git a/test/cypress/support/commands.ts b/test/cypress/support/commands.ts index f2cd85e8..6c1da817 100644 --- a/test/cypress/support/commands.ts +++ b/test/cypress/support/commands.ts @@ -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 => { 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': 'Text'}) + * * @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'); } diff --git a/test/playwright/tests/inline-tools/bold.spec.ts b/test/playwright/tests/inline-tools/bold.spec.ts index c9716420..92dc223b 100644 --- a/test/playwright/tests/inline-tools/bold.spec.ts +++ b/test/playwright/tests/inline-tools/bold.spec.ts @@ -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 => { }; 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 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 && /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 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(/first second<\/strong>/); + expect(html).toBe('first second'); + expect(html).not.toMatch(//); }); 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(/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(/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(/some<\/strong> text/); + + // Step 3: Make "text" bold (now we have some text) + await selectText(paragraph, 'text'); + await boldButton.click(); + + // Verify both words are now bold with space between them + html = await paragraph.innerHTML(); + + expect(html).toMatch(/some<\/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(//); + }); }); declare global { diff --git a/test/playwright/tests/inline-tools/link.spec.ts b/test/playwright/tests/inline-tools/link.spec.ts index a2b93806..3afdbb79 100644 --- a/test/playwright/tests/inline-tools/link.spec.ts +++ b/test/playwright/tests/inline-tools/link.spec.ts @@ -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 => { }; 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'); diff --git a/test/playwright/tests/modules/BlockEvents/Backspace.spec.ts b/test/playwright/tests/modules/BlockEvents/Backspace.spec.ts index 7e4b82e5..1fe1baea 100644 --- a/test/playwright/tests/modules/BlockEvents/Backspace.spec.ts +++ b/test/playwright/tests/modules/BlockEvents/Backspace.spec.ts @@ -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 => { }; test.describe('Backspace keydown', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + test.beforeEach(async ({ page }) => { await page.goto(TEST_PAGE_URL); await page.waitForFunction(() => typeof window.EditorJS === 'function'); diff --git a/test/playwright/tests/modules/BlockEvents/Tab.spec.ts b/test/playwright/tests/modules/BlockEvents/Tab.spec.ts index b1069e15..aaee6d78 100644 --- a/test/playwright/tests/modules/BlockEvents/Tab.spec.ts +++ b/test/playwright/tests/modules/BlockEvents/Tab.spec.ts @@ -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');