diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a1b00479d..64ccef078 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ### 2.30.0 +- `Improvement` — Ability to merge blocks with different types - `Fix` — `onChange` will be called when removing the entire text within a descendant element of a block. - `Fix` - Unexpected new line on Enter press with selected block without caret - `Fix` - Search input autofocus loosing after Block Tunes opening diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index ae8e4818c..3b56753c7 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -471,7 +471,12 @@ export default class BlockManager extends Module { * @returns {Promise} - the sequence that can be continued */ public async mergeBlocks(targetBlock: Block, blockToMerge: Block): Promise { - const blockToMergeData = await blockToMerge.data; + const blockToMergeData = targetBlock.name !== blockToMerge.name + ? convertStringToBlockData( + await blockToMerge.exportDataAsString(), + targetBlock.tool.conversionConfig + ) + : await blockToMerge.data; if (!_.isEmpty(blockToMergeData)) { await targetBlock.mergeWith(blockToMergeData); diff --git a/src/components/utils/blocks.ts b/src/components/utils/blocks.ts index 92a802eef..f9219c13b 100644 --- a/src/components/utils/blocks.ts +++ b/src/components/utils/blocks.ts @@ -9,11 +9,18 @@ import { isFunction, isString, log } from '../utils'; * We can merge two blocks if: * - they have the same type * - they have a merge function (.mergeable = true) + * - If they have valid conversions config * * @param targetBlock - block to merge to * @param blockToMerge - block to merge from */ export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boolean { + if (blockToMerge.mergeable && + blockToMerge.tool.conversionConfig?.export !== undefined && + targetBlock.tool.conversionConfig?.import !== undefined) { + return true; + } + return targetBlock.mergeable && targetBlock.name === blockToMerge.name; } diff --git a/test/cypress/fixtures/tools/SimpleHeader.ts b/test/cypress/fixtures/tools/SimpleHeader.ts new file mode 100644 index 000000000..71696b706 --- /dev/null +++ b/test/cypress/fixtures/tools/SimpleHeader.ts @@ -0,0 +1,89 @@ +import { + BaseTool, + BlockToolConstructorOptions, + BlockToolData, + ConversionConfig +} from '../../../../types'; + +/** + * Simplified Header for testing + */ +export class SimpleHeader implements BaseTool { + private _data: BlockToolData; + private element: HTMLHeadingElement; + + /** + * + * @param options - constructor options + */ + constructor({ data }: BlockToolConstructorOptions) { + this._data = data; + } + + /** + * Return Tool's view + * + * @returns {HTMLHeadingElement} + * @public + */ + public render(): HTMLHeadingElement { + this.element = document.createElement('h1'); + + this.element.innerHTML = this._data.text; + + return this.element; + } + + /** + * @param data - saved data to merger with current block + */ + public merge(data: BlockToolData): void { + this.data = { + text: this.data.text + data.text, + level: this.data.level, + }; + } + + /** + * Extract Tool's data from the view + * + * @param toolsContent - Text tools rendered view + */ + public save(toolsContent: HTMLHeadingElement): BlockToolData { + return { + text: toolsContent.innerHTML, + level: 1, + }; + } + + /** + * Allow Header to be converted to/from other blocks + */ + public static get conversionConfig(): ConversionConfig { + return { + export: 'text', // use 'text' property for other blocks + import: 'text', // fill 'text' property from other block's export string + }; + } + + /** + * Data getter + */ + private get data(): BlockToolData { + this._data.text = this.element.innerHTML; + this._data.level = 1; + + return this._data; + } + + /** + * Data setter + */ + private set data(data: BlockToolData) { + this._data = data; + + if (data.text !== undefined) { + this.element.innerHTML = this._data.text || ''; + } + } +} diff --git a/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts b/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts index ac988a0b0..7deff30d2 100644 --- a/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts +++ b/test/cypress/tests/modules/BlockEvents/Backspace.cy.ts @@ -1,5 +1,6 @@ import type EditorJS from '../../../../../types/index'; import Chainable = Cypress.Chainable; +import { SimpleHeader } from '../../../fixtures/tools/SimpleHeader'; /** @@ -293,6 +294,72 @@ describe('Backspace keydown', function () { .should('not.have.class', 'ce-toolbar--opened'); }); + it('should merge different types of blocks if they valid have a conversion config. Also, should close the Toolbox. Caret should be places in a place of glue', function () { + cy.createEditor({ + tools: { + header: SimpleHeader, + }, + data: { + blocks: [ + { + id: 'block1', + type: 'header', + data: { + text: 'First block heading', + }, + }, + { + id: 'block2', + type: 'paragraph', + data: { + text: 'Second block paragraph', + }, + }, + ], + }, + }).as('editorInstance'); + + cy.get('[data-cy=editorjs]') + .find('.ce-paragraph') + .last() + .click() + .type('{home}') // move caret to the beginning + .type('{backspace}'); + + cy.get('@editorInstance') + .then(async (editor) => { + const { blocks } = await editor.save(); + + expect(blocks.length).to.eq(1); // one block has been removed + expect(blocks[0].id).to.eq('block1'); // second block is still here + expect(blocks[0].data.text).to.eq('First block headingSecond block paragraph'); // text has been merged + }); + + /** + * Caret is set to the place of merging + */ + cy.window() + .then((window) => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + cy.get('[data-cy=editorjs]') + .find('[data-cy=block-wrapper]') + .should(($block) => { + expect($block[0].contains(range.startContainer)).to.be.true; + range.startContainer.normalize(); // glue merged text nodes + expect(range.startOffset).to.be.eq('First block heading'.length); + }); + }); + + /** + * Toolbox has been closed + */ + cy.get('[data-cy=editorjs]') + .find('.ce-toolbar') + .should('not.have.class', 'ce-toolbar--opened'); + }); + it('should simply set Caret to the end of the previous Block if Caret at the start of the Block but Blocks are not mergeable. Also, should close the Toolbox.', function () { /** * Mock of tool without merge method