diff --git a/.gitignore b/.gitignore index e5c4b039..ab6cd43b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules/* npm-debug.log yarn-error.log .yarn/install-state.gz +install-state.gz test-results diff --git a/.windsurf/rules/do-not-modify-configs.mdc b/.windsurf/rules/do-not-modify-configs.md similarity index 96% rename from .windsurf/rules/do-not-modify-configs.mdc rename to .windsurf/rules/do-not-modify-configs.md index bc24d21d..99df6461 100644 --- a/.windsurf/rules/do-not-modify-configs.mdc +++ b/.windsurf/rules/do-not-modify-configs.md @@ -1,5 +1,7 @@ --- -alwaysApply: true +trigger: always_on +description: +globs: --- # Rule: DO NOT MODIFY configuration files unless explicitly instructed diff --git a/.windsurf/rules/fix-problems.mdc b/.windsurf/rules/fix-problems.md similarity index 97% rename from .windsurf/rules/fix-problems.mdc rename to .windsurf/rules/fix-problems.md index 561e6f14..44bd8040 100644 --- a/.windsurf/rules/fix-problems.mdc +++ b/.windsurf/rules/fix-problems.md @@ -1,5 +1,7 @@ --- -alwaysApply: true +trigger: always_on +description: +globs: --- # Fix Problems Policy @@ -12,12 +14,12 @@ VERY IMPORTANT: When encountering ANY problem in the code—such as TypeScript e - **Investigate root causes**: Use tools like debugging, logging, or code searches to understand why the problem occurs before fixing it. - **Align with existing rules**: Follow related policies such as the Fix TypeScript Errors Policy (adapt for other languages), ESLint configurations, and accessibility guidelines. - **Test the fix**: After fixing, verify with tests, linting runs (e.g., `yarn lint:fix`), or manual checks to ensure the problem is truly resolved without introducing new issues. +- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely ## When to Apply - During any code editing, reviewing, or generation task. - Proactively scan for and fix problems in affected files using available tools (e.g., read_lints, grep, codebase_search). - If a problem persists after reasonable efforts, document it clearly and suggest next steps rather than suppressing it. -- **Terminal commands**: if you run a command in the terminal make sure to set timeout so the command is not being executed indefinitely ## Notes - This policy promotes robust, high-quality code that is easier to maintain and less prone to future issues. diff --git a/.windsurf/rules/src/frontend/accessibility.mdc b/.windsurf/rules/src/frontend/accessibility.md similarity index 99% rename from .windsurf/rules/src/frontend/accessibility.mdc rename to .windsurf/rules/src/frontend/accessibility.md index 085ff843..861428da 100644 --- a/.windsurf/rules/src/frontend/accessibility.mdc +++ b/.windsurf/rules/src/frontend/accessibility.md @@ -1,6 +1,7 @@ --- -alwaysApply: true +trigger: always_on description: Enforce accessibility best practices so all users can use the application +globs: --- ### Accessibility guidance (must follow) diff --git a/.windsurf/rules/src/frontend/code-style-eslint.mdc b/.windsurf/rules/src/frontend/code-style-eslint.md similarity index 95% rename from .windsurf/rules/src/frontend/code-style-eslint.mdc rename to .windsurf/rules/src/frontend/code-style-eslint.md index 578bce50..988ff676 100644 --- a/.windsurf/rules/src/frontend/code-style-eslint.mdc +++ b/.windsurf/rules/src/frontend/code-style-eslint.md @@ -1,7 +1,7 @@ --- -title: Frontend ESLint Code Style -alwaysApply: true +trigger: always_on description: Defer all code style decisions to the project's ESLint configuration; do not invent new style rules +globs: --- ### Code Style Source of Truth diff --git a/.windsurf/rules/src/frontend/fix-typescript-errors.mdc b/.windsurf/rules/src/frontend/fix-typescript-errors.md similarity index 98% rename from .windsurf/rules/src/frontend/fix-typescript-errors.mdc rename to .windsurf/rules/src/frontend/fix-typescript-errors.md index 83b32b71..2ae6095f 100644 --- a/.windsurf/rules/src/frontend/fix-typescript-errors.mdc +++ b/.windsurf/rules/src/frontend/fix-typescript-errors.md @@ -1,7 +1,7 @@ --- -alwaysApply: true -globs: *.ts,*.tsx +trigger: always_on description: Enforce fixing TypeScript errors by improving code quality, not suppressing them +globs: *.ts,*.tsx --- # Fix TypeScript Errors Policy diff --git a/.windsurf/rules/src/frontend/frontend-simplicity.mdc b/.windsurf/rules/src/frontend/frontend-simplicity.md similarity index 99% rename from .windsurf/rules/src/frontend/frontend-simplicity.mdc rename to .windsurf/rules/src/frontend/frontend-simplicity.md index e652a2ba..aed26b70 100644 --- a/.windsurf/rules/src/frontend/frontend-simplicity.mdc +++ b/.windsurf/rules/src/frontend/frontend-simplicity.md @@ -1,7 +1,7 @@ --- -alwaysApply: true -globs: "*.ts","*.tsx","*.js","*.jsx","src/frontend/**" +trigger: always_on description: "Frontend development principle: Keep solutions simple and avoid overengineering" +globs: "*.ts","*.tsx","*.js","*.jsx","src/frontend/**" --- # Frontend Simplicity Principle diff --git a/.windsurf/rules/src/frontend/lint-fix-policy.mdc b/.windsurf/rules/src/frontend/lint-fix-policy.md similarity index 97% rename from .windsurf/rules/src/frontend/lint-fix-policy.mdc rename to .windsurf/rules/src/frontend/lint-fix-policy.md index 3001cc45..614914c9 100644 --- a/.windsurf/rules/src/frontend/lint-fix-policy.mdc +++ b/.windsurf/rules/src/frontend/lint-fix-policy.md @@ -1,6 +1,7 @@ --- -alwaysApply: true +trigger: always_on description: Policy for handling ESLint issues by preferring autofix with yarn lint:fix +globs: --- # Lint Fix Policy diff --git a/.windsurf/rules/test/e2e-best-practices.mdc b/.windsurf/rules/test/e2e-best-practices.md similarity index 99% rename from .windsurf/rules/test/e2e-best-practices.mdc rename to .windsurf/rules/test/e2e-best-practices.md index 18a5122e..89838065 100644 --- a/.windsurf/rules/test/e2e-best-practices.mdc +++ b/.windsurf/rules/test/e2e-best-practices.md @@ -1,7 +1,7 @@ --- -alwaysApply: true -globs: tests/**/*.spec.ts,tests/**/*.ts +trigger: always_on description: Playwright end-to-end testing patterns and expectations +globs: tests/**/*.spec.ts,tests/**/*.ts --- # Playwright E2E Tests diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz deleted file mode 100644 index a49768a7..00000000 Binary files a/.yarn/install-state.gz and /dev/null differ diff --git a/src/codex.ts b/src/codex.ts index b9bf13ca..87eca741 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -142,6 +142,23 @@ export default class EditorJS { this.destroy = destroy; const apiMethods = editor.moduleInstances.API.methods; + const eventsDispatcherApi = editor.moduleInstances.EventsAPI?.methods ?? apiMethods.events; + + if (eventsDispatcherApi !== undefined) { + const defineDispatcher = (target: object): void => { + if (!Object.prototype.hasOwnProperty.call(target, 'eventsDispatcher')) { + Object.defineProperty(target, 'eventsDispatcher', { + value: eventsDispatcherApi, + configurable: true, + enumerable: true, + writable: false, + }); + } + }; + + defineDispatcher(apiMethods); + defineDispatcher(this as Record); + } if (Object.getPrototypeOf(apiMethods) !== EditorJS.prototype) { Object.setPrototypeOf(apiMethods, EditorJS.prototype); diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 2a191978..eda77e26 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -153,11 +153,21 @@ export default class Block extends EventsDispatcher { */ public readonly config: ToolConfig; + /** + * Stores last successfully extracted block data + */ + private lastSavedData: BlockToolData; + /** * Cached inputs */ private cachedInputs: HTMLElement[] = []; + /** + * Stores last successfully extracted tunes data + */ + private lastSavedTunes: { [name: string]: BlockTuneData } = {}; + /** * We'll store a reference to the tool's rendered element to access it later */ @@ -221,9 +231,11 @@ export default class Block extends EventsDispatcher { this.name = tool.name; this.id = id; this.settings = tool.settings; - this.config = tool.settings.config ?? {}; + this.config = this.settings; this.editorEventBus = eventBus || null; this.blockAPI = new BlockAPI(this); + this.lastSavedData = data ?? {}; + this.lastSavedTunes = tunesData ?? {}; this.tool = tool; @@ -318,7 +330,7 @@ export default class Block extends EventsDispatcher { */ public async save(): Promise { const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement); - const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData; + const tunesData: { [name: string]: BlockTuneData } = { ...this.unavailableTunesData }; [ ...this.tunesInstances.entries(), @@ -341,6 +353,11 @@ export default class Block extends EventsDispatcher { return Promise.resolve(extractedBlock) .then((finishedExtraction) => { + if (finishedExtraction !== undefined) { + this.lastSavedData = finishedExtraction; + this.lastSavedTunes = { ...tunesData }; + } + /** measure promise execution */ const measuringEnd = window.performance.now(); @@ -633,6 +650,20 @@ export default class Block extends EventsDispatcher { }); } + /** + * Returns last successfully extracted block data + */ + public get preservedData(): BlockToolData { + return this.lastSavedData ?? {}; + } + + /** + * Returns last successfully extracted tune data + */ + public get preservedTunes(): { [name: string]: BlockTuneData } { + return this.lastSavedTunes ?? {}; + } + /** * Returns tool's sanitizer config * @@ -866,6 +897,20 @@ export default class Block extends EventsDispatcher { if (!element.hasAttribute('data-block-tool') && this.name) { element.setAttribute('data-block-tool', this.name); } + + const placeholderAttribute = 'data-placeholder'; + const placeholder = this.config?.placeholder; + const placeholderText = typeof placeholder === 'string' ? placeholder.trim() : ''; + + if (placeholderText.length > 0) { + element.setAttribute(placeholderAttribute, placeholderText); + + return; + } + + if (placeholder === false && element.hasAttribute(placeholderAttribute)) { + element.removeAttribute(placeholderAttribute); + } } /** diff --git a/src/components/core.ts b/src/components/core.ts index 2a9c7d79..dcbdfac5 100644 --- a/src/components/core.ts +++ b/src/components/core.ts @@ -148,7 +148,9 @@ export default class Core { data: {}, }; - this.config.placeholder = this.config.placeholder ?? false; + if (this.config.placeholder === undefined) { + this.config.placeholder = false; + } this.config.sanitizer = this.config.sanitizer ?? {} as SanitizerConfig; this.config.hideToolbar = this.config.hideToolbar ?? false; diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 0775711a..94c8380f 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -919,7 +919,7 @@ export default class BlockManager extends Module { */ const savedBlock = await blockToConvert.save(); - if (!savedBlock) { + if (!savedBlock || savedBlock.data === undefined) { throw new Error('Could not convert Block. Failed to extract original Block data.'); } diff --git a/src/components/modules/dragNDrop.ts b/src/components/modules/dragNDrop.ts index 5a6530fa..a6f55cf0 100644 --- a/src/components/modules/dragNDrop.ts +++ b/src/components/modules/dragNDrop.ts @@ -13,6 +13,11 @@ export default class DragNDrop extends Module { */ private isStartedAtEditor = false; + /** + * Holds listener identifiers that prevent native drops in read-only mode + */ + private guardListenerIds: string[] = []; + /** * Toggle read-only state * @@ -27,7 +32,9 @@ export default class DragNDrop extends Module { public toggleReadOnly(readOnlyEnabled: boolean): void { if (readOnlyEnabled) { this.disableModuleBindings(); + this.bindPreventDropHandlers(); } else { + this.clearGuardListeners(); this.enableModuleBindings(); } } @@ -59,6 +66,62 @@ export default class DragNDrop extends Module { */ private disableModuleBindings(): void { this.readOnlyMutableListeners.clearAll(); + this.clearGuardListeners(); + } + + /** + * Prevents native drag-and-drop insertions while editor is locked + */ + private bindPreventDropHandlers(): void { + const { UI } = this.Editor; + + this.addGuardListener(UI.nodes.holder, 'dragover', this.preventNativeDrop, true); + this.addGuardListener(UI.nodes.holder, 'drop', this.preventNativeDrop, true); + } + + /** + * Cancels browser default drag/drop behavior + * + * @param event - drag-related event dispatched on the holder + */ + private preventNativeDrop = (event: Event): void => { + event.preventDefault(); + + if (event instanceof DragEvent) { + event.stopPropagation(); + event.dataTransfer?.clearData(); + } + }; + + /** + * Registers a listener to be cleaned up when unlocking editor + * + * @param element - target to bind listener to + * @param eventType - event type to listen for + * @param handler - event handler + * @param options - listener options + */ + private addGuardListener( + element: EventTarget, + eventType: string, + handler: (event: Event) => void, + options: boolean | AddEventListenerOptions = false + ): void { + const listenerId = this.listeners.on(element, eventType, handler, options); + + if (listenerId) { + this.guardListenerIds.push(listenerId); + } + } + + /** + * Removes guard listeners bound for read-only mode + */ + private clearGuardListeners(): void { + this.guardListenerIds.forEach((id) => { + this.listeners.offById(id); + }); + this.guardListenerIds = []; } /** @@ -75,12 +138,22 @@ export default class DragNDrop extends Module { dropEvent.preventDefault(); + if (this.Editor.ReadOnly?.isEnabled) { + this.preventNativeDrop(dropEvent); + + return; + } + for (const block of BlockManager.blocks) { block.dropTarget = false; } - if (SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed && this.isStartedAtEditor) { - document.execCommand('delete'); + const blockSelection = this.Editor.BlockSelection; + const hasBlockSelection = Boolean(blockSelection?.anyBlockSelected); + const hasTextSelection = SelectionUtils.isAtEditor && !SelectionUtils.isCollapsed; + + if (this.isStartedAtEditor && (hasTextSelection || hasBlockSelection)) { + this.removeDraggedSelection(); } this.isStartedAtEditor = false; @@ -113,6 +186,65 @@ export default class DragNDrop extends Module { await Paste.processDataTransfer(dataTransfer, true); } + /** + * Removes currently selected content when drag originated from Editor + */ + private removeDraggedSelection(): void { + const { BlockSelection, BlockManager } = this.Editor; + + if (!BlockSelection?.anyBlockSelected) { + this.removeTextSelection(); + + return; + } + + const removedIndex = BlockManager.removeSelectedBlocks(); + + if (removedIndex === undefined) { + return; + } + + BlockSelection.clearSelection(); + } + + /** + * Removes current text selection produced within the editor + */ + private removeTextSelection(): void { + const selection = SelectionUtils.get(); + + if (!selection) { + return; + } + + if (selection.rangeCount === 0) { + this.deleteCurrentSelection(selection); + + return; + } + + const range = selection.getRangeAt(0); + + if (!range.collapsed) { + range.deleteContents(); + + return; + } + + this.deleteCurrentSelection(selection); + } + + /** + * Removes current selection using browser API if available + * + * @param selection - current document selection + */ + private deleteCurrentSelection(selection: Selection): void { + if (typeof selection.deleteFromDocument === 'function') { + selection.deleteFromDocument(); + } + } + /** * Handle drag start event */ diff --git a/src/components/modules/paste.ts b/src/components/modules/paste.ts index 5e5065ea..7a8ae877 100644 --- a/src/components/modules/paste.ts +++ b/src/components/modules/paste.ts @@ -29,6 +29,25 @@ interface TagSubstitute { sanitizationConfig?: SanitizerRule; } +const SAFE_STRUCTURAL_TAGS = new Set([ + 'table', + 'thead', + 'tbody', + 'tfoot', + 'tr', + 'th', + 'td', + 'caption', + 'colgroup', + 'col', + 'ul', + 'ol', + 'li', + 'dl', + 'dt', + 'dd', +]); + /** * Pattern substitute object. */ @@ -144,6 +163,56 @@ export default class Paste extends Module { this.processTools(); } + /** + * Determines whether current block should be replaced by the pasted file tool. + * + * @param toolName - tool that is going to handle the file + */ + private shouldReplaceCurrentBlockForFile(toolName?: string): boolean { + const { BlockManager } = this.Editor; + const currentBlock = BlockManager.currentBlock; + + if (!currentBlock) { + return false; + } + + if (toolName && currentBlock.name === toolName) { + return true; + } + + const isCurrentBlockDefault = Boolean(currentBlock.tool.isDefault); + + return isCurrentBlockDefault && currentBlock.isEmpty; + } + + /** + * Builds sanitize config that keeps structural tags such as tables and lists intact. + * + * @param node - root node to inspect + */ + private getStructuralTagsSanitizeConfig(node: HTMLElement): SanitizerConfig { + const config: SanitizerConfig = {} as SanitizerConfig; + const nodesToProcess: Element[] = [ node ]; + + while (nodesToProcess.length > 0) { + const current = nodesToProcess.pop(); + + if (!current) { + continue; + } + + const tagName = current.tagName.toLowerCase(); + + if (SAFE_STRUCTURAL_TAGS.has(tagName)) { + config[tagName] = config[tagName] ?? {}; + } + + nodesToProcess.push(...Array.from(current.children)); + } + + return config; + } + /** * Set read-only state * @@ -157,6 +226,41 @@ export default class Paste extends Module { } } + /** + * Determines whether provided DataTransfer contains file-like entries + * + * @param dataTransfer - drag/drop payload to inspect + */ + private containsFiles(dataTransfer: DataTransfer): boolean { + const types = Array.from(dataTransfer.types); + + /** + * Common case: browser exposes explicit "Files" entry + */ + if (types.includes('Files')) { + return true; + } + + /** + * Drag/drop uploads sometimes omit `types` and set files directly + */ + if (dataTransfer.files?.length) { + return true; + } + + try { + const legacyList = dataTransfer.types as unknown as DOMStringList; + + if (typeof legacyList?.contains === 'function' && legacyList.contains('Files')) { + return true; + } + } catch { + // ignore and fallthrough + } + + return false; + } + /** * Handle pasted or dropped data transfer object * @@ -165,14 +269,7 @@ export default class Paste extends Module { */ public async processDataTransfer(dataTransfer: DataTransfer, isDragNDrop = false): Promise { const { Tools } = this.Editor; - const types = dataTransfer.types; - - /** - * In Microsoft Edge types is DOMStringList. So 'contains' is used to check if 'Files' type included - */ - const includesFiles = typeof types.includes === 'function' - ? types.includes('Files') - : (types as unknown as DOMStringList).contains('Files'); + const includesFiles = this.containsFiles(dataTransfer); if (includesFiles && !_.isEmpty(this.toolsFiles)) { await this.processFiles(dataTransfer.files); @@ -563,14 +660,15 @@ export default class Paste extends Module { ); const dataToInsert = processedFiles.filter((data): data is { type: string; event: PasteEvent } => data != null); - const isCurrentBlockDefault = Boolean(BlockManager.currentBlock?.tool.isDefault); - const needToReplaceCurrentBlock = isCurrentBlockDefault && Boolean(BlockManager.currentBlock?.isEmpty); + if (dataToInsert.length === 0) { + return; + } - dataToInsert.forEach( - (data, i) => { - BlockManager.paste(data.type, data.event, i === 0 && needToReplaceCurrentBlock); - } - ); + const shouldReplaceCurrentBlock = this.shouldReplaceCurrentBlockForFile(dataToInsert[0]?.type); + + dataToInsert.forEach((data, index) => { + BlockManager.paste(data.type, data.event, index === 0 && shouldReplaceCurrentBlock); + }); } /** @@ -695,7 +793,8 @@ export default class Paste extends Module { return nextResult; }, {} as SanitizerConfig); - const customConfig = Object.assign({}, toolTags, tool.baseSanitizeConfig); + const structuralSanitizeConfig = this.getStructuralTagsSanitizeConfig(content); + const customConfig = Object.assign({}, structuralSanitizeConfig, toolTags, tool.baseSanitizeConfig); const sanitizedContent = (() => { if (content.tagName.toLowerCase() !== 'table') { content.innerHTML = clean(content.innerHTML, customConfig); @@ -953,6 +1052,7 @@ export default class Paste extends Module { const isSubstitutable = tags.includes(element.tagName); const isBlockElement = $.blockElements.includes(element.tagName.toLowerCase()); + const isStructuralElement = SAFE_STRUCTURAL_TAGS.has(element.tagName.toLowerCase()); const containsAnotherToolTags = Array .from(element.children) .some( @@ -972,7 +1072,8 @@ export default class Paste extends Module { if ( (isSubstitutable && !containsAnotherToolTags) || - (isBlockElement && !containsBlockElements && !containsAnotherToolTags) + (isBlockElement && !containsBlockElements && !containsAnotherToolTags) || + (isStructuralElement && !containsAnotherToolTags) ) { return [...nodes, destNode, element]; } diff --git a/src/components/modules/saver.ts b/src/components/modules/saver.ts index 088f780f..73ea67f0 100644 --- a/src/components/modules/saver.ts +++ b/src/components/modules/saver.ts @@ -74,18 +74,21 @@ export default class Saver extends Module { private async getSavedData(block: Block): Promise { const blockData = await block.save(); const toolName = block.name; + const normalizedData = blockData?.data !== undefined + ? blockData + : this.getPreservedSavedData(block); - if (blockData === undefined) { + if (normalizedData === undefined) { return { tool: toolName, isValid: false, }; } - const isValid = await block.validate(blockData.data); + const isValid = await block.validate(normalizedData.data); return { - ...blockData, + ...normalizedData, isValid, }; } @@ -222,4 +225,27 @@ export default class Saver extends Module { public getLastSaveError(): unknown { return this.lastSaveError; } + + /** + * Returns the last successfully extracted data for the provided block, if any. + * + * @param block - block whose preserved data should be returned + */ + private getPreservedSavedData(block: Block): (SavedData & { tunes?: Record }) | undefined { + const preservedData = block.preservedData; + + if (_.isEmpty(preservedData)) { + return undefined; + } + + const preservedTunes = block.preservedTunes; + + return { + id: block.id, + tool: block.name, + data: preservedData, + ...( _.isEmpty(preservedTunes) ? {} : { tunes: preservedTunes }), + time: 0, + }; + } } diff --git a/src/components/modules/toolbar/index.ts b/src/components/modules/toolbar/index.ts index 3c3903f7..c72e6468 100644 --- a/src/components/modules/toolbar/index.ts +++ b/src/components/modules/toolbar/index.ts @@ -5,7 +5,7 @@ import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; import * as tooltip from '../../utils/tooltip'; import type { ModuleConfig } from '../../../types-internal/module-config'; -import type Block from '../../block'; +import Block from '../../block'; import Toolbox, { ToolboxEvent } from '../../ui/toolbox'; import { IconMenu, IconPlus } from '@codexteam/icons'; import { BlockHovered } from '../../events/BlockHovered'; @@ -627,6 +627,12 @@ export default class Toolbar extends Module { * Subscribe to the 'block-hovered' event */ this.eventsDispatcher.on(BlockHovered, (data) => { + const hoveredBlock = (data as { block?: Block }).block; + + if (!(hoveredBlock instanceof Block)) { + return; + } + /** * Do not move toolbar if Block Settings or Toolbox opened */ @@ -634,7 +640,7 @@ export default class Toolbar extends Module { return; } - this.moveAndOpen(data.block); + this.moveAndOpen(hoveredBlock); }); } } diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 610c30d8..d7950913 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -8,6 +8,7 @@ import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; import Shortcuts from '../../utils/shortcuts'; import type { ModuleConfig } from '../../../types-internal/module-config'; +import type { EditorModules } from '../../../types-internal/editor-modules'; import { CommonInternalSettings } from '../../tools/base'; import type { Popover, PopoverItemHtmlParams, PopoverItemParams, WithChildren } from '../../utils/popover'; import { PopoverItemType } from '../../utils/popover'; @@ -58,6 +59,11 @@ export default class InlineToolbar extends Module { */ private initialized = false; + /** + * Ensures we don't schedule multiple initialization attempts simultaneously + */ + private initializationScheduled = false; + /** * Currently visible tools instances */ @@ -73,6 +79,16 @@ export default class InlineToolbar extends Module { */ private savedShortcutRange: Range | null = null; + /** + * Tracks whether inline shortcuts have been registered + */ + private shortcutsRegistered = false; + + /** + * Prevents duplicate shortcut registration retries + */ + private shortcutRegistrationScheduled = false; + /** * @param moduleConfiguration - Module Configuration * @param moduleConfiguration.config - Editor's config @@ -96,9 +112,16 @@ export default class InlineToolbar extends Module { void this.tryToShow(); }, true); - window.requestIdleCallback(() => { - this.initialize(); - }, { timeout: 2000 }); + this.scheduleInitialization(); + this.tryRegisterShortcuts(); + } + + /** + * Setter for Editor modules that ensures shortcuts registration is retried once dependencies are available + */ + public override set state(Editor: EditorModules) { + super.state = Editor; + this.tryRegisterShortcuts(); } /** @@ -110,14 +133,81 @@ export default class InlineToolbar extends Module { } if (!this.Editor?.UI?.nodes?.wrapper || this.Editor.Tools === undefined) { + this.scheduleInitialization(); + return; } this.make(); - this.registerInitialShortcuts(); + this.tryRegisterShortcuts(); this.initialized = true; } + /** + * Attempts to register inline shortcuts as soon as tools are available + */ + private tryRegisterShortcuts(): void { + if (this.shortcutsRegistered) { + return; + } + + if (this.Editor?.Tools === undefined) { + this.scheduleShortcutRegistration(); + + return; + } + + const shortcutsWereRegistered = this.registerInitialShortcuts(); + + if (shortcutsWereRegistered) { + this.shortcutsRegistered = true; + } + } + + /** + * Schedules a retry for shortcut registration + */ + private scheduleShortcutRegistration(): void { + if (this.shortcutsRegistered || this.shortcutRegistrationScheduled) { + return; + } + + this.shortcutRegistrationScheduled = true; + + const callback = (): void => { + this.shortcutRegistrationScheduled = false; + this.tryRegisterShortcuts(); + }; + + if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') { + window.setTimeout(callback, 0); + } else { + callback(); + } + } + + /** + * Schedules the next initialization attempt, falling back to setTimeout when requestIdleCallback is unavailable + */ + private scheduleInitialization(): void { + if (this.initialized || this.initializationScheduled) { + return; + } + + this.initializationScheduled = true; + + const callback = (): void => { + this.initializationScheduled = false; + this.initialize(); + }; + + if ('requestIdleCallback' in window) { + window.requestIdleCallback(callback, { timeout: 2000 }); + } else { + window.setTimeout(callback, 0); + } + } + /** * Moving / appearance * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -736,6 +826,10 @@ export default class InlineToolbar extends Module { return; } + if (this.isShortcutTakenByAnotherTool(toolName, shortcut)) { + return; + } + if (registeredShortcut !== undefined) { Shortcuts.remove(document, registeredShortcut); this.registeredShortcuts.delete(toolName); @@ -777,6 +871,18 @@ export default class InlineToolbar extends Module { this.registeredShortcuts.set(toolName, shortcut); } + /** + * Check if shortcut is already registered by another inline tool + * + * @param toolName - tool that is currently being processed + * @param shortcut - shortcut to check + */ + private isShortcutTakenByAnotherTool(toolName: string, shortcut: string): boolean { + return Array.from(this.registeredShortcuts.entries()).some(([name, registeredShortcut]) => { + return name !== toolName && registeredShortcut === shortcut; + }); + } + /** * Inline Tool button clicks * @@ -886,14 +992,24 @@ export default class InlineToolbar extends Module { /** * Register shortcuts for inline tools ahead of time so they are available before the toolbar opens */ - private registerInitialShortcuts(): void { - const toolNames = Array.from(this.Editor.Tools.inlineTools.keys()); + private registerInitialShortcuts(): boolean { + const inlineTools = this.Editor.Tools?.inlineTools; + + if (!inlineTools) { + this.scheduleShortcutRegistration(); + + return false; + } + + const toolNames = Array.from(inlineTools.keys()); toolNames.forEach((toolName) => { const shortcut = this.getToolShortcut(toolName); this.tryEnableShortcut(toolName, shortcut); }); + + return true; } /** diff --git a/src/components/modules/tools.ts b/src/components/modules/tools.ts index cff744e2..cc1d713d 100644 --- a/src/components/modules/tools.ts +++ b/src/components/modules/tools.ts @@ -17,6 +17,7 @@ import MoveDownTune from '../block-tunes/block-tune-move-down'; import DeleteTune from '../block-tunes/block-tune-delete'; import MoveUpTune from '../block-tunes/block-tune-move-up'; import ToolsCollection from '../tools/collection'; +import { CriticalError } from '../errors/critical'; const cacheableSanitizer = _.cacheable as ( target: object, @@ -139,15 +140,15 @@ export default class Tools extends Module { * @returns {Promise} */ public async prepare(): Promise { - this.validateTools(); - /** - * Assign internal tools + * Assign internal tools before validation so required fallbacks (like stub) are always present */ const userTools = this.config.tools ?? {}; this.config.tools = _.deepMerge({}, this.internalTools, userTools); + this.validateTools(); + const toolsConfig = this.config.tools; if (!toolsConfig || Object.keys(toolsConfig).length === 0) { @@ -500,7 +501,7 @@ export default class Tools extends Module { const hasToolClass = _.isFunction(toolSettings.class); if (!isConstructorFunction && !hasToolClass) { - throw Error( + throw new CriticalError( `Tool «${toolName}» must be a constructor function or an object with function in the «class» property` ); } diff --git a/src/components/modules/ui.ts b/src/components/modules/ui.ts index 221c06ab..9d904056 100644 --- a/src/components/modules/ui.ts +++ b/src/components/modules/ui.ts @@ -598,7 +598,11 @@ export default class UI extends Module { * If any block selected and selection doesn't exists on the page (that means no other editable element is focused), * remove selected blocks */ - const shouldRemoveSelection = BlockSelection.anyBlockSelected && (!selectionExists || selectionCollapsed === true); + const shouldRemoveSelection = BlockSelection.anyBlockSelected && ( + !selectionExists || + selectionCollapsed === true || + this.Editor.CrossBlockSelection.isCrossBlockSelectionStarted + ); if (!shouldRemoveSelection) { return; diff --git a/test/playwright/tests/api/blocks.spec.ts b/test/playwright/tests/api/blocks.spec.ts index 06ad342b..64232d82 100644 --- a/test/playwright/tests/api/blocks.spec.ts +++ b/test/playwright/tests/api/blocks.spec.ts @@ -230,6 +230,118 @@ test.describe('api.blocks', () => { }); }); + test.describe('.renderFromHTML()', () => { + test('should clear existing content and render provided HTML string', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { text: 'initial content' }, + }, + ], + }, + }); + + await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + await window.editorInstance.blocks.renderFromHTML('

