diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bb241443..dae1953b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ – `New` – Block Tunes now supports nesting items – `New` – Block Tunes now supports separator items +– `New` – "Convert to" control is now also available in Block Tunes ### 2.30.0 diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 57631471..803a5044 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -21,11 +21,11 @@ import BlockTune from '../tools/tune'; import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import ToolsCollection from '../tools/collection'; import EventsDispatcher from '../utils/events'; -import { TunesMenuConfigItem } from '../../../types/tools'; +import { TunesMenuConfig, TunesMenuConfigItem } from '../../../types/tools'; import { isMutationBelongsToElement } from '../utils/mutations'; import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; import { RedactorDomChangedPayload } from '../events/RedactorDomChanged'; -import { convertBlockDataToString } from '../utils/blocks'; +import { convertBlockDataToString, isSameBlockData } from '../utils/blocks'; /** * Interface describes Block class constructor argument @@ -229,7 +229,6 @@ export default class Block extends EventsDispatcher { tunesData, }: BlockConstructorOptions, eventBus?: EventsDispatcher) { super(); - this.name = tool.name; this.id = id; this.settings = tool.settings; @@ -612,34 +611,60 @@ export default class Block extends EventsDispatcher { /** * Returns data to render in tunes menu. - * Splits block tunes settings into 2 groups: popover items and custom html. + * Splits block tunes into 3 groups: block specific tunes, common tunes + * and custom html that is produced by combining tunes html from both previous groups */ - public getTunes(): [PopoverItemParams[], HTMLElement] { + public getTunes(): { + toolTunes: PopoverItemParams[]; + commonTunes: PopoverItemParams[]; + customHtmlTunes: HTMLElement + } { const customHtmlTunesContainer = document.createElement('div'); - const tunesItems: TunesMenuConfigItem[] = []; + const commonTunesPopoverParams: TunesMenuConfigItem[] = []; /** Tool's tunes: may be defined as return value of optional renderSettings method */ const tunesDefinedInTool = typeof this.toolInstance.renderSettings === 'function' ? this.toolInstance.renderSettings() : []; + /** Separate custom html from Popover items params for tool's tunes */ + const { + items: toolTunesPopoverParams, + htmlElement: toolTunesHtmlElement, + } = this.getTunesDataSegregated(tunesDefinedInTool); + + if (toolTunesHtmlElement !== undefined) { + customHtmlTunesContainer.appendChild(toolTunesHtmlElement); + } + /** Common tunes: combination of default tunes (move up, move down, delete) and third-party tunes connected via tunes api */ const commonTunes = [ ...this.tunesInstances.values(), ...this.defaultTunesInstances.values(), ].map(tuneInstance => tuneInstance.render()); - [tunesDefinedInTool, commonTunes].flat().forEach(rendered => { - if ($.isElement(rendered)) { - customHtmlTunesContainer.appendChild(rendered); - } else if (Array.isArray(rendered)) { - tunesItems.push(...rendered); - } else { - tunesItems.push(rendered); + /** Separate custom html from Popover items params for common tunes */ + commonTunes.forEach(tuneConfig => { + const { + items, + htmlElement, + } = this.getTunesDataSegregated(tuneConfig); + + if (htmlElement !== undefined) { + customHtmlTunesContainer.appendChild(htmlElement); + } + + if (items !== undefined) { + commonTunesPopoverParams.push(...items); } }); - return [tunesItems, customHtmlTunesContainer]; + return { + toolTunes: toolTunesPopoverParams, + commonTunes: commonTunesPopoverParams, + customHtmlTunes: customHtmlTunesContainer, + }; } + /** * Update current input index with selection anchor node */ @@ -711,11 +736,8 @@ export default class Block extends EventsDispatcher { const blockData = await this.data; const toolboxItems = toolboxSettings; - return toolboxItems.find((item) => { - return Object.entries(item.data) - .some(([propName, propValue]) => { - return blockData[propName] && _.equals(blockData[propName], propValue); - }); + return toolboxItems?.find((item) => { + return isSameBlockData(item.data, blockData); }); } @@ -728,6 +750,25 @@ export default class Block extends EventsDispatcher { return convertBlockDataToString(blockData, this.tool.conversionConfig); } + /** + * Determines if tool's tunes settings are custom html or popover params and separates one from another by putting to different object fields + * + * @param tunes - tool's tunes config + */ + private getTunesDataSegregated(tunes: HTMLElement | TunesMenuConfig): { htmlElement?: HTMLElement; items: PopoverItemParams[] } { + const result = { } as { htmlElement?: HTMLElement; items: PopoverItemParams[] }; + + if ($.isElement(tunes)) { + result.htmlElement = tunes as HTMLElement; + } else if (Array.isArray(tunes)) { + result.items = tunes as PopoverItemParams[]; + } else { + result.items = [ tunes ]; + } + + return result; + } + /** * Make default Block wrappers and put Tool`s content there * diff --git a/src/components/i18n/locales/en/messages.json b/src/components/i18n/locales/en/messages.json index 32761be6..650a8b6d 100644 --- a/src/components/i18n/locales/en/messages.json +++ b/src/components/i18n/locales/en/messages.json @@ -18,7 +18,8 @@ }, "popover": { "Filter": "", - "Nothing found": "" + "Nothing found": "", + "Convert to": "" } }, "toolNames": { diff --git a/src/components/modules/toolbar/blockSettings.ts b/src/components/modules/toolbar/blockSettings.ts index e43a072e..3a2b7aa3 100644 --- a/src/components/modules/toolbar/blockSettings.ts +++ b/src/components/modules/toolbar/blockSettings.ts @@ -7,10 +7,13 @@ import { I18nInternalNS } from '../../i18n/namespace-internal'; import Flipper from '../../flipper'; import { TunesMenuConfigItem } from '../../../../types/tools'; import { resolveAliases } from '../../utils/resolve-aliases'; -import { type Popover, PopoverDesktop, PopoverMobile } from '../../utils/popover'; +import { type Popover, PopoverDesktop, PopoverMobile, PopoverItemParams, PopoverItemDefaultParams } from '../../utils/popover'; import { PopoverEvent } from '../../utils/popover/popover.types'; import { isMobileScreen } from '../../utils'; import { EditorMobileLayoutToggled } from '../../events'; +import * as _ from '../../utils'; +import { IconReplace } from '@codexteam/icons'; +import { isSameBlockData } from '../../utils/blocks'; /** * HTML Elements that used for BlockSettings @@ -105,7 +108,7 @@ export default class BlockSettings extends Module { * * @param targetBlock - near which Block we should open BlockSettings */ - public open(targetBlock: Block = this.Editor.BlockManager.currentBlock): void { + public async open(targetBlock: Block = this.Editor.BlockManager.currentBlock): Promise { this.opened = true; /** @@ -120,10 +123,8 @@ export default class BlockSettings extends Module { this.Editor.BlockSelection.selectBlock(targetBlock); this.Editor.BlockSelection.clearCache(); - /** - * Fill Tool's settings - */ - const [tunesItems, customHtmlTunesContainer] = targetBlock.getTunes(); + /** Get tool's settings data */ + const { toolTunes, commonTunes, customHtmlTunes } = targetBlock.getTunes(); /** Tell to subscribers that block settings is opened */ this.eventsDispatcher.emit(this.events.opened); @@ -132,9 +133,9 @@ export default class BlockSettings extends Module { this.popover = new PopoverClass({ searchable: true, - items: tunesItems.map(tune => this.resolveTuneAliases(tune)), - customContent: customHtmlTunesContainer, - customContentFlippableItems: this.getControls(customHtmlTunesContainer), + items: await this.getTunesItems(targetBlock, commonTunes, toolTunes), + customContent: customHtmlTunes, + customContentFlippableItems: this.getControls(customHtmlTunes), scopeElement: this.Editor.API.methods.ui.nodes.redactor, messages: { nothingFound: I18n.ui(I18nInternalNS.ui.popover, 'Nothing found'), @@ -197,6 +198,117 @@ export default class BlockSettings extends Module { } }; + /** + * Returns list of items to be displayed in block tunes menu. + * Merges tool specific tunes, conversion menu and common tunes in one list in predefined order + * + * @param currentBlock – block we are about to open block tunes for + * @param commonTunes – common tunes + * @param toolTunes - tool specific tunes + */ + private async getTunesItems(currentBlock: Block, commonTunes: TunesMenuConfigItem[], toolTunes?: TunesMenuConfigItem[]): Promise { + const items = [] as TunesMenuConfigItem[]; + + if (toolTunes !== undefined && toolTunes.length > 0) { + items.push(...toolTunes); + items.push({ + type: 'separator', + }); + } + + const convertToItems = await this.getConvertToItems(currentBlock); + + if (convertToItems.length > 0) { + items.push({ + icon: IconReplace, + title: I18n.ui(I18nInternalNS.ui.popover, 'Convert to'), + children: { + items: convertToItems, + }, + }); + items.push({ + type: 'separator', + }); + } + + items.push(...commonTunes); + + return items.map(tune => this.resolveTuneAliases(tune)); + } + + /** + * Returns list of all available conversion menu items + * + * @param currentBlock - block we are about to open block tunes for + */ + private async getConvertToItems(currentBlock: Block): Promise { + const conversionEntries = Array.from(this.Editor.Tools.blockTools.entries()); + + const resultItems: PopoverItemDefaultParams[] = []; + + const blockData = await currentBlock.data; + + conversionEntries.forEach(([toolName, tool]) => { + const conversionConfig = tool.conversionConfig; + + /** + * Skip tools without «import» rule specified + */ + if (!conversionConfig || !conversionConfig.import) { + return; + } + + tool.toolbox?.forEach((toolboxItem) => { + /** + * Skip tools that don't pass 'toolbox' property + */ + if (_.isEmpty(toolboxItem) || !toolboxItem.icon) { + return; + } + + let shouldSkip = false; + + if (toolboxItem.data !== undefined) { + /** + * When a tool has several toolbox entries, we need to make sure we do not add + * toolbox item with the same data to the resulting array. This helps exclude duplicates + */ + const hasSameData = isSameBlockData(toolboxItem.data, blockData); + + shouldSkip = hasSameData; + } else { + shouldSkip = toolName === currentBlock.name; + } + + + if (shouldSkip) { + return; + } + + resultItems.push({ + icon: toolboxItem.icon, + title: toolboxItem.title, + name: toolName, + onActivate: () => { + const { BlockManager, BlockSelection, Caret } = this.Editor; + + BlockManager.convert(this.Editor.BlockManager.currentBlock, toolName, toolboxItem.data); + + BlockSelection.clearSelection(); + + this.close(); + + window.requestAnimationFrame(() => { + Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END); + }); + }, + }); + }); + }); + + return resultItems; + } + /** * Handles popover close event */ @@ -224,7 +336,10 @@ export default class BlockSettings extends Module { * * @param item - item with resolved aliases */ - private resolveTuneAliases(item: TunesMenuConfigItem): TunesMenuConfigItem { + private resolveTuneAliases(item: TunesMenuConfigItem): PopoverItemParams { + if (item.type === 'separator') { + return item; + } const result = resolveAliases(item, { label: 'title' }); if (item.confirmation) { diff --git a/src/components/utils/blocks.ts b/src/components/utils/blocks.ts index 288e0057..9907c1be 100644 --- a/src/components/utils/blocks.ts +++ b/src/components/utils/blocks.ts @@ -1,7 +1,8 @@ import type { ConversionConfig } from '../../../types/configs/conversion-config'; import type { BlockToolData } from '../../../types/tools/block-tool-data'; import type Block from '../block'; -import { isFunction, isString, log } from '../utils'; +import { isFunction, isString, log, equals } from '../utils'; + /** * Check if block has valid conversion config for export or import. @@ -19,6 +20,18 @@ export function isBlockConvertable(block: Block, direction: 'export' | 'import') return isFunction(conversionProp) || isString(conversionProp); } +/** + * Checks that all the properties of the first block data exist in second block data with the same values. + * + * @param data1 – first block data + * @param data2 – second block data + */ +export function isSameBlockData(data1: BlockToolData, data2: BlockToolData): boolean { + return Object.entries(data1).some((([propName, propValue]) => { + return data2[propName] && equals(data2[propName], propValue); + })); +} + /** * Check if two blocks could be merged. * diff --git a/src/components/utils/popover/components/popover-item/popover-item.types.ts b/src/components/utils/popover/components/popover-item/popover-item.types.ts index 15ea856b..e9e7f95c 100644 --- a/src/components/utils/popover/components/popover-item/popover-item.types.ts +++ b/src/components/utils/popover/components/popover-item/popover-item.types.ts @@ -17,7 +17,7 @@ interface PopoverItemDefaultBaseParams { /** * Item type */ - type: 'default'; + type?: 'default'; /** * Displayed text diff --git a/test/cypress/tests/ui/BlockTunes.cy.ts b/test/cypress/tests/ui/BlockTunes.cy.ts index f652d2c7..b9acd027 100644 --- a/test/cypress/tests/ui/BlockTunes.cy.ts +++ b/test/cypress/tests/ui/BlockTunes.cy.ts @@ -1,4 +1,7 @@ import { selectionChangeDebounceTimeout } from '../../../../src/components/constants'; +import Header from '@editorjs/header'; +import { ToolboxConfig } from '../../../../types'; + describe('BlockTunes', function () { describe('Search', () => { @@ -104,4 +107,185 @@ describe('BlockTunes', function () { .should('have.class', 'ce-block--selected'); }); }); + + describe('Convert to', () => { + it('should display Convert to inside Block Tunes', () => { + cy.createEditor({ + tools: { + header: Header, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check "Convert to" option is present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .should('exist'); + + /** Click "Convert to" option*/ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .click(); + + /** Check nected popover with "Heading" option is present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested [data-item-name=header]') + .should('exist'); + }); + + it('should not display Convert to inside Block Tunes if there is nothing to convert to', () => { + /** Editor instance with single default tool */ + cy.createEditor({ + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.cdx-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Check "Convert to" option is not present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .should('not.exist'); + }); + + it('should not display tool with the same data in "Convert to" menu', () => { + /** + * Tool with several toolbox entries configured + */ + class TestTool { + /** + * Tool is convertable + */ + public static get conversionConfig(): { import: string } { + return { + import: 'text', + }; + } + + /** + * TestTool contains several toolbox options + */ + public static get toolbox(): ToolboxConfig { + return [ + { + title: 'Title 1', + icon: 'Icon1', + data: { + level: 1, + }, + }, + { + title: 'Title 2', + icon: 'Icon2', + data: { + level: 2, + }, + }, + ]; + } + + /** + * Tool can render itself + */ + public render(): HTMLDivElement { + const div = document.createElement('div'); + + div.innerText = 'Some text'; + + return div; + } + + /** + * Tool can save it's data + */ + public save(): { text: string; level: number } { + return { + text: 'Some text', + level: 1, + }; + } + } + + /** Editor instance with TestTool installed and one block of TestTool type */ + cy.createEditor({ + tools: { + testTool: TestTool, + }, + data: { + blocks: [ + { + type: 'testTool', + data: { + text: 'Some text', + level: 1, + }, + }, + ], + }, + }); + + /** Open block tunes menu */ + cy.get('[data-cy=editorjs]') + .get('.ce-block') + .click(); + + cy.get('[data-cy=editorjs]') + .get('.ce-toolbar__settings-btn') + .click(); + + /** Open "Convert to" menu */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover-item') + .contains('Convert to') + .click(); + + /** Check TestTool option with SAME data is NOT present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested [data-item-name=testTool]') + .contains('Title 1') + .should('not.exist'); + + /** Check TestTool option with DIFFERENT data IS present */ + cy.get('[data-cy=editorjs]') + .get('.ce-popover--nested [data-item-name=testTool]') + .contains('Title 2') + .should('exist'); + }); + }); }); diff --git a/test/cypress/tests/utils/flipper.cy.ts b/test/cypress/tests/utils/flipper.cy.ts index 50037c9c..1a91d81c 100644 --- a/test/cypress/tests/utils/flipper.cy.ts +++ b/test/cypress/tests/utils/flipper.cy.ts @@ -1,4 +1,4 @@ -import { PopoverItem } from '../../../../types/index.js'; +import { PopoverItemParams } from '../../../../types/index.js'; /** * Mock of some Block Tool @@ -26,7 +26,7 @@ class SomePlugin { /** * Used to display our tool in the Toolbox */ - public static get toolbox(): PopoverItem { + public static get toolbox(): PopoverItemParams { return { icon: '₷', title: 'Some tool', @@ -34,6 +34,15 @@ class SomePlugin { onActivate: (): void => {}, }; } + + /** + * Extracts data from the plugin's UI + */ + public save(): {data: string} { + return { + data: '123', + }; + } } describe('Flipper', () => { @@ -71,15 +80,16 @@ describe('Flipper', () => { cy.get('[data-cy=editorjs]') .get('.cdx-some-plugin') // Open tunes menu - .trigger('keydown', { code: 'Slash', ctrlKey: true }) + .trigger('keydown', { code: 'Slash', + ctrlKey: true }) // Navigate to delete button (the second button) .trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE }) .trigger('keydown', { keyCode: ARROW_DOWN_KEY_CODE }); /** - * Check whether we focus the Delete Tune or not + * Check whether we focus the Move Up Tune or not */ - cy.get('[data-item-name="delete"]') + cy.get('[data-item-name="move-up"]') .should('have.class', 'ce-popover-item--focused'); cy.get('[data-cy=editorjs]') diff --git a/test/cypress/tests/utils/popover.cy.ts b/test/cypress/tests/utils/popover.cy.ts index 7103ec71..0d89f3ba 100644 --- a/test/cypress/tests/utils/popover.cy.ts +++ b/test/cypress/tests/utils/popover.cy.ts @@ -16,7 +16,6 @@ describe('Popover', () => { * (Inside popover null value is set to confirmation property, so, object becomes unavailable otherwise) */ const confirmation: PopoverItemParams = { - type: 'default', icon: confirmActionIcon, title: confirmActionTitle, onActivate: cy.stub(), @@ -24,7 +23,6 @@ describe('Popover', () => { const items: PopoverItemParams[] = [ { - type: 'default', icon: actionIcon, title: actionTitle, name: 'testItem', @@ -73,7 +71,6 @@ describe('Popover', () => { it('should render the items with true isActive property value as active', () => { const items = [ { - type: 'default', icon: 'Icon', title: 'Title', isActive: true, @@ -98,7 +95,6 @@ describe('Popover', () => { it('should not execute item\'s onActivate callback if the item is disabled', () => { const items: PopoverItemParams[] = [ { - type: 'default', icon: 'Icon', title: 'Title', isDisabled: true, @@ -131,7 +127,6 @@ describe('Popover', () => { it('should close once item with closeOnActivate property set to true is activated', () => { const items = [ { - type: 'default', icon: 'Icon', title: 'Title', closeOnActivate: true, @@ -159,7 +154,6 @@ describe('Popover', () => { it('should highlight as active the item with toggle property set to true once activated', () => { const items = [ { - type: 'default', icon: 'Icon', title: 'Title', toggle: true, @@ -184,7 +178,6 @@ describe('Popover', () => { it('should perform radiobutton-like behavior among the items that have toggle property value set to the same string value', () => { const items = [ { - type: 'default', icon: 'Icon 1', title: 'Title 1', toggle: 'group-name', @@ -193,7 +186,6 @@ describe('Popover', () => { onActivate: (): void => {}, }, { - type: 'default', icon: 'Icon 2', title: 'Title 2', toggle: 'group-name', @@ -231,7 +223,6 @@ describe('Popover', () => { it('should toggle item if it is the only item in toggle group', () => { const items = [ { - type: 'default', icon: 'Icon', title: 'Title', toggle: 'key', @@ -279,7 +270,6 @@ describe('Popover', () => { /** Tool data displayed in block tunes popover */ public render(): TunesMenuConfig { return { - type: 'default', icon: 'Icon', title: 'Title', toggle: 'key', @@ -287,7 +277,6 @@ describe('Popover', () => { children: { items: [ { - type: 'default', icon: 'Icon', title: 'Title', name: 'nested-test-item', @@ -357,7 +346,6 @@ describe('Popover', () => { /** Tool data displayed in block tunes popover */ public render(): TunesMenuConfig { return { - type: 'default', icon: 'Icon', title: 'Tune', toggle: 'key', @@ -365,7 +353,6 @@ describe('Popover', () => { children: { items: [ { - type: 'default', icon: 'Icon', title: 'Title', name: 'nested-test-item', @@ -521,7 +508,6 @@ describe('Popover', () => { public render(): TunesMenuConfig { return [ { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune', @@ -585,7 +571,6 @@ describe('Popover', () => { public render(): TunesMenuConfig { return [ { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune 1', @@ -595,7 +580,6 @@ describe('Popover', () => { type: 'separator', }, { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune 2', @@ -633,7 +617,8 @@ describe('Popover', () => { .click(); /** Press Tab */ - cy.tab(); + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); /** Check first item is focused */ cy.get('[data-cy=editorjs]') @@ -648,7 +633,8 @@ describe('Popover', () => { .should('not.exist'); /** Press Tab */ - cy.tab(); + // eslint-disable-next-line cypress/require-data-selectors -- cy.tab() not working here + cy.get('body').tab(); /** Check first item is not focused */ cy.get('[data-cy=editorjs]') @@ -672,7 +658,6 @@ describe('Popover', () => { public render(): TunesMenuConfig { return [ { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune 1', @@ -682,7 +667,6 @@ describe('Popover', () => { type: 'separator', }, { - type: 'default', onActivate: (): void => {}, icon: 'Icon', title: 'Tune 2',