Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/core/src/api/BlocksAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,24 @@
public split({ block, key, offset, userId }: Parameters<BlocksApiInterface['split']>[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<BlocksApiInterface['convert']>[0]): void {
this.#blocksManager.convertBlock(block as BlockIndexOrId, createDataKey(key), newType, userId, dataOverrides);

Check warning on line 229 in packages/core/src/api/BlocksAPI.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}
}
168 changes: 168 additions & 0 deletions packages/core/src/components/BlockManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
59 changes: 59 additions & 0 deletions packages/core/src/components/BlockManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,65 @@
}, 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`);

Check warning on line 398 in packages/core/src/components/BlockManager.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 399 in packages/core/src/components/BlockManager.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
if (targetTool === undefined) {
throw new Error(`Cannot convert block: target tool "${newType}" is not registered`);

Check warning on line 401 in packages/core/src/components/BlockManager.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 402 in packages/core/src/components/BlockManager.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

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;
Comment on lines +418 to +423
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added doc for all parameter entry along all convertBlock() pipeline.


this.#model.removeBlock(userId, blockIndex);
this.#model.addBlock(userId, {
name: newType,
data: finalData,
}, blockIndex);
}

/**
* Returns block index where user caret is placed
*/
Expand Down
23 changes: 19 additions & 4 deletions packages/sdk/src/api/BlocksAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockAPI>;
// @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;
}
60 changes: 60 additions & 0 deletions packages/sdk/src/tools/facades/BaseToolFacade.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion packages/sdk/src/tools/facades/BlockToolFacade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -187,6 +187,29 @@ export class BlockToolFacade extends BaseToolFacade<ToolType.Block, BlockTool> {
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<TextNodeSerialized>(data, exportFnOrProp);

return node?.value ?? '';
}

/**
* Returns enabled inline tools for Tool
*/
Expand Down
Loading