Rendered from HTML

'); + }); + + const blocks = page.locator(BLOCK_WRAPPER_SELECTOR); + + await expect(blocks).toHaveCount(1); + await expect(blocks).toHaveText([ 'Rendered from HTML' ]); + + const savedData = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + return await window.editorInstance.save(); + }); + + expect(savedData.blocks).toHaveLength(1); + expect(savedData.blocks[0].type).toBe('paragraph'); + expect(savedData.blocks[0].data.text).toBe('Rendered from HTML'); + }); + }); + + test.describe('.composeBlockData()', () => { + const PREFILLED_TOOL_SOURCE = `class PrefilledTool { + constructor({ data }) { + this.initialData = { + text: data.text ?? 'Composed paragraph', + }; + } + + static get toolbox() { + return { + icon: 'P', + title: 'Prefilled', + }; + } + + render() { + const element = document.createElement('div'); + element.contentEditable = 'true'; + element.innerHTML = this.initialData.text; + + return element; + } + + save() { + return this.initialData; + } + }`; + + test('should compose default block data for an existing tool', async ({ page }) => { + await createEditor(page, { + tools: [ + { + name: 'prefilled', + classSource: PREFILLED_TOOL_SOURCE, + }, + ], + }); + + const data = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + return await window.editorInstance.blocks.composeBlockData('prefilled'); + }); + + expect(data).toStrictEqual({ text: 'Composed paragraph' }); + }); + + test('should throw when tool is not registered', async ({ page }) => { + await createEditor(page); + + const error = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + try { + await window.editorInstance.blocks.composeBlockData('missing-tool'); + + return null; + } catch (err) { + return { + message: (err as Error).message, + }; + } + }); + + expect(error?.message).toBe('Block Tool with type "missing-tool" not found'); + }); + }); + /** * api.blocks.update(id, newData) */ @@ -852,6 +964,282 @@ test.describe('api.blocks', () => { */ expect(blocks[0].data.text).toBe(JSON.stringify(conversionTargetToolConfig)); }); + + test('should apply provided data overrides when converting a Block', async ({ page }) => { + const SOURCE_TOOL_SOURCE = `class SourceTool { + constructor({ data }) { + this.data = data; + } + + static get conversionConfig() { + return { + export: 'text', + }; + } + + static get toolbox() { + return { + icon: 'S', + title: 'Source', + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.classList.add('cdx-block'); + element.innerHTML = this.data?.text ?? ''; + + return element; + } + + save(block) { + return { + text: block.innerHTML, + }; + } + }`; + + const TARGET_TOOL_SOURCE = `class TargetTool { + constructor({ data, config }) { + this.data = data ?? {}; + this.config = config ?? {}; + } + + static get conversionConfig() { + return { + import: (text, config) => ({ + text: (config?.prefix ?? '') + text, + level: config?.defaultLevel ?? 1, + }), + }; + } + + static get toolbox() { + return { + icon: 'T', + title: 'Target', + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.classList.add('cdx-block'); + element.innerHTML = this.data?.text ?? ''; + + return element; + } + + save(block) { + return { + ...this.data, + text: block.innerHTML, + }; + } + }`; + + const blockId = 'convert-source-block'; + const initialText = 'Source tool content'; + const dataOverrides = { + level: 4, + customStyle: 'attention', + }; + + await createEditor(page, { + tools: [ + { + name: 'sourceTool', + classSource: SOURCE_TOOL_SOURCE, + }, + { + name: 'targetTool', + classSource: TARGET_TOOL_SOURCE, + config: { + prefix: '[Converted] ', + defaultLevel: 1, + }, + }, + ], + data: { + blocks: [ + { + id: blockId, + type: 'sourceTool', + data: { + text: initialText, + }, + }, + ], + }, + }); + + await page.evaluate(async ({ targetBlockId, overrides }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const { convert } = window.editorInstance.blocks; + + await convert(targetBlockId, 'targetTool', overrides); + }, { targetBlockId: blockId, + overrides: dataOverrides }); + + await page.waitForFunction(async () => { + if (!window.editorInstance) { + return false; + } + + const saved = await window.editorInstance.save(); + + return saved.blocks.length > 0 && saved.blocks[0].type === 'targetTool'; + }); + + const savedData = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + return await window.editorInstance.save(); + }); + + expect(savedData.blocks).toHaveLength(1); + expect(savedData.blocks[0].type).toBe('targetTool'); + expect(savedData.blocks[0].data).toStrictEqual({ + text: `${'[Converted] '}${initialText}`, + level: dataOverrides.level, + customStyle: dataOverrides.customStyle, + }); + }); + + test('should throw when block data cannot be extracted before conversion', async ({ page }) => { + const NON_SAVABLE_TOOL_SOURCE = `class NonSavableTool { + constructor({ data }) { + this.data = data; + } + + static get conversionConfig() { + return { + export: 'text', + }; + } + + static get toolbox() { + return { + icon: 'N', + title: 'Non savable', + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.classList.add('cdx-block'); + element.innerHTML = this.data?.text ?? ''; + + return element; + } + + save() { + return undefined; + } + }`; + + const TARGET_TOOL_SOURCE = `class ConvertibleTargetTool { + constructor({ data }) { + this.data = data ?? {}; + } + + static get conversionConfig() { + return { + import: 'text', + }; + } + + static get toolbox() { + return { + icon: 'T', + title: 'Target', + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.classList.add('cdx-block'); + element.innerHTML = this.data?.text ?? ''; + + return element; + } + + save(block) { + return { + text: block.innerHTML, + }; + } + }`; + + const blockId = 'non-savable-block'; + + await createEditor(page, { + tools: [ + { + name: 'nonSavable', + classSource: NON_SAVABLE_TOOL_SOURCE, + }, + { + name: 'convertibleTarget', + classSource: TARGET_TOOL_SOURCE, + }, + ], + data: { + blocks: [ + { + id: blockId, + type: 'nonSavable', + data: { + text: 'Broken block', + }, + }, + ], + }, + }); + + const error = await page.evaluate(async ({ targetBlockId }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const { convert } = window.editorInstance.blocks; + + try { + await convert(targetBlockId, 'convertibleTarget'); + + return null; + } catch (err) { + return { + message: (err as Error).message, + }; + } + }, { targetBlockId: blockId }); + + expect(error?.message).toBe('Could not convert Block. Failed to extract original Block data.'); + + const savedData = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + return await window.editorInstance.save(); + }); + + expect(savedData.blocks).toHaveLength(1); + expect(savedData.blocks[0].type).toBe('nonSavable'); + }); }); /** diff --git a/test/playwright/tests/api/caret.spec.ts b/test/playwright/tests/api/caret.spec.ts index 46f27ac1..dd38b80b 100644 --- a/test/playwright/tests/api/caret.spec.ts +++ b/test/playwright/tests/api/caret.spec.ts @@ -684,5 +684,357 @@ test.describe('caret API', () => { expect(result.firstBlockSelected).toBe(false); }); }); + + test.describe('.setToFirstBlock', () => { + test('moves caret to the first block and places it at the start', async ({ page }) => { + const blocks = [ + createParagraphBlock('first-block', 'First block content'), + createParagraphBlock('second-block', 'Second block content'), + ]; + + await createEditor(page, { + data: { + blocks, + }, + }); + + await clearSelection(page); + + const result = await page.evaluate(({ blockSelector }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const returnedValue = window.editorInstance.caret.setToFirstBlock('start'); + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null; + + return { + returnedValue, + rangeExists: !!range, + selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)), + startOffset: range?.startOffset ?? null, + }; + }, { blockSelector: BLOCK_SELECTOR }); + + expect(result.returnedValue).toBe(true); + expect(result.rangeExists).toBe(true); + expect(result.selectionInFirstBlock).toBe(true); + expect(result.startOffset).toBe(0); + }); + }); + + test.describe('.setToLastBlock', () => { + test('moves caret to the last block and places it at the end', async ({ page }) => { + const blocks = [ + createParagraphBlock('first-block', 'First block content'), + createParagraphBlock('last-block', 'Last block text'), + ]; + + await createEditor(page, { + data: { + blocks, + }, + }); + + await clearSelection(page); + + const result = await page.evaluate(({ blockSelector }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const returnedValue = window.editorInstance.caret.setToLastBlock('end'); + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const blocksCollection = document.querySelectorAll(blockSelector); + const lastBlock = blocksCollection.item(blocksCollection.length - 1) as HTMLElement | null; + + return { + returnedValue, + rangeExists: !!range, + selectionInLastBlock: !!(range && lastBlock && lastBlock.contains(range.startContainer)), + startContainerTextLength: range?.startContainer?.textContent?.length ?? null, + startOffset: range?.startOffset ?? null, + }; + }, { blockSelector: BLOCK_SELECTOR }); + + expect(result.returnedValue).toBe(true); + expect(result.rangeExists).toBe(true); + expect(result.selectionInLastBlock).toBe(true); + expect(result.startOffset).toBe(result.startContainerTextLength); + }); + }); + + test.describe('.setToPreviousBlock', () => { + test('moves caret to the previous block relative to the current one', async ({ page }) => { + const blocks = [ + createParagraphBlock('first-block', 'First block'), + createParagraphBlock('middle-block', 'Middle block'), + createParagraphBlock('last-block', 'Last block'), + ]; + + await createEditor(page, { + data: { + blocks, + }, + }); + + const result = await page.evaluate(({ blockSelector }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const currentSet = window.editorInstance.caret.setToBlock(2); + + if (!currentSet) { + throw new Error('Failed to set initial caret position'); + } + + const returnedValue = window.editorInstance.caret.setToPreviousBlock('default'); + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const middleBlock = document.querySelectorAll(blockSelector).item(1) as HTMLElement | null; + + const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex(); + const currentBlockId = currentBlockIndex !== undefined + ? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null + : null; + + return { + returnedValue, + rangeExists: !!range, + selectionInMiddleBlock: !!(range && middleBlock && middleBlock.contains(range.startContainer)), + currentBlockId, + }; + }, { blockSelector: BLOCK_SELECTOR }); + + expect(result.returnedValue).toBe(true); + expect(result.rangeExists).toBe(true); + expect(result.selectionInMiddleBlock).toBe(true); + expect(result.currentBlockId).toBe('middle-block'); + }); + }); + + test.describe('.setToNextBlock', () => { + test('moves caret to the next block relative to the current one', async ({ page }) => { + const blocks = [ + createParagraphBlock('first-block', 'First block'), + createParagraphBlock('middle-block', 'Middle block'), + createParagraphBlock('last-block', 'Last block'), + ]; + + await createEditor(page, { + data: { + blocks, + }, + }); + + const result = await page.evaluate(({ blockSelector }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const currentSet = window.editorInstance.caret.setToBlock(0); + + if (!currentSet) { + throw new Error('Failed to set initial caret position'); + } + + const returnedValue = window.editorInstance.caret.setToNextBlock('default'); + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const middleBlock = document.querySelectorAll(blockSelector).item(1) as HTMLElement | null; + + const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex(); + const currentBlockId = currentBlockIndex !== undefined + ? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null + : null; + + return { + returnedValue, + rangeExists: !!range, + selectionInMiddleBlock: !!(range && middleBlock && middleBlock.contains(range.startContainer)), + currentBlockId, + }; + }, { blockSelector: BLOCK_SELECTOR }); + + expect(result.returnedValue).toBe(true); + expect(result.rangeExists).toBe(true); + expect(result.selectionInMiddleBlock).toBe(true); + expect(result.currentBlockId).toBe('middle-block'); + }); + }); + + test.describe('.focus', () => { + test('focuses the first block when called without arguments', async ({ page }) => { + const blocks = [ + createParagraphBlock('focus-first', 'First block content'), + createParagraphBlock('focus-second', 'Second block content'), + ]; + + await createEditor(page, { + data: { + blocks, + }, + }); + + await clearSelection(page); + + const result = await page.evaluate(({ blockSelector }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const returnedValue = window.editorInstance.focus(); + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null; + + return { + returnedValue, + rangeExists: !!range, + selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)), + startOffset: range?.startOffset ?? null, + }; + }, { blockSelector: BLOCK_SELECTOR }); + + expect(result.returnedValue).toBe(true); + expect(result.rangeExists).toBe(true); + expect(result.selectionInFirstBlock).toBe(true); + expect(result.startOffset).toBe(0); + }); + + test('focuses the last block when called with atEnd = true', async ({ page }) => { + const blocks = [ + createParagraphBlock('focus-first', 'First block'), + createParagraphBlock('focus-last', 'Last block content'), + ]; + + await createEditor(page, { + data: { + blocks, + }, + }); + + await clearSelection(page); + + const result = await page.evaluate(({ blockSelector }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const returnedValue = window.editorInstance.focus(true); + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const blocksCollection = document.querySelectorAll(blockSelector); + const lastBlock = blocksCollection.item(blocksCollection.length - 1) as HTMLElement | null; + + return { + returnedValue, + rangeExists: !!range, + selectionInLastBlock: !!(range && lastBlock && lastBlock.contains(range.startContainer)), + startContainerTextLength: range?.startContainer?.textContent?.length ?? null, + startOffset: range?.startOffset ?? null, + }; + }, { blockSelector: BLOCK_SELECTOR }); + + expect(result.returnedValue).toBe(true); + expect(result.rangeExists).toBe(true); + expect(result.selectionInLastBlock).toBe(true); + expect(result.startOffset).toBe(result.startContainerTextLength); + }); + + test('autofocus configuration moves caret to the first block after initialization', async ({ page }) => { + const blocks = [ createParagraphBlock('autofocus-block', 'Autofocus content') ]; + + await createEditor(page, { + data: { + blocks, + }, + config: { + autofocus: true, + }, + }); + + const result = await page.evaluate(({ blockSelector }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null; + + const currentBlockIndex = window.editorInstance.blocks.getCurrentBlockIndex(); + const currentBlockId = currentBlockIndex !== undefined + ? window.editorInstance.blocks.getBlockByIndex(currentBlockIndex)?.id ?? null + : null; + + return { + rangeExists: !!range, + selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)), + currentBlockId, + }; + }, { blockSelector: BLOCK_SELECTOR }); + + expect(result.rangeExists).toBe(true); + expect(result.selectionInFirstBlock).toBe(true); + expect(result.currentBlockId).toBe('autofocus-block'); + }); + + test('focus can be restored after editor operations clear the selection', async ({ page }) => { + const blocks = [ + createParagraphBlock('restore-first', 'First block'), + createParagraphBlock('restore-second', 'Second block'), + ]; + + await createEditor(page, { + data: { + blocks, + }, + }); + + const result = await page.evaluate(({ blockSelector }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const initialFocusResult = window.editorInstance.focus(); + const initialSelection = window.getSelection(); + const initialRangeCount = initialSelection?.rangeCount ?? 0; + + window.editorInstance.blocks.insert('paragraph', { text: 'Inserted block' }, undefined, 1, false); + window.getSelection()?.removeAllRanges(); + + const selectionAfterOperation = window.getSelection(); + const afterRangeCount = selectionAfterOperation?.rangeCount ?? 0; + + const returnedValue = window.editorInstance.focus(); + const selection = window.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + const firstBlock = document.querySelectorAll(blockSelector).item(0) as HTMLElement | null; + + return { + initialFocusResult, + initialRangeCount, + afterRangeCount, + returnedValue, + rangeExists: !!range, + selectionInFirstBlock: !!(range && firstBlock && firstBlock.contains(range.startContainer)), + blocksCount: window.editorInstance.blocks.getBlocksCount(), + }; + }, { blockSelector: BLOCK_SELECTOR }); + + expect(result.initialFocusResult).toBe(true); + expect(result.initialRangeCount).toBeGreaterThan(0); + expect(result.afterRangeCount).toBe(0); + expect(result.returnedValue).toBe(true); + expect(result.rangeExists).toBe(true); + expect(result.selectionInFirstBlock).toBe(true); + expect(result.blocksCount).toBe(3); + }); + }); }); diff --git a/test/playwright/tests/api/events.spec.ts b/test/playwright/tests/api/events.spec.ts new file mode 100644 index 00000000..95df9f1b --- /dev/null +++ b/test/playwright/tests/api/events.spec.ts @@ -0,0 +1,195 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; +import { BlockChanged } from '../../../../src/components/events/BlockChanged'; +import { BlockHovered } from '../../../../src/components/events/BlockHovered'; +import { RedactorDomChanged } from '../../../../src/components/events/RedactorDomChanged'; +import { FakeCursorAboutToBeToggled } from '../../../../src/components/events/FakeCursorAboutToBeToggled'; +import { FakeCursorHaveBeenSet } from '../../../../src/components/events/FakeCursorHaveBeenSet'; +import { EditorMobileLayoutToggled } from '../../../../src/components/events/EditorMobileLayoutToggled'; +import { BlockSettingsOpened } from '../../../../src/components/events/BlockSettingsOpened'; +import { BlockSettingsClosed } from '../../../../src/components/events/BlockSettingsClosed'; +import type { EditorEventMap } from '../../../../src/components/events'; +import { BlockChangedMutationType } from '../../../../types/events/block/BlockChanged'; + +declare global { + interface Window { + editorInstance?: EditorJS; + } +} + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; + +type EventTestCase = { + name: keyof EditorEventMap; + createPayload: () => unknown; +}; + +const EVENT_TEST_CASES: EventTestCase[] = [ + { + name: BlockChanged, + createPayload: () => ({ + event: { + type: BlockChangedMutationType, + detail: { + target: { + id: 'block-changed-test-block', + name: 'paragraph', + }, + index: 0, + }, + }, + }) as unknown as EditorEventMap[typeof BlockChanged], + }, + { + name: BlockHovered, + createPayload: () => ({ + block: { + id: 'hovered-block', + }, + }) as unknown as EditorEventMap[typeof BlockHovered], + }, + { + name: RedactorDomChanged, + createPayload: () => ({ + mutations: [], + }) as EditorEventMap[typeof RedactorDomChanged], + }, + { + name: FakeCursorAboutToBeToggled, + createPayload: () => ({ + state: true, + }) as EditorEventMap[typeof FakeCursorAboutToBeToggled], + }, + { + name: FakeCursorHaveBeenSet, + createPayload: () => ({ + state: false, + }) as EditorEventMap[typeof FakeCursorHaveBeenSet], + }, + { + name: EditorMobileLayoutToggled, + createPayload: () => ({ + isEnabled: true, + }) as EditorEventMap[typeof EditorMobileLayoutToggled], + }, + { + name: BlockSettingsOpened, + createPayload: () => ({}), + }, + { + name: BlockSettingsClosed, + createPayload: () => ({}), + }, +]; + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page): Promise => { + await resetEditor(page); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + + await page.evaluate(async ({ holderId }) => { + const editor = new window.EditorJS({ + holder: holderId, + }); + + window.editorInstance = editor; + + await editor.isReady; + }, { holderId: HOLDER_ID }); +}; + +const subscribeEmitAndUnsubscribe = async ( + page: Page, + eventName: keyof EditorEventMap, + payload: unknown +): Promise => { + return await page.evaluate(({ name, data }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const received: unknown[] = []; + const handler = (eventPayload: unknown): void => { + received.push(eventPayload); + }; + + window.editorInstance.on(name, handler); + window.editorInstance.emit(name, data); + window.editorInstance.off(name, handler); + window.editorInstance.emit(name, data); + + return received; + }, { + name: eventName, + data: payload, + }); +}; + +const TEST_PAGE_VISIT = async (page: Page): Promise => { + await page.goto(TEST_PAGE_URL); +}; + +const eventsDispatcherExists = async (page: Page): Promise => { + return await page.evaluate(() => { + return Boolean(window.editorInstance && 'eventsDispatcher' in window.editorInstance); + }); +}; + +test.describe('api.events', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await TEST_PAGE_VISIT(page); + }); + + test('should expose events dispatcher via core API', async ({ page }) => { + await createEditor(page); + + const dispatcherExists = await eventsDispatcherExists(page); + + expect(dispatcherExists).toBe(true); + }); + + test.describe('subscription lifecycle', () => { + for (const { name, createPayload } of EVENT_TEST_CASES) { + test(`should subscribe, emit and unsubscribe for event "${name}"`, async ({ page }) => { + await createEditor(page); + const payload = createPayload(); + + const receivedPayloads = await subscribeEmitAndUnsubscribe(page, name, payload); + + expect(receivedPayloads).toHaveLength(1); + expect(receivedPayloads[0]).toStrictEqual(payload); + }); + } + }); +}); diff --git a/test/playwright/tests/api/inline-toolbar.spec.ts b/test/playwright/tests/api/inline-toolbar.spec.ts new file mode 100644 index 00000000..4892c31f --- /dev/null +++ b/test/playwright/tests/api/inline-toolbar.spec.ts @@ -0,0 +1,237 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type EditorJS from '@/types'; +import type { OutputData } from '@/types'; +import { + EDITOR_INTERFACE_SELECTOR, + INLINE_TOOLBAR_INTERFACE_SELECTOR +} from '../../../../src/components/constants'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`; +const INLINE_TOOLBAR_CONTAINER_SELECTOR = `${INLINE_TOOLBAR_INTERFACE_SELECTOR} .ce-popover__container`; + +const INITIAL_DATA: OutputData = { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Inline toolbar API end-to-end coverage text.', + }, + }, + ], +}; + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page, data: OutputData): Promise => { + await resetEditor(page); + + await page.evaluate( + async ({ holderId, editorData }) => { + const editor = new window.EditorJS({ + holder: holderId, + data: editorData, + }); + + window.editorInstance = editor; + await editor.isReady; + }, + { + holderId: HOLDER_ID, + editorData: data, + } + ); +}; + +const setSelectionRange = async (locator: Locator, start: number, end: number): Promise => { + if (start < 0 || end < start) { + throw new Error(`Invalid selection offsets: start (${start}) must be >= 0 and end (${end}) must be >= start.`); + } + + await locator.scrollIntoViewIfNeeded(); + await locator.focus(); + + await locator.evaluate( + (element, { start: selectionStart, end: selectionEnd }) => { + const ownerDocument = element.ownerDocument; + + if (!ownerDocument) { + return; + } + + const selection = ownerDocument.getSelection(); + + if (!selection) { + return; + } + + const textNodes: Text[] = []; + const walker = ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + + let currentNode = walker.nextNode(); + + while (currentNode) { + textNodes.push(currentNode as Text); + currentNode = walker.nextNode(); + } + + if (textNodes.length === 0) { + return; + } + + const findPosition = (offset: number): { node: Text; nodeOffset: number } | null => { + let accumulated = 0; + + for (const node of textNodes) { + const length = node.textContent?.length ?? 0; + const nodeStart = accumulated; + const nodeEnd = accumulated + length; + + if (offset >= nodeStart && offset <= nodeEnd) { + return { + node, + nodeOffset: Math.min(length, offset - nodeStart), + }; + } + + accumulated = nodeEnd; + } + + if (offset === 0) { + const firstNode = textNodes[0]; + + return { + node: firstNode, + nodeOffset: 0, + }; + } + + return null; + }; + + const startPosition = findPosition(selectionStart); + const endPosition = findPosition(selectionEnd); + + if (!startPosition || !endPosition) { + return; + } + + const range = ownerDocument.createRange(); + + range.setStart(startPosition.node, startPosition.nodeOffset); + range.setEnd(endPosition.node, endPosition.nodeOffset); + + selection.removeAllRanges(); + selection.addRange(range); + ownerDocument.dispatchEvent(new Event('selectionchange')); + }, + { start, + end } + ); +}; + +const selectText = async (locator: Locator, text: string): Promise => { + const fullText = await locator.textContent(); + + if (!fullText || !fullText.includes(text)) { + throw new Error(`Text "${text}" was not found in element`); + } + + const startIndex = fullText.indexOf(text); + const endIndex = startIndex + text.length; + + await setSelectionRange(locator, startIndex, endIndex); +}; + +test.describe('api.inlineToolbar', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + }); + + test('inlineToolbar.open() shows the inline toolbar when selection exists', async ({ page }) => { + await createEditor(page, INITIAL_DATA); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await expect(paragraph).toHaveCount(1); + + await selectText(paragraph, 'Inline toolbar'); + + await page.evaluate(() => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + window.editorInstance.inlineToolbar.open(); + }); + + await expect(page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR)).toBeVisible(); + }); + + test('inlineToolbar.close() hides the inline toolbar', async ({ page }) => { + await createEditor(page, INITIAL_DATA); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await expect(paragraph).toHaveCount(1); + const toolbarContainer = page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR); + + await selectText(paragraph, 'Inline toolbar'); + + await page.evaluate(() => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + window.editorInstance.inlineToolbar.open(); + }); + + await expect(toolbarContainer).toBeVisible(); + + await page.evaluate(() => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + window.editorInstance.inlineToolbar.close(); + }); + + await expect(toolbarContainer).toHaveCount(0); + }); +}); + +declare global { + interface Window { + editorInstance?: EditorJS; + EditorJS: new (...args: unknown[]) => EditorJS; + } +} diff --git a/test/playwright/tests/api/listeners.spec.ts b/test/playwright/tests/api/listeners.spec.ts new file mode 100644 index 00000000..26cf5834 --- /dev/null +++ b/test/playwright/tests/api/listeners.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import type { EditorConfig } from '@/types'; +import type { Listeners as ListenersAPI } from '@/types/api/listeners'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; + +declare global { + interface Window { + editorInstance?: EditorJS; + listenerCallCount?: number; + lifecycleCallCount?: number; + listenersTestTarget?: HTMLElement; + listenersTestHandler?: (event?: Event) => void; + listenersLifecycleTarget?: HTMLElement; + listenersLifecycleHandler?: (event?: Event) => void; + firstListenerId?: string | null; + secondListenerId?: string | null; + } +} + +type EditorWithListeners = EditorJS & { listeners: ListenersAPI }; + +type CreateEditorOptions = Partial; + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise => { + await resetEditor(page); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + + await page.evaluate( + async (params: { holderId: string; editorOptions: Record }) => { + const config = Object.assign( + { holder: params.holderId }, + params.editorOptions + ) as EditorConfig; + + const editor = new window.EditorJS(config); + + window.editorInstance = editor; + await editor.isReady; + }, + { + holderId: HOLDER_ID, + editorOptions: options as Record, + } + ); +}; + +const clickElement = async (page: Page, selector: string): Promise => { + await page.evaluate((targetSelector) => { + const target = document.querySelector(targetSelector); + + target?.click(); + }, selector); +}; + +test.describe('api.listeners', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + }); + + test('registers and removes DOM listeners via the public API', async ({ page }) => { + await createEditor(page); + + await page.evaluate(() => { + const editor = window.editorInstance as EditorWithListeners | undefined; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + const target = document.createElement('button'); + + target.id = 'listeners-target'; + target.textContent = 'listener target'; + target.style.width = '2px'; + target.style.height = '2px'; + document.body.appendChild(target); + + window.listenerCallCount = 0; + window.listenersTestTarget = target; + window.listenersTestHandler = (): void => { + window.listenerCallCount = (window.listenerCallCount ?? 0) + 1; + }; + + const listenerId = editor.listeners.on(target, 'click', window.listenersTestHandler); + + window.firstListenerId = listenerId ?? null; + }); + + const firstListenerId = await page.evaluate(() => window.firstListenerId); + + expect(firstListenerId).toBeTruthy(); + + await clickElement(page, '#listeners-target'); + await page.waitForFunction(() => window.listenerCallCount === 1); + + await page.evaluate(() => { + const editor = window.editorInstance as EditorWithListeners | undefined; + + if (!editor || !window.listenersTestTarget || !window.listenersTestHandler) { + throw new Error('Listener prerequisites were not set'); + } + + editor.listeners.off(window.listenersTestTarget, 'click', window.listenersTestHandler); + }); + + await clickElement(page, '#listeners-target'); + + let callCount = await page.evaluate(() => window.listenerCallCount); + + expect(callCount).toBe(1); + + await page.evaluate(() => { + const editor = window.editorInstance as EditorWithListeners | undefined; + + if (!editor || !window.listenersTestTarget || !window.listenersTestHandler) { + throw new Error('Listener prerequisites were not set'); + } + + window.listenerCallCount = 0; + const listenerId = editor.listeners.on( + window.listenersTestTarget, + 'click', + window.listenersTestHandler + ); + + window.secondListenerId = listenerId ?? null; + }); + + await clickElement(page, '#listeners-target'); + await page.waitForFunction(() => window.listenerCallCount === 1); + + await page.evaluate(() => { + const editor = window.editorInstance as EditorWithListeners | undefined; + + if (window.secondListenerId && editor) { + editor.listeners.offById(window.secondListenerId); + } + }); + + await clickElement(page, '#listeners-target'); + + callCount = await page.evaluate(() => window.listenerCallCount); + + expect(callCount).toBe(1); + }); + + test('cleans up registered listeners when the editor is destroyed', async ({ page }) => { + await createEditor(page); + + await page.evaluate(() => { + const editor = window.editorInstance as EditorWithListeners | undefined; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + const target = document.createElement('button'); + + target.id = 'listeners-lifecycle-target'; + target.textContent = 'listener lifecycle target'; + document.body.appendChild(target); + + window.lifecycleCallCount = 0; + window.listenersLifecycleTarget = target; + window.listenersLifecycleHandler = (): void => { + window.lifecycleCallCount = (window.lifecycleCallCount ?? 0) + 1; + }; + + editor.listeners.on(target, 'click', window.listenersLifecycleHandler); + }); + + await clickElement(page, '#listeners-lifecycle-target'); + await page.waitForFunction(() => window.lifecycleCallCount === 1); + + await page.evaluate(() => { + window.editorInstance?.destroy?.(); + window.editorInstance = undefined; + }); + + await clickElement(page, '#listeners-lifecycle-target'); + + const finalLifecycleCount = await page.evaluate(() => window.lifecycleCallCount); + + expect(finalLifecycleCount).toBe(1); + }); +}); diff --git a/test/playwright/tests/api/notifier.spec.ts b/test/playwright/tests/api/notifier.spec.ts new file mode 100644 index 00000000..348d2419 --- /dev/null +++ b/test/playwright/tests/api/notifier.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import type { Notifier as NotifierAPI } from '@/types/api/notifier'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; + +declare global { + interface Window { + editorInstance?: EditorJS; + } +} + +type EditorWithNotifier = EditorJS & { notifier: NotifierAPI }; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const NOTIFIER_CONTAINER_SELECTOR = '.cdx-notifies'; +const NOTIFICATION_SELECTOR = '.cdx-notify'; + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + const holder = document.getElementById(holderId); + + holder?.remove(); + + // Remove leftover notifications between tests to keep DOM deterministic + document.querySelectorAll('.cdx-notifies').forEach((node) => node.remove()); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page): Promise => { + await resetEditor(page); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + + await page.evaluate(async ({ holderId }) => { + const editor = new window.EditorJS({ + holder: holderId, + }); + + window.editorInstance = editor; + await editor.isReady; + }, { holderId: HOLDER_ID }); +}; + +test.describe('api.notifier', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + }); + + test.afterEach(async ({ page }) => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.querySelectorAll('.cdx-notifies').forEach((node) => node.remove()); + document.getElementById(holderId)?.remove(); + }, { holderId: HOLDER_ID }); + }); + + test('should display notification message through the notifier API', async ({ page }) => { + await createEditor(page); + + const message = 'Editor notifier alert'; + + await page.evaluate(({ text }) => { + const editor = window.editorInstance as EditorWithNotifier | undefined; + + editor?.notifier.show({ + message: text, + style: 'success', + time: 1000, + }); + }, { text: message }); + + const notification = page.locator(NOTIFICATION_SELECTOR).filter({ hasText: message }); + + await expect(notification).toBeVisible(); + await expect(notification).toHaveClass(/cdx-notify--success/); + + await expect(page.locator(NOTIFIER_CONTAINER_SELECTOR)).toBeVisible(); + }); + + test('should render confirm notification with type-specific UI and styles', async ({ page }) => { + await createEditor(page); + + const message = 'Delete current block?'; + const okText = 'Yes, delete'; + const cancelText = 'No, keep'; + + await page.evaluate(({ text, ok, cancel }) => { + const editor = window.editorInstance as EditorWithNotifier | undefined; + + editor?.notifier.show({ + message: text, + type: 'confirm', + style: 'error', + okText: ok, + cancelText: cancel, + }); + }, { + text: message, + ok: okText, + cancel: cancelText, + }); + + const notification = page.locator(NOTIFICATION_SELECTOR).filter({ hasText: message }); + + await expect(notification).toBeVisible(); + await expect(notification).toHaveClass(/cdx-notify--error/); + await expect(notification.locator('.cdx-notify__button--confirm')).toHaveText(okText); + await expect(notification.locator('.cdx-notify__button--cancel')).toHaveText(cancelText); + }); +}); diff --git a/test/playwright/tests/api/render.spec.ts b/test/playwright/tests/api/render.spec.ts new file mode 100644 index 00000000..8496c4f4 --- /dev/null +++ b/test/playwright/tests/api/render.spec.ts @@ -0,0 +1,219 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; +import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`; + +const getBlockWrapperByIndex = (page: Page, index: number = 0): Locator => { + return page.locator(`:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`); +}; + +type SerializableOutputData = { + version?: string; + time?: number; + blocks: Array<{ + id?: string; + type: string; + data: Record; + tunes?: Record; + }>; +}; + +declare global { + interface Window { + editorInstance?: EditorJS; + } +} + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page, data?: SerializableOutputData): Promise => { + await resetEditor(page); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + + await page.evaluate( + async ({ holderId, rawData }) => { + const editorConfig: Record = { + holder: holderId, + }; + + if (rawData) { + editorConfig.data = rawData; + } + + const editor = new window.EditorJS(editorConfig); + + window.editorInstance = editor; + await editor.isReady; + }, + { + holderId: HOLDER_ID, + rawData: data ?? null, + } + ); +}; + +const defaultInitialData: SerializableOutputData = { + blocks: [ + { + id: 'initial-block', + type: 'paragraph', + data: { + text: 'Initial block content', + }, + }, + ], +}; + +test.describe('api.render', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + }); + + test('editor.render replaces existing document content', async ({ page }) => { + await createEditor(page, defaultInitialData); + + const initialBlock = getBlockWrapperByIndex(page); + + await expect(initialBlock).toHaveText('Initial block content'); + + const newData: SerializableOutputData = { + blocks: [ + { + id: 'rendered-block', + type: 'paragraph', + data: { text: 'Rendered via API' }, + }, + ], + }; + + await page.evaluate(async ({ data }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + await window.editorInstance.render(data); + }, { data: newData }); + + await expect(initialBlock).toHaveText('Rendered via API'); + }); + + test.describe('render accepts different data formats', () => { + const dataVariants: Array<{ title: string; data: SerializableOutputData; expectedText: string; }> = [ + { + title: 'with metadata (version + time)', + data: { + version: '2.30.0', + time: Date.now(), + blocks: [ + { + id: 'meta-block', + type: 'paragraph', + data: { text: 'Metadata format' }, + }, + ], + }, + expectedText: 'Metadata format', + }, + { + title: 'minimal object containing only blocks', + data: { + blocks: [ + { + type: 'paragraph', + data: { text: 'Minimal format' }, + }, + ], + }, + expectedText: 'Minimal format', + }, + ]; + + for (const variant of dataVariants) { + test(`renders data ${variant.title}`, async ({ page }) => { + await createEditor(page, defaultInitialData); + + await page.evaluate(async ({ data }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + await window.editorInstance.render(data); + }, { data: variant.data }); + + await expect(getBlockWrapperByIndex(page)).toHaveText(variant.expectedText); + }); + } + }); + + test.describe('edge cases', () => { + test('inserts a default block when empty data is rendered', async ({ page }) => { + await createEditor(page, defaultInitialData); + + const blockCount = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + await window.editorInstance.render({ blocks: [] }); + + return window.editorInstance.blocks.getBlocksCount(); + }); + + await expect(page.locator(BLOCK_WRAPPER_SELECTOR)).toHaveCount(1); + expect(blockCount).toBe(1); + }); + + test('throws a descriptive error when data is invalid', async ({ page }) => { + await createEditor(page, defaultInitialData); + + const errorMessage = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + try { + await window.editorInstance.render({} as SerializableOutputData); + + return null; + } catch (error) { + return (error as Error).message; + } + }); + + expect(errorMessage).toBe('Incorrect data passed to the render() method'); + await expect(getBlockWrapperByIndex(page)).toHaveText('Initial block content'); + }); + }); +}); diff --git a/test/playwright/tests/api/sanitizer.spec.ts b/test/playwright/tests/api/sanitizer.spec.ts new file mode 100644 index 00000000..4ac19dee --- /dev/null +++ b/test/playwright/tests/api/sanitizer.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; + +declare global { + interface Window { + editorInstance?: EditorJS; + } +} + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page): Promise => { + await resetEditor(page); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + + await page.evaluate(async ({ holderId }) => { + const editor = new window.EditorJS({ + holder: holderId, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Initial block', + }, + }, + ], + }, + }); + + window.editorInstance = editor; + await editor.isReady; + }, { holderId: HOLDER_ID }); +}; + +test.describe('api.sanitizer', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + await createEditor(page); + }); + + test('clean removes disallowed HTML', async ({ page }) => { + const sanitized = await page.evaluate(() => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const dirtyHtml = '

