diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index 82619fb6..3d91cbd4 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -3,6 +3,9 @@ import { jest, beforeEach, afterEach, describe, it, expect } from '@jest/globals import type { CoreConfigValidated } from '@editorjs/sdk'; // @ts-expect-error - TS don't import types via import() so have to import them here as well import type { BlocksManager } from '../components/BlockManager'; +// @ts-expect-error - TS don't import types via import() so have to import them here as well +import type ToolsManager from '../tools/ToolsManager'; +import type { TextNodeSerialized } from '@editorjs/model'; const USER_ID = 'integration-user'; const DOCUMENT_ID = 'integration-doc'; @@ -344,6 +347,671 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); }); + describe('split()', () => { + /** + * Helper to override the blockTools.get mock on the ToolsManager instance. + * @param getImpl - replacement function for blockTools.get + */ + function overrideBlockToolsGet(getImpl: (name: string) => unknown): void { + const results = jest.mocked(ToolsManager).mock.results; + /** + * Mock might be created several times, so we need the latest + */ + const currentToolsManager = results[results.length - 1].value as ToolsManager; + + // @ts-expect-error - override mock implementation + currentToolsManager.blockTools.get = jest.fn(getImpl); + } + + it('should split a block when the tool has canSplit = true', () => { + model.addBlock(USER_ID, { + name: 'paragraph', + data: { + text: { + $t: 't', + value: 'Hello World', + fragments: [], + }, + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'paragraph', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'text', 5); + + // Original block remains, a new block is appended after it + expect(model.length).toBe(2); + }); + + it('should split at the end of a block and insert an empty block when canSplit = true', () => { + model.addBlock(USER_ID, { + name: 'paragraph', + data: { + text: { + $t: 't', + value: 'Hello', + fragments: [], + }, + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'paragraph', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'text', 5); + + expect(model.length).toBe(2); + }); + + it('should use default block and importTextContent when the tool has canSplit = false', () => { + model.addBlock(USER_ID, { + name: 'header', + data: { + text: { + $t: 't', + value: 'Hello World', + fragments: [], + }, + }, + }, 0); + + const paragraphTool = { + name: 'paragraph', + options: { canSplit: true }, + importTextContent: jest.fn((_value: string, fragments: unknown[]) => ({ + text: { + $t: 't', + value: _value, + fragments, + }, + })), + create: jest.fn(), + }; + + overrideBlockToolsGet((name: string) => + name === 'header' + ? { + name: 'header', + options: { canSplit: false }, + importTextContent: jest.fn(), + create: jest.fn(), + } + : paragraphTool + ); + + blocksAPI.split(0, 'text', 5); + + expect(model.length).toBe(2); + expect(model.serialized.blocks[1]).toEqual( + expect.objectContaining({ name: 'paragraph' }) + ); + }); + + it('should throw when the data key is not found in the block', () => { + model.addBlock(USER_ID, { + name: 'paragraph', + data: { + text: { + $t: 't', + value: 'Hello', + fragments: [], + }, + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'paragraph', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + expect(() => blocksAPI.split(0, 'nonexistent', 0)).toThrow( + 'Data key "nonexistent" not found in block content' + ); + }); + + it('canSplit = true: block with two flat inputs — should split first input and move second to new block', () => { + model.addBlock(USER_ID, { + name: 'quote', + data: { + text: { + $t: 't', + value: 'Hello World', + fragments: [], + }, + caption: { + $t: 't', + value: 'Author Name', + fragments: [], + }, + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'quote', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'text', 5); // split 'Hello World' at index 5 → 'Hello' | ' World' + + expect(model.length).toBe(2); + + const originalBlock = model.serialized.blocks[0]; + const newBlock = model.serialized.blocks[1]; + + // Original block: text truncated; caption removed + expect(originalBlock.data.text).toEqual( + expect.objectContaining({ value: 'Hello' }) + ); + expect(originalBlock.data.caption).toBeUndefined(); + + // New block carries remainder of text and the full caption + expect(newBlock.name).toBe('quote'); + expect(newBlock.data.text).toEqual( + expect.objectContaining({ value: ' World' }) + ); + expect(newBlock.data.caption).toEqual( + expect.objectContaining({ value: 'Author Name' }) + ); + }); + + it('canSplit = true: block with two flat inputs — split at exact end of first input creates new block with only second input', () => { + model.addBlock(USER_ID, { + name: 'quote', + data: { + text: { + $t: 't', + value: 'Hello', + fragments: [], + }, + caption: { + $t: 't', + value: 'Author', + fragments: [], + }, + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'quote', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'text', 5); // offset === 'Hello'.length + + expect(model.length).toBe(2); + + const originalBlock = model.serialized.blocks[0]; + const newBlock = model.serialized.blocks[1]; + + // Original retains the complete first input; caption is removed + expect(originalBlock.data.text).toEqual( + expect.objectContaining({ value: 'Hello' }) + ); + expect(originalBlock.data.caption).toBeUndefined(); + + // New block: empty text continuation + full caption + expect(newBlock.data.text).toEqual( + expect.objectContaining({ value: '' }) + ); + expect(newBlock.data.caption).toEqual( + expect.objectContaining({ value: 'Author' }) + ); + }); + + it('canSplit = true: block with two flat inputs — split at second (last) input only carries truncation to new block', () => { + model.addBlock(USER_ID, { + name: 'quote', + data: { + text: { + $t: 't', + value: 'Hello', + fragments: [], + }, + caption: { + $t: 't', + value: 'Caption Text', + fragments: [], + }, + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'quote', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'caption', 7); // split 'Caption Text' → 'Caption' | ' Text' + + expect(model.length).toBe(2); + + const originalBlock = model.serialized.blocks[0]; + const newBlock = model.serialized.blocks[1]; + + // Original: first input untouched, caption truncated to 'Caption' + expect(originalBlock.data.text).toEqual( + expect.objectContaining({ value: 'Hello' }) + ); + expect(originalBlock.data.caption).toEqual( + expect.objectContaining({ value: 'Caption' }) + ); + + // New block: only the caption continuation (no text input since it was before the split) + expect(newBlock.data.caption).toEqual( + expect.objectContaining({ value: ' Text' }) + ); + }); + + it('canSplit = true: block with two flat inputs — split at end of last input inserts empty same-type block', () => { + model.addBlock(USER_ID, { + name: 'quote', + data: { + text: { + $t: 't', + value: 'Hello', + fragments: [], + }, + caption: { + $t: 't', + value: 'Caption', + fragments: [], + }, + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'quote', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'caption', 7); // 7 === 'Caption'.length — end of last input + + // Only triggers the "end of last input" early-return when splitIndex === last && offset === length. + // 'caption' is the second of two inputs (index 1), length is 7 — should add an empty block. + expect(model.length).toBe(2); + expect(model.serialized.blocks[1].name).toBe('quote'); + }); + + it('canSplit = false: block with two flat inputs — default block receives merged text content', () => { + model.addBlock(USER_ID, { + name: 'header', + data: { + text: { + $t: 't', + value: 'Hello World', + fragments: [], + }, + subtitle: { + $t: 't', + value: 'Subtitle', + fragments: [], + }, + }, + }, 0); + + const paragraphTool = { + name: 'paragraph', + options: { canSplit: true }, + // importTextContent receives the merged text and returns block data + importTextContent: jest.fn((value: string) => ({ + text: { + $t: 't', + value, + fragments: [], + }, + })), + create: jest.fn(), + }; + + overrideBlockToolsGet((name: string) => + name === 'header' + ? { + name: 'header', + options: { canSplit: false }, + importTextContent: jest.fn(), + create: jest.fn(), + } + : paragraphTool + ); + + blocksAPI.split(0, 'text', 5); // split 'Hello World' → 'Hello' | ' World' + + expect(model.length).toBe(2); + + const newBlock = model.serialized.blocks[1]; + + expect(newBlock.name).toBe('paragraph'); + // The merged text = ' World\n' + 'Subtitle\n' (real mergeTextNodes implementation) + expect(newBlock.data.text).toEqual( + expect.objectContaining({ value: expect.stringContaining(' World') }) + ); + }); + }); + + describe('split() — block data with an array field', () => { + /** + * Helper to override the blockTools.get mock on the ToolsManager instance. + * @param getImpl - replacement function for blockTools.get + */ + function overrideBlockToolsGet(getImpl: (name: string) => unknown): void { + const results = jest.mocked(ToolsManager).mock.results; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const currentToolsManager = results[results.length - 1].value as any; + + (currentToolsManager.blockTools as Record).get = jest.fn(getImpl); + } + + it('canSplit = true: single array item — splits text correctly across two blocks', () => { + // Block has one list item whose text we want to split + model.addBlock(USER_ID, { + name: 'list', + data: { + items: [ + { + text: { + $t: 't', + value: 'Hello World', + fragments: [], + }, + }, + ], + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'list', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'items.0.text', 5); // 'Hello World' → 'Hello' | ' World' + + expect(model.length).toBe(2); + + // Original block: first item text truncated to 'Hello' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalItems = model.serialized.blocks[0].data.items as any[]; + + expect(originalItems[0].text).toEqual( + expect.objectContaining({ value: 'Hello' }) + ); + + // New block: first item text has the remaining ' World' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newItems = model.serialized.blocks[1].data.items as any[]; + + expect(newItems[0].text).toEqual( + expect.objectContaining({ value: ' World' }) + ); + }); + + it('canSplit = true: single array item — split at end of text inserts empty same-type block', () => { + model.addBlock(USER_ID, { + name: 'list', + data: { + items: [ + { + text: { + $t: 't', + value: 'Hello', + fragments: [], + }, + }, + ], + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'list', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'items.0.text', 5); // offset === 'Hello'.length → early-return path + + expect(model.length).toBe(2); + expect(model.serialized.blocks[1].name).toBe('list'); + // The new block was created with empty data (early-return branch) + expect(model.serialized.blocks[1].data).toEqual({}); + }); + + it('canSplit = true: single array item — split at beginning creates original unchanged and new block with full text', () => { + model.addBlock(USER_ID, { + name: 'list', + data: { + items: [ + { + text: { + $t: 't', + value: 'Hello World', + fragments: [], + }, + }, + ], + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'list', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'items.0.text', 0); // split at very beginning + + expect(model.length).toBe(2); + + // Original: removeText(userId, 0, 'items.0.text', 0) removes everything → empty + expect(model.serialized.blocks[0].name).toEqual('list'); + + // New block: full original text + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((model.serialized.blocks[1].data.items as any[])[0].text).toEqual( + expect.objectContaining({ value: 'Hello World' }) + ); + }); + + it('canSplit = false: two array items — new default block receives merged text from split point onwards', () => { + // A non-splittable tool (e.g. a heading-like list) whose items text gets + // merged into the default paragraph block. + model.addBlock(USER_ID, { + name: 'fancy-list', + data: { + items: [ + { + text: { + $t: 't', + value: 'Hello World', + fragments: [], + }, + }, + { + text: { + $t: 't', + value: 'Second Item', + fragments: [], + }, + }, + ], + }, + }, 0); + + const paragraphTool = { + name: 'paragraph', + options: { canSplit: true }, + importTextContent: jest.fn((value: string) => ({ + text: { + $t: 't', + value, + fragments: [], + }, + })), + create: jest.fn(), + }; + + overrideBlockToolsGet((name: string) => + name === 'fancy-list' + ? { + name: 'fancy-list', + options: { canSplit: false }, + importTextContent: jest.fn(), + create: jest.fn(), + } + : paragraphTool + ); + + // Split 'Hello World' at offset 5 → textAfter = ' World' + // mergeTextNodes merges (' World\n', 'Second Item\n') → ' World\nSecond Item\n' + blocksAPI.split(0, 'items.0.text', 5); + + expect(model.length).toBe(2); + + const newBlock = model.serialized.blocks[1]; + + expect(newBlock.name).toBe('paragraph'); + // importTextContent was called with the merged string ' World\nSecond Item\n' + expect(paragraphTool.importTextContent).toHaveBeenCalledWith( + expect.stringContaining(' World'), + expect.any(Array) + ); + expect(newBlock.data.text).toEqual( + expect.objectContaining({ value: expect.stringContaining(' World') }) + ); + }); + + it('canSplit = false: two array items — split at end of first item merges only second item text', () => { + model.addBlock(USER_ID, { + name: 'fancy-list', + data: { + items: [ + { + text: { + $t: 't', + value: 'Hello', + fragments: [], + }, + }, + { + text: { + $t: 't', + value: 'Second Item', + fragments: [], + }, + }, + ], + }, + }, 0); + + const paragraphTool = { + name: 'paragraph', + options: { canSplit: true }, + importTextContent: jest.fn((value: string) => ({ + text: { + $t: 't', + value, + fragments: [], + }, + })), + create: jest.fn(), + }; + + overrideBlockToolsGet((name: string) => + name === 'fancy-list' + ? { + name: 'fancy-list', + options: { canSplit: false }, + importTextContent: jest.fn(), + create: jest.fn(), + } + : paragraphTool + ); + + // Split at end of first item (offset === 'Hello'.length = 5) + // textAfter = '' (empty string), mergeTextNodes merges ('\n', 'Second Item\n') + blocksAPI.split(0, 'items.0.text', 5); + + expect(model.length).toBe(2); + expect(model.serialized.blocks[1].name).toBe('paragraph'); + // The merged text should include 'Second Item' from the second array element + expect(paragraphTool.importTextContent).toHaveBeenCalledWith( + expect.stringContaining('Second Item'), + expect.any(Array) + ); + }); + + it('canSplit = true: two array items, split at first — documents current behavior (split text overwritten by renumbering)', () => { + model.addBlock(USER_ID, { + name: 'list', + data: { + items: [ + { + text: { + $t: 't', + value: 'Hello World', // split here at offset 5 + fragments: [], + }, + }, + { + text: { + $t: 't', + value: 'Second Item', + fragments: [], + }, + }, + ], + }, + }, 0); + + overrideBlockToolsGet(() => ({ + name: 'list', + options: { canSplit: true }, + importTextContent: jest.fn(), + create: jest.fn(), + })); + + blocksAPI.split(0, 'items.0.text', 5); + + // A second block IS created even though the data distribution is imperfect. + expect(model.length).toBe(2); + + // The original block retains items[0] truncated to 'Hello'. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((model.serialized.blocks[0].data.items as any[])[0].text).toEqual( + expect.objectContaining({ value: 'Hello' }) + ); + + expect(model.serialized.blocks[1].data.items as TextNodeSerialized[]).toEqual([ + { text: { value: ' World', + fragments: [], + $t: 't' } }, + { text: { value: 'Second Item', + fragments: [], + $t: 't' } }, + ]); + }); + }); + // ---------- combined operations ---------- describe('combined operations', () => { diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index a29055e1..83e1d012 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -5,7 +5,7 @@ import { BlocksManager } from '../components/BlockManager.js'; import { BlockToolData } from '@editorjs/editorjs'; import { CoreConfigValidated } from '@editorjs/sdk'; import { BlocksAPI as BlocksApiInterface } from '@editorjs/sdk'; -import { type BlockNodeInit, type EditorDocumentSerialized } from '@editorjs/model'; +import { BlockId, type BlockNodeInit, DataKey, type EditorDocumentSerialized } from '@editorjs/model'; /** * Blocks API @@ -113,4 +113,15 @@ export class BlocksAPI implements BlocksApiInterface { focus, }); }; + + /** + * Splits the block at the given data key and character offset. + * Delegates to BlocksManager.splitBlock. + * @param blockIndexOrId - numeric index or block id that locates the block + * @param dataKey - data key of the text input at which to split + * @param offset - character offset within the text value to split at + */ + public split(blockIndexOrId: number | string, dataKey: string, offset: number): void { + this.#blocksManager.splitBlock(blockIndexOrId as number | BlockId, dataKey as DataKey, offset); + } } diff --git a/packages/core/src/components/BlockManager.spec.ts b/packages/core/src/components/BlockManager.spec.ts index 36142393..4c3a1b94 100644 --- a/packages/core/src/components/BlockManager.spec.ts +++ b/packages/core/src/components/BlockManager.spec.ts @@ -1,6 +1,7 @@ -/* eslint-disable @stylistic/comma-dangle,@typescript-eslint/naming-convention */ +/* eslint-disable @stylistic/comma-dangle,@typescript-eslint/naming-convention,@typescript-eslint/no-magic-numbers */ import { beforeEach, jest } from '@jest/globals'; import type { CoreConfigValidated } from '@editorjs/sdk'; +import type { DataKey } from '@editorjs/model'; const BLOCKS_COUNT = 7; const USER_ID = 'user'; @@ -20,6 +21,10 @@ jest.unstable_mockModule('@editorjs/model', () => { clearBlocks: jest.fn(), getCaret: jest.fn(), getBlockSerialized: jest.fn(), + resolveBlockIndex: jest.fn((i: number) => i), + getBlockTextContent: jest.fn(), + removeText: jest.fn(), + removeDataNode: jest.fn(), get length() { return BLOCKS_COUNT; }, @@ -29,10 +34,27 @@ jest.unstable_mockModule('@editorjs/model', () => { const EventType = { Changed: 'changed' }; + const keypath = { + set: jest.fn(), + get: jest.fn(), + has: jest.fn(), + renumberKeys: jest.fn(() => new Map()), + }; + + const sliceFragments = jest.fn((frags: unknown[]) => frags); + const mergeTextNodes = jest.fn((_entries: unknown[], initial: unknown) => initial); + const NODE_TYPE_HIDDEN_PROP = '$t'; + const BlockChildType = { Text: 't' }; + return { EditorJSModel, EventBus, EventType, + keypath, + sliceFragments, + mergeTextNodes, + NODE_TYPE_HIDDEN_PROP, + BlockChildType, }; }); @@ -45,7 +67,7 @@ jest.unstable_mockModule('../tools/ToolsManager', () => ({ })); // Now import the modules (they will receive the mocks registered above) -const { EditorJSModel, EventBus } = await import('@editorjs/model'); +const { EditorJSModel, EventBus, keypath, mergeTextNodes, sliceFragments } = await import('@editorjs/model'); const ToolsManager = (await import('../tools/ToolsManager')).default; const { BlocksManager } = await import('./BlockManager.js'); @@ -305,4 +327,357 @@ describe('BlocksManager (unit, mocked deps)', () => { expect(model.addBlock).not.toHaveBeenCalled(); }); }); + + describe('.splitBlock()', () => { + /** + * Restore split-specific mock implementations that jest.resetAllMocks() clears. + */ + beforeEach(() => { + // @ts-expect-error — jest mock + keypath.renumberKeys.mockReturnValue(new Map()); + // @ts-expect-error — jest mock + keypath.set.mockImplementation(() => undefined); + // @ts-expect-error — jest mock + mergeTextNodes.mockImplementation((_entries: unknown[], init: unknown) => init); + // @ts-expect-error — jest mock + sliceFragments.mockImplementation((frags: unknown[]) => frags); + + model.resolveBlockIndex = jest.fn((i: number | string) => +i); + model.getBlockSerialized = jest.fn(() => ({ + name: 'paragraph', + id: 'b1', + data: {} + })); + model.getBlockTextContent = jest.fn(() => ({})); + }); + + it('should throw when the data key is not found in block text content', () => { + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + expect(() => blocksManager.splitBlock(0, 'nonexistent' as DataKey, 0)) + .toThrow('Data key "nonexistent" not found in block content'); + }); + + it('should throw a RangeError when offset is negative', () => { + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + expect(() => blocksManager.splitBlock(0, 'text' as DataKey, -1)) + .toThrow(RangeError); + expect(() => blocksManager.splitBlock(0, 'text' as DataKey, -1)) + .toThrow('Offset -1 is out of range for input "text" with length 5'); + }); + + it('should throw a RangeError when offset exceeds the input text length', () => { + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + expect(() => blocksManager.splitBlock(0, 'text' as DataKey, 6)) + .toThrow(RangeError); + expect(() => blocksManager.splitBlock(0, 'text' as DataKey, 6)) + .toThrow('Offset 6 is out of range for input "text" with length 5'); + }); + + it('canSplit = true: should call removeText with the given offset when splitting in the middle', () => { + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello World', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + blocksManager.splitBlock(0, 'text' as DataKey, 5); + + expect(model.removeText).toHaveBeenCalledWith(USER_ID, 0, 'text', 5); + }); + + it('canSplit = true: should NOT call removeText when offset equals input length', () => { + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + blocksManager.splitBlock(0, 'text' as DataKey, 5); // 5 === 'Hello'.length + + expect(model.removeText).not.toHaveBeenCalled(); + }); + + it('canSplit = true: should call addBlock with the same tool name after splitting', () => { + model.getBlockSerialized = jest.fn(() => ({ + name: 'header', + id: 'b1', + data: {} + })); + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello World', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + blocksManager.splitBlock(0, 'text' as DataKey, 5); + + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + expect.objectContaining({ name: 'header' }), + 1 + ); + }); + + it('canSplit = true: should insert empty same-type block when splitting at end of the only input', () => { + model.getBlockSerialized = jest.fn(() => ({ + name: 'customBlock', + id: 'b1', + data: {} + })); + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'All text', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + blocksManager.splitBlock(0, 'text' as DataKey, 8); // 8 === 'All text'.length + + expect(model.removeText).not.toHaveBeenCalled(); + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + { + name: 'customBlock', + data: {} + }, + 1 + ); + }); + + it('canSplit = true: multiple flat inputs — should call removeDataNode for inputs after the split point', () => { + model.getBlockTextContent = jest.fn(() => ({ + title: { + value: 'Hello', + fragments: [], + }, + caption: { + value: 'World', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + blocksManager.splitBlock(0, 'title' as DataKey, 3); + + expect(model.removeDataNode).toHaveBeenCalledWith(USER_ID, 0, 'caption'); + }); + + it('canSplit = true: multiple flat inputs — should set both the split text and subsequent inputs in the new block data', () => { + const captionContent = { + value: 'Caption', + fragments: [], + }; + + model.getBlockTextContent = jest.fn(() => ({ + title: { + value: 'Hello', + fragments: [], + }, + caption: captionContent, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + // @ts-expect-error — jest mock + keypath.renumberKeys.mockReturnValue(new Map([['caption', 'caption']])); + + blocksManager.splitBlock(0, 'title' as DataKey, 3); + + // keypath.set should be called for the split input and for each entry after + expect(keypath.set).toHaveBeenCalledTimes(2); + expect(keypath.set).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + 'title', + expect.objectContaining({ value: 'lo' }) // 'Hello'.slice(3) + ); + expect(keypath.set).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + 'caption', + captionContent + ); + }); + + it('canSplit = true: array-indexed inputs — should call renumberKeys with the keys of entries after the split point', () => { + model.getBlockTextContent = jest.fn(() => ({ + 'items.0.text': { + value: 'Item 0', + fragments: [], + }, + 'items.1.text': { + value: 'Item 1', + fragments: [], + }, + 'items.2.text': { + value: 'Item 2', + fragments: [], + }, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + blocksManager.splitBlock(0, 'items.0.text' as DataKey, 3); + + expect(keypath.renumberKeys).toHaveBeenCalledWith(['items.0.text', 'items.1.text', 'items.2.text']); + }); + + it('canSplit = true: array-indexed inputs — should use renumbered keys when setting subsequent inputs', () => { + const item1Content = { + value: 'Item 1', + fragments: [], + }; + const item2Content = { + value: 'Item 2', + fragments: [], + }; + + model.getBlockTextContent = jest.fn(() => ({ + 'items.0.text': { + value: 'Item 0', + fragments: [], + }, + 'items.1.text': item1Content, + 'items.2.text': item2Content, + })); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn(() => ({ options: { canSplit: true } })); + + // Simulate renumberKeys: items.1 → 0, items.2 → 1 + // @ts-expect-error — jest mock + keypath.renumberKeys.mockReturnValue( + new Map([ + ['items.1.text', 'items.0.text'], + ['items.2.text', 'items.1.text'], + ]) + ); + + blocksManager.splitBlock(0, 'items.0.text' as DataKey, 3); + + expect(keypath.set).toHaveBeenCalledWith( + expect.any(Object), + 'items.0.text', // renumbered from items.1.text + item1Content + ); + expect(keypath.set).toHaveBeenCalledWith( + expect.any(Object), + 'items.1.text', // renumbered from items.2.text + item2Content + ); + }); + + it('canSplit = false: should call importTextContent and insert a default block', () => { + const importMock = jest.fn(() => ({ text: { + value: 'World', + fragments: [], + } })); + const paragraphTool = { + options: { canSplit: true }, + importTextContent: importMock, + }; + + model.getBlockSerialized = jest.fn(() => ({ + name: 'header', + id: 'b1', + data: {} + })); + model.getBlockTextContent = jest.fn(() => ({ + text: { + value: 'Hello World', + fragments: [], + }, + })); + // @ts-expect-error — jest mock + mergeTextNodes.mockReturnValue({ + value: ' World\n', + fragments: [] + }); + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'paragraph' ? paragraphTool : { options: { canSplit: false } } + ); + + blocksManager.splitBlock(0, 'text' as DataKey, 5); + + expect(importMock).toHaveBeenCalledWith(' World\n', []); + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + expect.objectContaining({ name: 'paragraph' }), + 1 + ); + }); + + it('canSplit = false: multiple inputs — should pass all entries after split to mergeTextNodes', () => { + model.getBlockSerialized = jest.fn(() => ({ name: 'header', + id: 'b1', + data: {} })); + model.getBlockTextContent = jest.fn(() => ({ + title: { + value: 'Hello', + fragments: [], + }, + description: { + value: 'World', + fragments: [], + }, + })); + const importMock = jest.fn(() => ({})); + const paragraphTool = { + options: { canSplit: true }, + importTextContent: importMock + }; + + // @ts-expect-error — mock + toolsManager.blockTools.get = jest.fn((name: string) => + name === 'paragraph' ? paragraphTool : { options: { canSplit: false } } + ); + + blocksManager.splitBlock(0, 'title' as DataKey, 3); + + expect(mergeTextNodes).toHaveBeenCalledWith( + // entriesAfter contains ['description', { value: 'World', ... }] + expect.arrayContaining([ + expect.arrayContaining(['description']), + ]), + // initial accumulator contains text after offset in 'title' + expect.objectContaining({ value: 'lo\n' }) // 'Hello'.slice(3) + '\n' + ); + }); + }); }); diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 7cfd61c2..f646f81f 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -1,17 +1,22 @@ import { + BlockChildType, + type BlockId, type BlockNodeInit, + type DataKey, type EditorDocumentSerialized, - EditorJSModel + EditorJSModel, + type InlineTreeNodeSerialized, + keypath, + mergeTextNodes, + NODE_TYPE_HIDDEN_PROP, + sliceFragments } from '@editorjs/model'; import 'reflect-metadata'; import { inject, injectable } from 'inversify'; import { TOKENS } from '../tokens.js'; import ToolsManager from '../tools/ToolsManager.js'; import { BlockToolData } from '@editorjs/editorjs'; -import { - CoreConfigValidated, - EventBus -} from '@editorjs/sdk'; +import { CoreConfigValidated, EventBus } from '@editorjs/sdk'; /** * Parameters for the BlocksManager.insert() method @@ -207,6 +212,156 @@ export class BlocksManager { this.#model.addBlock(this.#config.userId, block, toIndex); } + /** + * Splits a block at the given data key and offset. + * If the tool has canSplit = true, a new block of the same type is inserted after, + * with data taken from the inputs after the caret; array-indexed keys are renumbered from 0. + * Otherwise, the default block is inserted with the extracted text content merged together. + * @param blockIndexOrId - numeric position or named identifier that locates the block + * @param dataKey - the data key at which the split is performed + * @param offset - character offset within the data key's text value to split at + */ + public splitBlock(blockIndexOrId: number | BlockId, dataKey: DataKey, offset: number): void { + const blockIndex = this.#model.resolveBlockIndex(blockIndexOrId); + + const block = this.#model.getBlockSerialized(blockIndex); + const toolName = block.name; + + const tool = this.#toolsManager.blockTools.get(toolName); + const canSplit = tool?.options.canSplit === true; + + const blockTextContent = Object.entries( + this.#model.getBlockTextContent(blockIndex) + ); + + const splitIndex = blockTextContent.findIndex(([key]) => key === dataKey); + + if (splitIndex === -1) { + throw new Error(`Data key "${dataKey}" not found in block content`); + } + + const [, splitInput] = blockTextContent[splitIndex]; + + if (offset < 0 || offset > splitInput.value.length) { + throw new RangeError( + `Offset ${offset} is out of range for input "${dataKey}" with length ${splitInput.value.length}` + ); + } + + /** + * Text and fragments from the input that was split + */ + const textAfter = splitInput.value.slice(offset); + const fragmentsAfter = sliceFragments(splitInput.fragments, offset); + const entriesAfter = blockTextContent.slice(splitIndex + 1); + + /** + * If split happens at the beginning of the first input - just insert an empty block before + */ + if (offset === 0 && splitIndex === 0) { + this.#model.addBlock( + this.#config.userId, + { + name: canSplit ? block.name : this.#config.defaultBlock, + data: {}, + }, + blockIndex + ); + + return; + } + + /** + * Remove text in the split input (fragments will be adjusted by the model) + */ + if (offset < splitInput.value.length) { + this.#model.removeText(this.#config.userId, blockIndex, dataKey, offset); + } + + /** + * Remove all the inputs in the current block after the split + */ + entriesAfter.forEach(([key]) => { + this.#model.removeDataNode(this.#config.userId, blockIndex, key); + }); + + /** + * In case the split is at the end of the last input, just add a new block + */ + if (offset === splitInput.value.length && splitIndex === blockTextContent.length - 1) { + this.#model.addBlock( + this.#config.userId, + { + name: canSplit ? block.name : this.#config.defaultBlock, + data: {}, + }, + blockIndex + 1 + ); + + return; + } + + /** + * If block doesn't support splitting into two, insert a new default block utilizing its conversionConfig.import to get the data + * @todo on initialization validate defaultTool must have conversionConfig.import + */ + if (!canSplit) { + const contentAfterAccInit: InlineTreeNodeSerialized = { + /** + * @todo check if \n is a proper option here + */ + value: textAfter + '\n', + fragments: fragmentsAfter, + }; + + const contentAfter = mergeTextNodes(entriesAfter, contentAfterAccInit); + + const defaultTool = this.#toolsManager.blockTools.get(this.#config.defaultBlock)!; + + const newBlockData = defaultTool.importTextContent(contentAfter.value, contentAfter.fragments); + + /** + * Insert new block with the content after the caret, converted using the default block's import method + */ + this.#model.addBlock(this.#config.userId, { + name: this.#config.defaultBlock, + data: newBlockData, + }, blockIndex + 1); + + return; + } + + /** + * In case Tool supports splitting into two blocks, extract inputs after the split and add new block with the extracted data + */ + const newData: Record = {}; + + /** + * Need to add split input to properly renumber array indexes + */ + entriesAfter.unshift([dataKey as string, { + value: textAfter, + fragments: fragmentsAfter, + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text as BlockChildType.Text, + }]); + + if (entriesAfter.length > 0) { + /** + * In case data contains an array, we need to renumber the keys to start from 0 + */ + const renumbered = keypath.renumberKeys(entriesAfter.map(([key]) => key)); + + entriesAfter.forEach(([key, content]) => { + keypath.set(newData, renumbered.get(key) ?? key, content); + }); + } + + this.#model.addBlock(this.#config.userId, { + name: toolName, + data: newData, + }, blockIndex + 1); + } + /** * Returns block index where user caret is placed */ diff --git a/packages/core/src/tools/internal/block-tools/paragraph/index.ts b/packages/core/src/tools/internal/block-tools/paragraph/index.ts index aaffc677..1819dfc1 100644 --- a/packages/core/src/tools/internal/block-tools/paragraph/index.ts +++ b/packages/core/src/tools/internal/block-tools/paragraph/index.ts @@ -45,6 +45,10 @@ export class Paragraph implements BlockTool { title: 'Text', icon: IconText, }, + conversionConfig: { + import: 'text', + export: 'text', + }, }; /** diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 070b0814..76bea5c1 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -11,7 +11,7 @@ import { import type { BeforeInputUIEvent, BeforeInputUIEventPayload, - CoreConfig + CoreConfig, EditorAPI } from '@editorjs/sdk'; import { BeforeInputUIEventName, BlockToolAdapter, EventBus } from '@editorjs/sdk'; @@ -46,6 +46,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { #caretAdapter: CaretAdapter; #formattingAdapter: FormattingAdapter; #inputsRegistry: InputsRegistry; + #api: EditorAPI; /** * Stored reference to the beforeinput event listener so it can be removed on destroy. @@ -60,6 +61,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * @param caretAdapter - CaretAdapter instance * @param formattingAdapter - needed to render formatted text * @param registry - shared inputs registry + * @param api - Editor's API */ constructor( @inject(TOKENS.EditorConfig) config: Required, @@ -67,13 +69,15 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { eventBus: EventBus, caretAdapter: CaretAdapter, formattingAdapter: FormattingAdapter, - registry: InputsRegistry + registry: InputsRegistry, + @inject(TOKENS.EditorAPI) api: EditorAPI ) { super(config, model, eventBus); this.#caretAdapter = caretAdapter; this.#formattingAdapter = formattingAdapter; this.#inputsRegistry = registry; + this.#api = api; /** * @param event - BeforeInputEvent @@ -444,40 +448,20 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ #handleSplit(key: DataKey, start: number, end: number): void { const currentBlockIndex = this.model.getBlockIndexById(this.blockId); - const currentValue = this.model.getText(this.blockId, key); - const newValueAfter = currentValue.slice(end); - - const relatedFragments = this.model.getFragments(this.blockId, key, end, currentValue.length); /** - * Fragment ranges bounds should be decreased by end index, because end is the index of the first character of the new block + * Remove selected text if range is not collapsed + * @todo Maybe move to the API */ - relatedFragments.forEach((fragment) => { - fragment.range[0] = Math.max(0, fragment.range[0] - end); - fragment.range[1] -= end; - }); + if (start !== end) { + this.model.removeText(this.config.userId, this.blockId, key, start, end); + } - this.model.removeText(this.config.userId, this.blockId, key, start, currentValue.length); - this.model.addBlock( - this.config.userId, - { - /** - * @todo when implementing split/merge, think of how to not use toolname here - */ - name: this.#toolName, - data: { - [key]: { - $t: 't', - value: newValueAfter, - fragments: relatedFragments, - }, - }, - }, - currentBlockIndex + 1 - ); + this.#api.blocks.split(currentBlockIndex, key as string, start); /** * Raf is needed to ensure that the new block is added so caret can be moved to it + * @todo maybe move to the API */ requestAnimationFrame(() => { this.#caretAdapter.updateIndex( diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index 5dbc2b43..609fd0d1 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; import type { - BlockToolAdapter, + BlockToolAdapter, EditorAPI, EditorJSAdapterPlugin, EditorjsAdapterPluginConstructor, EditorjsAdapterPluginParams } from '@editorjs/sdk'; @@ -41,8 +41,9 @@ export class DOMAdapters implements EditorJSAdapterPlugin { * @param params.model - Model instance * @param params.eventBus - EventBus instance */ - constructor({ config, model, eventBus }: EditorjsAdapterPluginParams) { - this.#iocContainer.bind>(TOKENS.EditorConfig).toConstantValue(config as Required); + constructor({ config, model, eventBus, api }: EditorjsAdapterPluginParams) { + this.#iocContainer.bind>(TOKENS.EditorConfig).toConstantValue(config); + this.#iocContainer.bind(TOKENS.EditorAPI).toConstantValue(api); this.#iocContainer.bind(EditorJSModel).toConstantValue(model); this.#iocContainer.bind(EventBus).toConstantValue(eventBus); this.#iocContainer diff --git a/packages/dom-adapters/src/tokens.ts b/packages/dom-adapters/src/tokens.ts index c547ca63..cd4b9ba1 100644 --- a/packages/dom-adapters/src/tokens.ts +++ b/packages/dom-adapters/src/tokens.ts @@ -8,4 +8,6 @@ export const TOKENS = { */ // eslint-disable-next-line @typescript-eslint/naming-convention EditorConfig: Symbol.for('EditorConfig'), + // eslint-disable-next-line @typescript-eslint/naming-convention + EditorAPI: Symbol.for('EditorAPI'), } as const; diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index 0e65a8c6..2cbfa345 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -40,6 +40,8 @@ describe('EditorJSModel', () => { 'getBlockId', 'getBlockIndexById', 'getBlockSerialized', + 'getBlockTextContent', + 'resolveBlockIndex', ]; const ownProperties = Object.getOwnPropertyNames(EditorJSModel.prototype); diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index a9a7f97b..974dc392 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -193,6 +193,17 @@ export class EditorJSModel extends EventBus { return this.#document.getBlockIndexById(id); } + /** + * Resolves a BlockIndexOrId to a numeric block index. + * If a number is passed it is returned as-is. + * If a BlockId is passed the index is looked up via the O(1) id map. + * @param indexOrId - numeric index or block id + * @throws Error if the id does not exist in the document + */ + public resolveBlockIndex(indexOrId: BlockIndexOrId): number { + return this.#document.resolveBlockIndex(indexOrId); + } + /** * Returns the serialized form of a single block without serializing the whole document. * @param blockIndexOrId - index or block id to look up @@ -422,6 +433,15 @@ export class EditorJSModel extends EventBus { return this.#document.getFragments(...parameters); } + /** + * Returns all text inputs content of a block by its index or id. + * Keys are dot-notation data keys; values are serialized text node content. + * @param parameters - arguments forwarded to EditorDocument.getBlockTextContent + */ + public getBlockTextContent(...parameters: Parameters): ReturnType { + return this.#document.getBlockTextContent(...parameters); + } + /** * Exposing document for dev-tools * diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index fad3e79c..0de84617 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -1779,4 +1779,83 @@ describe('BlockNode', () => { .toHaveBeenCalled(); }); }); + + describe('.getTextContent()', () => { + it('should return an empty object when block has no text inputs', () => { + const node = new BlockNode({ name: createBlockToolName('paragraph') }); + + expect(node.getTextContent()).toEqual({}); + }); + + it('should call .serialized getter of each TextNode and include results keyed by data key', () => { + const mockSerializedValue: TextNodeSerialized = { + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + value: 'hello', + fragments: [], + }; + const spy = jest.spyOn(TextNode.prototype, 'serialized', 'get').mockReturnValue(mockSerializedValue); + + const node = new BlockNode({ + name: createBlockToolName('paragraph'), + data: { + text: { + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + value: 'hello', + fragments: [], + }, + }, + parent: {} as EditorDocument, + }); + + const result = node.getTextContent(); + + expect(spy).toHaveBeenCalled(); + expect(result).toHaveProperty('text'); + }); + + it('should collect text inputs from nested array data', () => { + const mockSerializedValue: TextNodeSerialized = { + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + value: 'item', + fragments: [], + }; + + jest.spyOn(TextNode.prototype, 'serialized', 'get').mockReturnValue(mockSerializedValue); + + const node = new BlockNode({ + name: createBlockToolName('paragraph'), + data: { + items: [ + { + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + value: 'item', + fragments: [], + }, + ], + }, + parent: {} as EditorDocument, + }); + + const result = node.getTextContent(); + + // The key should contain an array index like 'items.0' + const keys = Object.keys(result); + + expect(keys.some(k => k.startsWith('items'))).toBe(true); + }); + + it('should not include ValueNode entries', () => { + const node = new BlockNode({ + name: createBlockToolName('paragraph'), + data: { + level: 2, + }, + parent: {} as EditorDocument, + }); + + const result = node.getTextContent(); + + expect(Object.keys(result)).toHaveLength(0); + }); + }); }); diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index c0b11e11..cdededfa 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -382,6 +382,40 @@ export class BlockNode extends EventBus { return node.getFragments(start, end, tool); } + /** + * Returns all text inputs content + */ + public getTextContent(): Record { + return this.#getTextContent(this.#data); + } + + /** + * Recursively iterates over data and collects all the text inputs content + * @param data - data to collect text from + * @param prefix - key prefix for nested data + */ + #getTextContent(data: BlockNodeData | BlockNodeData[] = this.#data, prefix = ''): Record { + const result: Record = {}; + + const entries = Array.isArray(data) + ? data.map((item, index) => [index, item] as const) + : Object.entries(data); + + for (const [key, value] of entries) { + const fullKey = prefix ? `${prefix}.${key}` : String(key); + + if (value instanceof TextNode) { + result[fullKey as DataKey] = value.serialized; + } else if (Array.isArray(value)) { + Object.assign(result, this.#getTextContent(value, fullKey)); + } else if (typeof value === 'object' && value !== null && !(value instanceof ValueNode)) { + Object.assign(result, this.#getTextContent(value as BlockNodeData, fullKey)); + } + } + + return result; + } + /** * Initializes BlockNode with passed block data * @param data - block data diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index 81f51629..05253e73 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -1809,4 +1809,44 @@ describe('EditorDocument', () => { expect(spy).toHaveBeenCalledWith(dataKey, start, end, tool); }); }); + + describe('.resolveBlockIndex()', () => { + it('should return the number unchanged when a numeric index is passed', () => { + const document = createEditorDocumentWithSomeBlocks(); + + expect(document.resolveBlockIndex(1)).toBe(1); + }); + + it('should throw when the BlockId does not exist in the document', () => { + const document = new EditorDocument({ identifier: 'doc' }); + + expect(() => document.resolveBlockIndex(createBlockId('non-existent-id'))).toThrow( + 'Block with id "non-existent-id" not found' + ); + }); + }); + + describe('.getBlockTextContent()', () => { + it('should call getTextContent on the correct block', () => { + const document = createEditorDocumentWithSomeBlocks(); + const blockIndex = 1; + const block = document.getBlock(blockIndex); + + // Manually attach a mock since BlockNode is auto-mocked and getTextContent may not be pre-mocked + (block as unknown as Record).getTextContent = jest.fn(() => ({})); + + const spy = jest.spyOn(block as unknown as { getTextContent: () => unknown }, 'getTextContent'); + + document.getBlockTextContent(blockIndex); + + expect(spy).toHaveBeenCalled(); + }); + + it('should throw when the index is out of bounds', () => { + const document = createEditorDocumentWithSomeBlocks(); + const outOfBoundsIndex = 100; + + expect(() => document.getBlockTextContent(outOfBoundsIndex)).toThrow(); + }); + }); }); diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index ec184c02..51072d75 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -174,7 +174,7 @@ export class EditorDocument extends EventBus { * @throws Error if the index is out of bounds */ public removeBlock(indexOrId: BlockIndexOrId): void { - const resolvedIndex = this.#resolveBlockIndex(indexOrId); + const resolvedIndex = this.resolveBlockIndex(indexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -196,7 +196,7 @@ export class EditorDocument extends EventBus { * @throws Error if the index is out of bounds */ public getBlock(indexOrId: BlockIndexOrId): BlockNode { - const resolvedIndex = this.#resolveBlockIndex(indexOrId); + const resolvedIndex = this.resolveBlockIndex(indexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -235,7 +235,7 @@ export class EditorDocument extends EventBus { * @param data - initial data of the node */ public createDataNode(blockIndexOrId: BlockIndexOrId, key: DataKey | string, data: BlockNodeDataSerializedValue): void { - const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -248,7 +248,7 @@ export class EditorDocument extends EventBus { * @param key - key of the node to remove */ public removeDataNode(indexOrId: BlockIndexOrId, key: DataKey | string): void { - const resolvedIndex = this.#resolveBlockIndex(indexOrId); + const resolvedIndex = this.resolveBlockIndex(indexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -261,13 +261,26 @@ export class EditorDocument extends EventBus { * @param key - data key of the data node */ public getDataNode(indexOrId: BlockIndexOrId, key: DataKey | string): ValueSerialized | TextNodeSerialized | undefined { - const resolvedIndex = this.#resolveBlockIndex(indexOrId); + const resolvedIndex = this.resolveBlockIndex(indexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); return this.#children[resolvedIndex].getDataNode(createDataKey(key)); } + /** + * Returns all text input content of a block, keyed by dot-notation data key. + * @param indexOrId - numeric block index or block id + * @throws Error if the index is out of bounds + */ + public getBlockTextContent(indexOrId: BlockIndexOrId): Record { + const resolvedIndex = this.resolveBlockIndex(indexOrId); + + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); + + return this.#children[resolvedIndex].getTextContent(); + } + /** * Returns the serialised properties of the EditorDocument. */ @@ -319,7 +332,7 @@ export class EditorDocument extends EventBus { * @throws Error if the index is out of bounds */ public updateValue(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, value: T): void { - const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -334,7 +347,7 @@ export class EditorDocument extends EventBus { * @throws Error if the index is out of bounds */ public updateTuneData(blockIndexOrId: BlockIndexOrId, tuneName: BlockTuneName, data: Record): void { - const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -347,7 +360,7 @@ export class EditorDocument extends EventBus { * @param dataKey - key of the data containing the text */ public getText(blockIndexOrId: BlockIndexOrId, dataKey: DataKey): string { - const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -362,7 +375,7 @@ export class EditorDocument extends EventBus { * @param [start] - char index where to insert text */ public insertText(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, text: string, start?: number): void { - const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -377,7 +390,7 @@ export class EditorDocument extends EventBus { * @param [end] - end char index of the range */ public removeText(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, start?: number, end?: number): string { - const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -394,7 +407,7 @@ export class EditorDocument extends EventBus { * @param [data] - Inline Tool data if applicable */ public format(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, tool: InlineToolName, start: number, end: number, data?: InlineToolData): void { - const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -410,7 +423,7 @@ export class EditorDocument extends EventBus { * @param end - end char index of the range */ public unformat(blockIndexOrId: BlockIndexOrId, key: DataKey, tool: InlineToolName, start: number, end: number): void { - const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + const resolvedIndex = this.resolveBlockIndex(blockIndexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); @@ -441,7 +454,7 @@ export class EditorDocument extends EventBus { * @param [tool] - name of the Inline Tool to filter by */ public getFragments(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, start?: number, end?: number, tool?: InlineToolName): InlineFragment[] { - return this.#children[this.#resolveBlockIndex(blockIndexOrId)].getFragments(dataKey, start, end, tool); + return this.#children[this.resolveBlockIndex(blockIndexOrId)].getFragments(dataKey, start, end, tool); } /** @@ -519,6 +532,27 @@ export class EditorDocument extends EventBus { Array.from(this.#children).forEach(() => this.removeBlock(0)); } + /** + * Resolves a BlockIndexOrId to a numeric block index. + * If a number is passed it is returned as-is. + * If a BlockId is passed the index is looked up via the O(1) id map. + * @param indexOrId - numeric index or block id + * @throws Error if the id does not exist in the document + */ + public resolveBlockIndex(indexOrId: BlockIndexOrId): number { + if (typeof indexOrId === 'number') { + return indexOrId; + } + + const index = this.getBlockIndexById(indexOrId); + + if (index === -1) { + throw new Error(`Block with id "${indexOrId}" not found`); + } + + return index; + } + /** * Listens to BlockNode events and bubbles them to the EditorDocument * @param block - BlockNode to listen to @@ -559,25 +593,4 @@ export class EditorDocument extends EventBus { throw new Error('Index out of bounds'); } } - - /** - * Resolves a BlockIndexOrId to a numeric block index. - * If a number is passed it is returned as-is. - * If a BlockId is passed the index is looked up via the O(1) id map. - * @param indexOrId - numeric index or block id - * @throws Error if the id does not exist in the document - */ - #resolveBlockIndex(indexOrId: BlockIndexOrId): number { - if (typeof indexOrId === 'number') { - return indexOrId; - } - - const index = this.getBlockIndexById(indexOrId); - - if (index === -1) { - throw new Error(`Block with id "${indexOrId}" not found`); - } - - return index; - } } diff --git a/packages/model/src/utils/index.ts b/packages/model/src/utils/index.ts index 06336d6d..9169aa12 100644 --- a/packages/model/src/utils/index.ts +++ b/packages/model/src/utils/index.ts @@ -1 +1,3 @@ export * from './Nominal.js'; +export * as keypath from './keypath.js'; +export * from './textUtils.js'; diff --git a/packages/model/src/utils/keypath.spec.ts b/packages/model/src/utils/keypath.spec.ts index aee7eb9b..047dd4af 100644 --- a/packages/model/src/utils/keypath.spec.ts +++ b/packages/model/src/utils/keypath.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { get, has, insert, remove, set } from './keypath.js'; +import { get, has, insert, remove, set, renumberKeys } from './keypath.js'; describe('keypath util', () => { const value = 'value'; @@ -393,4 +393,51 @@ describe('keypath util', () => { expect(object.a).not.toHaveProperty('b'); }); }); + + describe('renumberKeys()', () => { + it('should renumber array-indexed keys so the minimum index becomes 0', () => { + const result = renumberKeys(['items.2.text', 'items.3.text']); + + expect(result.get('items.2.text')).toBe('items.0.text'); + expect(result.get('items.3.text')).toBe('items.1.text'); + }); + + it('should return keys unchanged when they contain no numeric segment', () => { + const result = renumberKeys(['title', 'description']); + + expect(result.get('title')).toBe('title'); + expect(result.get('description')).toBe('description'); + }); + + it('should return an empty map for an empty input', () => { + const result = renumberKeys([]); + + expect(result.size).toBe(0); + }); + + it('should handle a single key starting at 0', () => { + const result = renumberKeys(['items.0.text']); + + expect(result.get('items.0.text')).toBe('items.0.text'); + }); + + it('should handle a single key starting at a non-zero index', () => { + const result = renumberKeys(['items.5.text']); + + expect(result.get('items.5.text')).toBe('items.0.text'); + }); + + it('should renumber multiple prefixes independently', () => { + const result = renumberKeys(['a.3.x', 'b.7.y']); + + expect(result.get('a.3.x')).toBe('a.0.x'); + expect(result.get('b.7.y')).toBe('b.0.y'); + }); + + it('should preserve suffix segments beyond the numeric index', () => { + const result = renumberKeys(['items.4.nested.value']); + + expect(result.get('items.4.nested.value')).toBe('items.0.nested.value'); + }); + }); }); diff --git a/packages/model/src/utils/keypath.ts b/packages/model/src/utils/keypath.ts index 72dbb994..1c90e212 100644 --- a/packages/model/src/utils/keypath.ts +++ b/packages/model/src/utils/keypath.ts @@ -125,3 +125,58 @@ export function remove(data: Record, keys: string delete parent[lastKey]; } } + +/** + * Renumbers array-indexed segments in a set of dot-notation keys so that + * each prefix's first array index becomes 0. + * + * For example, given keys `['items.2.text', 'items.3.text']` the result is + * `{ 'items.2.text': 'items.0.text', 'items.3.text': 'items.1.text' }`. + * + * Keys that contain no numeric segment are returned unchanged. + * @param keys - flat dot-notation data keys to renumber + * @returns a map of original key → renumbered key + */ +export function renumberKeys(keys: string[]): Map { + const minArrayIndices = new Map(); + + for (const key of keys) { + const segments = key.split('.'); + const numericIdx = segments.findIndex(s => !isNaN(Number(s)) && s !== ''); + + if (numericIdx !== -1) { + const prefix = segments.slice(0, numericIdx).join('.'); + const index = Number(segments[numericIdx]); + const current = minArrayIndices.get(prefix); + + if (current === undefined || index < current) { + minArrayIndices.set(prefix, index); + } + } + } + + const result = new Map(); + + for (const key of keys) { + const segments = key.split('.'); + const numericIdx = segments.findIndex(s => !isNaN(Number(s)) && s !== ''); + + if (numericIdx !== -1) { + const prefix = segments.slice(0, numericIdx).join('.'); + const minIndex = minArrayIndices.get(prefix); + + if (minIndex !== undefined) { + const renumbered = [...segments]; + + renumbered[numericIdx] = String(Number(segments[numericIdx]) - minIndex); + result.set(key, renumbered.join('.')); + + continue; + } + } + + result.set(key, key); + } + + return result; +} diff --git a/packages/model/src/utils/textUtils.spec.ts b/packages/model/src/utils/textUtils.spec.ts new file mode 100644 index 00000000..790f4e1b --- /dev/null +++ b/packages/model/src/utils/textUtils.spec.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/no-explicit-any */ +import { sliceFragments, mergeTextNodes } from './textUtils.js'; +import type { InlineFragment, InlineTreeNodeSerialized, TextNodeSerialized } from '../entities/inline-fragments/InlineNode/index.js'; +import type { InlineToolName } from '../entities/inline-fragments/index.js'; +import { BlockChildType, NODE_TYPE_HIDDEN_PROP } from '../entities/BlockNode/index.js'; + +/** + * Helper to create a TextNodeSerialized with the required hidden-type prop. + * @param value - the text content of the node + * @param fragments - optional inline fragments for the node + */ +function textNode(value: string, fragments: TextNodeSerialized['fragments'] = []): TextNodeSerialized { + return { + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + value, + fragments, + }; +} + +describe('textUtils', () => { + describe('sliceFragments()', () => { + it('should return an empty array when given no fragments', () => { + expect(sliceFragments([], 0)).toEqual([]); + }); + + it('should drop fragments that end at or before the offset', () => { + const fragments: InlineFragment[] = [ + { + tool: 'bold' as InlineToolName, + range: [0, 3], + }, + { + tool: 'italic' as InlineToolName, + range: [4, 8], + }, + ]; + + const result = sliceFragments(fragments, 5); + + expect(result).toHaveLength(1); + expect(result[0].range).toEqual([0, 3]); + }); + + it('should shift fragment ranges by the offset', () => { + const fragments: InlineFragment[] = [ + { tool: 'bold' as InlineToolName, + range: [3, 7] }, + ]; + + const result = sliceFragments(fragments, 3); + + expect(result).toHaveLength(1); + expect(result[0].range).toEqual([0, 4]); + }); + + it('should clamp the start of a partially-overlapping fragment to 0', () => { + const fragments: InlineFragment[] = [ + { + tool: 'bold' as InlineToolName, + range: [1, 5], + }, + ]; + + const result = sliceFragments(fragments, 3); + + expect(result).toHaveLength(1); + expect(result[0].range[0]).toBe(0); + expect(result[0].range[1]).toBe(2); + }); + + it('should keep all fragments when offset is 0', () => { + const fragments: InlineFragment[] = [ + { + tool: 'bold' as InlineToolName, + range: [0, 4], + }, + { + tool: 'italic' as InlineToolName, + range: [5, 10], + }, + ]; + + const result = sliceFragments(fragments, 0); + + expect(result).toHaveLength(2); + }); + }); + + describe('mergeTextNodes()', () => { + it('should return the initial accumulator unchanged when entries is empty', () => { + const initial: InlineTreeNodeSerialized = { + value: 'hello', + fragments: [], + }; + + const result = mergeTextNodes([], initial); + + expect(result).toEqual(initial); + }); + + it('should concatenate values separated by newlines', () => { + const initial: InlineTreeNodeSerialized = { + value: 'first\n', + fragments: [], + }; + const entries: [string, TextNodeSerialized][] = [ + ['b', textNode('second')], + ]; + + const result = mergeTextNodes(entries, initial); + + expect(result.value).toBe('first\nsecond\n'); + }); + + it('should adjust fragment ranges to be relative to the merged value', () => { + /** + * 'abc\n' is 4 chars long so 'def' starts at offset 4 + */ + const initialLength = 4; + const initial: InlineTreeNodeSerialized = { + value: 'abc\n', + fragments: [], + }; + const entries: [string, TextNodeSerialized][] = [ + [ + 'b', + textNode('def', [{ + tool: 'bold' as InlineToolName, + range: [0, 3], + }]), + ], + ]; + + const result = mergeTextNodes(entries, initial); + + expect(result.fragments).toHaveLength(1); + expect(result.fragments[0].range).toEqual([initialLength, initialLength + 3]); + }); + + it('should handle multiple entries in order', () => { + const initial: InlineTreeNodeSerialized = { + value: 'a\n', + fragments: [], + }; + const entries: [string, TextNodeSerialized][] = [ + ['x', textNode('b')], + ['y', textNode('c')], + ]; + + const result = mergeTextNodes(entries, initial); + + expect(result.value).toBe('a\nb\nc\n'); + }); + }); +}); diff --git a/packages/model/src/utils/textUtils.ts b/packages/model/src/utils/textUtils.ts new file mode 100644 index 00000000..377a41af --- /dev/null +++ b/packages/model/src/utils/textUtils.ts @@ -0,0 +1,43 @@ +import type { InlineFragment, InlineTreeNodeSerialized, TextNodeSerialized } from '../entities/inline-fragments/InlineNode/index.js'; + +/** + * Returns the fragments that fall after a given character offset, + * with their ranges shifted so that `offset` becomes 0. + * Fragments that end at or before the offset are dropped. + * @param fragments - source inline fragments + * @param offset - character offset to slice at + */ +export function sliceFragments(fragments: InlineFragment[], offset: number): InlineFragment[] { + return fragments + .map(fragment => ({ + ...fragment, + range: [Math.max(fragment.range[0] - offset, 0), fragment.range[1] - offset] as [number, number], + })) + .filter(fragment => fragment.range[1] > 0); +} + +/** + * Concatenates an array of text node entries into a single InlineTreeNodeSerialized, + * adjusting fragment ranges so they are relative to the merged value. + * Each entry's value is appended followed by a newline. + * @param entries - [key, TextNodeSerialized] pairs to merge + * @param initial - accumulator seed (already contains the first value/fragments) + */ +export function mergeTextNodes( + entries: [string, TextNodeSerialized][], + initial: InlineTreeNodeSerialized +): InlineTreeNodeSerialized { + return entries.reduce((acc, [, content]) => { + const currentLength = acc.value.length; + + acc.value += content.value + '\n'; + acc.fragments.push( + ...content.fragments.map((fragment): InlineFragment => ({ + ...fragment, + range: [fragment.range[0] + currentLength, fragment.range[1] + currentLength], + })) + ); + + return acc; + }, initial); +} diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index a98f9340..f5adf9fd 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -100,6 +100,16 @@ export interface BlocksAPI { index?: number, ): void; // BlockAPI[]; + /** + * Splits the block at the given data key and character offset. + * If the tool supports splitting (canSplit = true) a new block of the same type is inserted after the current one. + * Otherwise, the default block is inserted with the content after the caret. + * @param blockIndexOrId - numeric index or block id that locates the block + * @param key - data key of the text input at which to split + * @param offset - character offset within the text value to split at + */ + split(blockIndexOrId: number | string, key: string, offset: number): void; + /** * Creates data of an empty block with a passed type. * @param toolName - block tool name diff --git a/packages/sdk/src/entities/BlockTool.ts b/packages/sdk/src/entities/BlockTool.ts index 5361dafd..9ad9e662 100644 --- a/packages/sdk/src/entities/BlockTool.ts +++ b/packages/sdk/src/entities/BlockTool.ts @@ -9,6 +9,25 @@ import type { BlockToolAdapter } from './BlockToolAdapter.js'; import type { ToolType } from '@/entities/EntityType.js'; import type { BaseToolConstructor, BaseToolOptions } from '@/entities/BaseTool'; +/** + * Configuration for converting block content to/from other block types. + * @template Data - Block tool data type + */ +interface ConversionConfig { + /** + * How to import plain text into this tool's data. + * Either a function that receives the text and returns tool data, + * or a string data key where the text should be placed. + */ + import?: ((data: string) => Data) | string; + /** + * How to export this tool's data to plain text. + * Either a function that receives tool data and returns a string, + * or a string data key whose value should be used as the text. + */ + export?: ((data: Data) => string) | string; +} + /** * Canonical keys for Block Tool options. * Use these instead of raw string literals when reading or writing block-tool options. @@ -23,14 +42,18 @@ export enum BlockToolOptionKey { /** Inline tools enabled for blocks of this type. */ InlineToolbar = 'inlineToolbar', /** Block tunes enabled for blocks of this type. */ - Tunes = 'tunes' + Tunes = 'tunes', + /** Conversion configuration for this tool. */ + ConversionConfig = 'conversionConfig', + /** Whether the block can be split into multiple blocks of its type. */ + CanSplit = 'canSplit' } /** * Options available on **Block Tools** (`static options` or `use()` overrides). * @template Config - Shape of the plugin-specific {@link BaseToolOptions.config} object. */ -export interface BlockToolOptions +export interface BlockToolOptions extends BaseToolOptions { /** * Toolbox entry (or entries) that represent this tool in the toolbox. @@ -60,6 +83,22 @@ export interface BlockToolOptions */ [BlockToolOptionKey.Tunes]?: boolean | string[]; + /** + * Configuration for converting to/from other tools. The `import` and `export` properties can be either: + * - A function that performs the conversion, or + * - A string representing the property data key in tool's data + * + * If a string is provided, the editor will insert/extract data from using the string as DataKey + */ + [BlockToolOptionKey.ConversionConfig]?: ConversionConfig; + + /** + * If true, on split a new block of the same type would be rendered with the content after the caret + * + * If false or omitted, the default block would be rendered instead. + */ + [BlockToolOptionKey.CanSplit]?: boolean; + /** Any additional custom options exposed by the tool developer. */ [key: string]: unknown; } @@ -145,5 +184,4 @@ export interface BlockToolConstructor< * * any is used as a placeholder to allow using BlockToolData without generic */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type BlockToolData = any> = T; +export type BlockToolData = Record> = T; diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts index 1506bd43..76d4207c 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.spec.ts @@ -1,12 +1,15 @@ -/* eslint-disable jsdoc/require-jsdoc -- test doubles and nested literals */ +/* eslint-disable jsdoc/require-jsdoc,@typescript-eslint/no-magic-numbers */ import { describe, expect, it } from '@jest/globals'; import type { API as ApiMethods } from '@editorjs/editorjs'; -import type { BlockToolConstructor } from '../../entities/BlockTool.js'; +import type { BlockToolConstructor, BlockToolData } from '../../entities/BlockTool.js'; +import { BlockToolOptionKey } from '../../entities/BlockTool.js'; import { ToolType } from '../../entities/EntityType.js'; import type { ToolOptions } from './BaseToolFacade.js'; import { UserToolOptions } from './BaseToolFacade.js'; import { BlockToolFacade } from './BlockToolFacade.js'; +import type { InlineFragment } from '@editorjs/model'; +import { BlockChildType, NODE_TYPE_HIDDEN_PROP } from '@editorjs/model'; const emptyApi = {} as ApiMethods; @@ -161,4 +164,72 @@ describe('BaseToolFacade (via BlockToolFacade)', () => { expect(facade.config).toEqual({}); }); }); + + describe('importTextContent', () => { + it('throws when the tool has no conversionConfig.import', () => { + const facade = createBlockFacade({}, {} as ToolOptions); + + expect(() => facade.importTextContent('hello', [])).toThrow( + /does not have import configuration/ + ); + }); + + it('calls the import function when conversionConfig.import is a function', () => { + const importFn = (text: string): BlockToolData => ({ text: { value: text } }); + const facade = createBlockFacade( + { [BlockToolOptionKey.ConversionConfig]: { import: importFn } }, + {} as ToolOptions + ); + + const result = facade.importTextContent('hello', []); + + expect(result).toEqual({ text: { value: 'hello' } }); + }); + + it('creates a top-level key when conversionConfig.import is a simple string', () => { + const facade = createBlockFacade( + { [BlockToolOptionKey.ConversionConfig]: { import: 'text' } }, + {} as ToolOptions + ); + const fragments = [{ + tool: 'bold', + range: [0, 5], + }] as InlineFragment[]; + + const result = facade.importTextContent('hello', fragments); + + expect(result).toEqual({ + text: { + value: 'hello', + fragments, + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + }, + }); + }); + + it('creates nested structure when conversionConfig.import is a dot-notation key', () => { + const facade = createBlockFacade( + { [BlockToolOptionKey.ConversionConfig]: { import: 'items.0.text' } }, + {} as ToolOptions + ); + const fragments = [{ + tool: 'bold', + range: [0, 5], + }] as InlineFragment[]; + + const result = facade.importTextContent('hello', fragments); + + expect(result).toEqual({ + items: [ + { + text: { + value: 'hello', + fragments, + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + }, + }, + ], + }); + }); + }); }); diff --git a/packages/sdk/src/tools/facades/BlockToolFacade.ts b/packages/sdk/src/tools/facades/BlockToolFacade.ts index bada5e22..34a34295 100644 --- a/packages/sdk/src/tools/facades/BlockToolFacade.ts +++ b/packages/sdk/src/tools/facades/BlockToolFacade.ts @@ -16,13 +16,15 @@ import { import { type InlineToolFacade } from './InlineToolFacade.js'; import { type BlockTuneFacade } from './BlockTuneFacade.js'; import { ToolsCollection } from '../ToolsCollection.js'; -import type { BlockToolConstructor, BlockToolConstructorOptions, BlockTool as IBlockTool } from '../../entities'; +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'; /** * Class to work with Block tools constructables */ -export class BlockToolFacade extends BaseToolFacade { +export class BlockToolFacade extends BaseToolFacade { /** * Tool type for BlockToolFacade tools — Block */ @@ -55,10 +57,9 @@ export class BlockToolFacade extends BaseToolFacade * @param options.block - BlockAPI for current Block * @param options.readOnly - True if Editor is in read-only mode */ - public create({ data, block, readOnly, adapter }: Pick): IBlockTool { + public create({ data, block, readOnly, adapter }: Pick): BlockTool { return new this.constructable({ adapter, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data, block, readOnly, @@ -153,6 +154,39 @@ export class BlockToolFacade extends BaseToolFacade // return this.constructable[InternalBlockToolSettings.ConversionConfig]; // } + /** + * Returns block data from plain text using the tool's conversion config import function. + * If the import config is a function, it is called with the text value. + * Otherwise, a default text node structure is returned. + * @param value - plain text to convert + * @param fragments - inline fragments associated with the text + */ + public importTextContent(value: string, fragments: InlineFragment[]): BlockToolData { + const conversionConfig = this.options[BlockToolOptionKey.ConversionConfig]; + const importFnOrProp = conversionConfig?.import; + + if (importFnOrProp === undefined) { + throw new Error(`Tool ${this.name} does not have import configuration for text content`); + } + + /** + * @todo pass fragments to the import function? + */ + if (typeof importFnOrProp === 'function') { + return importFnOrProp(value); + } + + const result: BlockToolData = {}; + + keypath.set(result, importFnOrProp, { + value, + fragments, + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text as BlockChildType.Text, + }); + + return result; + } + /** * Returns enabled inline tools for Tool */