diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index b61d8893..8d09afce 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -208,4 +208,24 @@ export class BlocksAPI implements BlocksApiInterface { public split({ block, key, offset, userId }: Parameters[0]): void { this.#blocksManager.splitBlock(block as BlockIndexOrId, createDataKey(key), offset, userId); } + + /** + * Converts a block to a new type + * @param params - conversion parameters + * @param params.block - index or id of the block to convert + * @param params.key - data key of the text input at which to convert + * @param params.newType - block tool name to convert to + * @param [params.dataOverrides] - optional data overrides for the new block. Merged shallowly: + * object-valued keys are replaced wholesale, not deep-merged. + * @param [params.userId] - user id to attribute the change to + */ + public convert({ + block, + key, + newType, + dataOverrides, + userId = this.#config.userId, + }: Parameters[0]): void { + this.#blocksManager.convertBlock(block as BlockIndexOrId, createDataKey(key), newType, userId, dataOverrides); + } } diff --git a/packages/core/src/components/BlockManager.spec.ts b/packages/core/src/components/BlockManager.spec.ts index 8102c397..6f7ebaaa 100644 --- a/packages/core/src/components/BlockManager.spec.ts +++ b/packages/core/src/components/BlockManager.spec.ts @@ -680,4 +680,172 @@ 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: [] + } + } + })); + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello', + fragments: [] + } + })); + }); + + it('should export 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(0, 'text' as DataKey, '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('should merge 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(0, 'text' as DataKey, 'paragraph', USER_ID, { level: 2 }); + + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + { + name: 'paragraph', + data: { + text: { + value: 'Hello', + fragments: [] + }, + level: 2 + } + }, + 0 + ); + }); + + it('should throw if dataKey is not found in block text content', () => { + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ + exportTextContent: jest.fn(() => 'Hello'), + importTextContent: jest.fn() + })); + + expect(() => blocksManager.convertBlock(0, 'nonexistent' as DataKey, 'paragraph')) + .toThrow('Data key "nonexistent" not found in block content'); + }); + + it('should pass fragments from getBlockTextContent to importTextContent', () => { + const fragments = [{ + type: 'bold', + range: [0, 5] + }]; + + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello', + fragments + } + })); + + const sourceTool = { exportTextContent: jest.fn(() => 'Hello') }; + const targetTool = { importTextContent: jest.fn(() => ({})) }; + + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'header' ? sourceTool : targetTool + ); + + blocksManager.convertBlock(0, 'text' as DataKey, 'paragraph'); + + expect(targetTool.importTextContent).toHaveBeenCalledWith('Hello', fragments); + }); + + it('should throw 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(0, 'text' as DataKey, 'paragraph')) + .toThrow('does not have export configuration'); + }); + + it('should throw 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(0, 'text' as DataKey, 'paragraph')) + .toThrow('does not have import configuration'); + }); + }); }); diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 3e5abbde..df3e68db 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -370,6 +370,65 @@ 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 blockIndexOrId - numeric position or named identifier that locates the block + * @param dataKey - the data key at which the conversion is performed + * @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. Merged shallowly: + * object-valued keys are replaced wholesale, not deep-merged. + */ + public convertBlock( + blockIndexOrId: number | BlockId, + dataKey: DataKey, + newType: string, + userId: string | number = this.#config.userId, + dataOverrides?: BlockToolData + ): void { + const blockIndex = this.#model.resolveBlockIndex(blockIndexOrId); + + const block = this.#model.getBlockSerialized(blockIndex); + + const sourceTool = this.#toolsManager.blockTools.get(block.name); + const targetTool = this.#toolsManager.blockTools.get(newType); + + if (sourceTool === undefined) { + throw new Error(`Cannot convert block: source tool "${block.name}" is not registered`); + } + if (targetTool === undefined) { + throw new Error(`Cannot convert block: target tool "${newType}" is not registered`); + } + + const text = sourceTool.exportTextContent(block.data); + + const blockInputs = Object.entries( + this.#model.getBlockTextContent(blockIndex) + ); + const convertIndex = blockInputs.findIndex(([key]) => key === dataKey); + + if (convertIndex === -1) { + throw new Error(`Data key "${dataKey}" not found in block content`); + } + + const [, convertInput] = blockInputs[convertIndex]; + + const newData = targetTool.importTextContent(text, convertInput.fragments); + const finalData = dataOverrides !== undefined + ? { + ...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 565498e5..932125d3 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -249,10 +249,25 @@ 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.key - data key of the text input at which to split + * @param params.newType - new block type. Should provide 'conversionConfig.import' method + * @param [params.dataOverrides] - optional data overrides for the new block. Merged shallowly: + * object-valued keys are replaced wholesale, not deep-merged. + * @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; + /** Data key of the text input to split */ + key: string; + /** Block tool name to convert to */ + newType: string; + /** Optional data overrides for the new block. Merged shallowly: object-valued keys are replaced wholesale, not deep-merged. */ + 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 */