Safe

'; + + return window.editorInstance.sanitizer.clean(dirtyHtml, { + p: true, + }); + }); + + expect(sanitized).toBe('

Safe

'); + expect(sanitized).not.toContain(' +

Another line

+ + `; + + await block.click(); + await paste(page, block, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': maliciousHtml, + }); + + const texts = (await page.locator(BLOCK_SELECTOR).allTextContents()).map((text) => text.trim()).filter(Boolean); + + expect(texts).toStrictEqual(['Safe text', 'Another line']); + + const scriptExecuted = await page.evaluate(() => { + return window.__maliciousPasteExecuted ?? false; + }); + + expect(scriptExecuted).toBe(false); + }); + + test('should fall back to plain text when invalid EditorJS data is pasted', async ({ page }) => { + await createEditor(page); + + const paragraph = getParagraphByIndex(page, 0); + + await paragraph.click(); + await paste(page, paragraph, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'application/x-editor-js': '{not-valid-json', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': 'Fallback plain text', + }); + + await expect(getParagraphByIndex(page, 0)).toContainText('Fallback plain text'); + }); + + test('should handle file pastes via paste config', async ({ page }) => { + const fileToolSource = ` + class FilePasteTool { + constructor({ data }) { + this.data = data ?? {}; + this.element = null; + } + + static get pasteConfig() { + return { + files: { + extensions: ['png'], + mimeTypes: ['image/png'], + }, + }; + } + + render() { + this.element = document.createElement('div'); + this.element.className = 'file-paste-tool'; + this.element.contentEditable = 'true'; + this.element.textContent = this.data.text ?? 'Paste file here'; + + return this.element; + } + + save(element) { + return { + text: element.textContent ?? '', + }; + } + + onPaste(event) { + const file = event.detail?.file ?? null; + + window.__lastPastedFile = file + ? { name: file.name, type: file.type, size: file.size } + : null; + + if (file && this.element) { + this.element.textContent = 'Pasted file: ' + file.name; + } + } + } + `; + + await createEditor(page, { + tools: { + fileTool: { + classCode: fileToolSource, + }, + }, + data: { + blocks: [ + { + type: 'fileTool', + data: {}, + }, + ], + }, + }); + + const block = page.locator('.file-paste-tool'); + + await expect(block).toHaveCount(1); + await block.click(); + + await pasteFiles(page, block, [ + { + name: 'pasted-image.png', + type: 'image/png', + content: 'fake-image-content', + }, + ]); + + await expect(block).toContainText('Pasted file: pasted-image.png'); + + const fileMeta = await page.evaluate(() => window.__lastPastedFile); + + expect(fileMeta).toMatchObject({ + name: 'pasted-image.png', + type: 'image/png', + }); + }); + + test('should paste content copied from external applications', async ({ page }) => { + await createEditor(page); + + const block = getBlockByIndex(page, 0); + const externalHtml = ` + + + + + + + +

