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
17 changes: 17 additions & 0 deletions packages/core/src/api/BlocksAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,21 @@
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({

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

View workflow job for this annotation

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

🕹️ Function is not covered

Warning! Not covered function
block,
newType,
dataOverrides,
userId = this.#config.userId,

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

View workflow job for this annotation

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

🌿 Branch is not covered

Warning! Not covered branch
}: Parameters<BlocksApiInterface['convert']>[0]): void {
this.#blocksManager.convertBlock(block, newType, userId, dataOverrides);

Check warning on line 142 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
}
}
91 changes: 91 additions & 0 deletions packages/core/src/components/BlockManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,97 @@
});
});

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: [] } })) };

Check failure on line 344 in packages/core/src/components/BlockManager.spec.ts

View workflow job for this annotation

GitHub Actions / lint

Object properties must go on a new line

// @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: [] } })) };

Check failure on line 368 in packages/core/src/components/BlockManager.spec.ts

View workflow job for this annotation

GitHub Actions / lint

Object properties must go on a new line

// @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.
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/components/BlockManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,31 @@
}, 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;

Check failure on line 383 in packages/core/src/components/BlockManager.ts

View workflow job for this annotation

GitHub Actions / lint

Object properties must go on a new line

Check failure on line 383 in packages/core/src/components/BlockManager.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any value in conditional. An explicit comparison or type conversion is required

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

/**
* Returns block index where user caret is placed
*/
Expand Down
19 changes: 15 additions & 4 deletions packages/sdk/src/api/BlocksAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockAPI>;
// @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;
}
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