From 7901a5e472a51b33d42a5453ab94a90a1e7623e6 Mon Sep 17 00:00:00 2001 From: Reversean Date: Sun, 24 May 2026 15:22:47 +0300 Subject: [PATCH] feat: implement block conversion (export side) Add exportTextContent() to BlockToolFacade: supports function, string keypath, and dot-notation keypath via conversionConfig.export. Add convertBlock() to BlocksManager: exports text from the source block, imports it into the target type, and replaces the block at the same index. --- packages/core/src/api/BlocksAPI.ts | 17 ++++ .../core/src/components/BlockManager.spec.ts | 93 +++++++++++++++++++ packages/core/src/components/BlockManager.ts | 28 ++++++ packages/sdk/src/api/BlocksAPI.ts | 19 +++- .../src/tools/facades/BaseToolFacade.spec.ts | 60 ++++++++++++ .../sdk/src/tools/facades/BlockToolFacade.ts | 25 ++++- 6 files changed, 237 insertions(+), 5 deletions(-) diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index 83e1d012..16aeda27 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -124,4 +124,21 @@ export class BlocksAPI implements BlocksApiInterface { public split(blockIndexOrId: number | string, dataKey: string, offset: number): void { this.#blocksManager.splitBlock(blockIndexOrId as number | BlockId, dataKey as DataKey, offset); } + + /** + * Converts a block to a new type + * @param params - conversion parameters + * @param params.block - index or id of the block to convert + * @param params.newType - block tool name to convert to + * @param [params.dataOverrides] - optional data overrides for the new block + * @param [params.userId] - user id to attribute the change to + */ + public convert({ + block, + newType, + dataOverrides, + userId = this.#config.userId, + }: Parameters[0]): void { + this.#blocksManager.convertBlock(block, newType, userId, dataOverrides); + } } diff --git a/packages/core/src/components/BlockManager.spec.ts b/packages/core/src/components/BlockManager.spec.ts index 4c3a1b94..511cdd87 100644 --- a/packages/core/src/components/BlockManager.spec.ts +++ b/packages/core/src/components/BlockManager.spec.ts @@ -328,6 +328,99 @@ describe('BlocksManager (unit, mocked deps)', () => { }); }); + describe('.convertBlock()', () => { + beforeEach(() => { + model.resolveBlockIndex = jest.fn(() => 0); + model.getBlockSerialized = jest.fn(() => ({ + name: 'header', + id: 'b1', + data: { text: { value: 'Hello', + fragments: [] } }, + })); + }); + + it('exports text from source, imports into target, and replaces block at the same index', () => { + const sourceTool = { exportTextContent: jest.fn(() => 'Hello') }; + const targetTool = { importTextContent: jest.fn(() => ({ text: { value: 'Hello', + fragments: [] } })) }; + + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'header' ? sourceTool : targetTool + ); + + blocksManager.convertBlock('b1', 'paragraph'); + + expect(sourceTool.exportTextContent).toHaveBeenCalledWith({ text: { value: 'Hello', + fragments: [] } }); + expect(targetTool.importTextContent).toHaveBeenCalledWith('Hello', []); + expect(model.removeBlock).toHaveBeenCalledWith(USER_ID, 0); + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + { name: 'paragraph', + data: { text: { value: 'Hello', + fragments: [] } } }, + 0 + ); + }); + + it('merges dataOverrides on top of the imported data', () => { + const sourceTool = { exportTextContent: jest.fn(() => 'Hello') }; + const targetTool = { importTextContent: jest.fn(() => ({ text: { value: 'Hello', + fragments: [] } })) }; + + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'header' ? sourceTool : targetTool + ); + + blocksManager.convertBlock('b1', 'paragraph', USER_ID, { level: 2 }); + + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + { name: 'paragraph', + data: { text: { value: 'Hello', + fragments: [] }, + level: 2 } }, + 0 + ); + }); + + it('throws if source tool has no export config', () => { + const sourceTool = { + exportTextContent: jest.fn(() => { + throw new Error('Tool header does not have export configuration for text content'); + }), + }; + const targetTool = { importTextContent: jest.fn() }; + + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'header' ? sourceTool : targetTool + ); + + expect(() => blocksManager.convertBlock('b1', 'paragraph')) + .toThrow('does not have export configuration'); + }); + + it('throws if target tool has no import config', () => { + const sourceTool = { exportTextContent: jest.fn(() => 'Hello') }; + const targetTool = { + importTextContent: jest.fn(() => { + throw new Error('Tool paragraph does not have import configuration for text content'); + }), + }; + + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'header' ? sourceTool : targetTool + ); + + expect(() => blocksManager.convertBlock('b1', 'paragraph')) + .toThrow('does not have import configuration'); + }); + }); + describe('.splitBlock()', () => { /** * Restore split-specific mock implementations that jest.resetAllMocks() clears. diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 36979710..055ac1eb 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -362,6 +362,34 @@ export class BlocksManager { }, blockIndex + 1); } + /** + * Converts a block to a new type by exporting its text content and importing it into the new tool. + * Both the source and target tools must define conversionConfig. + * @param blockId - id or index of the block to convert + * @param newType - block tool name to convert to + * @param [userId] - user id to attribute the change to + * @param [dataOverrides] - optional data fields to merge on top of the converted data + */ + public convertBlock(blockId: string | number, newType: string, userId: string | number = this.#config.userId, dataOverrides?: BlockToolData): void { + const blockIndex = this.#model.resolveBlockIndex(blockId as BlockId); + + const block = this.#model.getBlockSerialized(blockIndex); + + const sourceTool = this.#toolsManager.blockTools.get(block.name)!; + const targetTool = this.#toolsManager.blockTools.get(newType)!; + + const text = sourceTool.exportTextContent(block.data); + const newData = targetTool.importTextContent(text, []); + const finalData = dataOverrides + ? { ...newData, + ...dataOverrides } + : newData; + + this.#model.removeBlock(userId, blockIndex); + this.#model.addBlock(userId, { name: newType, + data: finalData }, blockIndex); + } + /** * Returns block index where user caret is placed */ diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index f5adf9fd..587dcc0a 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -126,10 +126,21 @@ export interface BlocksAPI { /** * Converts block to another type. Both blocks should provide the conversionConfig. - * @param id - id of the existed block to convert. Should provide 'conversionConfig.export' method - * @param newType - new block type. Should provide 'conversionConfig.import' method - * @param dataOverrides - optional data overrides for the new block + * @param params.block - index or id of the block to convert. Should provide 'conversionConfig.export' method + * @param params.newType - new block type. Should provide 'conversionConfig.import' method + * @param [params.dataOverrides] - optional data overrides for the new block + * @param [params.userId] - user id to attribute the change to * @throws Error if conversion is not possible */ - // convert(id: string, newType: string, dataOverrides?: BlockToolData): Promise; + // @todo return BlockAPI when it is implemented + convert(params: { + /** Index or id of the block to convert */ + block: number | string; + /** Block tool name to convert to */ + newType: string; + /** Optional data overrides for the new block */ + dataOverrides?: BlockToolData; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; + }): void; } diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts index 76d4207c..2994ef1c 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts @@ -165,6 +165,66 @@ describe('BaseToolFacade (via BlockToolFacade)', () => { }); }); + describe('exportTextContent', () => { + it('throws when the tool has no conversionConfig.export', () => { + const facade = createBlockFacade({}, {} as ToolOptions); + + expect(() => facade.exportTextContent({})).toThrow( + /does not have export configuration/ + ); + }); + + it('calls the export function when conversionConfig.export is a function', () => { + const exportFn = (data: BlockToolData): string => (data.text as { value: string }).value; + const facade = createBlockFacade( + { [BlockToolOptionKey.ConversionConfig]: { export: exportFn } }, + {} as ToolOptions + ); + + const result = facade.exportTextContent({ text: { value: 'hello' } }); + + expect(result).toBe('hello'); + }); + + it('reads a top-level string key from data', () => { + const facade = createBlockFacade( + { [BlockToolOptionKey.ConversionConfig]: { export: 'text' } }, + {} as ToolOptions + ); + + const result = facade.exportTextContent({ + text: { + value: 'hello', + fragments: [], + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + }, + }); + + expect(result).toBe('hello'); + }); + + it('reads a dot-notation keypath from data', () => { + const facade = createBlockFacade( + { [BlockToolOptionKey.ConversionConfig]: { export: 'items.0.text' } }, + {} as ToolOptions + ); + + const result = facade.exportTextContent({ + items: [ + { + text: { + value: 'hello', + fragments: [], + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + }, + }, + ], + }); + + expect(result).toBe('hello'); + }); + }); + describe('importTextContent', () => { it('throws when the tool has no conversionConfig.import', () => { const facade = createBlockFacade({}, {} as ToolOptions); diff --git a/packages/sdk/src/tools/facades/BlockToolFacade.ts b/packages/sdk/src/tools/facades/BlockToolFacade.ts index 34a34295..879f5c64 100644 --- a/packages/sdk/src/tools/facades/BlockToolFacade.ts +++ b/packages/sdk/src/tools/facades/BlockToolFacade.ts @@ -19,7 +19,7 @@ import { ToolsCollection } from '../ToolsCollection.js'; import type { BlockToolConstructor, BlockToolConstructorOptions, BlockTool, BlockToolData } from '../../entities'; import { ToolType } from '../../entities'; import { BlockChildType, NODE_TYPE_HIDDEN_PROP, keypath } from '@editorjs/model'; -import type { InlineFragment } from '@editorjs/model'; +import type { InlineFragment, TextNodeSerialized } from '@editorjs/model'; /** * Class to work with Block tools constructables @@ -187,6 +187,29 @@ export class BlockToolFacade extends BaseToolFacade { return result; } + /** + * Returns block data serialized to a plain-text string using the tool's conversion config export function. + * If the export config is a function, it is called with the block data. + * If the export config is a string keypath, the value at that path is read from the data. + * @param data - serialized block data to convert to plain text + */ + public exportTextContent(data: BlockToolData): string { + const conversionConfig = this.options[BlockToolOptionKey.ConversionConfig]; + const exportFnOrProp = conversionConfig?.export; + + if (exportFnOrProp === undefined) { + throw new Error(`Tool ${this.name} does not have export configuration for text content`); + } + + if (typeof exportFnOrProp === 'function') { + return exportFnOrProp(data); + } + + const node = keypath.get(data, exportFnOrProp); + + return node?.value ?? ''; + } + /** * Returns enabled inline tools for Tool */