diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 1668a181..5e524079 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -63,7 +63,7 @@ describe('CollaborationManager', () => { collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -73,7 +73,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -104,7 +104,7 @@ describe('CollaborationManager', () => { collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -114,7 +114,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -143,7 +143,7 @@ describe('CollaborationManager', () => { collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -153,7 +153,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -217,7 +217,7 @@ describe('CollaborationManager', () => { collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -230,7 +230,7 @@ describe('CollaborationManager', () => { }], }, }, - }], + })], properties: {}, }); }); @@ -296,7 +296,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -306,7 +306,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -344,7 +344,7 @@ describe('CollaborationManager', () => { collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -357,7 +357,7 @@ describe('CollaborationManager', () => { }], }, }, - }], + })], properties: {}, }); }); @@ -395,7 +395,7 @@ describe('CollaborationManager', () => { collaborationManager.undo(); expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -405,7 +405,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -438,7 +438,7 @@ describe('CollaborationManager', () => { collaborationManager.undo(); expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -448,7 +448,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -481,7 +481,7 @@ describe('CollaborationManager', () => { collaborationManager.undo(); expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -491,7 +491,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -525,7 +525,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -535,7 +535,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -603,7 +603,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -613,7 +613,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -653,7 +653,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -666,7 +666,7 @@ describe('CollaborationManager', () => { }], }, }, - }], + })], properties: {}, }); }); @@ -707,7 +707,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -720,7 +720,7 @@ describe('CollaborationManager', () => { }], }, }, - }], + })], properties: {}, }); }); @@ -755,7 +755,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [block], + blocks: [expect.objectContaining(block)], properties: {}, }); }); @@ -795,7 +795,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [block], + blocks: [expect.objectContaining(block)], properties: {}, }); }); @@ -837,7 +837,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [block], + blocks: [expect.objectContaining(block)], properties: {}, }); }); @@ -923,7 +923,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -933,7 +933,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -976,7 +976,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -986,7 +986,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -1013,7 +1013,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -1023,7 +1023,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -1105,7 +1105,7 @@ describe('CollaborationManager', () => { // Verify the operations were transformed correctly expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -1115,7 +1115,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -1146,7 +1146,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -1156,7 +1156,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -1214,7 +1214,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -1224,7 +1224,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -1275,7 +1275,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -1285,7 +1285,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); @@ -1327,7 +1327,7 @@ describe('CollaborationManager', () => { expect(model.serialized).toStrictEqual({ identifier: documentId, - blocks: [{ + blocks: [expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -1337,7 +1337,7 @@ describe('CollaborationManager', () => { fragments: [], }, }, - }], + })], properties: {}, }); }); diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index 13b9cfeb..82619fb6 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -271,8 +271,11 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { blocksAPI.render({ identifier: 'new-doc', blocks: [ - { name: 'header', - data: {} }, + { + id: 'mock', + name: 'header', + data: {}, + }, ], properties: {}, }); @@ -375,10 +378,16 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { blocksAPI.render({ identifier: 'doc-2', blocks: [ - { name: 'header', - data: {} }, - { name: 'list', - data: {} }, + { + id: 'mock-header', + name: 'header', + data: {}, + }, + { + id: 'mock-list', + name: 'list', + data: {}, + }, ], properties: {}, }); diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index 844dddfb..a29055e1 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 BlockNodeSerialized, EditorDocumentSerialized } from '@editorjs/model'; +import { type BlockNodeInit, type EditorDocumentSerialized } from '@editorjs/model'; /** * Blocks API @@ -80,7 +80,7 @@ export class BlocksAPI implements BlocksApiInterface { * @param blocks - array of blocks to insert * @param [index] - index to insert blocks at. If undefined, inserts at the end */ - public insertMany(blocks: BlockNodeSerialized[], index?: number): void { + public insertMany(blocks: BlockNodeInit[], index?: number): void { return this.#blocksManager.insertMany(blocks, index); } diff --git a/packages/core/src/components/BlockManager.spec.ts b/packages/core/src/components/BlockManager.spec.ts index 411dc7c1..36142393 100644 --- a/packages/core/src/components/BlockManager.spec.ts +++ b/packages/core/src/components/BlockManager.spec.ts @@ -19,6 +19,7 @@ jest.unstable_mockModule('@editorjs/model', () => { initializeDocument: jest.fn(), clearBlocks: jest.fn(), getCaret: jest.fn(), + getBlockSerialized: jest.fn(), get length() { return BLOCKS_COUNT; }, @@ -222,6 +223,7 @@ describe('BlocksManager (unit, mocked deps)', () => { identifier: 'doc', blocks: [ { + id: 'mock', name: 'x', data: {} } @@ -255,20 +257,24 @@ describe('BlocksManager (unit, mocked deps)', () => { expect(model.removeBlock).toHaveBeenCalledWith(USER_ID, 0); }); + + it('should call model.getCaret with the configured userId to resolve current block', () => { + // @ts-expect-error - mock return value does not need full Caret shape + model.getCaret = jest.fn(() => ({ index: { blockIndex: 2 } })); + + blocksManager.deleteBlock(); + + expect(model.getCaret).toHaveBeenCalledWith(USER_ID); + expect(model.removeBlock).toHaveBeenCalledWith(USER_ID, 2); + }); }); describe('.move()', () => { it('should call removeBlock and addBlock when moving current block forward', () => { - // @ts-expect-error - need to assign read only property to mock it - model.serialized = { - blocks: [ - { name: 'a' }, - { name: 'b' }, - { name: 'c' } - ] - }; // @ts-expect-error - mock return value does not need full Caret shape model.getCaret = jest.fn(() => ({ index: { blockIndex: 0 } })); + // @ts-expect-error - mock return value does not need full BlockNodeSerialized shape + model.getBlockSerialized = jest.fn(() => ({ name: 'a' })); blocksManager.move(2); @@ -283,14 +289,8 @@ describe('BlocksManager (unit, mocked deps)', () => { }); it('should pass toIndex directly when toIndex is less than fromIndex', () => { - // @ts-expect-error - need to assign read only property to mock it - model.serialized = { - blocks: [ - { name: 'a' }, - { name: 'b' }, - { name: 'c' } - ] - }; + // @ts-expect-error - mock return value does not need full BlockNodeSerialized shape + model.getBlockSerialized = jest.fn(() => ({ name: 'c' })); blocksManager.move(0, 2); @@ -299,15 +299,6 @@ describe('BlocksManager (unit, mocked deps)', () => { }); it('should do nothing when toIndex equals fromIndex', () => { - // @ts-expect-error - need to assign read only property to mock it - model.serialized = { - blocks: [ - { name: 'a' }, - { name: 'b' }, - { name: 'c' } - ] - }; - blocksManager.move(1, 1); expect(model.removeBlock).not.toHaveBeenCalled(); diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index bd4ebb86..7cfd61c2 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -1,5 +1,5 @@ import { - type BlockNodeSerialized, + type BlockNodeInit, type EditorDocumentSerialized, EditorJSModel } from '@editorjs/model'; @@ -17,7 +17,10 @@ import { * Parameters for the BlocksManager.insert() method */ interface InsertBlockParameters { - // id?: string; + /** + * Block ID + */ + id?: string; /** * Block tool name to insert */ @@ -111,7 +114,7 @@ export class BlocksManager { * @param parameters.replace - flag indicates if block at index should be replaced */ public insert({ - // id = undefined, + id = undefined, type = this.#config.defaultBlock, data = {}, index, @@ -131,6 +134,7 @@ export class BlocksManager { this.#model.addBlock(this.#config.userId, { ...data, + id, name: type, }, newIndex); @@ -146,7 +150,7 @@ export class BlocksManager { * @param blocks - array of blocks to insert * @param [index] - index to insert blocks at. If undefined, inserts at the end */ - public insertMany(blocks: BlockNodeSerialized[], index: number = this.#model.length): void { + public insertMany(blocks: BlockNodeInit[], index: number = this.#model.length): void { blocks.forEach((block, i) => this.#model.addBlock(this.#config.userId, block, index + i)); } @@ -197,7 +201,7 @@ export class BlocksManager { return; } - const block = this.#model.serialized.blocks[fromIndex]; + const block = this.#model.getBlockSerialized(fromIndex); this.#model.removeBlock(this.#config.userId, fromIndex); this.#model.addBlock(this.#config.userId, block, toIndex); diff --git a/packages/core/src/components/BlockRenderer.spec.ts b/packages/core/src/components/BlockRenderer.spec.ts index 41f2fe8b..ed4c105c 100644 --- a/packages/core/src/components/BlockRenderer.spec.ts +++ b/packages/core/src/components/BlockRenderer.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable jsdoc/require-jsdoc, @stylistic/comma-dangle,@typescript-eslint/naming-convention */ import { beforeEach, jest } from '@jest/globals'; import type { BlockToolFacade, EditorJSAdapterPlugin } from '@editorjs/sdk'; -import type { Index } from '@editorjs/model'; +import type { BlockId, Index } from '@editorjs/model'; const USER_ID = 'user'; @@ -45,6 +45,7 @@ jest.unstable_mockModule('@editorjs/model', () => { BlockAddedEvent, BlockRemovedEvent, EventType, + createBlockId: (str: string) => str, }; }); @@ -114,8 +115,11 @@ describe('BlockRenderer (unit, mocked deps)', () => { const event = new BlockAddedEvent( { blockIndex: 0 } as Index, - { name: 'tool', - data: {} }, + { + name: 'tool', + id: 'test-block-id' as BlockId, + data: {} + }, USER_ID, ); @@ -132,8 +136,11 @@ describe('BlockRenderer (unit, mocked deps)', () => { it('should throw when blockIndex is undefined', async () => { const event = new BlockAddedEvent( {} as Index, - { name: 'tool', - data: {} }, + { + name: 'tool', + id: 'test-block-id' as BlockId, + data: {} + }, USER_ID, ); @@ -149,8 +156,11 @@ describe('BlockRenderer (unit, mocked deps)', () => { const event = new BlockAddedEvent( { blockIndex: 0 } as Index, - { name: 'missing-tool', - data: {} }, + { + name: 'missing-tool', + id: 'test-block-id' as BlockId, + data: {} + }, USER_ID, ); @@ -174,8 +184,11 @@ describe('BlockRenderer (unit, mocked deps)', () => { const event = new BlockAddedEvent( { blockIndex: 0 } as Index, - { name: 'tool', - data: {} }, + { + name: 'tool', + id: 'test-block-id' as BlockId, + data: {} + }, USER_ID, ); @@ -194,8 +207,11 @@ describe('BlockRenderer (unit, mocked deps)', () => { it('should dispatch BlockRemovedCoreEvent via EventBus', () => { const event = new BlockRemovedEvent( { blockIndex: 1 } as Index, - { name: 'tool', - data: {} }, + { + name: 'tool', + id: 'test-block-id' as BlockId, + data: {} + }, USER_ID, ); @@ -204,24 +220,31 @@ describe('BlockRenderer (unit, mocked deps)', () => { expect(eventBus.dispatchEvent).toHaveBeenCalled(); }); - it('should call destroyBlockToolAdapter with the block index', () => { + it('should call destroyBlockToolAdapter with the block id', () => { + const blockId = 'test-block-id' as unknown as BlockId; const event = new BlockRemovedEvent( - { blockIndex: 2 } as Index, - { name: 'tool', - data: {} }, + { blockIndex: 1 } as Index, + { + name: 'tool', + id: blockId, + data: {} + }, USER_ID, ); void changedListener(event); - expect(adapter.destroyBlockToolAdapter).toHaveBeenCalledWith(2); + expect(adapter.destroyBlockToolAdapter).toHaveBeenCalledWith(blockId); }); it('should throw when blockIndex is undefined', () => { const event = new BlockRemovedEvent( {} as Index, - { name: 'tool', - data: {} }, + { + name: 'tool', + id: 'test-block-id' as BlockId, + data: {} + }, USER_ID, ); diff --git a/packages/core/src/components/BlockRenderer.ts b/packages/core/src/components/BlockRenderer.ts index ecf81a9c..fabd6b7b 100644 --- a/packages/core/src/components/BlockRenderer.ts +++ b/packages/core/src/components/BlockRenderer.ts @@ -1,6 +1,7 @@ import { BlockAddedEvent, BlockRemovedEvent, + createBlockId, EditorJSModel, EventType, ModelEvents @@ -110,7 +111,7 @@ export class BlockRenderer { throw new Error(`[BlockRenderer] Block Tool ${data.name} not found`); } - const blockToolAdapter = this.#adapter.createBlockToolAdapter(index.blockIndex, tool.name); + const blockToolAdapter = this.#adapter.createBlockToolAdapter(createBlockId(data.id), tool.name); const block = tool.create({ adapter: blockToolAdapter, @@ -146,7 +147,7 @@ export class BlockRenderer { throw new Error('[BlockRenderer] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); } - this.#adapter.destroyBlockToolAdapter(index.blockIndex); + this.#adapter.destroyBlockToolAdapter(createBlockId(data.id)); this.#eventBus.dispatchEvent(new BlockRemovedCoreEvent({ tool: data.name, diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 91a76bac..6f2c1ad5 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -15,7 +15,7 @@ import { import { inject, injectable } from 'inversify'; import { TOKENS } from '../tokens.js'; import { InlineToolFormatData } from '@editorjs/sdk'; -import ToolsManager from '../tools/ToolsManager'; +import ToolsManager from '../tools/ToolsManager.js'; /** * SelectionManager responsible for handling selection changes and applying inline tools formatting diff --git a/packages/core/src/utils/composeDataFromVersion2.ts b/packages/core/src/utils/composeDataFromVersion2.ts index 972a8f1c..c6d8dcb3 100644 --- a/packages/core/src/utils/composeDataFromVersion2.ts +++ b/packages/core/src/utils/composeDataFromVersion2.ts @@ -1,6 +1,6 @@ import type { OutputData } from '@editorjs/editorjs'; import type { InlineFragment } from '@editorjs/model'; -import { createInlineToolData, createInlineToolName, TextNode, ValueNode, type BlockNodeSerialized } from '@editorjs/model'; +import { createInlineToolData, createInlineToolName, TextNode, ValueNode, type BlockNodeInit } from '@editorjs/model'; /** * Removes HTML tags from the input string @@ -92,7 +92,7 @@ export function composeDataFromVersion2(data: OutputData): { /** * The child BlockNodes of the EditorDocument */ - blocks: BlockNodeSerialized[]; + blocks: BlockNodeInit[]; } { return { blocks: data.blocks.map((block) => { diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 7b845ad4..070b0814 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -47,6 +47,11 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { #formattingAdapter: FormattingAdapter; #inputsRegistry: InputsRegistry; + /** + * Stored reference to the beforeinput event listener so it can be removed on destroy. + */ + #beforeInputListener: EventListener; + /** * BlockToolAdapter constructor * @param config - Editor's config @@ -71,11 +76,25 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { this.#inputsRegistry = registry; /** - * @todo Needs to be documented. If UI module is replaced and doesn't dispatch the event nothing would work + * @param event - BeforeInputEvent */ - eventBus.addEventListener(`ui:${BeforeInputUIEventName}`, (event: BeforeInputUIEvent) => { + this.#beforeInputListener = ((event: BeforeInputUIEvent) => { this.#processDelegatedBeforeInput(event); - }); + }) as EventListener; + + /** + * @todo Needs to be documented. If UI module is replaced and doesn't dispatch the event nothing would work + */ + eventBus.addEventListener(`ui:${BeforeInputUIEventName}`, this.#beforeInputListener); + } + + /** + * Releases all resources held by this adapter. + * Removes both the model change listener (via super) and the eventBus beforeinput listener. + */ + public override destroy(): void { + super.destroy(); + this.eventBus.removeEventListener(`ui:${BeforeInputUIEventName}`, this.#beforeInputListener); } /** @@ -97,7 +116,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { const key = createDataKey(keyRaw); if (input === undefined) { - this.#inputsRegistry.unregister(this.blockIndex, key); + this.#inputsRegistry.unregister(this.blockId, key); return; } @@ -116,10 +135,10 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { return; } - const value = this.model.getText(this.blockIndex, key); - const fragments = this.model.getFragments(this.blockIndex, key); + const value = this.model.getText(this.blockId, key); + const fragments = this.model.getFragments(this.blockId, key); - this.#inputsRegistry.register(this.blockIndex, key, input); + this.#inputsRegistry.register(this.blockId, key, input); input.textContent = value; @@ -132,7 +151,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * Returns the (dataKey → element) map for this block from the shared registry. */ get #attachedInputs(): Map { - return this.#inputsRegistry.getBlockInputs(this.blockIndex) ?? new Map(); + return this.#inputsRegistry.getBlockInputs(this.blockId) ?? new Map(); } /** @@ -263,7 +282,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * Middle block in a cross-input selection: remove the whole block, not the same as removeText(0, length). */ if (isInputInBetweenSelection(input, range)) { - this.model.removeBlock(this.config.userId, this.blockIndex); + this.model.removeBlock(this.config.userId, this.blockId); return; } @@ -291,7 +310,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { } const [start, end] = clipped; - const removedText = this.model.removeText(this.config.userId, this.blockIndex, key, start, end); + const removedText = this.model.removeText(this.config.userId, this.blockId, key, start, end); let newCaretIndex: number | null = null; @@ -316,7 +335,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (newCaretIndex !== null) { this.#caretAdapter.updateIndex( new IndexBuilder() - .addBlockIndex(this.blockIndex) + .addBlockIndex(this.model.getBlockIndexById(this.blockId)) .addDataKey(key) .addTextRange([newCaretIndex, newCaretIndex]) .build() @@ -357,7 +376,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (data !== undefined && input.contains(range.startContainer)) { start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - this.model.insertText(this.config.userId, this.blockIndex, key, data, start); + this.model.insertText(this.config.userId, this.blockId, key, data, start); } break; } @@ -369,7 +388,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (data !== undefined && input.contains(range.startContainer)) { start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - this.model.insertText(this.config.userId, this.blockIndex, key, data, start); + this.model.insertText(this.config.userId, this.blockId, key, data, start); } break; } @@ -424,10 +443,11 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * @param end - end index of the selected range */ #handleSplit(key: DataKey, start: number, end: number): void { - const currentValue = this.model.getText(this.blockIndex, key); + 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.blockIndex, key, end, currentValue.length); + 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 @@ -437,7 +457,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { fragment.range[1] -= end; }); - this.model.removeText(this.config.userId, this.blockIndex, key, start, currentValue.length); + this.model.removeText(this.config.userId, this.blockId, key, start, currentValue.length); this.model.addBlock( this.config.userId, { @@ -453,7 +473,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { }, }, }, - this.blockIndex + 1 + currentBlockIndex + 1 ); /** @@ -462,7 +482,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { requestAnimationFrame(() => { this.#caretAdapter.updateIndex( new IndexBuilder() - .addBlockIndex(this.blockIndex + 1) + .addBlockIndex(currentBlockIndex + 1) .addDataKey(key) .addTextRange([0, 0]) .build() @@ -478,7 +498,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ #handleModelUpdateForContentEditableElement(event: ModelEvents, input: HTMLElement, key: DataKey): void { const { userId, index, action } = event.detail; - const { textRange } = index; + const { textRange, blockIndex: eventBlockIndex } = index; const [start, end] = textRange!; @@ -490,7 +510,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { const builder = new IndexBuilder(); - builder.addDataKey(key).addBlockIndex(this.blockIndex); + builder.addDataKey(key).addBlockIndex(eventBlockIndex); let newCaretIndex: number | null = null; diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index 530adda0..ce1c0c5d 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -6,7 +6,8 @@ import { EventType, Index, IndexBuilder, - createDataKey + createDataKey, + createBlockId } from '@editorjs/model'; import type { CoreConfig } from '@editorjs/sdk'; import { @@ -107,13 +108,20 @@ export class CaretAdapter { } /** - * Finds input by block index and data key + * Finds input by block index and data key. + * Translates the numeric block index to a BlockId before querying the registry. * @param blockIndex - index of the block * @param dataKeyRaw - data key of the input * @returns input element or undefined if not found */ public findInput(blockIndex: number, dataKeyRaw: string): HTMLElement | undefined { - return this.#inputsRegistry.getInput(blockIndex, createDataKey(dataKeyRaw)); + const blockId = this.#model.getBlockId(blockIndex); + + if (blockId === undefined) { + return undefined; + } + + return this.#inputsRegistry.getInput(blockId, createDataKey(dataKeyRaw)); } /** @@ -194,7 +202,7 @@ export class CaretAdapter { const segments: Index[] = []; - for (const [blockIndex, dataKeyStr, input] of this.#inputsRegistry.entries()) { + for (const [blockId, dataKeyStr, input] of this.#inputsRegistry.entries()) { if (isNativeInput(input) === true) { continue; } @@ -205,6 +213,12 @@ export class CaretAdapter { continue; } + const blockIndex = this.#model.getBlockIndexById(createBlockId(blockId)); + + if (blockIndex === -1) { + continue; + } + const builder = new IndexBuilder(); builder diff --git a/packages/dom-adapters/src/InputsRegistry/index.ts b/packages/dom-adapters/src/InputsRegistry/index.ts index 84f7a3b8..037ecb43 100644 --- a/packages/dom-adapters/src/InputsRegistry/index.ts +++ b/packages/dom-adapters/src/InputsRegistry/index.ts @@ -1,100 +1,98 @@ -import type { DataKey } from '@editorjs/model'; +import type { BlockId, DataKey } from '@editorjs/model'; import { injectable } from 'inversify'; /** - * A registry that maps (blockIndex, dataKey) pairs to their DOM input elements. + * A registry that maps (blockId, dataKey) pairs to their DOM input elements. * - * Inputs are stored in an array indexed by block position, so inserting or - * removing a block is a single Array.splice() call — no manual key-shifting needed. + * Inputs are stored in a Map keyed by BlockId, so inserting or removing a block + * is a single O(1) Map operation — no positional shifting needed. */ @injectable() export class InputsRegistry { /** - * Index = block position. Each entry is a (dataKey → element) map for that block. + * Key = block id. Each entry is a (dataKey → element) map for that block. */ - #inputs: Map[] = []; + #inputs: Map> = new Map(); /** * Registers (or replaces) an input element for a given block + data key. - * @param blockIndex - position of the block in the document + * @param blockId - unique id of the block * @param dataKey - data key of the input within the block * @param element - the DOM element to register */ - public register(blockIndex: number, dataKey: DataKey, element: HTMLElement): void { - if (this.#inputs[blockIndex] === undefined) { - this.#inputs[blockIndex] = new Map(); + public register(blockId: BlockId, dataKey: DataKey, element: HTMLElement): void { + let blockMap = this.#inputs.get(blockId); + + if (blockMap === undefined) { + blockMap = new Map(); + this.#inputs.set(blockId, blockMap); } - this.#inputs[blockIndex].set(dataKey, element); + blockMap.set(dataKey, element); } /** * Removes the registration for a specific input. * If no dataKey is given, removes all inputs for the block. - * @param blockIndex - position of the block + * @param blockId - unique id of the block * @param dataKey - optional specific data key to unregister */ - public unregister(blockIndex: number, dataKey?: DataKey): void { + public unregister(blockId: BlockId, dataKey?: DataKey): void { if (dataKey === undefined) { - this.#inputs.splice(blockIndex, 1); + this.#inputs.delete(blockId); return; } - this.#inputs[blockIndex]?.delete(dataKey); + this.#inputs.get(blockId)?.delete(dataKey); } /** - * Looks up a single input by block index and data key. - * @param blockIndex - position of the block + * Looks up a single input by block id and data key. + * @param blockId - unique id of the block * @param dataKey - data key of the input */ - public getInput(blockIndex: number, dataKey: DataKey): HTMLElement | undefined { - return this.#inputs[blockIndex]?.get(dataKey); + public getInput(blockId: BlockId, dataKey: DataKey): HTMLElement | undefined { + return this.#inputs.get(blockId)?.get(dataKey); } /** * Returns all inputs for a block as a (dataKey → element) map. - * @param blockIndex - position of the block + * @param blockId - unique id of the block */ - public getBlockInputs(blockIndex: number): Map | undefined { - return this.#inputs[blockIndex]; + public getBlockInputs(blockId: BlockId): Map | undefined { + return this.#inputs.get(blockId); } /** - * Returns all registered entries as an iterable of [blockIndex, dataKey, element] tuples. + * Returns all registered entries as an iterable of [blockId, dataKey, element] tuples. * Useful for CaretAdapter to iterate all inputs during selection mapping. * @yields */ - public *entries(): Iterable<[number, DataKey, HTMLElement]> { - for (let blockIndex = 0; blockIndex < this.#inputs.length; blockIndex++) { - const keyMap = this.#inputs[blockIndex]; - - if (keyMap === undefined) { - continue; - } - + public *entries(): Iterable<[BlockId, DataKey, HTMLElement]> { + for (const [blockId, keyMap] of this.#inputs) { for (const [dataKey, element] of keyMap) { - yield [blockIndex, dataKey, element]; + yield [blockId, dataKey, element]; } } } /** - * Inserts an empty slot at blockIndex, shifting all subsequent blocks up by one. - * Call this before registering inputs for a newly inserted block. - * @param blockIndex - position of the new block + * Reserves a slot for a new block. No-op if the block is already registered + * (the slot is created lazily on first {@link register} call anyway). + * @param blockId - unique id of the new block */ - public insertBlock(blockIndex: number): void { - this.#inputs.splice(blockIndex, 0, new Map()); + public insertBlock(blockId: BlockId): void { + if (!this.#inputs.has(blockId)) { + this.#inputs.set(blockId, new Map()); + } } /** - * Removes the slot at blockIndex, shifting all subsequent blocks down by one. - * Call this when a block is removed from the document. - * @param blockIndex - position of the removed block + * Removes all registered inputs for the given block. + * @param blockId - unique id of the removed block */ - public removeBlock(blockIndex: number): void { - this.#inputs.splice(blockIndex, 1); + public removeBlock(blockId: BlockId): void { + this.#inputs.delete(blockId); } } diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index 4e82199f..5dbc2b43 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -1,3 +1,4 @@ +import 'reflect-metadata'; import type { BlockToolAdapter, EditorJSAdapterPlugin, EditorjsAdapterPluginConstructor, @@ -8,6 +9,7 @@ import { PluginType } from '@editorjs/sdk'; import { DOMBlockToolAdapter } from './BlockToolAdapter/index.js'; import { InputsRegistry } from './InputsRegistry/index.js'; import { EditorJSModel } from '@editorjs/model'; +import type { BlockId } from '@editorjs/model'; import { Container } from 'inversify'; import { TOKENS } from './tokens.js'; import type { CoreConfig } from '@editorjs/sdk'; @@ -28,10 +30,10 @@ export class DOMAdapters implements EditorJSAdapterPlugin { }); /** - * All created block tool adapters, kept so their blockIndex can be updated - * when blocks are inserted or removed. + * Map of active BlockToolAdapter instances keyed by block id. + * Used to properly destroy adapters (remove their event listeners) when blocks are removed. */ - #adapters: DOMBlockToolAdapter[] = []; + #adapters: Map = new Map(); /** * @param params - Plugin parameters @@ -50,51 +52,42 @@ export class DOMAdapters implements EditorJSAdapterPlugin { } /** - * Creates a BlockToolAdapter for a block inserted at the given index. - * Shifts registry entries and existing adapter indices before the new - * adapter is created, keeping everything consistent. - * @param blockIndex - position at which the new block is being inserted + * Creates a BlockToolAdapter for the block with the given id. + * @param blockId - unique id of the block being inserted * @param toolName - name of the tool for this block */ - public createBlockToolAdapter(blockIndex: number, toolName: string): BlockToolAdapter { + public createBlockToolAdapter(blockId: BlockId, toolName: string): BlockToolAdapter { const registry = this.#iocContainer.get(InputsRegistry); - registry.insertBlock(blockIndex); - - this.#adapters.forEach((adapter) => { - if (adapter.getBlockIndex() >= blockIndex) { - adapter.setBlockIndex(adapter.getBlockIndex() + 1); - } - }); + registry.insertBlock(blockId); const adapter = this.#iocContainer.get(DOMBlockToolAdapter); - adapter.setBlockIndex(blockIndex); + adapter.setBlockId(blockId); adapter.setToolName(toolName); - this.#adapters.splice(blockIndex, 0, adapter); + this.#adapters.set(blockId, adapter); return adapter; } /** - * Destroys the BlockToolAdapter for the block at the given index. + * Destroys the BlockToolAdapter for the given block. * Cleans up all inputs registered for the block and removes the adapter instance. * Called by BlockRenderer when a block is removed from the model. - * @param blockIndex - index of the removed block + * @param blockId - unique id of the removed block */ - public destroyBlockToolAdapter(blockIndex: number): void { - const registry = this.#iocContainer.get(InputsRegistry); + public destroyBlockToolAdapter(blockId: BlockId): void { + const adapter = this.#adapters.get(blockId); - registry.removeBlock(blockIndex); + if (adapter !== undefined) { + adapter.destroy(); + this.#adapters.delete(blockId); + } - this.#adapters.splice(blockIndex, 1); + const registry = this.#iocContainer.get(InputsRegistry); - this.#adapters.forEach((adapter) => { - if (adapter.getBlockIndex() > blockIndex) { - adapter.setBlockIndex(adapter.getBlockIndex() - 1); - } - }); + registry.removeBlock(blockId); } } diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index d461b739..0e65a8c6 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -3,6 +3,7 @@ import { beforeEach, describe } from '@jest/globals'; import { EditorJSModel } from './EditorJSModel.js'; import { createDataKey, IndexBuilder } from './entities/index.js'; import type { DocumentId } from './EventBus/index.js'; +import type { BlockId } from './entities/BlockNode/index.js'; describe('EditorJSModel', () => { it('should expose only the public API', () => { @@ -36,12 +37,220 @@ describe('EditorJSModel', () => { 'updateCaret', 'removeCaret', 'devModeGetDocument', + 'getBlockId', + 'getBlockIndexById', + 'getBlockSerialized', ]; const ownProperties = Object.getOwnPropertyNames(EditorJSModel.prototype); expect(ownProperties.sort()).toEqual(allowedMethods.sort()); }); + describe('.getBlockId()', () => { + const userId = 'user'; + const documentId = 'doc'; + let model: EditorJSModel; + + beforeEach(() => { + model = new EditorJSModel(userId, { identifier: documentId as DocumentId }); + model.initializeDocument({ + blocks: [ + { name: 'paragraph', + data: {} }, + { name: 'header', + data: {} }, + ], + }); + }); + + it('should return the block id for a valid index', () => { + const id = model.getBlockId(0); + + expect(id).toBeDefined(); + expect(typeof id).toBe('string'); + }); + + it('should return different ids for different blocks', () => { + const id0 = model.getBlockId(0); + const id1 = model.getBlockId(1); + + expect(id0).not.toBe(id1); + }); + + it('should return undefined for a negative index', () => { + expect(model.getBlockId(-1)).toBeUndefined(); + }); + + it('should return undefined for an index equal to the document length', () => { + expect(model.getBlockId(2)).toBeUndefined(); + }); + + it('should return undefined for an index greater than the document length', () => { + expect(model.getBlockId(99)).toBeUndefined(); + }); + }); + + describe('.getBlockIndexById()', () => { + const userId = 'user'; + const documentId = 'doc'; + let model: EditorJSModel; + + beforeEach(() => { + model = new EditorJSModel(userId, { identifier: documentId as DocumentId }); + model.initializeDocument({ + blocks: [ + { name: 'paragraph', + data: {} }, + { name: 'header', + data: {} }, + ], + }); + }); + + it('should return the correct index for the first block', () => { + const id = model.getBlockId(0) as BlockId; + + expect(model.getBlockIndexById(id)).toBe(0); + }); + + it('should return the correct index for a non-first block', () => { + const id = model.getBlockId(1) as BlockId; + + expect(model.getBlockIndexById(id)).toBe(1); + }); + + it('should return -1 for a non-existent id', () => { + expect(model.getBlockIndexById('non-existent-id')).toBe(-1); + }); + }); + + describe('.getBlockSerialized()', () => { + const userId = 'user'; + const documentId = 'doc'; + let model: EditorJSModel; + + beforeEach(() => { + model = new EditorJSModel(userId, { identifier: documentId as DocumentId }); + model.initializeDocument({ + blocks: [ + { name: 'paragraph', + data: {} }, + { name: 'header', + data: {} }, + ], + }); + }); + + it('should return serialized block at the specified index', () => { + const serialized = model.getBlockSerialized(0); + + expect(serialized).toMatchObject({ name: 'paragraph' }); + }); + + it('should return the correct block when second index is specified', () => { + const serialized = model.getBlockSerialized(1); + + expect(serialized).toMatchObject({ name: 'header' }); + }); + + it('should include id in the serialized block', () => { + const serialized = model.getBlockSerialized(0); + + expect(serialized.id).toBeDefined(); + }); + + it('should return serialized block when addressed by BlockId', () => { + const id = model.getBlockId(1) as BlockId; + const serialized = model.getBlockSerialized(id); + + expect(serialized).toMatchObject({ name: 'header' }); + expect(serialized.id).toBe(id); + }); + }); + + describe('.getCaret()', () => { + const userId = 'user'; + const documentId = 'doc'; + let model: EditorJSModel; + + beforeEach(() => { + model = new EditorJSModel(userId, { identifier: documentId as DocumentId }); + model.initializeDocument({ + blocks: [ + { + name: 'paragraph', + data: { text: { $t: 't', + value: '' } }, + }, + ], + }); + }); + + it('should return undefined when no caret has been created for the user', () => { + expect(model.getCaret(userId)).toBeUndefined(); + }); + + it('should return the caret after it has been created', () => { + const index = new IndexBuilder() + .addDocumentId(documentId as DocumentId) + .addBlockIndex(0) + .build(); + + model.createCaret(userId, index); + + expect(model.getCaret(userId)).toBeDefined(); + }); + + it('should return undefined for a different user id than the one that created the caret', () => { + const index = new IndexBuilder() + .addDocumentId(documentId as DocumentId) + .addBlockIndex(0) + .build(); + + model.createCaret(userId, index); + + expect(model.getCaret('someone-else')).toBeUndefined(); + }); + }); + + describe('.getDataNode()', () => { + const userId = 'user'; + const documentId = 'doc'; + let model: EditorJSModel; + + beforeEach(() => { + model = new EditorJSModel(userId, { identifier: documentId as DocumentId }); + model.initializeDocument({ + blocks: [ + { + name: 'paragraph', + data: { + text: { $t: 't', + value: 'hello' }, + }, + }, + ], + }); + }); + + it('should return the serialized data node for the specified block index and key', async () => { + // DataNodeAdded events are queued as microtasks, flush before asserting + await Promise.resolve(); + + const node = model.getDataNode(userId, 0, 'text'); + + expect(node).toBeDefined(); + }); + + it('should return undefined for a non-existent key', async () => { + await Promise.resolve(); + + const node = model.getDataNode(userId, 0, 'nonexistent'); + + expect(node).toBeUndefined(); + }); + }); + describe('Caret updates on remote operations', () => { const currentUserId = 'currentUser'; const remoteUserId = 'remoteUser'; diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index 71872c6d..a9a7f97b 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -1,7 +1,7 @@ // Stryker disable all -- we don't count mutation test coverage fot this file as it just proxy calls to EditorDocument /* istanbul ignore file -- we don't count test coverage fot this file as it just proxy calls to EditorDocument */ import { type EditorDocumentSerialized, type Index, IndexBuilder } from './entities/index.js'; -import { type BlockNodeSerialized, EditorDocument } from './entities/index.js'; +import { type BlockNodeSerialized, type BlockNodeInit, type BlockId, type BlockIndexOrId, EditorDocument } from './entities/index.js'; import { BlockAddedEvent, BlockRemovedEvent, EventAction, @@ -108,7 +108,7 @@ export class EditorJSModel extends EventBus { * Fills the EditorDocument with the provided blocks. * @param document - document data to initialize */ - public initializeDocument(document: Partial & Pick): void { + public initializeDocument(document: Partial> & { blocks: BlockNodeInit[] }): void { this.#document.initialize(document); } @@ -171,6 +171,36 @@ export class EditorJSModel extends EventBus { return this.#document.getProperty(...parameters); } + /** + * Returns the BlockId for a block at the given index. + * Returns undefined if no block exists at that index. + * @param blockIndex - index of the block + */ + public getBlockId(blockIndex: number): BlockId | undefined { + if (blockIndex < 0 || blockIndex >= this.length) { + return undefined; + } + + return this.#document.getBlock(blockIndex).id; + } + + /** + * Returns the index of the block with the given id. + * Returns -1 if no block with that id exists. + * @param id - block id + */ + public getBlockIndexById(id: BlockId | string): number { + return this.#document.getBlockIndexById(id); + } + + /** + * Returns the serialized form of a single block without serializing the whole document. + * @param blockIndexOrId - index or block id to look up + */ + public getBlockSerialized(blockIndexOrId: BlockIndexOrId): BlockNodeSerialized { + return this.#document.getBlock(blockIndexOrId).serialized; + } + /** * Updates a property of the EditorDocument. * Adds the property if it does not exist. @@ -217,7 +247,7 @@ export class EditorJSModel extends EventBus { * @param data - data to insert (text or blocks) */ @WithContext - public insertData(_userId: string | number | undefined, index: Index, data: string | BlockNodeSerialized[]): void { + public insertData(_userId: string | number | undefined, index: Index, data: string | BlockNodeInit[]): void { this.#document.insertData(index, data); } @@ -228,7 +258,7 @@ export class EditorJSModel extends EventBus { * @param data - text or blocks to remove */ @WithContext - public removeData(_userId: string | number | undefined, index: Index, data: string | BlockNodeSerialized[]): void { + public removeData(_userId: string | number | undefined, index: Index, data: string | BlockNodeInit[]): void { this.#document.removeData(index, data); } diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index b90ab8c5..fad3e79c 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -1,7 +1,7 @@ import { EventAction } from '../../EventBus/index.js'; import { Index } from '../Index/index.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; -import { BlockNode, createBlockToolName, createDataKey } from './index.js'; +import { BlockNode, createBlockToolName, createDataKey, createBlockId } from './index.js'; import { NonExistingKeyError } from './errors/NonExistingKeyError.js'; import type { BlockTuneName, BlockTuneSerialized } from '../BlockTune/index.js'; @@ -69,6 +69,32 @@ describe('BlockNode', () => { }); }); + describe('.id', () => { + it('should use the provided id', () => { + const expectedId = createBlockId('my-custom-id'); + const node = new BlockNode({ + name: createBlockToolName('paragraph'), + id: expectedId, + }); + + expect(node.id).toBe(expectedId); + }); + + it('should auto-generate an id when none is provided', () => { + const node = new BlockNode({ name: createBlockToolName('paragraph') }); + + expect(node.id).toBeDefined(); + expect(typeof node.id).toBe('string'); + }); + + it('should generate different ids for different instances', () => { + const node1 = new BlockNode({ name: createBlockToolName('paragraph') }); + const node2 = new BlockNode({ name: createBlockToolName('paragraph') }); + + expect(node1.id).not.toBe(node2.id); + }); + }); + describe('.serialized', () => { afterEach(() => { jest.clearAllMocks(); diff --git a/packages/model/src/entities/BlockNode/__mocks__/index.ts b/packages/model/src/entities/BlockNode/__mocks__/index.ts index 28724161..70899bb2 100644 --- a/packages/model/src/entities/BlockNode/__mocks__/index.ts +++ b/packages/model/src/entities/BlockNode/__mocks__/index.ts @@ -1,13 +1,37 @@ import { EventBus } from '../../../EventBus/EventBus.js'; import { create } from '../../../utils/index.js'; import type { DataKey } from '../types/index.js'; +import type { BlockId } from '../types/BlockId.js'; +import { createBlockId } from '../types/BlockId.js'; export const createDataKey = create(); +let _instanceCounter = 0; + /** * Mock for BlockNode class */ export class BlockNode extends EventBus { + /** + * Unique id per mock instance + */ + readonly #id: BlockId; + + /** + * Mock constructor — assigns a unique id to each instance + */ + constructor() { + super(); + this.#id = createBlockId(`mock-block-${++_instanceCounter}`); + } + + /** + * Mock getter + */ + public get id(): BlockId { + return this.#id; + } + /** * Mock method */ diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index 13c4b698..c0b11e11 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -17,6 +17,8 @@ import type { DataKey } from './types/index.js'; import { BlockChildType, createBlockToolName, createDataKey } from './types/index.js'; +import type { BlockId } from './types/index.js'; +import { createBlockId, generateBlockId } from './types/index.js'; import type { ValueSerialized } from '../ValueNode/index.js'; import { ValueNode } from '../ValueNode/index.js'; import type { InlineFragment, InlineToolData, InlineToolName, TextNodeSerialized } from '../inline-fragments/index.js'; @@ -44,6 +46,11 @@ import { AlreadyExistingKeyError } from './errors/AlreadyExistingKeyError.js'; * It can also be associated with one or more BlockTunes, which can modify the behavior of the BlockNode. */ export class BlockNode extends EventBus { + /** + * Unique identifier of the Block + */ + #id: BlockId; + /** * Field representing a name of the Tool created this Block */ @@ -67,12 +74,14 @@ export class BlockNode extends EventBus { /** * Constructor for BlockNode class. * @param args - BlockNode constructor arguments. + * @param [args.id] - The unique identifier of the BlockNode. Auto-generated if not provided. * @param args.name - The name of the BlockNode. * @param [args.data] - The content of the BlockNode. * @param [args.parent] - The parent EditorDocument of the BlockNode. * @param [args.tunes] - The BlockTunes associated with the BlockNode. */ constructor({ + id, name, data = {}, parent, @@ -80,6 +89,7 @@ export class BlockNode extends EventBus { }: BlockNodeConstructorParameters) { super(); + this.#id = id !== undefined ? createBlockId(id) : generateBlockId(); this.#name = createBlockToolName(name); this.#parent = parent ?? null; this.#tunes = mapObject( @@ -113,6 +123,13 @@ export class BlockNode extends EventBus { return this.#data; } + /** + * Allows accessing Block unique identifier + */ + public get id(): BlockId { + return this.#id; + } + /** * Getter to access BlockNode parent */ @@ -121,7 +138,7 @@ export class BlockNode extends EventBus { } /** - * Getter to access BlockNode data + * Getter to access BlockNode tunes */ public get tunes(): Readonly> { return this.#tunes; @@ -142,6 +159,7 @@ export class BlockNode extends EventBus { ); return { + id: this.#id, name: this.#name, data: serializedData, tunes: serializedTunes, @@ -190,6 +208,13 @@ export class BlockNode extends EventBus { .addDataKey(dataKey) .build(); + /** + * Capture the context synchronously before entering the microtask, + * because the WithContext wrapper will have already popped the stack + * by the time the queued callback runs. + */ + const userId = getContext(); + /** * Need to delay the event so the order is * 1. BlockNodeAdded @@ -198,7 +223,7 @@ export class BlockNode extends EventBus { * If done in sync, DataNodeAdded would be fired first */ queueMicrotask(() => { - this.dispatchEvent(new DataNodeAddedEvent(index, data, getContext()!)); + this.dispatchEvent(new DataNodeAddedEvent(index, data, userId!)); }); }; @@ -559,12 +584,17 @@ export class BlockNode extends EventBus { export type { BlockToolName, DataKey, - BlockNodeSerialized + BlockNodeSerialized, + BlockId }; +export type { BlockNodeInit, BlockIndexOrId } from './types/index.js'; + export { createBlockToolName, - createDataKey + createDataKey, + createBlockId, + generateBlockId }; export { NODE_TYPE_HIDDEN_PROP } from './consts.js'; diff --git a/packages/model/src/entities/BlockNode/types/BlockId.ts b/packages/model/src/entities/BlockNode/types/BlockId.ts new file mode 100644 index 00000000..87bd8036 --- /dev/null +++ b/packages/model/src/entities/BlockNode/types/BlockId.ts @@ -0,0 +1,45 @@ +import type { Nominal } from '../../../utils/Nominal.js'; +import { create } from '../../../utils/Nominal.js'; + +/** + * Base type of the block node identifier field + */ +type BlockIdBase = string; + +/** + * Nominal type for a unique block identifier + */ +export type BlockId = Nominal; + +/** + * Accepts either a numeric block index or a block's unique string identifier. + * Pass a number to address a block by position; pass a BlockId to address by id. + */ +export type BlockIndexOrId = number | BlockId; + +/** + * Function returns a value with the nominal BlockId type + */ +export const createBlockId = create(); + +/** + * URL-safe alphabet used for ID generation + */ +// Stryker disable next-line StringLiteral +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; + +/** + * Length of generated block IDs (21 chars ≈ 126 bits of randomness, matching nanoid defaults) + */ +const ID_LENGTH = 21; + +/** + * Generates a new unique BlockId — a 21-character URL-safe random string + */ +export const generateBlockId = (): BlockId => { + const bytes = crypto.getRandomValues(new Uint8Array(ID_LENGTH)); + + /* Stryker disable next-line ArithmeticOperator -- bitwise mask is intentional, not an arithmetic mutation target */ + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + return createBlockId(Array.from(bytes, byte => ALPHABET[byte & 63]).join('')); +}; diff --git a/packages/model/src/entities/BlockNode/types/BlockNodeConstructorParameters.ts b/packages/model/src/entities/BlockNode/types/BlockNodeConstructorParameters.ts index dc651e87..902c8827 100644 --- a/packages/model/src/entities/BlockNode/types/BlockNodeConstructorParameters.ts +++ b/packages/model/src/entities/BlockNode/types/BlockNodeConstructorParameters.ts @@ -1,8 +1,15 @@ import type { EditorDocument } from '../../EditorDocument/index.js'; import type { BlockTuneSerialized } from '../../BlockTune/index.js'; import type { BlockNodeDataSerialized } from './BlockNodeSerialized.js'; +import type { BlockId } from './BlockId.js'; export interface BlockNodeConstructorParameters { + /** + * Unique identifier of the Block. + * If not provided, a new UUID will be generated. + */ + id?: BlockId | string; + /** * The name of the tool created a Block */ diff --git a/packages/model/src/entities/BlockNode/types/BlockNodeSerialized.ts b/packages/model/src/entities/BlockNode/types/BlockNodeSerialized.ts index 444c3983..8796a300 100644 --- a/packages/model/src/entities/BlockNode/types/BlockNodeSerialized.ts +++ b/packages/model/src/entities/BlockNode/types/BlockNodeSerialized.ts @@ -1,6 +1,7 @@ import type { BlockTuneSerialized } from '../../BlockTune/index.js'; import type { ValueSerialized } from '../../ValueNode/types/ValueSerialized.js'; import type { TextNodeSerialized } from '../../inline-fragments/index.js'; +import type { BlockId } from './BlockId.js'; /** * Union type of serialized BlockNode child nodes @@ -23,6 +24,11 @@ export interface BlockNodeDataSerialized { * Serialized version of the BlockNode */ export interface BlockNodeSerialized { + /** + * Unique identifier of the Block + */ + id: string; + /** * The name of the tool created a Block */ @@ -38,3 +44,10 @@ export interface BlockNodeSerialized { */ tunes?: Record; } + +/** + * Input type for creating a BlockNode. + * Only `name` is required — all other fields (including `id`) are optional. + * A unique id will be auto-generated when omitted. + */ +export type BlockNodeInit = { name: string } & Partial>; diff --git a/packages/model/src/entities/BlockNode/types/index.ts b/packages/model/src/entities/BlockNode/types/index.ts index 86607734..6c0e5e05 100644 --- a/packages/model/src/entities/BlockNode/types/index.ts +++ b/packages/model/src/entities/BlockNode/types/index.ts @@ -1,8 +1,10 @@ export type { BlockNodeConstructorParameters } from './BlockNodeConstructorParameters.js'; export type { BlockToolName } from './BlockToolName.js'; export { createBlockToolName } from './BlockToolName.js'; +export type { BlockId, BlockIndexOrId } from './BlockId.js'; +export { createBlockId, generateBlockId } from './BlockId.js'; export type { DataKey } from './DataKey.js'; export { createDataKey } from './DataKey.js'; export type { BlockNodeData, ChildNode, BlockNodeDataValue } from './BlockNodeData.js'; -export type { BlockNodeSerialized, BlockChildNodeSerialized, BlockNodeDataSerialized, BlockNodeDataSerializedValue } from './BlockNodeSerialized.js'; +export type { BlockNodeSerialized, BlockChildNodeSerialized, BlockNodeDataSerialized, BlockNodeDataSerializedValue, BlockNodeInit } from './BlockNodeSerialized.js'; export { BlockChildType } from './BlockChildType.js'; diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index 155a991c..81f51629 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -2,6 +2,7 @@ import { IndexBuilder } from '../Index/IndexBuilder.js'; import { EditorDocument } from './index.js'; import type { BlockToolName, DataKey } from '../BlockNode/index.js'; import { BlockNode } from '../BlockNode/index.js'; +import { createBlockId } from '../BlockNode/types/BlockId.js'; import type { BlockTuneName } from '../BlockTune/index.js'; import type { InlineToolData, InlineToolName } from '../inline-fragments/index.js'; import { EventType } from '../../EventBus/types/EventType.js'; @@ -13,6 +14,7 @@ import { } from '../../EventBus/events/index.js'; import { EventAction } from '../../EventBus/types/EventAction.js'; import { describe, jest } from '@jest/globals'; +import { BlockAlreadyExistsError } from './errors/BlockAlreadyExistsError.js'; jest.mock('../BlockNode'); @@ -48,6 +50,7 @@ function createEditorDocumentWithSomeBlocks(): EditorDocument { describe('EditorDocument', () => { afterEach(() => { jest.clearAllMocks(); + jest.restoreAllMocks(); }); describe('.initialize()', () => { @@ -319,10 +322,23 @@ describe('EditorDocument', () => { .toThrowError('Index out of bounds'); }); + it('should throw BlockAlreadyExistsError when a block with the same id is added twice', () => { + const document = new EditorDocument({ identifier: 'document' }); + const duplicateId = createBlockId('duplicate'); + + jest.spyOn(BlockNode.prototype, 'id', 'get').mockReturnValue(duplicateId); + + document.addBlock({ name: 'header' as BlockToolName }); + + expect(() => document.addBlock({ name: 'paragraph' as BlockToolName })) + .toThrow(BlockAlreadyExistsError); + }); + it('should emit BlockAddedEvent with block node data in details and block index', () => { const document = createEditorDocumentWithSomeBlocks(); const index = 1; const blockData = { + id: createBlockId('test-block'), name: 'header-1a2b' as BlockToolName, data: { level: 1, @@ -420,6 +436,7 @@ describe('EditorDocument', () => { const index = 1; const blockData = { + id: createBlockId('test-block'), name: 'header' as BlockToolName, data: { level: 1, @@ -493,6 +510,57 @@ describe('EditorDocument', () => { }); }); + describe('.getBlockById()', () => { + it('should return the BlockNode with the given id', () => { + const document = new EditorDocument({ identifier: 'document' }); + const expectedId = createBlockId('find-me'); + + jest.spyOn(BlockNode.prototype, 'id', 'get').mockReturnValue(expectedId); + + document.addBlock({ name: 'header' as BlockToolName }); + + const found = document.getBlockById(expectedId); + + expect(found).toBeInstanceOf(BlockNode); + expect(found!.id).toBe(expectedId); + }); + + it('should return undefined when id does not exist', () => { + const document = createEditorDocumentWithSomeBlocks(); + + const result = document.getBlockById(createBlockId('non-existent')); + + expect(result).toBeUndefined(); + }); + }); + + describe('.getBlockIndexById()', () => { + it('should return the correct index for an existing block id', () => { + const document = new EditorDocument({ identifier: 'document' }); + const expectedId = createBlockId('my-block'); + + jest.spyOn(BlockNode.prototype, 'id', 'get').mockReturnValue(expectedId); + + document.addBlock({ name: 'header' as BlockToolName }); + + expect(document.getBlockIndexById(expectedId)).toBe(0); + }); + + it('should return -1 for a non-existent block id', () => { + const document = createEditorDocumentWithSomeBlocks(); + + expect(document.getBlockIndexById(createBlockId('does-not-exist'))).toBe(-1); + }); + + it('should throw an error when a string id is passed to a mutating method and the id does not exist', () => { + const document = createEditorDocumentWithSomeBlocks(); + const nonExistentId = createBlockId('ghost'); + + expect(() => document.removeBlock(nonExistentId)) + .toThrowError(`Block with id "${nonExistentId}" not found`); + }); + }); + describe('.properties', () => { it('should return the properties of the document', () => { const properties = { diff --git a/packages/model/src/entities/EditorDocument/errors/BlockAlreadyExistsError.ts b/packages/model/src/entities/EditorDocument/errors/BlockAlreadyExistsError.ts new file mode 100644 index 00000000..e679cf38 --- /dev/null +++ b/packages/model/src/entities/EditorDocument/errors/BlockAlreadyExistsError.ts @@ -0,0 +1,14 @@ +import type { BlockId } from '../../BlockNode/types/BlockId.js'; + +/** + * Error thrown when attempting to add a block whose id already exists in the document. + */ +export class BlockAlreadyExistsError extends Error { + /** + * @param blockId - The id of the block that already exists + */ + constructor(blockId: BlockId) { + super(`EditorDocument: block with id "${blockId}" already exists`); + this.name = 'BlockAlreadyExistsError'; + } +} diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index 5d1caae8..ec184c02 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -2,6 +2,8 @@ import type { DocumentId } from '../../EventBus/index.js'; import { getContext } from '../../utils/Context.js'; import { createDataKey, type DataKey } from '../BlockNode/index.js'; import { BlockNode } from '../BlockNode/index.js'; +import type { BlockId } from '../BlockNode/index.js'; +import type { BlockIndexOrId } from '../BlockNode/index.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; import type { EditorDocumentSerialized, EditorDocumentConstructorParameters, Properties } from './types/index.js'; import type { BlockTuneName } from '../BlockTune/index.js'; @@ -15,7 +17,7 @@ import { } from '../inline-fragments/index.js'; import { IoCContainer, TOOLS_REGISTRY } from '../../IoC/index.js'; import { ToolsRegistry } from '../../tools/index.js'; -import type { BlockNodeDataSerializedValue, BlockNodeSerialized } from '../BlockNode/types/index.js'; +import type { BlockNodeDataSerializedValue, BlockNodeSerialized, BlockNodeInit } from '../BlockNode/types/index.js'; import type { DeepReadonly } from '../../utils/DeepReadonly.js'; import { EventBus } from '../../EventBus/EventBus.js'; import { EventType } from '../../EventBus/types/EventType.js'; @@ -33,6 +35,7 @@ import type { Constructor } from '../../utils/types.js'; import { BaseDocumentEvent, type ModifiedEventData } from '../../EventBus/events/BaseEvent.js'; import type { Index } from '../Index/index.js'; import type { ValueSerialized } from '../ValueNode/index.js'; +import { BlockAlreadyExistsError } from './errors/BlockAlreadyExistsError.js'; export type * from './types/index.js'; @@ -51,6 +54,12 @@ export class EditorDocument extends EventBus { */ #children: BlockNode[] = []; + /** + * lookup index: maps each block's id to its BlockNode instance. + * Kept in sync with #children by addBlock, removeBlock, and clear. + */ + #blockById: Map = new Map(); + /** * Private field representing the properties of the document */ @@ -86,7 +95,7 @@ export class EditorDocument extends EventBus { * Initializes EditorDocument with passed blocks * @param document - document serialized data */ - public initialize(document: Partial & Pick): void { + public initialize(document: Partial> & { blocks: BlockNodeInit[] }): void { this.clear(); if (document.identifier !== undefined) { @@ -120,16 +129,20 @@ export class EditorDocument extends EventBus { /** * Adds a BlockNode to the EditorDocument at the specified index. * If no index is provided, the BlockNode will be added to the end of the array. - * @param blockNodeData - The data to create the BlockNode with + * @param blockNodeData - The data to create the BlockNode with. The `id` field is optional — auto-generated when omitted. * @param index - The index at which to add the BlockNode * @throws Error if the index is out of bounds */ - public addBlock(blockNodeData: Pick & Partial>, index?: number): void { + public addBlock(blockNodeData: BlockNodeInit, index?: number): void { const blockNode = new BlockNode({ ...blockNodeData, parent: this, }); + if (blockNode.id !== undefined && this.#blockById.has(blockNode.id)) { + throw new BlockAlreadyExistsError(blockNode.id); + } + if (index === undefined) { this.#children.push(blockNode); @@ -140,6 +153,7 @@ export class EditorDocument extends EventBus { this.#children.splice(index, 0, blockNode); } + this.#blockById.set(blockNode.id, blockNode); this.#listenAndBubbleBlockEvent(blockNode); const builder = new IndexBuilder(); @@ -155,67 +169,103 @@ export class EditorDocument extends EventBus { } /** - * Removes a BlockNode from the EditorDocument at the specified index. - * @param index - The index of the BlockNode to remove + * Removes a BlockNode from the EditorDocument at the specified index or by id. + * @param indexOrId - The index or block id of the BlockNode to remove * @throws Error if the index is out of bounds */ - public removeBlock(index: number): void { - this.#checkIndexOutOfBounds(index, this.length - 1); + public removeBlock(indexOrId: BlockIndexOrId): void { + const resolvedIndex = this.#resolveBlockIndex(indexOrId); - const [blockNode] = this.#children.splice(index, 1); + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); + + const [blockNode] = this.#children.splice(resolvedIndex, 1); + + this.#blockById.delete(blockNode.id); const builder = new IndexBuilder(); - builder.addBlockIndex(index); + builder.addBlockIndex(resolvedIndex); this.dispatchEvent(new BlockRemovedEvent(builder.build(), blockNode.serialized, getContext()!)); } /** - * Returns the BlockNode at the specified index. - * Throws an error if the index is out of bounds. - * @param index - The index of the BlockNode to return + * Returns the BlockNode at the specified index or by id. + * Throws an error if the index is out of bounds or id is not found. + * @param indexOrId - The index or block id of the BlockNode to return * @throws Error if the index is out of bounds */ - public getBlock(index: number): BlockNode { - this.#checkIndexOutOfBounds(index, this.length - 1); + public getBlock(indexOrId: BlockIndexOrId): BlockNode { + const resolvedIndex = this.#resolveBlockIndex(indexOrId); + + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); + + return this.#children[resolvedIndex]; + } + + /** + * Returns the BlockNode with the specified unique identifier in O(1). + * Returns undefined if no block with that id exists. + * @param id - The unique block identifier + */ + public getBlockById(id: BlockId | string): BlockNode | undefined { + return this.#blockById.get(id as BlockId); + } + + /** + * Returns the index of the BlockNode with the specified unique identifier. + * Returns -1 if no block with that id exists. + * @param id - The unique block identifier + */ + public getBlockIndexById(id: BlockId | string): number { + const block = this.#blockById.get(id as BlockId); + + if (block === undefined) { + return -1; + } - return this.#children[index]; + return this.#children.indexOf(block); } /** - * Creates a data node with passed key with initial data for the BlockNode at specified index + * Creates a data node with passed key with initial data for the BlockNode at specified index or id * Throws an error if the index is out of bounds. - * @param index - block index + * @param blockIndexOrId - block index or block id * @param key - key for the node * @param data - initial data of the node */ - public createDataNode(index: number, key: DataKey | string, data: BlockNodeDataSerializedValue): void { - this.#checkIndexOutOfBounds(index, this.length - 1); + public createDataNode(blockIndexOrId: BlockIndexOrId, key: DataKey | string, data: BlockNodeDataSerializedValue): void { + const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); - this.#children[index].createDataNode(createDataKey(key), data); + this.#children[resolvedIndex].createDataNode(createDataKey(key), data); } /** - * Removes a data node with the passed key in the BlockNode at the specified index - * @param index - block index + * Removes a data node with the passed key in the BlockNode at the specified index or id + * @param indexOrId - block index or block id * @param key - key of the node to remove */ - public removeDataNode(index: number, key: DataKey | string): void { - this.#checkIndexOutOfBounds(index, this.length - 1); + public removeDataNode(indexOrId: BlockIndexOrId, key: DataKey | string): void { + const resolvedIndex = this.#resolveBlockIndex(indexOrId); + + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); - this.#children[index].removeDataNode(createDataKey(key)); + this.#children[resolvedIndex].removeDataNode(createDataKey(key)); } /** - * Returns data node by the block index and data key - * @param index - block index where data node is stored + * Returns data node by the block index or id and data key + * @param indexOrId - block index or block id where data node is stored * @param key - data key of the data node */ - public getDataNode(index: number, key: DataKey | string): ValueSerialized | TextNodeSerialized | undefined { - this.#checkIndexOutOfBounds(index, this.length - 1); + public getDataNode(indexOrId: BlockIndexOrId, key: DataKey | string): ValueSerialized | TextNodeSerialized | undefined { + const resolvedIndex = this.#resolveBlockIndex(indexOrId); - return this.#children[index].getDataNode(createDataKey(key)); + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); + + return this.#children[resolvedIndex].getDataNode(createDataKey(key)); } /** @@ -262,95 +312,109 @@ export class EditorDocument extends EventBus { } /** - * Updates the ValueNode data associated with the BlockNode at the specified index. - * @param blockIndex - The index of the BlockNode to update + * Updates the ValueNode data associated with the BlockNode at the specified index or id. + * @param blockIndexOrId - The index or block id of the BlockNode to update * @param dataKey - The key of the ValueNode to update * @param value - The new value of the ValueNode * @throws Error if the index is out of bounds */ - public updateValue(blockIndex: number, dataKey: DataKey, value: T): void { - this.#checkIndexOutOfBounds(blockIndex, this.length - 1); + public updateValue(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, value: T): void { + const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); - this.#children[blockIndex].updateValue(dataKey, value); + this.#children[resolvedIndex].updateValue(dataKey, value); } /** - * Updates BlockTune data associated with the BlockNode at the specified index. - * @param blockIndex - The index of the BlockNode to update + * Updates BlockTune data associated with the BlockNode at the specified index or id. + * @param blockIndexOrId - The index or block id of the BlockNode to update * @param tuneName - The name of the BlockTune to update * @param data - The data to update the BlockTune with * @throws Error if the index is out of bounds */ - public updateTuneData(blockIndex: number, tuneName: BlockTuneName, data: Record): void { - this.#checkIndexOutOfBounds(blockIndex, this.length - 1); + public updateTuneData(blockIndexOrId: BlockIndexOrId, tuneName: BlockTuneName, data: Record): void { + const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); - this.#children[blockIndex].updateTuneData(tuneName, data); + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); + + this.#children[resolvedIndex].updateTuneData(tuneName, data); } /** * Returns text for the specified block and data key - * @param blockIndex - index of the block + * @param blockIndexOrId - index or block id of the block * @param dataKey - key of the data containing the text */ - public getText(blockIndex: number, dataKey: DataKey): string { - this.#checkIndexOutOfBounds(blockIndex, this.length - 1); + public getText(blockIndexOrId: BlockIndexOrId, dataKey: DataKey): string { + const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); - return this.#children[blockIndex].getText(dataKey); + return this.#children[resolvedIndex].getText(dataKey); } /** * Inserts text to the specified block - * @param blockIndex - index of the block + * @param blockIndexOrId - index or block id of the block * @param dataKey - key of the data * @param text - text to insert * @param [start] - char index where to insert text */ - public insertText(blockIndex: number, dataKey: DataKey, text: string, start?: number): void { - this.#checkIndexOutOfBounds(blockIndex, this.length - 1); + public insertText(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, text: string, start?: number): void { + const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); - this.#children[blockIndex].insertText(dataKey, text, start); + this.#children[resolvedIndex].insertText(dataKey, text, start); } /** * Removes text from specified block - * @param blockIndex - index of the block + * @param blockIndexOrId - index or block id of the block * @param dataKey - key of the data * @param [start] - start char index of the range * @param [end] - end char index of the range */ - public removeText(blockIndex: number, dataKey: DataKey, start?: number, end?: number): string { - this.#checkIndexOutOfBounds(blockIndex, this.length - 1); + public removeText(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, start?: number, end?: number): string { + const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); - return this.#children[blockIndex].removeText(dataKey, start, end); + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); + + return this.#children[resolvedIndex].removeText(dataKey, start, end); } /** * Formats text in the specified block - * @param blockIndex - index of the block + * @param blockIndexOrId - index or block id of the block * @param dataKey - key of the data * @param tool - name of the Inline Tool to apply * @param start - start char index of the range * @param end - end char index of the range * @param [data] - Inline Tool data if applicable */ - public format(blockIndex: number, dataKey: DataKey, tool: InlineToolName, start: number, end: number, data?: InlineToolData): void { - this.#checkIndexOutOfBounds(blockIndex, this.length - 1); + public format(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, tool: InlineToolName, start: number, end: number, data?: InlineToolData): void { + const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); + + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); - this.#children[blockIndex].format(dataKey, tool, start, end, data); + this.#children[resolvedIndex].format(dataKey, tool, start, end, data); } /** * Removes formatting from the specified block - * @param blockIndex - index of the block + * @param blockIndexOrId - index or block id of the block * @param key - key of the data * @param tool - name of the Inline Tool to remove * @param start - start char index of the range * @param end - end char index of the range */ - public unformat(blockIndex: number, key: DataKey, tool: InlineToolName, start: number, end: number): void { - this.#checkIndexOutOfBounds(blockIndex, this.length - 1); + public unformat(blockIndexOrId: BlockIndexOrId, key: DataKey, tool: InlineToolName, start: number, end: number): void { + const resolvedIndex = this.#resolveBlockIndex(blockIndexOrId); - this.#children[blockIndex].unformat(key, tool, start, end); + this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); + + this.#children[resolvedIndex].unformat(key, tool, start, end); } /** @@ -370,14 +434,14 @@ export class EditorDocument extends EventBus { /** * Returns array of InlineFragment objects for the specified range - * @param blockIndex - index of the block + * @param blockIndexOrId - index or block id of the block * @param dataKey - key of the data * @param [start] - start char index of the range * @param [end] - end char index of the range * @param [tool] - name of the Inline Tool to filter by */ - public getFragments(blockIndex: number, dataKey: DataKey, start?: number, end?: number, tool?: InlineToolName): InlineFragment[] { - return this.#children[blockIndex].getFragments(dataKey, start, end, tool); + public getFragments(blockIndexOrId: BlockIndexOrId, dataKey: DataKey, start?: number, end?: number, tool?: InlineToolName): InlineFragment[] { + return this.#children[this.#resolveBlockIndex(blockIndexOrId)].getFragments(dataKey, start, end, tool); } /** @@ -385,7 +449,7 @@ export class EditorDocument extends EventBus { * @param index - index to insert data * @param data - data to insert (text or blocks) */ - public insertData(index: Index, data: string | BlockNodeSerialized[] | BlockNodeDataSerializedValue): void { + public insertData(index: Index, data: string | BlockNodeInit[] | BlockNodeDataSerializedValue): void { switch (true) { case index.isTextIndex: this.insertText(index.blockIndex!, index.dataKey!, data as string, index.textRange![0]); @@ -409,7 +473,7 @@ export class EditorDocument extends EventBus { * @param index - index to remove data from * @param data - text or blocks to remove */ - public removeData(index: Index, data: string | BlockNodeSerialized[] | BlockNodeDataSerializedValue): void { + public removeData(index: Index, data: string | BlockNodeInit[] | BlockNodeDataSerializedValue): void { switch (true) { case index.isTextIndex: this.removeText(index.blockIndex!, index.dataKey!, index.textRange![0], index.textRange![0] + (data as string).length); @@ -495,4 +559,25 @@ 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/mocks/data.ts b/packages/model/src/mocks/data.ts index 07a76411..cc3d7a42 100644 --- a/packages/model/src/mocks/data.ts +++ b/packages/model/src/mocks/data.ts @@ -1,12 +1,14 @@ // Stryker disable all /* eslint-disable @typescript-eslint/no-magic-numbers */ import type { EditorDocumentSerialized } from '../entities/EditorDocument/types/index.js'; +import { createBlockId } from '../entities/BlockNode/types/BlockId.js'; export const data: EditorDocumentSerialized = { identifier: 'document', properties: {}, blocks: [ { + id: createBlockId('block-mock-1'), name: 'header', data: { text: { @@ -17,6 +19,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-2'), name: 'paragraph', data: { text: { @@ -26,6 +29,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-3'), name: 'header', data: { text: { @@ -36,6 +40,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-4'), name: 'list', data: { items: [ @@ -65,6 +70,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-5'), name: 'header', data: { text: { @@ -75,6 +81,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-6'), name: 'paragraph', data: { text: { @@ -90,6 +97,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-7'), name: 'paragraph', data: { text: { @@ -115,6 +123,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-8'), name: 'header', data: { text: { @@ -125,6 +134,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-9'), name: 'paragraph', data: { text: { @@ -134,6 +144,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-10'), name: 'paragraph', data: { text: { @@ -165,6 +176,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-11'), name: 'paragraph', data: { text: { @@ -174,10 +186,12 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-12'), name: 'delimiter', data: {}, }, { + id: createBlockId('block-mock-13'), name: 'paragraph', data: { text: { @@ -187,6 +201,7 @@ export const data: EditorDocumentSerialized = { }, }, { + id: createBlockId('block-mock-14'), name: 'image', data: { url: 'assets/codex2x.png', diff --git a/packages/ot-server/src/DocumentManager.spec.ts b/packages/ot-server/src/DocumentManager.spec.ts index 4e1c0c23..15e9f409 100644 --- a/packages/ot-server/src/DocumentManager.spec.ts +++ b/packages/ot-server/src/DocumentManager.spec.ts @@ -79,7 +79,7 @@ describe('DocumentManager', () => { expect(manager.currentModelState()).toEqual({ identifier: 'document', blocks: [ - { + expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -89,7 +89,7 @@ describe('DocumentManager', () => { fragments: [], }, }, - }, + }), ], properties: {}, }); @@ -140,7 +140,7 @@ describe('DocumentManager', () => { expect(manager.currentModelState()).toEqual({ identifier: 'document', blocks: [ - { + expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -150,7 +150,7 @@ describe('DocumentManager', () => { fragments: [], }, }, - }, + }), ], properties: {}, }); @@ -210,7 +210,7 @@ describe('DocumentManager', () => { expect(manager.currentModelState()).toEqual({ identifier: 'document', blocks: [ - { + expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -220,7 +220,7 @@ describe('DocumentManager', () => { fragments: [], }, }, - }, + }), ], properties: {}, }); @@ -283,7 +283,7 @@ describe('DocumentManager', () => { expect(manager.currentModelState()).toEqual({ identifier: 'document', blocks: [ - { + expect.objectContaining({ name: 'paragraph', tunes: {}, data: { @@ -293,7 +293,7 @@ describe('DocumentManager', () => { fragments: [], }, }, - }, + }), ], properties: {}, }); diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index d3ef929b..a98f9340 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -1,5 +1,5 @@ import type { BlockToolData } from '@editorjs/editorjs'; -import type { BlockNodeSerialized, EditorDocumentSerialized } from '@editorjs/model'; +import type { BlockNodeInit, EditorDocumentSerialized } from '@editorjs/model'; /** * Blocks API interface @@ -96,7 +96,7 @@ export interface BlocksAPI { * @param [index] - index to insert blocks at. If undefined, inserts at the end */ insertMany( - blocks: BlockNodeSerialized[], + blocks: BlockNodeInit[], index?: number, ): void; // BlockAPI[]; diff --git a/packages/sdk/src/entities/BlockToolAdapter.ts b/packages/sdk/src/entities/BlockToolAdapter.ts index 9d37361e..e31cb47e 100644 --- a/packages/sdk/src/entities/BlockToolAdapter.ts +++ b/packages/sdk/src/entities/BlockToolAdapter.ts @@ -1,4 +1,4 @@ -import type { DataKey, EditorJSModel, EventBus, ModelEvents, TextNodeSerialized, ValueSerialized } from '@editorjs/model'; +import type { BlockId, DataKey, EditorJSModel, EventBus, ModelEvents, TextNodeSerialized, ValueSerialized } from '@editorjs/model'; import { createDataKey, DataNodeAddedEvent, @@ -21,9 +21,9 @@ export abstract class BlockToolAdapter extends EventTarget { protected model: EditorJSModel; /** - * Index of the block that this adapter is connected to + * Unique identifier of the block that this adapter is connected to */ - protected blockIndex: number = 0; + protected blockId!: BlockId; /** * Editor's config @@ -35,6 +35,11 @@ export abstract class BlockToolAdapter extends EventTarget { */ protected eventBus: EventBus; + /** + * Stored reference to the model change listener so it can be removed on destroy. + */ + #modelChangeListener: EventListener; + /** * @param config - editor's configuration * @param model - model instance @@ -47,22 +52,51 @@ export abstract class BlockToolAdapter extends EventTarget { this.config = config; this.eventBus = eventBus; - this.model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event)); + this.#modelChangeListener = ((event: ModelEvents) => this.#handleModelUpdate(event)) as EventListener; + this.model.addEventListener(EventType.Changed, this.#modelChangeListener); + } + + /** + * Releases all resources held by this adapter. + * Removes the model change listener registered in the constructor. + * Subclasses that register additional listeners should override this method, + * call `super.destroy()`, and then remove their own listeners. + */ + public destroy(): void { + this.model.removeEventListener(EventType.Changed, this.#modelChangeListener); } /** - * Updates the internal block index. + * Updates the block id the adapter is connected to. + * @param id - new block id + */ + public setBlockId(id: BlockId): void { + this.blockId = id; + } + + /** + * Returns the block id of the adapter + */ + public getBlockId(): BlockId { + return this.blockId; + } + + /** + * @deprecated Use {@link setBlockId} + {@link getBlockId} instead. + * Kept temporarily for backward compatibility while callers are migrated. + * Updates the internal block index (derived on demand from the model). * @param index - new block index value */ public setBlockIndex(index: number): void { - this.blockIndex = index; + void index; // no-op – adapters are now addressed by blockId } /** - * Returns block index of the adapter + * @deprecated Use {@link getBlockId} instead. + * Returns the current block index by asking the model. */ public getBlockIndex(): number { - return this.blockIndex; + return this.model.getBlockIndexById(this.blockId); } /** @@ -89,7 +123,7 @@ export abstract class BlockToolAdapter extends EventTarget { this.#createDataNode(createDataKey(keyRaw), initialData); return (newValue: V) => { - this.model.updateValue(this.config.userId, this.blockIndex, createDataKey(keyRaw), newValue); + this.model.updateValue(this.config.userId, this.blockId, createDataKey(keyRaw), newValue); }; } @@ -98,11 +132,11 @@ export abstract class BlockToolAdapter extends EventTarget { * @param keyRaw - key of the node to remove */ public removeKey(keyRaw: string): void { - if (this.model.getDataNode(this.config.userId, this.blockIndex, keyRaw) === undefined) { + if (this.model.getDataNode(this.config.userId, this.blockId, keyRaw) === undefined) { return; } - this.model.removeDataNode(this.config.userId, this.blockIndex, createDataKey(keyRaw)); + this.model.removeDataNode(this.config.userId, this.blockId, createDataKey(keyRaw)); } /** @@ -117,11 +151,11 @@ export abstract class BlockToolAdapter extends EventTarget { * this.#createDataNode(createDataKey('items[0].content'), { $t: 'v', value: 'Item text' }); */ #createDataNode(key: DataKey, initialData?: TextNodeSerialized | ValueSerialized): void { - if (this.model.getDataNode(this.config.userId, this.blockIndex, key) !== undefined) { + if (this.model.getDataNode(this.config.userId, this.blockId, key) !== undefined) { return; } - this.model.createDataNode(this.config.userId, this.blockIndex, key, initialData); + this.model.createDataNode(this.config.userId, this.blockId, key, initialData); } /** @@ -131,7 +165,13 @@ export abstract class BlockToolAdapter extends EventTarget { #handleModelUpdate(event: ModelEvents): void { const { blockIndex } = event.detail.index; - if (blockIndex !== this.blockIndex) { + if (blockIndex === undefined) { + return; + } + + const eventBlockId = this.model.getBlockId(blockIndex); + + if (eventBlockId !== this.blockId) { return; } diff --git a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts index 07698a02..a2c7d35f 100644 --- a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts +++ b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts @@ -1,5 +1,5 @@ import type { EditorjsPlugin, EditorjsPluginConstructor, EditorjsPluginParams } from '@/entities/EditorjsPlugin'; -import type { EditorJSModel } from '@editorjs/model'; +import type { BlockId, EditorJSModel } from '@editorjs/model'; import type { PluginType } from '@/entities/EntityType'; import type { BlockToolAdapter } from '@/entities/BlockToolAdapter'; @@ -20,16 +20,18 @@ export interface EditorjsAdapterPluginParams extends EditorjsPluginParams { export interface EditorJSAdapterPlugin extends EditorjsPlugin { /** * Factory for the BlockToolAdapter. Called when a new block should be rendered - * @param blockIndex - index of the added block + * @param blockId - unique identifier of the added block * @param name - tool name */ - createBlockToolAdapter(blockIndex: number, name: string): BlockToolAdapter; + createBlockToolAdapter(blockId: BlockId, name: string): BlockToolAdapter; + /** - * Destroys the BlockToolAdapter for the block at the given index. - * Called by BlockRenderer when a block is removed from the model. - * @param blockIndex - index of the removed block + * Destroys the BlockToolAdapter for the given block. + * Cleans up all inputs registered for this block and removes the adapter instance. + * Called when a block is removed from the model. + * @param blockId - unique identifier of the removed block */ - destroyBlockToolAdapter(blockIndex: number): void; + destroyBlockToolAdapter(blockId: BlockId): void; } /**