Copied from Word

+

Styled paragraph

+ + + + `; + const plainFallback = 'Copied from Word\n\nStyled paragraph'; + + await block.click(); + await paste(page, block, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': externalHtml, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': plainFallback, + }); + + const blocks = page.locator(BLOCK_SELECTOR); + const secondParagraph = getParagraphByIndex(page, 1); + + await expect(blocks).toHaveCount(2); + await expect(getParagraphByIndex(page, 0)).toContainText('Copied from Word'); + await expect(secondParagraph).toContainText('Styled paragraph'); + await expect(secondParagraph.locator('b')).toHaveText('Styled'); + }); test('should not prevent default behaviour if block paste config equals false', async ({ page }) => { const blockToolSource = ` class BlockToolWithPasteHandler { @@ -617,6 +830,8 @@ declare global { editorInstance?: EditorJS; EditorJS: new (...args: unknown[]) => EditorJS; blockToolPasteEvents?: Array<{ defaultPrevented: boolean }>; + __lastPastedFile?: { name: string; type: string; size: number } | null; + __maliciousPasteExecuted?: boolean; } } diff --git a/test/playwright/tests/error-handling.spec.ts b/test/playwright/tests/error-handling.spec.ts new file mode 100644 index 00000000..254f24f4 --- /dev/null +++ b/test/playwright/tests/error-handling.spec.ts @@ -0,0 +1,254 @@ +/* eslint-disable jsdoc/require-jsdoc, @typescript-eslint/explicit-function-return-type */ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import type { OutputBlockData, OutputData } from '@/types'; +import { ensureEditorBundleBuilt } from './helpers/ensure-build'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; + +declare global { + interface Window { + editorInstance?: EditorJS; + } +} + +type SerializableOutputData = { + blocks?: Array; +}; + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +test.describe('editor error handling', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + }); + + test('reports a descriptive error when tool configuration is invalid', async ({ page }) => { + await resetEditor(page); + + const errorMessage = await page.evaluate(async ({ holderId }) => { + try { + const editor = new window.EditorJS({ + holder: holderId, + tools: { + brokenTool: { + inlineToolbar: true, + }, + }, + }); + + window.editorInstance = editor; + await editor.isReady; + + return null; + } catch (error) { + return (error as Error).message; + } + }, { holderId: HOLDER_ID }); + + expect(errorMessage).toBe('Tool «brokenTool» must be a constructor function or an object with function in the «class» property'); + }); + + test('logs a warning when required inline tool methods are missing', async ({ page }) => { + await resetEditor(page); + + const warningPromise = page.waitForEvent('console', { + predicate: (message) => message.type() === 'warning' && message.text().includes('Incorrect Inline Tool'), + }); + + await page.evaluate(async ({ holderId }) => { + class InlineWithoutRender { + public static isInline = true; + } + + const editor = new window.EditorJS({ + holder: holderId, + tools: { + inlineWithoutRender: { + class: InlineWithoutRender, + }, + }, + }); + + window.editorInstance = editor; + await editor.isReady; + }, { holderId: HOLDER_ID }); + + const warningMessage = await warningPromise; + + expect(warningMessage.text()).toContain('Incorrect Inline Tool: inlineWithoutRender'); + }); + + test('throws a descriptive error when render() receives invalid data format', async ({ page }) => { + await resetEditor(page); + + const initialData: SerializableOutputData = { + blocks: [ + { + id: 'initial-block', + type: 'paragraph', + data: { text: 'Initial block' }, + }, + ], + }; + + await page.evaluate(async ({ holderId, data }) => { + const editor = new window.EditorJS({ + holder: holderId, + data, + }); + + window.editorInstance = editor; + await editor.isReady; + }, { holderId: HOLDER_ID, + data: initialData }); + + const errorMessage = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + try { + await window.editorInstance.render({} as OutputData); + + return null; + } catch (error) { + return (error as Error).message; + } + }); + + expect(errorMessage).toBe('Incorrect data passed to the render() method'); + }); + + test('blocks read-only initialization when tools do not support read-only mode', async ({ page }) => { + await resetEditor(page); + + const errorMessage = await page.evaluate(async ({ holderId }) => { + try { + class NonReadOnlyTool { + public static get toolbox() { + return { + title: 'Non-readonly tool', + icon: '', + }; + } + + public render(): HTMLElement { + const element = document.createElement('div'); + + element.textContent = 'Non read-only block'; + + return element; + } + + public save(element: HTMLElement): Record { + return { + text: element.textContent ?? '', + }; + } + } + + const editor = new window.EditorJS({ + holder: holderId, + readOnly: true, + tools: { + nonReadOnly: { + class: NonReadOnlyTool, + }, + }, + data: { + blocks: [ + { + type: 'nonReadOnly', + data: { text: 'content' }, + }, + ], + }, + }); + + window.editorInstance = editor; + await editor.isReady; + + return null; + } catch (error) { + return (error as Error).message; + } + }, { holderId: HOLDER_ID }); + + expect(errorMessage).toContain('To enable read-only mode all connected tools should support it.'); + expect(errorMessage).toContain('nonReadOnly'); + }); + + test('throws a descriptive error when default holder element is missing', async ({ page }) => { + await page.evaluate(({ holderId }) => { + document.getElementById(holderId)?.remove(); + }, { holderId: HOLDER_ID }); + + const errorMessage = await page.evaluate(async () => { + try { + const editor = new window.EditorJS(); + + window.editorInstance = editor; + await editor.isReady; + + return null; + } catch (error) { + return (error as Error).message; + } + }); + + expect(errorMessage).toBe('element with ID «editorjs» is missing. Pass correct holder\'s ID.'); + }); + + test('throws a descriptive error when holder config is not an Element node', async ({ page }) => { + await resetEditor(page); + + const errorMessage = await page.evaluate(async ({ holderId }) => { + try { + const fakeHolder = { id: holderId }; + const editor = new window.EditorJS({ + holder: fakeHolder as unknown as HTMLElement, + }); + + window.editorInstance = editor; + await editor.isReady; + + return null; + } catch (error) { + return (error as Error).message; + } + }, { holderId: HOLDER_ID }); + + expect(errorMessage).toBe('«holder» value must be an Element node'); + }); +}); diff --git a/test/playwright/tests/modules/blockManager.spec.ts b/test/playwright/tests/modules/blockManager.spec.ts new file mode 100644 index 00000000..e594a7e4 --- /dev/null +++ b/test/playwright/tests/modules/blockManager.spec.ts @@ -0,0 +1,537 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import type { OutputData } from '@/types'; +import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`; +const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`; + +type SerializableToolConfig = { + className?: string; + classCode?: string; + config?: Record; +}; + +type CreateEditorOptions = { + data?: OutputData | null; + tools?: Record; + config?: Record; +}; + +type OutputBlock = OutputData['blocks'][number]; + +declare global { + interface Window { + editorInstance?: EditorJS; + } +} + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise => { + const { data = null, tools = {}, config = {} } = options; + + await resetEditor(page); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + + const serializedTools = Object.entries(tools).map(([name, tool]) => { + return { + name, + className: tool.className ?? null, + classCode: tool.classCode ?? null, + config: tool.config ?? {}, + }; + }); + + await page.evaluate( + async ({ holderId, data: initialData, serializedTools: toolsConfig, config: editorConfigOverrides }) => { + const resolveToolClass = (toolConfig: { className: string | null; classCode: string | null }): unknown => { + if (toolConfig.className) { + const toolClass = (window as unknown as Record)[toolConfig.className]; + + if (toolClass) { + return toolClass; + } + } + + if (toolConfig.classCode) { + // eslint-disable-next-line no-new-func -- evaluated in browser context to revive tool class + return new Function(`return (${toolConfig.classCode});`)(); + } + + return null; + }; + + const resolvedTools = toolsConfig.reduce>>((accumulator, toolConfig) => { + if (toolConfig.name === undefined) { + return accumulator; + } + + const toolClass = resolveToolClass(toolConfig); + + if (!toolClass) { + throw new Error(`Tool "${toolConfig.name}" is not available globally`); + } + + return { + ...accumulator, + [toolConfig.name]: { + class: toolClass, + ...toolConfig.config, + }, + }; + }, {}); + + const editorConfig: Record = { + holder: holderId, + ...editorConfigOverrides, + ...(initialData ? { data: initialData } : {}), + ...(toolsConfig.length > 0 ? { tools: resolvedTools } : {}), + }; + + const editor = new window.EditorJS(editorConfig); + + window.editorInstance = editor; + await editor.isReady; + }, + { + holderId: HOLDER_ID, + data, + serializedTools, + config, + } + ); +}; + +const createEditorWithBlocks = async (page: Page, blocks: OutputData['blocks']): Promise => { + await createEditor(page, { + data: { + blocks, + }, + }); +}; + +const saveEditor = async (page: Page): Promise => { + return await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + return await window.editorInstance.save(); + }); +}; + +const getParagraphByIndex = (page: Page, index: number): Locator => { + return page.locator(`:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`); +}; + +const focusBlockByIndex = async (page: Page, index: number): Promise => { + await page.evaluate(({ blockIndex }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + const didSetCaret = window.editorInstance.caret.setToBlock(blockIndex); + + if (!didSetCaret) { + throw new Error(`Failed to set caret to block at index ${blockIndex}`); + } + }, { blockIndex: index }); +}; + +const openBlockSettings = async (page: Page, index: number): Promise => { + await focusBlockByIndex(page, index); + + const block = page.locator(`:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`); + + await block.scrollIntoViewIfNeeded(); + await block.click(); + await block.hover(); + + const settingsButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__settings-btn`); + + await settingsButton.waitFor({ state: 'visible' }); + await settingsButton.click(); +}; + +const clickTune = async (page: Page, tuneName: string): Promise => { + const tuneButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} [data-item-name=${tuneName}]`); + + await tuneButton.waitFor({ state: 'visible' }); + await tuneButton.click(); +}; + +test.describe('modules/blockManager', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + }); + + test('deletes the last block without adding fillers when other blocks remain', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + id: 'block1', + type: 'paragraph', + data: { text: 'First block' }, + }, + { + id: 'block2', + type: 'paragraph', + data: { text: 'Second block' }, + }, + ]); + + await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + await window.editorInstance.blocks.delete(1); + }); + + const { blocks } = await saveEditor(page); + + expect(blocks).toHaveLength(1); + expect((blocks[0]?.data as { text: string }).text).toBe('First block'); + }); + + test('replaces a single deleted block with a new default block', async ({ page }) => { + const initialId = 'single-block'; + + await createEditorWithBlocks(page, [ + { + id: initialId, + type: 'paragraph', + data: { text: 'Only block' }, + }, + ]); + + await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + await window.editorInstance.blocks.delete(0); + }); + + const { blocks } = await saveEditor(page); + + expect(blocks).toHaveLength(1); + expect(blocks[0]?.id).not.toBe(initialId); + expect((blocks[0]?.data as { text?: string }).text ?? '').toBe(''); + }); + + test('converts a block to a compatible tool via API', async ({ page }) => { + const CONVERTABLE_SOURCE_TOOL = `(() => { + return class ConvertableSourceTool { + constructor({ data }) { + this.data = data || {}; + } + + static get toolbox() { + return { icon: '', title: 'Convertible Source' }; + } + + static get conversionConfig() { + return { + export: (data) => data.text ?? '', + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text ?? ''; + + return element; + } + + save(element) { + return { text: element.innerHTML }; + } + }; + })();`; + + const CONVERTABLE_TARGET_TOOL = `(() => { + return class ConvertableTargetTool { + constructor({ data }) { + this.data = data || {}; + } + + static get toolbox() { + return { icon: '', title: 'Convertible Target' }; + } + + static get conversionConfig() { + return { + import: (content) => ({ text: content.toUpperCase() }), + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text ?? ''; + + return element; + } + + save(element) { + return { text: element.innerHTML }; + } + }; + })();`; + + await createEditor(page, { + tools: { + convertibleSource: { + classCode: CONVERTABLE_SOURCE_TOOL, + }, + convertibleTarget: { + classCode: CONVERTABLE_TARGET_TOOL, + }, + }, + data: { + blocks: [ + { + id: 'source-block', + type: 'convertibleSource', + data: { text: 'convert me' }, + }, + ], + }, + config: { + defaultBlock: 'convertibleSource', + }, + }); + + await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + await window.editorInstance.blocks.convert('source-block', 'convertibleTarget'); + }); + + const { blocks } = await saveEditor(page); + + expect(blocks).toHaveLength(1); + expect(blocks[0]?.type).toBe('convertibleTarget'); + expect((blocks[0]?.data as { text?: string }).text).toBe('CONVERT ME'); + }); + + test('fails conversion when target tool lacks conversionConfig', async ({ page }) => { + const CONVERTABLE_SOURCE_TOOL = `(() => { + return class ConvertableSourceTool { + constructor({ data }) { + this.data = data || {}; + } + + static get toolbox() { + return { icon: '', title: 'Convertible Source' }; + } + + static get conversionConfig() { + return { + export: (data) => data.text ?? ''; + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text ?? ''; + + return element; + } + + save(element) { + return { text: element.innerHTML }; + } + }; + })();`; + + const TOOL_WITHOUT_CONVERSION = `(() => { + return class ToolWithoutConversionConfig { + constructor({ data }) { + this.data = data || {}; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text ?? ''; + + return element; + } + + save(element) { + return { text: element.innerHTML }; + } + }; + })();`; + + await createEditor(page, { + tools: { + convertibleSource: { + classCode: CONVERTABLE_SOURCE_TOOL, + }, + withoutConversion: { + classCode: TOOL_WITHOUT_CONVERSION, + }, + }, + data: { + blocks: [ + { + id: 'non-convertable', + type: 'convertibleSource', + data: { text: 'stay text' }, + }, + ], + }, + config: { + defaultBlock: 'convertibleSource', + }, + }); + + const errorMessage = await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + try { + await window.editorInstance.blocks.convert('non-convertable', 'withoutConversion'); + + return null; + } catch (error) { + return (error as Error).message; + } + }); + + expect(errorMessage).toContain('Conversion from "convertibleSource" to "withoutConversion" is not possible'); + + const { blocks } = await saveEditor(page); + + expect(blocks).toHaveLength(1); + expect(blocks[0]?.type).toBe('convertibleSource'); + expect((blocks[0]?.data as { text?: string }).text).toBe('stay text'); + }); + + test('moves a block up via the default tune', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { text: 'First block' }, + }, + { + type: 'paragraph', + data: { text: 'Second block' }, + }, + { + type: 'paragraph', + data: { text: 'Third block' }, + }, + ]); + + await openBlockSettings(page, 1); + await clickTune(page, 'move-up'); + + const { blocks } = await saveEditor(page); + + expect(blocks.map((block: OutputBlock) => (block.data as { text: string }).text)).toStrictEqual([ + 'Second block', + 'First block', + 'Third block', + ]); + }); + + test('moves a block down via the default tune', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { text: 'First block' }, + }, + { + type: 'paragraph', + data: { text: 'Second block' }, + }, + { + type: 'paragraph', + data: { text: 'Third block' }, + }, + ]); + + await openBlockSettings(page, 1); + await clickTune(page, 'move-down'); + + const { blocks } = await saveEditor(page); + + expect(blocks.map((block: OutputBlock) => (block.data as { text: string }).text)).toStrictEqual([ + 'First block', + 'Third block', + 'Second block', + ]); + }); + + test('generates unique ids for newly inserted blocks', async ({ page }) => { + await createEditor(page); + + const firstParagraph = getParagraphByIndex(page, 0); + + await firstParagraph.click(); + await page.keyboard.type('First block'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Second block'); + await page.keyboard.press('Enter'); + await page.keyboard.type('Third block'); + + const { blocks } = await saveEditor(page); + const ids = blocks.map((block) => block.id); + + expect(blocks).toHaveLength(3); + ids.forEach((id, index) => { + if (id === undefined) { + throw new Error(`Block id at index ${index} is undefined`); + } + + expect(typeof id).toBe('string'); + expect(id).not.toHaveLength(0); + }); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/test/playwright/tests/modules/drag-and-drop.spec.ts b/test/playwright/tests/modules/drag-and-drop.spec.ts new file mode 100644 index 00000000..c53ed8ed --- /dev/null +++ b/test/playwright/tests/modules/drag-and-drop.spec.ts @@ -0,0 +1,501 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import type { EditorConfig, OutputData } from '@/types'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; +import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const HOLDER_SELECTOR = `#${HOLDER_ID}`; +const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block`; +const SIMPLE_IMAGE_TOOL_UMD_PATH = path.resolve( + __dirname, + '../../../../node_modules/@editorjs/simple-image/dist/simple-image.umd.js' +); + +type SerializableToolConfig = { + className?: string; + classCode?: string; + config?: Record; +}; + +type CreateEditorOptions = Pick & { + tools?: Record; +}; + +type DropPayload = { + types?: Record; + files?: Array<{ name: string; type: string; content: string }>; +}; + +declare global { + interface Window { + editorInstance?: EditorJS; + } +} + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise => { + await resetEditor(page); + + const { tools = {}, ...editorOptions } = options; + const serializedTools = Object.entries(tools).map(([name, tool]) => { + return { + name, + className: tool.className ?? null, + classCode: tool.classCode ?? null, + toolConfig: tool.config ?? {}, + }; + }); + + await page.evaluate( + async ({ holderId, editorOptions: rawOptions, serializedTools: toolsConfig }) => { + const { data, ...restOptions } = rawOptions; + const editorConfig: Record = { + holder: holderId, + ...restOptions, + }; + + if (data) { + editorConfig.data = data; + } + + if (toolsConfig.length > 0) { + const resolvedTools = toolsConfig.reduce>>( + (accumulator, { name, className, classCode, toolConfig }) => { + let toolClass: unknown = null; + + if (className) { + toolClass = (window as unknown as Record)[className] ?? null; + } + + if (!toolClass && classCode) { + // eslint-disable-next-line no-new-func -- executed in browser context to reconstruct tool + toolClass = new Function(`return (${classCode});`)(); + } + + if (!toolClass) { + throw new Error(`Tool "${name}" is not available globally`); + } + + return { + ...accumulator, + [name]: { + class: toolClass, + ...toolConfig, + }, + }; + }, + {} + ); + + editorConfig.tools = resolvedTools; + } + + const editor = new window.EditorJS(editorConfig as EditorConfig); + + window.editorInstance = editor; + await editor.isReady; + }, + { + holderId: HOLDER_ID, + editorOptions, + serializedTools, + } + ); +}; + +const saveEditor = async (page: Page): Promise => { + return await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + return await window.editorInstance.save(); + }); +}; + +const selectAllText = async (locator: Locator): Promise => { + await locator.evaluate((element) => { + const selection = element.ownerDocument.getSelection(); + + if (!selection) { + throw new Error('Selection API is not available'); + } + + const range = element.ownerDocument.createRange(); + + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + element.ownerDocument.dispatchEvent(new Event('selectionchange')); + }); +}; + +const getBlockByIndex = (page: Page, index: number): Locator => { + return page.locator(`${BLOCK_SELECTOR}:nth-of-type(${index + 1})`); +}; + +const getParagraphByIndex = (page: Page, index: number): Locator => { + return getBlockByIndex(page, index).locator('.ce-paragraph'); +}; + +const selectText = async (locator: Locator, targetText: string): Promise => { + await locator.evaluate((element, text) => { + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let foundNode: Text | null = null; + let offset = -1; + + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const content = node.textContent ?? ''; + const index = content.indexOf(text); + + if (index !== -1) { + foundNode = node; + offset = index; + break; + } + } + + if (!foundNode || offset === -1) { + throw new Error(`Text "${text}" not found inside element`); + } + + const selection = element.ownerDocument.getSelection(); + const range = element.ownerDocument.createRange(); + + range.setStart(foundNode, offset); + range.setEnd(foundNode, offset + text.length); + selection?.removeAllRanges(); + selection?.addRange(range); + element.ownerDocument.dispatchEvent(new Event('selectionchange')); + }, targetText); +}; + +const startEditorDrag = async (page: Page): Promise => { + await page.evaluate(({ selector }) => { + const holder = document.querySelector(selector); + + if (!holder) { + throw new Error('Editor holder not found'); + } + + holder.dispatchEvent(new DragEvent('dragstart', { + bubbles: true, + cancelable: true, + })); + }, { selector: HOLDER_SELECTOR }); +}; + +const dispatchDrop = async (page: Page, targetSelector: string, payload: DropPayload): Promise => { + await page.evaluate(({ selector, payload: data }) => { + const target = document.querySelector(selector); + + if (!target) { + throw new Error('Drop target not found'); + } + + const dataTransfer = new DataTransfer(); + + if (data.types) { + Object.entries(data.types).forEach(([type, value]) => { + dataTransfer.setData(type, value); + }); + } + + if (data.files) { + data.files.forEach(({ name, type, content }) => { + const file = new File([ content ], name, { type }); + + dataTransfer.items.add(file); + }); + } + + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + + target.dispatchEvent(dropEvent); + }, { + selector: targetSelector, + payload, + }); +}; + +const getBlockTexts = async (page: Page): Promise => { + return await page.locator(BLOCK_SELECTOR).allTextContents() + .then((texts) => { + return texts.map((text) => text.trim()).filter(Boolean); + }); +}; + +const toggleReadOnly = async (page: Page, state: boolean): Promise => { + await page.evaluate(async ({ readOnlyState }) => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + await window.editorInstance.readOnly.toggle(readOnlyState); + }, { readOnlyState: state }); +}; + +test.describe('modules/drag-and-drop', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + }); + + test('moves blocks when dragging their content between positions', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { type: 'paragraph', + data: { text: 'First block' } }, + { type: 'paragraph', + data: { text: 'Second block' } }, + { type: 'paragraph', + data: { text: 'Third block' } }, + ], + }, + }); + + const secondParagraph = getParagraphByIndex(page, 1); + + await selectAllText(secondParagraph); + await startEditorDrag(page); + + await dispatchDrop(page, `${BLOCK_SELECTOR}:nth-of-type(3) .ce-paragraph`, { + types: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': 'Second block', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': '

Second block

', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'application/x-editor-js': JSON.stringify([ + { + tool: 'paragraph', + data: { text: 'Second block' }, + }, + ]), + }, + }); + + await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(3); + expect(await getBlockTexts(page)).toStrictEqual([ + 'First block', + 'Third block', + 'Second block', + ]); + }); + + test('drags partial text between blocks', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { type: 'paragraph', + data: { text: 'Alpha block' } }, + { type: 'paragraph', + data: { text: 'Beta block' } }, + ], + }, + }); + + const firstParagraph = getParagraphByIndex(page, 0); + + await selectText(firstParagraph, 'Alpha'); + await startEditorDrag(page); + + await dispatchDrop(page, `${BLOCK_SELECTOR}:nth-of-type(2) .ce-paragraph`, { + types: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': 'Alpha', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': 'Alpha', + }, + }); + + const texts = await getBlockTexts(page); + + expect(texts[0]).toBe('block'); + expect(texts[1]).toBe('Beta blockAlpha'); + }); + + test('drops files into tools that support file paste config', async ({ page }) => { + await page.addScriptTag({ path: SIMPLE_IMAGE_TOOL_UMD_PATH }); + await page.addScriptTag({ + content: ` + class SimpleImageWithInlineUpload extends window.SimpleImage { + static get isReadOnlySupported() { + return true; + } + + static get pasteConfig() { + return { + files: { + mimeTypes: ['image/*'], + }, + }; + } + + async onDropHandler(dropData) { + if (dropData.type !== 'file') { + return super.onDropHandler(dropData); + } + + const file = dropData.file; + + this.data = { + url: this.createObjectURL(file), + }; + + this._toggleLoader(false); + } + + uploadFile(file) { + return Promise.resolve({ + success: 1, + file: { + url: this.createObjectURL(file), + }, + }); + } + + createObjectURL(file) { + if (window.URL && typeof window.URL.createObjectURL === 'function') { + return window.URL.createObjectURL(file); + } + + return 'data:' + file.type + ';base64,' + btoa(file.name); + } + } + + window.SimpleImage = SimpleImageWithInlineUpload; + `, + }); + + await createEditor(page, { + tools: { + image: { + className: 'SimpleImage', + }, + }, + }); + + await dispatchDrop(page, HOLDER_SELECTOR, { + files: [ + { + name: 'test.png', + type: 'image/png', + content: 'fake image content', + }, + ], + }); + + const image = page.locator(`${EDITOR_INTERFACE_SELECTOR} img`); + + await expect(image).toHaveCount(1); + + const { blocks } = await saveEditor(page); + + expect(blocks[blocks.length - 1]?.type).toBe('image'); + }); + + test('shows and clears drop-target highlighting while dragging over blocks', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { type: 'paragraph', + data: { text: 'Highlight A' } }, + { type: 'paragraph', + data: { text: 'Highlight B' } }, + ], + }, + }); + + const targetBlock = getBlockByIndex(page, 1); + + await targetBlock.locator('.ce-block__content').dispatchEvent('dragover', { + bubbles: true, + cancelable: true, + }); + + await expect(targetBlock).toHaveClass(/ce-block--drop-target/); + + await targetBlock.locator('.ce-block__content').dispatchEvent('dragleave', { + bubbles: true, + cancelable: true, + }); + + await expect(targetBlock).not.toHaveClass(/ce-block--drop-target/); + }); + + test('ignores drops while read-only mode is enabled', async ({ page }) => { + await createEditor(page, { + readOnly: true, + data: { + blocks: [ + { type: 'paragraph', + data: { text: 'Locked block' } }, + ], + }, + }); + + await dispatchDrop(page, HOLDER_SELECTOR, { + types: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': 'Should not appear', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': '

Should not appear

', + }, + }); + + await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(1); + + await toggleReadOnly(page, false); + + await dispatchDrop(page, HOLDER_SELECTOR, { + types: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': 'New block', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': '

New block

', + }, + }); + + await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(2); + await expect(getBlockTexts(page)).resolves.toContain('New block'); + }); +}); + diff --git a/test/playwright/tests/modules/selection.spec.ts b/test/playwright/tests/modules/selection.spec.ts new file mode 100644 index 00000000..5f8f2175 --- /dev/null +++ b/test/playwright/tests/modules/selection.spec.ts @@ -0,0 +1,634 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +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'; +import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const BLOCK_WRAPPER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-cy="block-wrapper"]`; +const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`; +const SELECT_ALL_SHORTCUT = process.platform === 'darwin' ? 'Meta+A' : 'Control+A'; +const RECTANGLE_OVERLAY_SELECTOR = '.codex-editor-overlay__container'; +const RECTANGLE_ELEMENT_SELECTOR = '.codex-editor-overlay__rectangle'; +const FAKE_BACKGROUND_SELECTOR = '.codex-editor__fake-background'; +const BLOCK_SELECTED_CLASS = 'ce-block--selected'; + +declare global { + interface Window { + editorInstance?: EditorJS; + } +} + +type BoundingBox = { + x: number; + y: number; + width: number; + height: number; +}; + +type ToolDefinition = { + name: string; + classSource: string; + config?: Record; +}; + +const getBlockWrapperSelectorByIndex = (index: number): string => { + return `:nth-match(${BLOCK_WRAPPER_SELECTOR}, ${index + 1})`; +}; + +const getParagraphSelectorByIndex = (index: number): string => { + return `:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`; +}; + +const getBlockByIndex = (page: Page, index: number): Locator => { + return page.locator(getBlockWrapperSelectorByIndex(index)); +}; + +const getParagraphByIndex = (page: Page, index: number): Locator => { + return page.locator(getParagraphSelectorByIndex(index)); +}; + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditorWithBlocks = async ( + page: Page, + blocks: OutputData['blocks'], + tools: ToolDefinition[] = [] +): Promise => { + await resetEditor(page); + await page.evaluate(async ({ holderId, blocks: editorBlocks, serializedTools }) => { + const reviveToolClass = (classSource: string): unknown => { + return new Function(`return (${classSource});`)(); + }; + + const revivedTools = serializedTools.reduce>((accumulator, toolConfig) => { + const revivedClass = reviveToolClass(toolConfig.classSource); + + return { + ...accumulator, + [toolConfig.name]: toolConfig.config + ? { + ...toolConfig.config, + class: revivedClass, + } + : revivedClass, + }; + }, {}); + + const editor = new window.EditorJS({ + holder: holderId, + data: { blocks: editorBlocks }, + ...(serializedTools.length > 0 ? { tools: revivedTools } : {}), + }); + + window.editorInstance = editor; + await editor.isReady; + }, { + holderId: HOLDER_ID, + blocks, + serializedTools: tools, + }); +}; + +const selectText = async (locator: Locator, text: string): Promise => { + await locator.evaluate((element, targetText) => { + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let startNode: Text | null = null; + let startOffset = -1; + + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const content = node.textContent ?? ''; + const index = content.indexOf(targetText); + + if (index !== -1) { + startNode = node; + startOffset = index; + break; + } + } + + if (!startNode || startOffset === -1) { + throw new Error(`Text "${targetText}" not found inside locator`); + } + + const range = element.ownerDocument.createRange(); + + range.setStart(startNode, startOffset); + range.setEnd(startNode, startOffset + targetText.length); + + const selection = element.ownerDocument.getSelection(); + + selection?.removeAllRanges(); + selection?.addRange(range); + element.ownerDocument.dispatchEvent(new Event('selectionchange')); + }, text); +}; + +const placeCaretAtEnd = async (locator: Locator): Promise => { + await locator.evaluate((element) => { + const doc = element.ownerDocument; + const selection = doc.getSelection(); + + if (!selection) { + return; + } + + const range = doc.createRange(); + const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let lastTextNode: Text | null = null; + + while (walker.nextNode()) { + lastTextNode = walker.currentNode as Text; + } + + if (lastTextNode) { + range.setStart(lastTextNode, lastTextNode.textContent?.length ?? 0); + } else { + range.selectNodeContents(element); + range.collapse(false); + } + + selection.removeAllRanges(); + selection.addRange(range); + doc.dispatchEvent(new Event('selectionchange')); + }); +}; + +const StaticBlockTool = class { + private data: { text?: string }; + + /** + * @param options - static block options + */ + constructor({ data }: { data?: { text?: string } }) { + this.data = data ?? {}; + } + + /** + * Toolbox metadata for static block + */ + public static get toolbox(): { title: string } { + return { + title: 'Static block', + }; + } + + /** + * Renders static block content wrapper + */ + public render(): HTMLElement { + const wrapper = document.createElement('div'); + + wrapper.textContent = this.data.text ?? 'Static block without inputs'; + wrapper.contentEditable = 'false'; + + return wrapper; + } + + /** + * Serializes static block DOM into data + * + * @param element - block root element + */ + public save(element: HTMLElement): { text: string } { + return { + text: element.textContent ?? '', + }; + } +}; + +const EditableTitleTool = class { + private data: { text?: string }; + + /** + * @param options - editable title options + */ + constructor({ data }: { data?: { text?: string } }) { + this.data = data ?? {}; + } + + /** + * Toolbox metadata for editable title block + */ + public static get toolbox(): { title: string } { + return { + title: 'Editable title', + }; + } + + /** + * Renders editable title block wrapper + */ + public render(): HTMLElement { + const wrapper = document.createElement('div'); + + wrapper.contentEditable = 'true'; + wrapper.dataset.cy = 'editable-title-block'; + wrapper.textContent = this.data.text ?? 'Editable block'; + + return wrapper; + } + + /** + * Serializes editable title DOM into data + * + * @param element - block root element + */ + public save(element: HTMLElement): { text: string } { + return { + text: element.textContent ?? '', + }; + } +}; + +const STATIC_BLOCK_TOOL_SOURCE = StaticBlockTool.toString(); +const EDITABLE_TITLE_TOOL_SOURCE = EditableTitleTool.toString(); + +const getRequiredBoundingBox = async (locator: Locator): Promise => { + const box = await locator.boundingBox(); + + if (!box) { + throw new Error('Unable to determine element bounds for drag operation'); + } + + return box; +}; + +const getElementCenter = async (locator: Locator): Promise<{ x: number; y: number }> => { + const box = await getRequiredBoundingBox(locator); + + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }; +}; + +test.describe('modules/selection', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + }); + + test('selects all blocks via CMD/CTRL + A', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'First block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Second block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Third block', + }, + }, + ]); + + const firstParagraph = getParagraphByIndex(page, 0); + + await firstParagraph.click(); + await page.keyboard.press(SELECT_ALL_SHORTCUT); + await page.keyboard.press(SELECT_ALL_SHORTCUT); + + const blocks = page.locator(BLOCK_WRAPPER_SELECTOR); + + await expect(blocks).toHaveCount(3); + + for (const index of [0, 1, 2]) { + await expect(getBlockByIndex(page, index)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + } + }); + + test('cross-block selection selects contiguous blocks when dragging across content', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'First block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Second block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Third block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Fourth block', + }, + }, + ]); + + const firstParagraph = getParagraphByIndex(page, 0); + const thirdParagraph = getParagraphByIndex(page, 2); + + const firstCenter = await getElementCenter(firstParagraph); + const thirdCenter = await getElementCenter(thirdParagraph); + + await page.mouse.move(firstCenter.x, firstCenter.y); + await page.mouse.down(); + await page.mouse.move(thirdCenter.x, thirdCenter.y, { steps: 10 }); + await page.mouse.up(); + + await expect(getBlockByIndex(page, 0)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + await expect(getBlockByIndex(page, 1)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + await expect(getBlockByIndex(page, 2)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + await expect(getBlockByIndex(page, 3)).not.toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + }); + + test('rectangle selection highlights multiple blocks when dragging overlay rectangle', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'Alpha', + }, + }, + { + type: 'paragraph', + data: { + text: 'Beta', + }, + }, + { + type: 'paragraph', + data: { + text: 'Gamma', + }, + }, + { + type: 'paragraph', + data: { + text: 'Delta', + }, + }, + ]); + + const firstBlock = getBlockByIndex(page, 0); + const thirdBlock = getBlockByIndex(page, 2); + + const firstBox = await getRequiredBoundingBox(firstBlock); + const thirdBox = await getRequiredBoundingBox(thirdBlock); + + const startX = Math.max(0, firstBox.x - 20); + const startY = Math.max(0, firstBox.y - 20); + const endX = thirdBox.x + thirdBox.width + 20; + const endY = thirdBox.y + thirdBox.height / 2; + + const overlay = page.locator(RECTANGLE_OVERLAY_SELECTOR); + + await expect(overlay).toHaveCount(1); + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 15 }); + await expect(page.locator(RECTANGLE_ELEMENT_SELECTOR)).toBeVisible(); + await page.mouse.up(); + + await expect(getBlockByIndex(page, 0)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + await expect(getBlockByIndex(page, 1)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + await expect(getBlockByIndex(page, 2)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + await expect(getBlockByIndex(page, 3)).not.toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + }); + + test('selection API exposes save/restore, expandToTag, fake background helpers', async ({ page }) => { + const text = 'Important bold text inside paragraph'; + + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text, + }, + }, + ]); + + const paragraph = getParagraphByIndex(page, 0); + + await selectText(paragraph, 'bold'); + + const paragraphText = (await paragraph.innerText()).trim(); + + const apiResults = await page.evaluate(({ fakeBackgroundSelector }) => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance is not ready'); + } + + const selection = window.getSelection(); + + const savedText = selection?.toString() ?? ''; + + editor.selection.save(); + + selection?.removeAllRanges(); + + const paragraphEl = document.querySelector('.ce-paragraph'); + const textNode = paragraphEl?.firstChild as Text | null; + + if (textNode) { + const range = document.createRange(); + + range.setStart(textNode, textNode.textContent?.length ?? 0); + range.collapse(true); + selection?.addRange(range); + } + + editor.selection.restore(); + + const restored = window.getSelection()?.toString() ?? ''; + const strongTag = editor.selection.findParentTag('STRONG'); + + if (paragraphEl instanceof HTMLElement) { + editor.selection.expandToTag(paragraphEl); + } + + const expanded = window.getSelection()?.toString() ?? ''; + + editor.selection.setFakeBackground(); + const fakeWrappersCount = document.querySelectorAll(fakeBackgroundSelector).length; + + editor.selection.removeFakeBackground(); + const fakeWrappersAfterRemoval = document.querySelectorAll(fakeBackgroundSelector).length; + + return { + savedText, + restored, + strongTag: strongTag?.tagName ?? null, + expanded, + fakeWrappersCount, + fakeWrappersAfterRemoval, + }; + }, { fakeBackgroundSelector: FAKE_BACKGROUND_SELECTOR }); + + expect(apiResults.savedText).toBe('bold'); + expect(apiResults.restored).toBe('bold'); + expect(apiResults.strongTag).toBe('STRONG'); + expect(apiResults.expanded.trim()).toBe(paragraphText); + expect(apiResults.fakeWrappersCount).toBeGreaterThan(0); + expect(apiResults.fakeWrappersAfterRemoval).toBe(0); + }); + + test('cross-block selection deletes multiple blocks with Backspace', async ({ page }) => { + await createEditorWithBlocks(page, [ + { + type: 'paragraph', + data: { + text: 'First block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Second block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Third block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Fourth block', + }, + }, + ]); + + const firstParagraph = getParagraphByIndex(page, 0); + + await firstParagraph.click(); + await placeCaretAtEnd(firstParagraph); + + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + + await expect(getBlockByIndex(page, 0)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + await expect(getBlockByIndex(page, 1)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + await expect(getBlockByIndex(page, 2)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + + await page.keyboard.press('Backspace'); + + const blocks = page.locator(BLOCK_WRAPPER_SELECTOR); + + await expect(blocks).toHaveCount(2); + + const savedData = await page.evaluate(async () => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance is not ready'); + } + + return editor.save(); + }); + + expect(savedData.blocks).toHaveLength(2); + + const blockTexts = savedData.blocks.map((block) => { + return (block.data as { text?: string }).text ?? ''; + }); + + expect(blockTexts[0].trim()).toBe(''); + expect(blockTexts[1]).toBe('Fourth block'); + }); + + test('cross-block selection spans different block types with shift navigation', async ({ page }) => { + await createEditorWithBlocks( + page, + [ + { + type: 'paragraph', + data: { + text: 'Paragraph content', + }, + }, + { + type: 'static-block', + data: { + text: 'Static content', + }, + }, + { + type: 'editable-title', + data: { + text: 'Editable tail', + }, + }, + ], + [ + { + name: 'static-block', + classSource: STATIC_BLOCK_TOOL_SOURCE, + }, + { + name: 'editable-title', + classSource: EDITABLE_TITLE_TOOL_SOURCE, + }, + ] + ); + + const firstParagraph = getParagraphByIndex(page, 0); + + await firstParagraph.click(); + await placeCaretAtEnd(firstParagraph); + + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + + for (const index of [0, 1, 2]) { + await expect(getBlockByIndex(page, index)).toHaveClass(new RegExp(BLOCK_SELECTED_CLASS)); + } + }); +}); diff --git a/test/playwright/tests/read-only.spec.ts b/test/playwright/tests/read-only.spec.ts new file mode 100644 index 00000000..c1226729 --- /dev/null +++ b/test/playwright/tests/read-only.spec.ts @@ -0,0 +1,572 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type EditorJS from '@/types'; +import type { EditorConfig } from '@/types'; +import { ensureEditorBundleBuilt } from './helpers/ensure-build'; +import { + EDITOR_INTERFACE_SELECTOR, + INLINE_TOOLBAR_INTERFACE_SELECTOR +} from '../../../src/components/constants'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const HOLDER_SELECTOR = `#${HOLDER_ID}`; +const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-block`; +const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`; +const TOOLBAR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar`; +const SETTINGS_BUTTON_SELECTOR = `${TOOLBAR_SELECTOR} .ce-toolbar__settings-btn`; +const INLINE_TOOL_SELECTOR = `${INLINE_TOOLBAR_INTERFACE_SELECTOR} .ce-popover-item`; + +const HEADER_TOOL_UMD_PATH = path.resolve( + __dirname, + '../../../node_modules/@editorjs/header/dist/header.umd.js' +); + +const READ_ONLY_INLINE_TOOL_SOURCE = ` +class ReadOnlyInlineTool { + static isInline = true; + static isReadOnlySupported = true; + + render() { + return { + title: 'Read-only tool', + name: 'read-only-inline', + onActivate: () => {}, + }; + } +} +`; + +const UNSUPPORTED_INLINE_TOOL_SOURCE = ` +class UnsupportedInlineTool { + static isInline = true; + + render() { + return { + title: 'Legacy inline tool', + name: 'unsupported-inline', + onActivate: () => {}, + }; + } +} +`; + +const UNSUPPORTED_BLOCK_TOOL_SOURCE = ` +class LegacyBlockTool { + constructor({ data }) { + this.data = data ?? { text: 'Legacy block' }; + } + + static get toolbox() { + return { + title: 'Legacy', + icon: 'L', + }; + } + + static get isReadOnlySupported() { + return false; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.innerHTML = this.data?.text ?? ''; + + return element; + } + + save(element) { + return { + text: element.innerHTML, + }; + } +} +`; + +type SerializableToolConfig = { + className?: string; + classCode?: string; + config?: Record; +}; + +type CreateEditorOptions = Partial> & { + tools?: Record; +}; + +declare global { + interface Window { + editorInstance?: EditorJS; + EditorJS: new (...args: unknown[]) => EditorJS; + } +} + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise => { + await resetEditor(page); + + const { tools = {}, ...editorOptions } = options; + const serializedTools = Object.entries(tools).map(([name, tool]) => { + return { + name, + className: tool.className ?? null, + classCode: tool.classCode ?? null, + toolConfig: tool.config ?? {}, + }; + }); + + await page.evaluate( + async ({ holderId, editorOptions: rawOptions, serializedTools: toolsConfig }) => { + const { data, ...restOptions } = rawOptions; + const editorConfig: Record = { + holder: holderId, + ...restOptions, + }; + + if (data) { + editorConfig.data = data; + } + + if (toolsConfig.length > 0) { + const resolvedTools = toolsConfig.reduce>>( + (accumulator, { name, className, classCode, toolConfig }) => { + let toolClass: unknown = null; + + if (className) { + toolClass = (window as unknown as Record)[className] ?? null; + } + + if (!toolClass && classCode) { + // eslint-disable-next-line no-new-func -- executed in browser context to recreate the tool class + toolClass = new Function(`return (${classCode});`)(); + } + + if (!toolClass) { + throw new Error(`Tool "${name}" is not available globally`); + } + + return { + ...accumulator, + [name]: { + class: toolClass, + ...toolConfig, + }, + }; + }, + {} + ); + + editorConfig.tools = resolvedTools; + } + + const editor = new window.EditorJS(editorConfig as EditorConfig); + + window.editorInstance = editor; + await editor.isReady; + }, + { + holderId: HOLDER_ID, + editorOptions, + serializedTools, + } + ); +}; + +const toggleReadOnly = async (page: Page, state: boolean): Promise => { + await page.evaluate(async ({ targetState }) => { + const editor = window.editorInstance ?? (() => { + throw new Error('Editor instance not found'); + })(); + + await editor.readOnly.toggle(targetState); + }, { targetState: state }); +}; + +const selectText = async (locator: Locator, text: string): Promise => { + await locator.evaluate((element, targetText) => { + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let foundNode: Text | null = null; + let offset = -1; + + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const content = node.textContent ?? ''; + const index = content.indexOf(targetText); + + if (index !== -1) { + foundNode = node; + offset = index; + break; + } + } + + if (!foundNode || offset === -1) { + throw new Error(`Text "${targetText}" was not found inside element`); + } + + const selection = element.ownerDocument.getSelection(); + const range = element.ownerDocument.createRange(); + + range.setStart(foundNode, offset); + range.setEnd(foundNode, offset + targetText.length); + selection?.removeAllRanges(); + selection?.addRange(range); + element.ownerDocument.dispatchEvent(new Event('selectionchange')); + }, text); +}; + +const paste = async (page: Page, locator: Locator, data: Record): Promise => { + await locator.evaluate((element: HTMLElement, pasteData: Record) => { + const pasteEvent = Object.assign(new Event('paste', { + bubbles: true, + cancelable: true, + }), { + clipboardData: { + getData: (type: string): string => pasteData[type] ?? '', + types: Object.keys(pasteData), + }, + }); + + element.dispatchEvent(pasteEvent); + }, data); + + await page.evaluate(() => { + return new Promise((resolve) => { + setTimeout(resolve, 200); + }); + }); +}; + +type DropPayload = { + types?: Record; + files?: Array<{ name: string; type: string; content: string }>; +}; + +const dispatchDrop = async (page: Page, payload: DropPayload): Promise => { + await page.evaluate(({ selector, payload: data }) => { + const holder = document.querySelector(selector); + + if (!holder) { + throw new Error('Drop target not found'); + } + + const dataTransfer = new DataTransfer(); + + if (data.types) { + Object.entries(data.types).forEach(([type, value]) => { + dataTransfer.setData(type, value); + }); + } + + if (data.files) { + data.files.forEach(({ name, type, content }) => { + const file = new File([ content ], name, { type }); + + dataTransfer.items.add(file); + }); + } + + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer, + }); + + holder.dispatchEvent(dropEvent); + }, { + selector: HOLDER_SELECTOR, + payload, + }); +}; + +const expectSettingsButtonToDisappear = async (page: Page): Promise => { + await page.waitForFunction((selector) => document.querySelector(selector) === null, SETTINGS_BUTTON_SELECTOR); +}; + +const waitForReadOnlyState = async (page: Page, expected: boolean): Promise => { + await page.waitForFunction(({ expectedState }) => { + return window.editorInstance?.readOnly.isEnabled === expectedState; + }, { expectedState: expected }); +}; + +test.describe('read-only mode', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + }); + + test('allows toggling editing state dynamically', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Editable text', + }, + }, + ], + }, + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await expect(paragraph).toHaveCount(1); + await paragraph.click(); + await page.keyboard.type(' + edit'); + await expect(paragraph).toContainText('Editable text + edit'); + + await toggleReadOnly(page, true); + await waitForReadOnlyState(page, true); + await expect(paragraph).toHaveAttribute('contenteditable', 'false'); + await expect(paragraph).toHaveText('Editable text + edit'); + + await paragraph.click(); + await page.keyboard.type(' should not appear'); + await expect(paragraph).toHaveText('Editable text + edit'); + + await toggleReadOnly(page, false); + await waitForReadOnlyState(page, false); + await expect(paragraph).toHaveAttribute('contenteditable', 'true'); + + await paragraph.click(); + await page.keyboard.type(' + writable again'); + await expect(paragraph).toContainText('writable again'); + }); + + test('only shows read-only inline tools when editor is locked', async ({ page }) => { + await page.addScriptTag({ path: HEADER_TOOL_UMD_PATH }); + + await createEditor(page, { + readOnly: true, + data: { + blocks: [ + { + type: 'header', + data: { + text: 'Read me carefully', + }, + }, + ], + }, + tools: { + header: { + className: 'Header', + config: { + inlineToolbar: ['readOnlyInline', 'legacyInline'], + }, + }, + readOnlyInline: { + classCode: READ_ONLY_INLINE_TOOL_SOURCE, + }, + legacyInline: { + classCode: UNSUPPORTED_INLINE_TOOL_SOURCE, + }, + }, + }); + + const headerBlock = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-header`); + + await selectText(headerBlock, 'Read me'); + + const readOnlyToolItem = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="read-only-inline"]`); + const unsupportedToolItem = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="unsupported-inline"]`); + + await expect(readOnlyToolItem).toBeVisible(); + await expect(unsupportedToolItem).toHaveCount(0); + + await toggleReadOnly(page, false); + await waitForReadOnlyState(page, false); + await selectText(headerBlock, 'Read me'); + + await expect(readOnlyToolItem).toBeVisible(); + await expect(unsupportedToolItem).toBeVisible(); + }); + + test('removes block settings UI while read-only is enabled', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Block tunes availability', + }, + }, + ], + }, + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await expect(paragraph).toHaveCount(1); + await paragraph.click(); + await expect(page.locator(SETTINGS_BUTTON_SELECTOR)).toBeVisible(); + + await toggleReadOnly(page, true); + await expectSettingsButtonToDisappear(page); + + await toggleReadOnly(page, false); + await waitForReadOnlyState(page, false); + await paragraph.click(); + await expect(page.locator(SETTINGS_BUTTON_SELECTOR)).toBeVisible(); + }); + + test('prevents paste operations while read-only is enabled', async ({ page }) => { + await createEditor(page, { + readOnly: true, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Original content', + }, + }, + ], + }, + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await expect(paragraph).toHaveCount(1); + await paste(page, paragraph, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': ' + pasted text', + }); + + await expect(paragraph).toHaveText('Original content'); + + await toggleReadOnly(page, false); + await waitForReadOnlyState(page, false); + await paragraph.click(); + + await paste(page, paragraph, { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': ' + pasted text', + }); + + await expect(paragraph).toContainText('Original content + pasted text'); + }); + + test('blocks drag-and-drop insertions while read-only is enabled', async ({ page }) => { + await createEditor(page, { + readOnly: true, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Initial block', + }, + }, + ], + }, + }); + + await dispatchDrop(page, { + types: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': 'Dropped text', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': '

Dropped text

', + }, + }); + + await expect(page.locator(BLOCK_SELECTOR)).toHaveCount(1); + + await toggleReadOnly(page, false); + await waitForReadOnlyState(page, false); + + await dispatchDrop(page, { + types: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/plain': 'Dropped text', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'text/html': '

Dropped text

', + }, + }); + + const blocks = page.locator(BLOCK_SELECTOR); + + await expect(async () => { + const count = await blocks.count(); + + expect(count).toBeGreaterThanOrEqual(2); + }).toPass(); + + await expect(blocks).toHaveCount(2); + await expect(blocks.filter({ hasText: 'Dropped text' })).toHaveCount(1); + }); + + test('throws descriptive error when enabling read-only with unsupported tools', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { + type: 'legacy', + data: { + text: 'Legacy feature block', + }, + }, + ], + }, + tools: { + legacy: { + classCode: UNSUPPORTED_BLOCK_TOOL_SOURCE, + }, + }, + }); + + const errorMessage = await page.evaluate(async () => { + const editor = window.editorInstance ?? (() => { + throw new Error('Editor instance not found'); + })(); + + try { + await editor.readOnly.toggle(true); + + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }); + + expect(errorMessage).toContain('Tools legacy don\'t support read-only mode'); + + const isReadOnlyEnabled = await page.evaluate(() => { + return window.editorInstance?.readOnly.isEnabled ?? false; + }); + + expect(isReadOnlyEnabled).toBe(false); + }); +}); diff --git a/test/playwright/tests/ui/configuration.spec.ts b/test/playwright/tests/ui/configuration.spec.ts new file mode 100644 index 00000000..8ad90291 --- /dev/null +++ b/test/playwright/tests/ui/configuration.spec.ts @@ -0,0 +1,985 @@ +import { expect, test } from '@playwright/test'; +import type { ConsoleMessage, Page } from '@playwright/test'; +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'; +import { + EDITOR_INTERFACE_SELECTOR, + INLINE_TOOLBAR_INTERFACE_SELECTOR, + MODIFIER_KEY +} from '../../../../src/components/constants'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-paragraph`; +const REDACTOR_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .codex-editor__redactor`; +const TOOLBOX_POPOVER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover`; +const FAILING_TOOL_SOURCE = ` + class FailingTool { + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + + return element; + } + + save() { + throw new Error('Save failure'); + } + } +`; + +type ToolDefinition = { + name: string; + classSource: string; + config?: Record; + inlineToolbar?: string[] | boolean; + toolbox?: { title: string; icon?: string }; + shortcut?: string; +}; + +type CreateEditorOptions = { + data?: OutputData; + config?: Record; + tools?: ToolDefinition[]; +}; + +declare global { + interface Window { + editorInstance?: EditorJS; + EditorJS: new (...args: unknown[]) => EditorJS; + __toolConfigReceived?: unknown; + __onReadyCalls?: number; + } +} + +const getParagraphByIndex = (page: Page, index = 0): ReturnType => { + return page.locator(`:nth-match(${PARAGRAPH_SELECTOR}, ${index + 1})`); +}; + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditor = async (page: Page, options: CreateEditorOptions = {}): Promise => { + const { data = null, config = {}, tools = [] } = options; + + await resetEditor(page); + + await page.evaluate( + async ({ holderId, editorData, editorConfig, toolDefinitions }) => { + const reviveToolClass = (source: string): unknown => { + // eslint-disable-next-line no-new-func -- revive tool class inside the page context + return new Function(`return (${source});`)(); + }; + + const finalConfig: Record = { + holder: holderId, + ...editorConfig, + }; + + if (editorData) { + finalConfig.data = editorData; + } + + if (toolDefinitions.length > 0) { + const revivedTools = toolDefinitions.reduce>>( + (accumulator, toolConfig) => { + const revivedClass = reviveToolClass(toolConfig.classSource); + + const toolSettings: Record = { + class: revivedClass, + }; + + if (toolConfig.config) { + toolSettings.config = toolConfig.config; + } + + if (toolConfig.inlineToolbar !== undefined) { + if (toolConfig.inlineToolbar === false) { + toolSettings.inlineToolbar = false; + } else { + toolSettings.inlineToolbar = toolConfig.inlineToolbar; + } + } + + if (toolConfig.toolbox) { + toolSettings.toolbox = toolConfig.toolbox; + } + + if (toolConfig.shortcut) { + toolSettings.shortcut = toolConfig.shortcut; + } + + return { + ...accumulator, + [toolConfig.name]: toolSettings, + }; + }, + {} + ); + + finalConfig.tools = revivedTools; + } + + const editor = new window.EditorJS(finalConfig); + + window.editorInstance = editor; + await editor.isReady; + }, + { + holderId: HOLDER_ID, + editorData: data, + editorConfig: config, + toolDefinitions: tools, + } + ); +}; + +const getSelectionState = async (page: Page): Promise<{ isInsideParagraph: boolean; offset: number }> => { + return await page.evaluate(({ paragraphSelector }) => { + const paragraph = document.querySelector(paragraphSelector); + const selection = window.getSelection(); + + if (!paragraph || !selection || selection.rangeCount === 0) { + return { + isInsideParagraph: false, + offset: -1, + }; + } + + return { + isInsideParagraph: paragraph.contains(selection.anchorNode ?? null), + offset: selection.anchorOffset ?? -1, + }; + }, { paragraphSelector: PARAGRAPH_SELECTOR }); +}; + +const openToolbox = async (page: Page): Promise => { + const paragraph = getParagraphByIndex(page); + + await paragraph.click(); + + const plusButton = page.locator(`${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__plus`); + + await plusButton.waitFor({ state: 'visible' }); + await plusButton.click(); + + await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeVisible(); +}; + +const insertFailingToolAndTriggerSave = async (page: Page): Promise => { + await page.evaluate(async () => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + editor.blocks.insert('failingTool'); + + try { + await editor.save(); + } catch (error) { + // Intentionally swallow to observe console logging side effects + } + }); + + await page.waitForFunction((waitMs) => { + return new Promise((resolve) => { + setTimeout(() => resolve(true), waitMs); + }); + }, 50); +}; + +test.describe('editor configuration options', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + }); + + test.describe('autofocus', () => { + test('focuses the default block when editor starts empty', async ({ page }) => { + await createEditor(page, { + config: { + autofocus: true, + }, + }); + + await expect.poll(async () => { + const { isInsideParagraph } = await getSelectionState(page); + + return isInsideParagraph; + }).toBe(true); + }); + + test('focuses the first block when initial data is provided', async ({ page }) => { + await createEditor(page, { + config: { + autofocus: true, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Prefilled content', + }, + }, + ], + }, + }); + + await expect.poll(async () => { + const { isInsideParagraph, offset } = await getSelectionState(page); + + return isInsideParagraph && offset === 0; + }).toBe(true); + }); + + test('does not focus any block when autofocus is false on empty editor', async ({ page }) => { + await createEditor(page, { + config: { + autofocus: false, + }, + }); + + const selectionState = await getSelectionState(page); + + expect(selectionState.isInsideParagraph).toBe(false); + expect(selectionState.offset).toBe(-1); + }); + + test('does not focus when autofocus is omitted on empty editor', async ({ page }) => { + await createEditor(page); + + const selectionState = await getSelectionState(page); + + expect(selectionState.isInsideParagraph).toBe(false); + expect(selectionState.offset).toBe(-1); + }); + + test('does not focus last block when autofocus is false for prefilled data', async ({ page }) => { + await createEditor(page, { + config: { + autofocus: false, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Prefilled content', + }, + }, + ], + }, + }); + + const selectionState = await getSelectionState(page); + + expect(selectionState.isInsideParagraph).toBe(false); + }); + + test('does not focus when autofocus is omitted for prefilled data', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Prefilled content', + }, + }, + ], + }, + }); + + const selectionState = await getSelectionState(page); + + expect(selectionState.isInsideParagraph).toBe(false); + }); + }); + + test.describe('placeholder', () => { + const getPlaceholderValue = async (page: Page): Promise => { + return await page.evaluate(({ paragraphSelector }) => { + const paragraph = document.querySelector(paragraphSelector); + + if (!(paragraph instanceof HTMLElement)) { + return null; + } + + return paragraph.getAttribute('data-placeholder'); + }, { paragraphSelector: PARAGRAPH_SELECTOR }); + }; + + test('uses provided placeholder string', async ({ page }) => { + const placeholder = 'Start typing...'; + + await createEditor(page, { + config: { + placeholder, + }, + }); + + await expect.poll(async () => { + return await getPlaceholderValue(page); + }).toBe(placeholder); + }); + + test('hides placeholder when set to false', async ({ page }) => { + await createEditor(page, { + config: { + placeholder: false, + }, + }); + + await expect.poll(async () => { + return await getPlaceholderValue(page); + }).toBeNull(); + }); + + test('does not set placeholder when option is omitted', async ({ page }) => { + await createEditor(page); + + await expect.poll(async () => { + return await getPlaceholderValue(page); + }).toBeNull(); + }); + }); + + test('applies custom minHeight padding', async ({ page }) => { + await createEditor(page, { + config: { + minHeight: 180, + }, + }); + + const paddingBottom = await page.evaluate(({ selector }) => { + const redactor = document.querySelector(selector) as HTMLElement | null; + + return redactor?.style.paddingBottom ?? null; + }, { selector: REDACTOR_SELECTOR }); + + expect(paddingBottom).toBe('180px'); + }); + + test('uses default minHeight when option is omitted', async ({ page }) => { + await createEditor(page); + + const paddingBottom = await page.evaluate(({ selector }) => { + const redactor = document.querySelector(selector) as HTMLElement | null; + + return redactor?.style.paddingBottom ?? null; + }, { selector: REDACTOR_SELECTOR }); + + expect(paddingBottom).toBe('300px'); + }); + + test('respects logLevel configuration', async ({ page }) => { + const consoleMessages: { type: string; text: string }[] = []; + + const listener = (message: ConsoleMessage): void => { + consoleMessages.push({ + type: message.type(), + text: message.text(), + }); + }; + + page.on('console', listener); + + const triggerInvalidMove = async (): Promise => { + await page.evaluate(() => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + editor.blocks.move(-1, -1); + }); + + await page.evaluate(() => { + return new Promise((resolve) => { + setTimeout(resolve, 50); + }); + }); + }; + + await createEditor(page); + await triggerInvalidMove(); + + const warningsWithDefaultLevel = consoleMessages.filter((message) => { + return message.type === 'warning' && message.text.includes("Warning during 'move' call"); + }).length; + + await createEditor(page, { + config: { + logLevel: 'ERROR', + }, + }); + + const warningsBeforeSuppressedMove = consoleMessages.length; + + await triggerInvalidMove(); + + const warningsAfterSuppressedMove = consoleMessages + .slice(warningsBeforeSuppressedMove) + .filter((message) => message.type === 'warning' && message.text.includes("Warning during 'move' call")) + .length; + + page.off('console', listener); + + expect(warningsWithDefaultLevel).toBeGreaterThan(0); + expect(warningsAfterSuppressedMove).toBe(0); + }); + + test('logLevel VERBOSE outputs both warnings and log messages', async ({ page }) => { + const consoleMessages: { type: string; text: string }[] = []; + + const listener = (message: ConsoleMessage): void => { + consoleMessages.push({ + type: message.type(), + text: message.text(), + }); + }; + + page.on('console', listener); + + await createEditor(page, { + config: { + logLevel: 'VERBOSE', + }, + data: { + blocks: [ + { + type: 'missingTool', + data: { text: 'should warn' }, + }, + ], + }, + tools: [ + { + name: 'failingTool', + classSource: FAILING_TOOL_SOURCE, + }, + ], + }); + + await insertFailingToolAndTriggerSave(page); + + page.off('console', listener); + + const warningCount = consoleMessages.filter((message) => { + return message.type === 'warning'; + }).length; + + const logCount = consoleMessages.filter((message) => { + return message.type === 'log' && message.text.includes('Saving process for'); + }).length; + + expect(warningCount).toBeGreaterThan(0); + expect(logCount).toBeGreaterThan(0); + }); + + test('logLevel INFO suppresses labeled warnings but keeps log messages', async ({ page }) => { + const consoleMessages: { type: string; text: string }[] = []; + + const listener = (message: ConsoleMessage): void => { + consoleMessages.push({ + type: message.type(), + text: message.text(), + }); + }; + + page.on('console', listener); + + await createEditor(page, { + config: { + logLevel: 'INFO', + }, + data: { + blocks: [ + { + type: 'missingTool', + data: { text: 'should warn' }, + }, + ], + }, + tools: [ + { + name: 'failingTool', + classSource: FAILING_TOOL_SOURCE, + }, + ], + }); + + await insertFailingToolAndTriggerSave(page); + + page.off('console', listener); + + const warningCount = consoleMessages.filter((message) => message.type === 'warning').length; + const logCount = consoleMessages.filter((message) => message.type === 'log').length; + + expect(warningCount).toBe(0); + expect(logCount).toBeGreaterThan(0); + }); + + test('logLevel WARN outputs warnings while suppressing log messages', async ({ page }) => { + const consoleMessages: { type: string; text: string }[] = []; + + const listener = (message: ConsoleMessage): void => { + consoleMessages.push({ + type: message.type(), + text: message.text(), + }); + }; + + page.on('console', listener); + + await createEditor(page, { + config: { + logLevel: 'WARN', + }, + data: { + blocks: [ + { + type: 'missingTool', + data: { text: 'should warn' }, + }, + ], + }, + tools: [ + { + name: 'failingTool', + classSource: FAILING_TOOL_SOURCE, + }, + ], + }); + + await insertFailingToolAndTriggerSave(page); + + page.off('console', listener); + + const warningCount = consoleMessages.filter((message) => message.type === 'warning').length; + const logCount = consoleMessages.filter((message) => message.type === 'log').length; + + expect(warningCount).toBeGreaterThan(0); + expect(logCount).toBe(0); + }); + + test('uses configured defaultBlock when data is empty', async ({ page }) => { + const simpleBlockTool = ` + class SimpleBlockTool { + constructor({ data }) { + this.data = data || {}; + } + + static get toolbox() { + return { + title: 'Simple block', + icon: '', + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.textContent = this.data.text || ''; + + return element; + } + + save(element) { + return { + text: element.textContent || '', + }; + } + } + `; + + await createEditor(page, { + config: { + defaultBlock: 'simple', + }, + tools: [ + { + name: 'simple', + classSource: simpleBlockTool, + }, + ], + }); + + const firstBlockType = await page.evaluate(async () => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + const data = await editor.save(); + + return data.blocks[0]?.type ?? null; + }); + + expect(firstBlockType).toBe('simple'); + }); + + test('falls back to paragraph when configured defaultBlock is missing', async ({ page }) => { + await createEditor(page, { + config: { + defaultBlock: 'nonexistentTool', + }, + }); + + const firstBlockType = await page.evaluate(async () => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + const data = await editor.save(); + + return data.blocks[0]?.type ?? null; + }); + + expect(firstBlockType).toBe('paragraph'); + }); + + test('applies custom sanitizer configuration', async ({ page }) => { + await createEditor(page, { + config: { + sanitizer: { + span: true, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Span content', + }, + }, + ], + }, + }); + + const savedHtml = await page.evaluate(async () => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + const data = await editor.save(); + + return data.blocks[0]?.data?.text ?? ''; + }); + + expect(savedHtml).toContain(' { + await createEditor(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Safe text', + }, + }, + ], + }, + }); + + const savedHtml = await page.evaluate(async () => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + const data = await editor.save(); + + return data.blocks[0]?.data?.text ?? ''; + }); + + expect(savedHtml).not.toContain(' { + await resetEditor(page); + + const onReadyCalls = await page.evaluate(async ({ holderId }) => { + window.__onReadyCalls = 0; + + const editor = new window.EditorJS({ + holder: holderId, + onReady() { + window.__onReadyCalls = (window.__onReadyCalls ?? 0) + 1; + }, + }); + + window.editorInstance = editor; + await editor.isReady; + + return window.__onReadyCalls ?? 0; + }, { holderId: HOLDER_ID }); + + expect(onReadyCalls).toBe(1); + }); + + test('activates tool via configured shortcut', async ({ page }) => { + const shortcutTool = ` + class ShortcutTool { + constructor({ data }) { + this.data = data || {}; + } + + static get toolbox() { + return { + title: 'Shortcut block', + icon: '', + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.textContent = this.data.text || ''; + + return element; + } + + save(element) { + return { + text: element.textContent || '', + }; + } + } + `; + + await createEditor(page, { + tools: [ + { + name: 'shortcutTool', + classSource: shortcutTool, + shortcut: 'CMD+SHIFT+L', + }, + ], + }); + + const paragraph = getParagraphByIndex(page); + + await paragraph.click(); + await paragraph.type('Shortcut text'); + + const combo = `${MODIFIER_KEY}+Shift+KeyL`; + + await page.keyboard.press(combo); + + await expect.poll(async () => { + const data = await page.evaluate(async () => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + return await editor.save(); + }); + + return data.blocks.some((block: { type: string }) => block.type === 'shortcutTool'); + }).toBe(true); + }); + + test('applies tool inlineToolbar, toolbox, and config overrides', async ({ page }) => { + const configurableToolSource = ` + class ConfigurableTool { + constructor({ data, config }) { + this.data = data || {}; + this.config = config || {}; + window.__toolConfigReceived = config; + } + + static get toolbox() { + return { + title: 'Default title', + icon: '', + }; + } + + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.textContent = this.data.text || ''; + + if (this.config.placeholderText) { + element.dataset.placeholder = this.config.placeholderText; + } + + return element; + } + + save(element) { + return { + text: element.textContent || '', + }; + } + } + `; + + await page.evaluate(() => { + window.__toolConfigReceived = undefined; + }); + + await createEditor(page, { + tools: [ + { + name: 'configurableTool', + classSource: configurableToolSource, + inlineToolbar: [ 'bold' ], + toolbox: { + title: 'Configured Tool', + icon: '', + }, + config: { + placeholderText: 'Custom placeholder', + }, + }, + ], + }); + + await page.evaluate(() => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + editor.blocks.insert('configurableTool'); + }); + + const configurableSelector = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="configurableTool"]`; + const blockCount = await page.locator(configurableSelector).count(); + + expect(blockCount).toBeGreaterThan(0); + + const customBlock = page.locator(`:nth-match(${configurableSelector}, ${blockCount})`); + const blockContent = customBlock.locator('[contenteditable="true"]'); + + await blockContent.click(); + await blockContent.type('Config text'); + + await expect(blockContent).toHaveAttribute('data-placeholder', 'Custom placeholder'); + + await blockContent.selectText(); + + const inlineToolbar = page.locator(INLINE_TOOLBAR_INTERFACE_SELECTOR); + + await expect(inlineToolbar).toBeVisible(); + await expect(inlineToolbar.locator('[data-item-name="bold"]')).toBeVisible(); + await expect(inlineToolbar.locator('[data-item-name="link"]')).toHaveCount(0); + + await openToolbox(page); + + const toolboxItem = page.locator(`${TOOLBOX_POPOVER_SELECTOR} [data-item-name="configurableTool"]`); + + await expect(toolboxItem).toContainText('Configured Tool'); + + const receivedConfig = await page.evaluate(() => { + return window.__toolConfigReceived ?? null; + }); + + expect(receivedConfig).toMatchObject({ + placeholderText: 'Custom placeholder', + }); + }); + + test('disables inline toolbar when tool config sets inlineToolbar to false', async ({ page }) => { + const inlineToggleTool = ` + class InlineToggleTool { + render() { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + + return element; + } + + save(element) { + return { + text: element.textContent || '', + }; + } + } + `; + + await createEditor(page, { + tools: [ + { + name: 'inlineToggleTool', + classSource: inlineToggleTool, + inlineToolbar: false, + }, + ], + }); + + await page.evaluate(() => { + const editor = window.editorInstance; + + if (!editor) { + throw new Error('Editor instance not found'); + } + + editor.blocks.insert('inlineToggleTool'); + }); + + const inlineToggleSelector = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="inlineToggleTool"]`; + const customBlock = page.locator(`${inlineToggleSelector}:last-of-type`); + const blockContent = customBlock.locator('[contenteditable="true"]'); + + await blockContent.click(); + await blockContent.type('inline toolbar disabled'); + await blockContent.selectText(); + + const inlineToolbar = page.locator(INLINE_TOOLBAR_INTERFACE_SELECTOR); + + await expect(inlineToolbar).toBeHidden(); + }); +}); diff --git a/test/playwright/tests/ui/inline-toolbar.spec.ts b/test/playwright/tests/ui/inline-toolbar.spec.ts index 3a54c619..ff0e4979 100644 --- a/test/playwright/tests/ui/inline-toolbar.spec.ts +++ b/test/playwright/tests/ui/inline-toolbar.spec.ts @@ -610,6 +610,78 @@ test.describe('inline toolbar', () => { expect(submitCount).toBe(0); }); + test('allows controlling inline toolbar visibility via API', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Inline toolbar API control test', + }, + }, + ], + }, + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await selectText(paragraph, 'toolbar'); + + const toolbarContainer = page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR); + + await expect(toolbarContainer).toBeVisible(); + + await page.evaluate(() => { + window.editorInstance?.inlineToolbar?.close(); + }); + + await expect(toolbarContainer).toHaveCount(0); + + await selectText(paragraph, 'toolbar'); + + await page.evaluate(() => { + window.editorInstance?.inlineToolbar?.open(); + }); + + await expect(page.locator(INLINE_TOOLBAR_CONTAINER_SELECTOR)).toBeVisible(); + }); + + test('reflects inline tool state changes based on current selection', async ({ page }) => { + await createEditor(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Bold part and plain part', + }, + }, + ], + }, + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await selectText(paragraph, 'Bold part'); + + const boldButton = page.locator(`${INLINE_TOOL_SELECTOR}[data-item-name="bold"]`); + + await expect(boldButton).not.toHaveClass(/ce-popover-item--active/); + + await boldButton.click(); + + await expect(boldButton).toHaveClass(/ce-popover-item--active/); + + await selectText(paragraph, 'plain part'); + + await page.evaluate(() => { + window.editorInstance?.inlineToolbar?.open(); + }); + + await expect(boldButton).not.toHaveClass(/ce-popover-item--active/); + }); + test('should restore caret after converting a block', async ({ page }) => { await page.addScriptTag({ path: HEADER_TOOL_UMD_PATH }); diff --git a/test/playwright/tests/ui/keyboard-shortcuts.spec.ts b/test/playwright/tests/ui/keyboard-shortcuts.spec.ts new file mode 100644 index 00000000..8a363be2 --- /dev/null +++ b/test/playwright/tests/ui/keyboard-shortcuts.spec.ts @@ -0,0 +1,457 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type EditorJS from '@/types'; +import type { OutputData } from '@/types'; +import type { BlockToolConstructable, InlineToolConstructable } from '@/types/tools'; +import { EDITOR_INTERFACE_SELECTOR, MODIFIER_KEY } from '../../../../src/components/constants'; +import { ensureEditorBundleBuilt } from '../helpers/ensure-build'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`; + +type ToolDefinition = { + name: string; + class: BlockToolConstructable | InlineToolConstructable; + config?: Record; +}; + +type SerializedToolConfig = { + name: string; + classSource: string; + config?: Record; + staticProps?: Record; +}; + +declare global { + interface Window { + editorInstance?: EditorJS; + __inlineShortcutLog?: string[]; + __lastShortcutEvent?: { metaKey: boolean; ctrlKey: boolean } | null; + } +} + +class ShortcutBlockTool { + private data: { text?: string }; + + constructor({ data }: { data?: { text?: string } }) { + this.data = data ?? {}; + } + + public static get toolbox(): { title: string; icon: string } { + return { + title: 'Shortcut block', + icon: '', + }; + } + + public render(): HTMLElement { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.textContent = this.data.text ?? ''; + + return element; + } + + public save(element: HTMLElement): { text: string } { + return { + text: element.textContent ?? '', + }; + } +} + +class CmdShortcutBlockTool { + private data: { text?: string }; + + constructor({ data }: { data?: { text?: string } }) { + this.data = data ?? {}; + } + + public static get toolbox(): { title: string; icon: string } { + return { + title: 'CMD shortcut block', + icon: '', + }; + } + + public render(): HTMLElement { + const element = document.createElement('div'); + + element.contentEditable = 'true'; + element.textContent = this.data.text ?? ''; + + return element; + } + + public save(element: HTMLElement): { text: string } { + return { + text: element.textContent ?? '', + }; + } +} + +class PrimaryShortcutInlineTool { + public static isInline = true; + public static title = 'Primary inline shortcut'; + public static shortcut = 'CMD+SHIFT+8'; + + public render(): HTMLElement { + const button = document.createElement('button'); + + button.type = 'button'; + button.textContent = 'Primary inline'; + + return button; + } + + public surround(): void { + window.__inlineShortcutLog = window.__inlineShortcutLog ?? []; + window.__inlineShortcutLog.push('primary-inline'); + } + + public checkState(): boolean { + return false; + } +} + +class SecondaryShortcutInlineTool { + public static isInline = true; + public static title = 'Secondary inline shortcut'; + public static shortcut = 'CMD+SHIFT+8'; + + public render(): HTMLElement { + const button = document.createElement('button'); + + button.type = 'button'; + button.textContent = 'Secondary inline'; + + return button; + } + + public surround(): void { + window.__inlineShortcutLog = window.__inlineShortcutLog ?? []; + window.__inlineShortcutLog.push('secondary-inline'); + } + + public checkState(): boolean { + return false; + } +} + +const STATIC_PROP_BLACKLIST = new Set(['length', 'name', 'prototype']); + +const extractSerializableStaticProps = (toolClass: ToolDefinition['class']): Record => { + return Object.getOwnPropertyNames(toolClass).reduce>((props, propName) => { + if (STATIC_PROP_BLACKLIST.has(propName)) { + return props; + } + + const descriptor = Object.getOwnPropertyDescriptor(toolClass, propName); + + if (!descriptor || typeof descriptor.value === 'function' || descriptor.value === undefined) { + return props; + } + + return { + ...props, + [propName]: descriptor.value, + }; + }, {}); +}; + +const serializeTools = (tools: ToolDefinition[]): SerializedToolConfig[] => { + return tools.map((tool) => { + const staticProps = extractSerializableStaticProps(tool.class); + + return { + name: tool.name, + classSource: tool.class.toString(), + config: tool.config, + staticProps: Object.keys(staticProps).length > 0 ? staticProps : undefined, + }; + }); +}; + +const resetEditor = async (page: Page): Promise => { + await page.evaluate(async ({ holderId }) => { + if (window.editorInstance) { + await window.editorInstance.destroy?.(); + window.editorInstance = undefined; + } + + document.getElementById(holderId)?.remove(); + + const container = document.createElement('div'); + + container.id = holderId; + container.dataset.cy = holderId; + container.style.border = '1px dotted #388AE5'; + + document.body.appendChild(container); + }, { holderId: HOLDER_ID }); +}; + +const createEditorWithTools = async ( + page: Page, + options: { data?: OutputData; tools?: ToolDefinition[] } = {} +): Promise => { + const { data = null, tools = [] } = options; + const serializedTools = serializeTools(tools); + + await resetEditor(page); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + + await page.evaluate( + async ({ holderId, serializedTools: toolConfigs, initialData }) => { + const reviveToolClass = (classSource: string): unknown => { + // eslint-disable-next-line no-new-func -- executed inside the browser context to revive tool classes + return new Function(`return (${classSource});`)(); + }; + + const revivedTools = toolConfigs.reduce>((accumulator, toolConfig) => { + const revivedClass = reviveToolClass(toolConfig.classSource); + + if (toolConfig.staticProps) { + Object.entries(toolConfig.staticProps).forEach(([prop, value]) => { + Object.defineProperty(revivedClass, prop, { + value, + configurable: true, + writable: true, + }); + }); + } + + const toolSettings: Record = { + class: revivedClass, + ...(toolConfig.config ?? {}), + }; + + return { + ...accumulator, + [toolConfig.name]: toolSettings, + }; + }, {}); + + const editorConfig: Record = { + holder: holderId, + }; + + if (initialData) { + editorConfig.data = initialData; + } + + if (toolConfigs.length > 0) { + editorConfig.tools = revivedTools; + } + + const editor = new window.EditorJS(editorConfig); + + window.editorInstance = editor; + await editor.isReady; + }, + { + holderId: HOLDER_ID, + serializedTools, + initialData: data, + } + ); +}; + +const saveEditor = async (page: Page): Promise => { + return await page.evaluate(async () => { + if (!window.editorInstance) { + throw new Error('Editor instance not found'); + } + + return await window.editorInstance.save(); + }); +}; + +const selectAllText = async (locator: Locator): Promise => { + await locator.evaluate((element) => { + const range = document.createRange(); + const selection = window.getSelection(); + + range.selectNodeContents(element); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +}; + +test.describe('keyboard shortcuts', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + }); + + test('activates custom block tool via configured shortcut', async ({ page }) => { + await createEditorWithTools(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Custom shortcut block', + }, + }, + ], + }, + tools: [ + { + name: 'shortcutBlock', + class: ShortcutBlockTool as unknown as BlockToolConstructable, + config: { + shortcut: 'CMD+SHIFT+M', + }, + }, + ], + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await expect(paragraph).toHaveCount(1); + await paragraph.click(); + await paragraph.type(' — activated'); + + const combo = `${MODIFIER_KEY}+Shift+KeyM`; + + await page.keyboard.press(combo); + + await expect.poll(async () => { + const data = await saveEditor(page); + + return data.blocks.map((block) => block.type); + }).toContain('shortcutBlock'); + }); + + test('registers first inline tool when shortcuts conflict', async ({ page }) => { + await createEditorWithTools(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Conflict test paragraph', + }, + }, + ], + }, + tools: [ + { + name: 'primaryInline', + class: PrimaryShortcutInlineTool as unknown as InlineToolConstructable, + config: { + shortcut: 'CMD+SHIFT+8', + }, + }, + { + name: 'secondaryInline', + class: SecondaryShortcutInlineTool as unknown as InlineToolConstructable, + config: { + shortcut: 'CMD+SHIFT+8', + }, + }, + ], + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + const pageErrors: Error[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error); + }); + + await paragraph.click(); + await selectAllText(paragraph); + await page.evaluate(() => { + window.__inlineShortcutLog = []; + }); + + const combo = `${MODIFIER_KEY}+Shift+Digit8`; + + await page.keyboard.press(combo); + + const activations = await page.evaluate(() => window.__inlineShortcutLog ?? []); + + expect(activations).toStrictEqual([ 'primary-inline' ]); + expect(pageErrors).toHaveLength(0); + }); + + test('maps CMD shortcut definitions to platform-specific modifier keys', async ({ page }) => { + await createEditorWithTools(page, { + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Platform modifier paragraph', + }, + }, + ], + }, + tools: [ + { + name: 'cmdShortcutBlock', + class: CmdShortcutBlockTool as unknown as BlockToolConstructable, + config: { + shortcut: 'CMD+SHIFT+J', + }, + }, + ], + }); + + const isMacPlatform = process.platform === 'darwin'; + + const paragraph = page.locator(PARAGRAPH_SELECTOR); + + await paragraph.click(); + + expect(MODIFIER_KEY).toBe(isMacPlatform ? 'Meta' : 'Control'); + + await page.evaluate(() => { + window.__lastShortcutEvent = null; + document.addEventListener( + 'keydown', + (event) => { + if (event.code === 'KeyJ' && event.shiftKey) { + window.__lastShortcutEvent = { + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + }; + } + }, + { + once: true, + capture: true, + } + ); + }); + + const combo = `${MODIFIER_KEY}+Shift+KeyJ`; + + await page.keyboard.press(combo); + + const shortcutEvent = await page.evaluate(() => window.__lastShortcutEvent); + + expect(shortcutEvent).toBeTruthy(); + + expect(shortcutEvent?.metaKey).toBe(isMacPlatform); + expect(shortcutEvent?.ctrlKey).toBe(!isMacPlatform); + + await expect.poll(async () => { + const data = await saveEditor(page); + + return data.blocks.map((block) => block.type); + }).toContain('cmdShortcutBlock'); + }); +}); diff --git a/test/testcases.md b/test/testcases.md index 22ff10ea..2d08c3e7 100644 --- a/test/testcases.md +++ b/test/testcases.md @@ -4,105 +4,105 @@ This document will describe various test cases of the editor.js functionality. F ## Configuration -- [ ] Zero configuration - - [ ] Editor.js should be initialized on the element with the default `editorjs` id. - - [ ] Editor.js should throw an error in case when there is no element with `editorjs` id. - - [ ] Editor.js should be initialized with the Paragraph tool only. - - [ ] The Inline Toolbar of the Paragraph tool should contain all default Inline Tools - `bold`, `italic`, `link`. +- [x] Zero configuration + - [x] Editor.js should be initialized on the element with the default `editorjs` id. + - [x] Editor.js should throw an error in case when there is no element with `editorjs` id. + - [x] Editor.js should be initialized with the Paragraph tool only. + - [x] The Inline Toolbar of the Paragraph tool should contain all default Inline Tools - `bold`, `italic`, `link`. -- [ ] `holder` property - - [ ] Editor.js should be initialized on the element with passed via `holder` property. - - [ ] Editor.js should throw an error if passed `holder` value is not an Element node. +- [x] `holder` property + - [x] Editor.js should be initialized on the element with passed via `holder` property. + - [x] Editor.js should throw an error if passed `holder` value is not an Element node. -- [ ] `autofocus` property - - [ ] With the empty editor - - [ ] If `true` passed, the caret should be placed to the first empty block. - - [ ] If `false` passed, the caret shouldn't be placed anywhere. - - [ ] If omitted, the caret shouldn't be placed anywhere. - - [ ] With the not-empty editor - - [ ] If `true` passed, the caret should be placed to the end of the last block. - - [ ] If `false` passed, the caret shouldn't be placed anywhere. - - [ ] If omitted, the caret shouldn't be placed anywhere. +- [x] `autofocus` property + - [x] With the empty editor + - [x] If `true` passed, the caret should be placed to the first empty block. + - [x] If `false` passed, the caret shouldn't be placed anywhere. + - [x] If omitted, the caret shouldn't be placed anywhere. + - [x] With the not-empty editor + - [x] If `true` passed, the caret should be placed to the end of the last block. + - [x] If `false` passed, the caret shouldn't be placed anywhere. + - [x] If omitted, the caret shouldn't be placed anywhere. -- [ ] `placeholder` property - - [ ] With the empty editor - - [ ] If `string` passed, the string should be placed as a placeholder to the first empty block only. - - [ ] If `false` passed, the first empty block should be placed without a placeholder. - - [ ] If omitted, the first empty block should be placed without a placeholder. +- [x] `placeholder` property + - [x] With the empty editor + - [x] If `string` passed, the string should be placed as a placeholder to the first empty block only. + - [x] If `false` passed, the first empty block should be placed without a placeholder. + - [x] If omitted, the first empty block should be placed without a placeholder. -- [ ] `minHeight` property - - [ ] If `number` passed, the height of the editor's bottom area from the last Block should be the `number`. - - [ ] If omitted the height of editor's bottom area from the last Block should be the default `300`. +- [x] `minHeight` property + - [x] If `number` passed, the height of the editor's bottom area from the last Block should be the `number`. + - [x] If omitted the height of editor's bottom area from the last Block should be the default `300`. -- [ ] `logLevel` property - - [ ] If `VERBOSE` passed, the editor should output all messages to the console. - - [ ] If `INFO` passed, the editor should output info and debug messages to the console. - - [ ] If `WARN` passed, the editor should output only warning messages to the console. - - [ ] If `ERROR` passed, the editor should output only error messages to the console. - - [ ] If omitted, the editor should output all messages to the console. +- [x] `logLevel` property + - [x] If `VERBOSE` passed, the editor should output all messages to the console. + - [x] If `INFO` passed, the editor should output info and debug messages to the console. + - [x] If `WARN` passed, the editor should output only warning messages to the console. + - [x] If `ERROR` passed, the editor should output only error messages to the console. + - [x] If omitted, the editor should output all messages to the console. -- [ ] `defaultBlock` property - - [ ] If `string` passed - - [ ] If passed `string` in the `tools` option, the passed tool should be used as the default tool. - - [ ] If passed `string` not in the `tools` option, the Paragraph tool should be used as the default tool. - - [ ] If omitted the Paragraph tool should be used as default tool. +- [x] `defaultBlock` property + - [x] If `string` passed + - [x] If passed `string` in the `tools` option, the passed tool should be used as the default tool. + - [x] If passed `string` not in the `tools` option, the Paragraph tool should be used as the default tool. + - [x] If omitted the Paragraph tool should be used as default tool. -- [ ] `sanitizer` property - - [ ] If `object` passed - - [ ] The Editor.js should clean the HTML tags according to mentioned configuration. - - [ ] If omitted the Editor.js should be initialized with the default `sanitizer` configuration, which allows the tags like `paragraph`, `anchor`, and `bold` for cleaning HTML. +- [x] `sanitizer` property + - [x] If `object` passed + - [x] The Editor.js should clean the HTML tags according to mentioned configuration. + - [x] If omitted the Editor.js should be initialized with the default `sanitizer` configuration, which allows the tags like `paragraph`, `anchor`, and `bold` for cleaning HTML. -- [ ] `tools` property - - [ ] If omitted,the Editor.js should be initialized with the Paragraph tool only. - - [ ] If `object` passed - - [ ] Editor.js should be initialized with all the passed tools. - - [ ] The keys of the object should be represented as `type` fields for corresponded blocks in output JSON - - [ ] If value is a JavaScript class, the class should be used as a tool - - [ ] If value is an `object` - - [ ] Checking the `class` property - - [ ] If omitted, the tool should be skipped with a warning in a console. - - [ ] If existed, the value of the `class` property should be used as a tool - - [ ] Checking the `config` property - - [ ] If `object` passed Editor.js should initialize `tool` and pass this object as `config` parameter of the tool's constructor - - [ ] Checking the `shortcut` property - - [ ] If `string` passed Editor.js should append the `tool` when such keys combination executed. - - [ ] Checking the `inlineToolbar` property - - [ ] If `true` passed, the Editor.js should show the Inline Toolbar for this tool with [common](https://editorjs.io/configuration#inline-toolbar-order) settings. - - [ ] If `false` passed, the Editor.js should not show the Inline Toolbar for this tool. - - [ ] If `array` passed, the Editor.js should show the Inline Toolbar for this tool with a passed list of tools and their order. - - [ ] If omitted, the Editor.js should not show the Inline Toolbar for this tool. - - [ ] Checking the `toolbox` property - - [ ] If it contains `title`, this title should be used as a tool title - - [ ] If it contains `icon`, this HTML code (maybe SVG) should be used as a tool icon +- [x] `tools` property + - [x] If omitted,the Editor.js should be initialized with the Paragraph tool only. + - [x] If `object` passed + - [x] Editor.js should be initialized with all the passed tools. + - [x] The keys of the object should be represented as `type` fields for corresponded blocks in output JSON + - [x] If value is a JavaScript class, the class should be used as a tool + - [x] If value is an `object` + - [x] Checking the `class` property + - [x] If omitted, the tool should be skipped with a warning in a console. + - [x] If existed, the value of the `class` property should be used as a tool + - [x] Checking the `config` property + - [x] If `object` passed Editor.js should initialize `tool` and pass this object as `config` parameter of the tool's constructor + - [x] Checking the `shortcut` property + - [x] If `string` passed Editor.js should append the `tool` when such keys combination executed. + - [x] Checking the `inlineToolbar` property + - [x] If `true` passed, the Editor.js should show the Inline Toolbar for this tool with [common](https://editorjs.io/configuration#inline-toolbar-order) settings. + - [x] If `false` passed, the Editor.js should not show the Inline Toolbar for this tool. + - [x] If `array` passed, the Editor.js should show the Inline Toolbar for this tool with a passed list of tools and their order. + - [x] If omitted, the Editor.js should not show the Inline Toolbar for this tool. + - [x] Checking the `toolbox` property + - [x] If it contains `title`, this title should be used as a tool title + - [x] If it contains `icon`, this HTML code (maybe SVG) should be used as a tool icon -- [ ] `onReady` property - - [ ] If `function` passed, the Editor.js should call the `function` when it's ready to work. - - [ ] If omitted, the Editor.js should be initialized with the `tools` only. +- [x] `onReady` property + - [x] If `function` passed, the Editor.js should call the `function` when it's ready to work. + - [x] If omitted, the Editor.js should be initialized with the `tools` only. -- [ ] `onChange` property - - [ ] If `function` passed,the Editor.js should call the `function` when something changed in Editor.js DOM. - - [ ] If omitted, the Editor.js should be initialized with the `tools` only. +- [x] `onChange` property + - [x] If `function` passed,the Editor.js should call the `function` when something changed in Editor.js DOM. + - [x] If omitted, the Editor.js should be initialized with the `tools` only. -- [ ] `data` property - - [ ] If omitted - - [ ] the Editor.js should be initialized with the `tools` only. - - [ ] the Editor.js should be empty. - - [ ] If `object` passed - - [ ] Checking the `blocks` property - - [ ] If `array` of `object` passed, - - [ ] for each `object` - - [ ] Checking the `type` and `data` property - - [ ] the Editor.js should be initialize with `block` of class `type` +- [x] `data` property + - [x] If omitted + - [x] the Editor.js should be initialized with the `tools` only. + - [x] the Editor.js should be empty. + - [x] If `object` passed + - [x] Checking the `blocks` property + - [x] If `array` of `object` passed, + - [x] for each `object` + - [x] Checking the `type` and `data` property + - [x] the Editor.js should be initialize with `block` of class `type` - [ ] If `type` not present in `tools`, the Editor.js should throw an error. - - [ ] If omitted - - [ ] the Editor.js should be initialized with the `tools` only. - - [ ] the Editor.js should be empty. + - [x] If omitted + - [x] the Editor.js should be initialized with the `tools` only. + - [x] the Editor.js should be empty. -- [ ] `readOnly` property - - [ ] If `true` passed, - - [ ] If any `tool` have not readOnly getter defined,The Editor.js should throw an error. - - [ ] otherwise, the Editor.js should be initialize with readOnly mode. - - [ ] If `false` passed,the Editor.js should be initialized with the `tools` only. - - [ ] If omitted,the Editor.js should be initialized with the `tools` only. +- [x] `readOnly` property + - [x] If `true` passed, + - [x] If any `tool` have not readOnly getter defined,The Editor.js should throw an error. + - [x] otherwise, the Editor.js should be initialize with readOnly mode. + - [x] If `false` passed,the Editor.js should be initialized with the `tools` only. + - [x] If omitted,the Editor.js should be initialized with the `tools` only. - [ ] `i18n` property diff --git a/test/unit/components/modules/api/toolbar.test.ts b/test/unit/components/modules/api/toolbar.test.ts index eb7aea09..5cd2c7e9 100644 --- a/test/unit/components/modules/api/toolbar.test.ts +++ b/test/unit/components/modules/api/toolbar.test.ts @@ -30,6 +30,7 @@ type ToolbarEditorMock = { describe('ToolbarAPI', () => { let toolbarApi: ToolbarAPI; let editorMock: ToolbarEditorMock; + const unspecifiedState = undefined as unknown as boolean; const createToolbarApi = (overrides?: Partial): void => { const eventsDispatcher = new EventsDispatcher(); @@ -71,68 +72,97 @@ describe('ToolbarAPI', () => { vi.restoreAllMocks(); }); - it('opens the toolbar via Editor module', () => { - toolbarApi.open(); + describe('methods getter', () => { + it('exposes bound toolbar controls', () => { + const openSpy = vi.spyOn(toolbarApi, 'open').mockImplementation(() => {}); + const closeSpy = vi.spyOn(toolbarApi, 'close').mockImplementation(() => {}); + const toggleBlockSettingsSpy = vi + .spyOn(toolbarApi, 'toggleBlockSettings') + .mockImplementation(() => {}); + const toggleToolboxSpy = vi + .spyOn(toolbarApi, 'toggleToolbox') + .mockImplementation(() => {}); - expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1); + const { open, close, toggleBlockSettings, toggleToolbox } = toolbarApi.methods; + + open(); + close(); + toggleBlockSettings(true); + toggleToolbox(false); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(toggleBlockSettingsSpy).toHaveBeenCalledWith(true); + expect(toggleToolboxSpy).toHaveBeenCalledWith(false); + }); }); - it('closes the toolbar via Editor module', () => { - toolbarApi.close(); + describe('open/close', () => { + it('opens the toolbar via Editor module', () => { + toolbarApi.open(); - expect(editorMock.Toolbar.close).toHaveBeenCalledTimes(1); + expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1); + }); + + it('closes the toolbar via Editor module', () => { + toolbarApi.close(); + + expect(editorMock.Toolbar.close).toHaveBeenCalledTimes(1); + }); }); - it('opens block settings when toggleBlockSettings decides to open', () => { - toolbarApi.toggleBlockSettings(undefined as unknown as boolean); + describe('toggleBlockSettings', () => { + it('opens block settings when state is omitted and block settings are closed', () => { + toolbarApi.toggleBlockSettings(unspecifiedState); - expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1); - expect(editorMock.BlockSettings.open).toHaveBeenCalledTimes(1); - expect(editorMock.BlockSettings.close).not.toHaveBeenCalled(); - }); + expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1); + expect(editorMock.BlockSettings.open).toHaveBeenCalledTimes(1); + expect(editorMock.BlockSettings.close).not.toHaveBeenCalled(); + }); - it('closes block settings when toggleBlockSettings decides to close', () => { - editorMock.BlockSettings.opened = true; + it('closes block settings when state is omitted and block settings are opened', () => { + editorMock.BlockSettings.opened = true; - toolbarApi.toggleBlockSettings(undefined as unknown as boolean); + toolbarApi.toggleBlockSettings(unspecifiedState); - expect(editorMock.BlockSettings.close).toHaveBeenCalledTimes(1); - expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled(); - expect(editorMock.BlockSettings.open).not.toHaveBeenCalled(); - }); + expect(editorMock.BlockSettings.close).toHaveBeenCalledTimes(1); + expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled(); + expect(editorMock.BlockSettings.open).not.toHaveBeenCalled(); + }); - it('forces opening block settings when openingState is true', () => { - editorMock.BlockSettings.opened = true; + it('forces opening when openingState is true', () => { + editorMock.BlockSettings.opened = true; - toolbarApi.toggleBlockSettings(true); + toolbarApi.toggleBlockSettings(true); - expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1); - expect(editorMock.BlockSettings.open).toHaveBeenCalledTimes(1); - expect(editorMock.BlockSettings.close).not.toHaveBeenCalled(); - }); + expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1); + expect(editorMock.BlockSettings.open).toHaveBeenCalledTimes(1); + expect(editorMock.BlockSettings.close).not.toHaveBeenCalled(); + }); - it('forces closing block settings when openingState is false', () => { - toolbarApi.toggleBlockSettings(false); + it('forces closing when openingState is false', () => { + toolbarApi.toggleBlockSettings(false); - expect(editorMock.BlockSettings.close).toHaveBeenCalledTimes(1); - expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled(); - expect(editorMock.BlockSettings.open).not.toHaveBeenCalled(); - }); + expect(editorMock.BlockSettings.close).toHaveBeenCalledTimes(1); + expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled(); + expect(editorMock.BlockSettings.open).not.toHaveBeenCalled(); + }); - it('logs a warning when no block is selected for toggleBlockSettings', () => { - const logSpy = vi.spyOn(utils, 'logLabeled').mockImplementation(() => {}); + it('logs a warning when no block is selected', () => { + const logSpy = vi.spyOn(utils, 'logLabeled').mockImplementation(() => {}); - editorMock.BlockManager.currentBlockIndex = -1; + editorMock.BlockManager.currentBlockIndex = -1; - toolbarApi.toggleBlockSettings(true); + toolbarApi.toggleBlockSettings(true); - expect(logSpy).toHaveBeenCalledWith( - 'Could\'t toggle the Toolbar because there is no block selected ', - 'warn' - ); - expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled(); - expect(editorMock.BlockSettings.open).not.toHaveBeenCalled(); - expect(editorMock.BlockSettings.close).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + 'Could\'t toggle the Toolbar because there is no block selected ', + 'warn' + ); + expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled(); + expect(editorMock.BlockSettings.open).not.toHaveBeenCalled(); + expect(editorMock.BlockSettings.close).not.toHaveBeenCalled(); + }); }); it('opens toolbox when toggleToolbox receives opening state', () => { @@ -152,7 +182,7 @@ describe('ToolbarAPI', () => { }); it('toggles toolbox when opening state is omitted', () => { - toolbarApi.toggleToolbox(undefined as unknown as boolean); + toolbarApi.toggleToolbox(unspecifiedState); expect(editorMock.Toolbar.moveAndOpen).toHaveBeenCalledTimes(1); expect(editorMock.Toolbar.toolbox.open).toHaveBeenCalledTimes(1); @@ -160,7 +190,7 @@ describe('ToolbarAPI', () => { vi.clearAllMocks(); editorMock.Toolbar.toolbox.opened = true; - toolbarApi.toggleToolbox(undefined as unknown as boolean); + toolbarApi.toggleToolbox(unspecifiedState); expect(editorMock.Toolbar.toolbox.close).toHaveBeenCalledTimes(1); expect(editorMock.Toolbar.moveAndOpen).not.toHaveBeenCalled();