From 0d5817e0d4a3e82f18bbdc42e7358c10ccf10be7 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 17 Apr 2026 20:55:27 +0300 Subject: [PATCH 01/14] Fix inputs and values attachments to be data-first --- packages/core/src/components/BlockManager.ts | 43 ++++- .../internal/block-tools/paragraph/index.ts | 33 +++- .../src/BlockToolAdapter/index.ts | 150 ++++++++++++++---- packages/model/src/EditorJSModel.ts | 5 + .../model/src/entities/BlockNode/index.ts | 19 +++ .../src/entities/EditorDocument/index.ts | 21 ++- packages/sdk/src/entities/BlockToolAdapter.ts | 31 +++- 7 files changed, 260 insertions(+), 42 deletions(-) diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index eb6b7c95..f3ceb336 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -79,6 +79,13 @@ export class BlocksManager { * Will be passed to BlockToolAdapter for rendering inputs` formatted text */ #formattingAdapter: FormattingAdapter; + /** + * Local registry of block adapters maintained by BlocksManager. + * This allows us to update adapter indices synchronously when blocks are + * added/removed to ensure adapters reflect the current model state before + * any nested model events are processed. + */ + #adapters: BlockToolAdapter[] = []; /** * Returns Blocks count @@ -257,7 +264,17 @@ export class BlocksManager { throw new Error('[BlockManager] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); } - const toolName = event.detail.data.name; + // Shift existing adapters indices to make room for the new block. + // This must happen synchronously before we create and render the new + // block adapter so that any nested model events produced during tool + // rendering will see correct adapter indices. + for (const adapter of this.#adapters) { + const current = adapter.getBlockIndex().blockIndex; + + if (current !== undefined && current >= index.blockIndex) { + adapter.setBlockIndex(current + 1); + } + } const blockToolAdapter = new BlockToolAdapter( this.#config, @@ -265,8 +282,7 @@ export class BlocksManager { this.#eventBus, this.#caretAdapter, index.blockIndex, - this.#formattingAdapter, - toolName + this.#formattingAdapter ); /** @@ -274,6 +290,8 @@ export class BlocksManager { * without additional storing inputs in the caret adapter * Thus, it won't care about block index change (block removed, block added, block moved) */ + // Register new adapter locally and attach it to caret adapter. + this.#adapters.splice(index.blockIndex, 0, blockToolAdapter); this.#caretAdapter.attachBlock(blockToolAdapter); const tool = this.#toolsManager.blockTools.get(data.name); @@ -315,6 +333,25 @@ export class BlocksManager { throw new Error('Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); } + // Remove and detach adapter related to the removed block, then shift + // indices of adapters that were after the removed one. + const removedIndex = index.blockIndex; + + const adapterIndex = this.#adapters.findIndex(a => a.getBlockIndex().blockIndex === removedIndex); + + if (adapterIndex !== -1) { + const [removedAdapter] = this.#adapters.splice(adapterIndex, 1); + this.#caretAdapter.detachBlock(removedAdapter.getBlockIndex()); + } + + for (const adapter of this.#adapters) { + const current = adapter.getBlockIndex().blockIndex; + + if (current !== undefined && current > removedIndex) { + adapter.setBlockIndex(current - 1); + } + } + this.#eventBus.dispatchEvent(new BlockRemovedCoreEvent({ tool: data.name, index: index.blockIndex, 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 97c5f088..401a8fd7 100644 --- a/packages/core/src/tools/internal/block-tools/paragraph/index.ts +++ b/packages/core/src/tools/internal/block-tools/paragraph/index.ts @@ -55,30 +55,47 @@ export class Paragraph implements BlockTool { */ #data: ParagraphData; + #wrapper!: HTMLDivElement; + /** * @param options - Block tool constructor options */ constructor({ adapter, data }: BlockToolConstructorOptions) { this.#adapter = adapter; this.#data = data; + + this.#buildUI(); + + this.#adapter.init(this, this.onUpdate); + this.#adapter.registerKey('text', 'text'); + } + + #buildUI(): void { + const wrapper = document.createElement('div'); + + wrapper.classList.add('editorjs-paragraph'); + + this.#wrapper = wrapper; } /** * Creates tool element */ public render(): HTMLElement { - const wrapper = document.createElement('div'); + return this.#wrapper; + } - wrapper.classList.add('editorjs-paragraph'); + onUpdate = (key: string, type: 'text' | 'value'): HTMLElement => { + const paragraph = document.createElement('div'); - wrapper.contentEditable = 'true'; - wrapper.style.outline = 'none'; - wrapper.style.whiteSpace = 'pre-wrap'; + paragraph.contentEditable = 'true'; + paragraph.style.outline = 'none'; + paragraph.style.whiteSpace = 'pre-wrap'; - this.#adapter.attachInput('text', wrapper); + this.#wrapper.append(paragraph); - return wrapper; - } + return paragraph; + }; } Paragraph satisfies BlockToolConstructor; diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 46e7ce8d..ddf32112 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -19,7 +19,8 @@ import type { BlockToolAdapter as BlockToolAdapterInterface, CoreConfig, BeforeInputUIEvent, - BeforeInputUIEventPayload + BeforeInputUIEventPayload, + BlockTool } from '@editorjs/sdk'; import { BeforeInputUIEventName } from '@editorjs/sdk'; import type { CaretAdapter } from '../CaretAdapter/index.js'; @@ -35,6 +36,8 @@ import { } from '../utils/index.js'; import { InputType } from './types/InputType.js'; +type ToolOnUpdateCallback = (key: string, type: 'text' | 'value') => HTMLElement; + /** * BlockToolAdapter is using inside Block tools to connect browser DOM elements to the model * It can handle beforeinput events and update model data @@ -62,15 +65,20 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { #formattingAdapter: FormattingAdapter; /** - * Name of the tool that this adapter is connected to + * Tool instance */ - #toolName: string; + #tool!: BlockTool; /** * Editor's config */ #config: Required; + /** + * Callback registered by the tool to create DOM elements when data nodes are added + */ + #toolOnUpdateCallback: ToolOnUpdateCallback | null = null; + /** * Inputs that bound to the model */ @@ -90,7 +98,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @param caretAdapter - CaretAdapter instance * @param blockIndex - index of the block that this adapter is connected to * @param formattingAdapter - needed to render formatted text - * @param toolName - tool name of the block */ constructor( config: Required, @@ -98,15 +105,13 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { eventBus: EventBus, caretAdapter: CaretAdapter, blockIndex: number, - formattingAdapter: FormattingAdapter, - toolName: string + formattingAdapter: FormattingAdapter ) { this.#config = config; this.#model = model; this.#blockIndex = blockIndex; this.#caretAdapter = caretAdapter; this.#formattingAdapter = formattingAdapter; - this.#toolName = toolName; this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event)); @@ -115,6 +120,85 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { }); } + /** + * Registers a callback that will be called when a text or value node is added to the model. + * + * @param callback - receives the data key and node type, should create, mount and return the DOM element + */ + public onUpdate(callback: ToolOnUpdateCallback): void { + this.#toolOnUpdateCallback = callback; + } + + /** + * Initializes the adapter with the tool instance and the update callback. + * Scans existing model data and renders inputs for all existing text nodes. + * + * @param tool - the block tool instance + * @param onUpdateCallback - callback to create DOM elements for data nodes + */ + public init(tool: BlockTool, onUpdateCallback: ToolOnUpdateCallback): void { + this.#tool = tool; + this.#toolOnUpdateCallback = onUpdateCallback; + + const blockData = this.#model.serialized.blocks[this.#blockIndex]; + + Object.entries(blockData.data).forEach(([key, value]) => { + if (this.#attachedInputs.has(createDataKey(key))) { + return; + } + + if (typeof value === 'object' && value !== null && '$t' in value && (value as { $t: unknown }).$t === 't') { + this.onTextNodeAdded(key); + } + }); + } + + /** + * Called when a text DataNode is added to the model for this block. + * Creates and attaches the DOM input via the registered onUpdate callback. + * + * @param dataKey - key of the added text node + */ + public onTextNodeAdded(dataKey: string): void { + if (this.#attachedInputs.has(createDataKey(dataKey))) { + return; + } + + if (this.#toolOnUpdateCallback === null) { + return; + } + + const input = this.#toolOnUpdateCallback(dataKey, 'text'); + + this.attachInput(dataKey, input); + } + + public registerKey(keyRaw: string, type: 'text' | 'value', initialData?: unknown): void { + if (this.#model.getDataNode(this.#config.userId, this.#blockIndex, createDataKey(keyRaw)) !== undefined) { + return; + } + + let data; + + switch (type) { + case 'text': + data = { + $t: 't', + text: initialData as string ?? '', + }; + break; + case 'value': + if (typeof initialData === 'object') { + data = { $t: 'v', ...initialData }; + } else { + data = initialData; + } + break; + } + + this.#model.createDataNode(this.#config.userId, this.#blockIndex, keyRaw, data); + } + /** * Attaches input to the model using key * It handles beforeinput events and updates model data @@ -131,16 +215,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#attachedInputs.set(key, input); - this.#model.createDataNode( - this.#config.userId, - this.#blockIndex, - key, - { - $t: 't', - value: '', - } - ); - const builder = new IndexBuilder(); builder.addBlockIndex(this.#blockIndex).addDataKey(key); @@ -189,6 +263,17 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { .build(); } + /** + * Updates the internal block index. + * Should only be called by the CaretAdapter registry when blocks are inserted or removed, + * guaranteeing the update happens before any nested model events fire. + * + * @param index - new block index value + */ + public setBlockIndex(index: number): void { + this.#blockIndex = index; + } + /** * @todo - move to sdk BlockToolAdapter interface if it would be used * Public getter for all attached inputs. @@ -719,7 +804,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#model.addBlock( this.#config.userId, { - name: this.#toolName, + name: this.#tool.constructor.name, data: { [key]: { $t: 't', @@ -874,10 +959,11 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ #handleModelUpdate(event: ModelEvents): void { if (event instanceof BlockAddedEvent || event instanceof BlockRemovedEvent) { - if (event.detail.index.blockIndex! <= this.#blockIndex) { - this.#blockIndex += event.detail.action === EventAction.Added ? 1 : -1; - } - + /** + * Block index shifting is now handled externally by CaretAdapter.shiftBlockIndices(), + * which is called synchronously by BlocksManager before any nested model events fire. + * No action needed here. + */ return; } @@ -887,6 +973,20 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { return; } + if (event instanceof DataNodeAddedEvent) { + if (dataKey === undefined) { + return; + } + + const data = event.detail.data; + + if (typeof data === 'object' && data !== null && '$t' in data && (data as { $t: unknown }).$t === 't') { + this.onTextNodeAdded(dataKey.toString()); + } + + return; + } + if (event instanceof DataNodeRemovedEvent) { if (dataKey === undefined) { return; @@ -901,12 +1001,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#handleModelUpdateForValue(event, dataKey!); } - if (event instanceof DataNodeAddedEvent) { - /** - * @todo Decide how to handle this case as only BlockTool knows how to render an input - */ - } - if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) { return; } diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index 1ee384c9..0ecd7c72 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -277,6 +277,11 @@ export class EditorJSModel extends EventBus { return this.#document.removeDataNode(...parameters); } + @WithContext + public getDataNode(_userId: string | number, ...parameters: Parameters): ReturnType { + return this.#document.getDataNode(...parameters); + } + /** * Updates the ValueNode data associated with the BlockNode at the specified index. * diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index 0fc33ae5..f3ad86b7 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -18,6 +18,7 @@ import type { DataKey } from './types'; import { BlockChildType, createBlockToolName, createDataKey } 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'; import { TextNode } from '../inline-fragments/index.js'; @@ -146,6 +147,24 @@ export class BlockNode extends EventBus { }; } + /** + * + * @param dataKey + */ + public getDataNode(dataKey: DataKey): ValueSerialized | TextNodeSerialized | undefined { + const node = get(this.data, dataKey as string); + + if (node === undefined) { + return; + } + + if (!(node instanceof TextNode) && !(node instanceof ValueNode)) { + throw new InvalidNodeTypeError(dataKey, 'text or a value'); + } + + return node.serialized; + } + /** * Creates a node at passed key with initial data * diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index 107c8054..aafe0831 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -5,7 +5,14 @@ import { BlockNode } from '../BlockNode/index.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; import type { EditorDocumentSerialized, EditorDocumentConstructorParameters, Properties } from './types'; import type { BlockTuneName } from '../BlockTune'; -import { type InlineFragment, type InlineToolData, type InlineToolName } from '../inline-fragments/index.js'; +import type { + TextNodeSerialized +} from '../inline-fragments/index.js'; +import { + type InlineFragment, + type InlineToolData, + type InlineToolName +} 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'; @@ -25,6 +32,7 @@ import { 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'; export * from './types/index.js'; @@ -203,6 +211,17 @@ export class EditorDocument extends EventBus { this.#children[index].removeDataNode(createDataKey(key)); } + /** + * + * @param index + * @param key + */ + public getDataNode(index: number, key: DataKey | string): ValueSerialized | TextNodeSerialized { + this.#checkIndexOutOfBounds(index, this.length - 1); + + return this.#children[index].getDataNode(createDataKey(key)); + } + /** * Returns the serialised properties of the EditorDocument. diff --git a/packages/sdk/src/entities/BlockToolAdapter.ts b/packages/sdk/src/entities/BlockToolAdapter.ts index 43aaf560..c52e6e71 100644 --- a/packages/sdk/src/entities/BlockToolAdapter.ts +++ b/packages/sdk/src/entities/BlockToolAdapter.ts @@ -3,8 +3,25 @@ */ export interface BlockToolAdapter { /** - * Attaches input to the model using key - * It handles beforeinput events and updates model data + * Registers a callback that will be called when a text or value node is added to the model. + * The callback should create, mount, and return the corresponding DOM element. + * + * @param callback - receives the data key and node type, returns the created element + */ + onUpdate(callback: (key: string, type: 'text' | 'value') => HTMLElement): void; + + /** + * Initializes the adapter with the tool instance and the update callback. + * Scans existing model data and calls onTextNodeAdded for each existing text node. + * + * @param tool - the block tool instance + * @param onUpdateCallback - callback to create DOM elements for data nodes + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + init(tool: any, onUpdateCallback: (key: string, type: 'text' | 'value') => HTMLElement): void; + + /** + * Attaches input to the model using key. * @param keyRaw - tools data key to attach input to * @param input - input element */ @@ -30,4 +47,14 @@ export interface BlockToolAdapter { * @param keyRaw - string data key used for value identification */ detachValue(keyRaw: string): void; + + /** + * Registers a data key with the model, creating a DataNode if it doesn't already exist. + * Use this to declare inputs/values that the tool owns before rendering. + * + * @param keyRaw - string data key to register + * @param type - node type: 'text' for contenteditable inputs, 'value' for arbitrary values + * @param initialData - optional initial data for the node + */ + registerKey(keyRaw: string, type: 'text' | 'value', initialData?: unknown): void; } From e4c246d58000303eb52ec27d92cf703974847c49 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Sun, 26 Apr 2026 22:22:57 +0100 Subject: [PATCH 02/14] Load adapters as plugin --- packages/core/package.json | 4 +- .../src/api/BlocksAPI.integration.spec.ts | 11 +- packages/core/src/api/BlocksAPI.ts | 7 +- packages/core/src/api/SelectionAPI.ts | 4 +- packages/core/src/api/index.ts | 8 +- .../core/src/components/BlockManager.spec.ts | 271 +------- packages/core/src/components/BlockManager.ts | 195 +----- .../core/src/components/BlockRenderer.spec.ts | 220 +++++++ packages/core/src/components/BlockRenderer.ts | 158 +++++ .../src/components/SelectionManager.spec.ts | 251 ++++--- .../core/src/components/SelectionManager.ts | 132 ++-- packages/core/src/index.ts | 110 ++-- packages/core/src/tokens.ts | 15 + packages/core/src/tools/ToolsManager.ts | 7 +- .../internal/block-tools/paragraph/index.ts | 65 +- packages/dom-adapters/package.json | 4 +- .../src/BlockToolAdapter/index.ts | 621 +++--------------- .../dom-adapters/src/CaretAdapter/index.ts | 183 ++---- .../src/FormattingAdapter/index.ts | 105 +-- .../dom-adapters/src/InputsRegistry/index.ts | 107 +++ packages/dom-adapters/src/index.ts | 100 ++- packages/dom-adapters/src/tokens.ts | 11 + packages/dom-adapters/tsconfig.json | 1 + packages/model/.eslintrc.yml | 1 + packages/model/src/EditorJSModel.ts | 17 + .../model/src/entities/BlockNode/index.ts | 21 +- .../src/entities/EditorDocument/index.ts | 14 +- packages/playground/src/components/Input.vue | 67 -- packages/playground/src/components/index.ts | 4 +- packages/sdk/eslint.config.mjs | 3 + packages/sdk/src/entities/BlockTool.ts | 20 +- packages/sdk/src/entities/BlockToolAdapter.ts | 175 ++++- .../sdk/src/entities/EditorjsAdapterPlugin.ts | 37 ++ packages/sdk/src/entities/EditorjsPlugin.ts | 7 +- packages/sdk/src/entities/EntityType.ts | 8 +- .../sdk/src/entities/EventBus/EventBus.ts | 2 - .../events/adapter/AdapterEventType.ts | 9 + .../EventBus/events/adapter/KeyAdded.ts | 28 + .../EventBus/events/adapter/KeyRemoved.ts | 28 + .../events/adapter/ValueNodeChanged.ts | 36 + .../entities/EventBus/events/adapter/index.ts | 4 + .../EventBus/events/core/CoreEventBase.ts | 1 - .../sdk/src/entities/EventBus/events/index.ts | 1 + .../EventBus/events/ui/UIEventBase.ts | 1 - packages/sdk/src/entities/index.ts | 3 +- .../sdk/src/tools/facades/BaseToolFacade.ts | 3 +- .../sdk/src/tools/facades/InlineToolFacade.ts | 30 +- yarn.lock | 79 ++- 48 files changed, 1668 insertions(+), 1521 deletions(-) create mode 100644 packages/core/src/components/BlockRenderer.spec.ts create mode 100644 packages/core/src/components/BlockRenderer.ts create mode 100644 packages/core/src/tokens.ts create mode 100644 packages/dom-adapters/src/InputsRegistry/index.ts create mode 100644 packages/dom-adapters/src/tokens.ts delete mode 100644 packages/playground/src/components/Input.vue create mode 100644 packages/sdk/src/entities/EditorjsAdapterPlugin.ts create mode 100644 packages/sdk/src/entities/EventBus/events/adapter/AdapterEventType.ts create mode 100644 packages/sdk/src/entities/EventBus/events/adapter/KeyAdded.ts create mode 100644 packages/sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts create mode 100644 packages/sdk/src/entities/EventBus/events/adapter/ValueNodeChanged.ts create mode 100644 packages/sdk/src/entities/EventBus/events/adapter/index.ts diff --git a/packages/core/package.json b/packages/core/package.json index fa41447b..657b7a27 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,7 @@ "@editorjs/helpers": "^1.2.1", "@editorjs/model": "workspace:^", "@editorjs/sdk": "workspace:^", - "reflect-metadata": "^0.2.2", - "typedi": "^0.10.0" + "inversify": "^8.1.0", + "reflect-metadata": "^0.2.2" } } diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index a594a581..13b9cfeb 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -51,7 +51,6 @@ jest.unstable_mockModule('../tools/ToolsManager', () => ({ // Import real model (no mock) and mocked adapters const { EditorJSModel, EventType, BlockAddedEvent, BlockRemovedEvent } = await import('@editorjs/model'); const { EventBus } = await import('@editorjs/sdk'); -const { CaretAdapter, FormattingAdapter } = await import('@editorjs/dom-adapters'); const ToolsManager = (await import('../tools/ToolsManager')).default; const { BlocksManager } = await import('../components/BlockManager.js'); const { BlocksAPI } = await import('./BlocksAPI.js'); @@ -73,19 +72,13 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { model = new EditorJSModel(USER_ID, { identifier: DOCUMENT_ID }); eventBus = new EventBus(); - // @ts-expect-error — mock constructor, no real DOM needed - const caretAdapter = new CaretAdapter(); // @ts-expect-error — mock constructor const toolsManager = new ToolsManager(); - // @ts-expect-error — mock constructor - const formattingAdapter = new FormattingAdapter(); blocksManager = new BlocksManager( model, eventBus, - caretAdapter, toolsManager, - formattingAdapter, config ); @@ -333,7 +326,7 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { expect(handler).toHaveBeenCalledWith(expect.any(BlockAddedEvent)); }); - it('should emit BlockRemovedEvent on model when delete is called', () => { + it('should emit BlockRemovedEvent on model when delete is called', async () => { blocksAPI.insert('paragraph'); const handler = jest.fn(); @@ -342,6 +335,8 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { blocksAPI.delete(0); + await Promise.resolve(); // flush queueMicrotask used by removeBlock + expect(handler).toHaveBeenCalledWith(expect.any(BlockRemovedEvent)); }); }); diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index 58fc16c6..844dddfb 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -1,5 +1,6 @@ import 'reflect-metadata'; -import { Inject, Service } from 'typedi'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens.js'; import { BlocksManager } from '../components/BlockManager.js'; import { BlockToolData } from '@editorjs/editorjs'; import { CoreConfigValidated } from '@editorjs/sdk'; @@ -10,7 +11,7 @@ import { type BlockNodeSerialized, EditorDocumentSerialized } from '@editorjs/mo * Blocks API * - provides methods to work with blocks */ -@Service() +@injectable() export class BlocksAPI implements BlocksApiInterface { /** * BlocksManager instance to work with blocks @@ -29,7 +30,7 @@ export class BlocksAPI implements BlocksApiInterface { */ constructor( blocksManager: BlocksManager, - @Inject('EditorConfig') config: CoreConfigValidated + @inject(TOKENS.EditorConfig) config: CoreConfigValidated ) { this.#blocksManager = blocksManager; this.#config = config; diff --git a/packages/core/src/api/SelectionAPI.ts b/packages/core/src/api/SelectionAPI.ts index b50eae25..4e139b26 100644 --- a/packages/core/src/api/SelectionAPI.ts +++ b/packages/core/src/api/SelectionAPI.ts @@ -1,5 +1,5 @@ import 'reflect-metadata'; -import { Service } from 'typedi'; +import { injectable } from 'inversify'; import { SelectionManager } from '../components/SelectionManager.js'; import { createInlineToolName } from '@editorjs/model'; @@ -10,7 +10,7 @@ import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk'; * Selection API class * - provides methods to work with selection */ -@Service() +@injectable() export class SelectionAPI implements SelectionApiInterface { #selectionManager: SelectionManager; diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index d8dcc517..b855d06a 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -1,5 +1,5 @@ import 'reflect-metadata'; -import { Inject, Service } from 'typedi'; +import { inject, injectable } from 'inversify'; import { EditorAPI as EditorApiInterface } from '@editorjs/sdk'; import { BlocksAPI } from './BlocksAPI.js'; import { SelectionAPI } from './SelectionAPI.js'; @@ -7,17 +7,17 @@ import { SelectionAPI } from './SelectionAPI.js'; /** * Class gathers all Editor's APIs */ -@Service() +@injectable() export class EditorAPI implements EditorApiInterface { /** * Blocks API instance to work with blocks */ - @Inject() + @inject(BlocksAPI) public blocks!: BlocksAPI; /** * Selection API instance to work with selection and inline formatting */ - @Inject() + @inject(SelectionAPI) public selection!: SelectionAPI; } diff --git a/packages/core/src/components/BlockManager.spec.ts b/packages/core/src/components/BlockManager.spec.ts index 24519284..411dc7c1 100644 --- a/packages/core/src/components/BlockManager.spec.ts +++ b/packages/core/src/components/BlockManager.spec.ts @@ -1,19 +1,11 @@ -/* eslint-disable jsdoc/require-jsdoc, @stylistic/comma-dangle,@typescript-eslint/naming-convention */ +/* eslint-disable @stylistic/comma-dangle,@typescript-eslint/naming-convention */ import { beforeEach, jest } from '@jest/globals'; -import type { BlockToolFacade, CoreConfigValidated } from '@editorjs/sdk'; -import type { Index } from '@editorjs/model'; +import type { CoreConfigValidated } from '@editorjs/sdk'; const BLOCKS_COUNT = 7; const USER_ID = 'user'; -/** - * Mock console.error to suppress expected error logs - */ -console.error = jest.fn(); - jest.unstable_mockModule('@editorjs/sdk', () => ({ - BlockAddedCoreEvent: jest.fn(), - BlockRemovedCoreEvent: jest.fn(), EventBus: jest.fn(), })); @@ -26,6 +18,7 @@ jest.unstable_mockModule('@editorjs/model', () => { removeBlock: jest.fn(), initializeDocument: jest.fn(), clearBlocks: jest.fn(), + getCaret: jest.fn(), get length() { return BLOCKS_COUNT; }, @@ -33,82 +26,41 @@ jest.unstable_mockModule('@editorjs/model', () => { const EventBus = jest.fn(() => ({ dispatchEvent: jest.fn() })); - const BlockAddedEvent = function (this: { detail: unknown }, index: Index, data: unknown): void { - this.detail = { - index, - data - }; - }; - - const BlockRemovedEvent = function (this: { detail: unknown }, index: Index, data: unknown): void { - this.detail = { - index, - data - }; - }; - const EventType = { Changed: 'changed' }; return { EditorJSModel, EventBus, - BlockAddedEvent, - BlockRemovedEvent, EventType, }; }); -jest.unstable_mockModule('@editorjs/dom-adapters', () => ({ - BlockToolAdapter: jest.fn(() => ({})), - CaretAdapter: jest.fn(() => ({ - attachBlock: jest.fn(), - })), - FormattingAdapter: jest.fn(() => ({})), -})); - jest.unstable_mockModule('../tools/ToolsManager', () => ({ default: jest.fn(() => ({ blockTools: { - get: jest.fn(() => ({ name: 'tool', - create: jest.fn(() => ({ render: jest.fn(() => Promise.resolve({})) })) })), + get: jest.fn(), }, })), })); // Now import the modules (they will receive the mocks registered above) -const { EditorJSModel, EventBus, BlockAddedEvent, BlockRemovedEvent } = await import('@editorjs/model'); -const { CaretAdapter, FormattingAdapter } = await import('@editorjs/dom-adapters'); +const { EditorJSModel, EventBus } = await import('@editorjs/model'); const ToolsManager = (await import('../tools/ToolsManager')).default; const { BlocksManager } = await import('./BlockManager.js'); describe('BlocksManager (unit, mocked deps)', () => { // @ts-expect-error - mock object, dont need to pass any arguments const model = new EditorJSModel(); - let changedListener: (event: unknown) => void | Promise = () => undefined; - - // capture model change listener so tests can invoke it - model.addEventListener = jest.fn((type: string, callback: (event: unknown) => void) => { - if (type === 'changed') { - changedListener = callback; - } - }); - const eventBus = new EventBus(); // @ts-expect-error - Mock instance - const caretAdapter = new CaretAdapter(); - // @ts-expect-error - Mock instance const toolsManager = new ToolsManager(); - // @ts-expect-error - Mock instance - const formattingAdapter = new FormattingAdapter(); const defaultBlock = 'paragraph'; const blocksManager = new BlocksManager( model, eventBus, - caretAdapter, toolsManager, - formattingAdapter, { defaultBlock, userId: USER_ID } as CoreConfigValidated ); @@ -170,6 +122,17 @@ describe('BlocksManager (unit, mocked deps)', () => { ); }); + it('should call model.addBlock when focus is true', () => { + blocksManager.insert({ focus: true }); + + expect(model.addBlock).toHaveBeenCalledTimes(1); + expect(model.addBlock).toHaveBeenCalledWith( + USER_ID, + expect.objectContaining({ name: 'paragraph' }), + BLOCKS_COUNT + ); + }); + it('should use model.length as insertion/removal index when replace is true and index is omitted', () => { blocksManager.insert({ replace: true @@ -282,6 +245,8 @@ describe('BlocksManager (unit, mocked deps)', () => { describe('.deleteBlock()', () => { it('should throw when no caret and no index is provided', () => { + model.getCaret = jest.fn(() => undefined); + expect(() => blocksManager.deleteBlock()).toThrow('No block selected to delete'); }); @@ -297,37 +262,22 @@ describe('BlocksManager (unit, mocked deps)', () => { // @ts-expect-error - need to assign read only property to mock it model.serialized = { blocks: [ - { - name: 'a' - }, - { - name: 'b' - }, - { - name: 'c' - } + { name: 'a' }, + { name: 'b' }, + { name: 'c' } ] }; - // @ts-expect-error - need to assign read only property to mock it - caretAdapter.userCaretIndex = { - blockIndex: 0 - }; + // @ts-expect-error - mock return value does not need full Caret shape + model.getCaret = jest.fn(() => ({ index: { blockIndex: 0 } })); blocksManager.move(2); expect(model.removeBlock).toHaveBeenCalledWith(USER_ID, 0); - expect(model.addBlock).toHaveBeenCalledWith( - USER_ID, - { - name: 'a' - }, - 2 - ); + expect(model.addBlock).toHaveBeenCalledWith(USER_ID, { name: 'a' }, 2); }); it('should throw when there is no current block and no index provided', () => { - // @ts-expect-error - need to assign read only property to mock it - caretAdapter.userCaretIndex = undefined; + model.getCaret = jest.fn(() => undefined); expect(() => blocksManager.move(1)).toThrow('No block selected to move'); }); @@ -336,15 +286,9 @@ describe('BlocksManager (unit, mocked deps)', () => { // @ts-expect-error - need to assign read only property to mock it model.serialized = { blocks: [ - { - name: 'a' - }, - { - name: 'b' - }, - { - name: 'c' - } + { name: 'a' }, + { name: 'b' }, + { name: 'c' } ] }; @@ -358,15 +302,9 @@ describe('BlocksManager (unit, mocked deps)', () => { // @ts-expect-error - need to assign read only property to mock it model.serialized = { blocks: [ - { - name: 'a' - }, - { - name: 'b' - }, - { - name: 'c' - } + { name: 'a' }, + { name: 'b' }, + { name: 'c' } ] }; @@ -376,151 +314,4 @@ describe('BlocksManager (unit, mocked deps)', () => { expect(model.addBlock).not.toHaveBeenCalled(); }); }); - - describe('#handleModelUpdate()', () => { - it('should ignore unknown model events', () => { - void changedListener({ - type: 'unknown-event' - }); - - expect(eventBus.dispatchEvent).not.toHaveBeenCalled(); - }); - - describe('BlockAddedEvent handling', () => { - it('should create tool and dispatch BlockAddedCoreEvent via EventBus', async () => { - const createMock = jest.fn(() => ({ render: jest.fn() })); - - jest.spyOn(toolsManager.blockTools, 'get').mockReturnValue({ - name: 'tool', - create: createMock - } as unknown as BlockToolFacade); - - const event = new BlockAddedEvent( - { - blockIndex: 0 - } as Index, - { - name: 'tool', - data: {} - }, - USER_ID, - ); - - await changedListener(event); - - expect(toolsManager.blockTools.get).toHaveBeenCalledWith('tool'); - expect(createMock).toHaveBeenCalled(); - expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ - readOnly: false - })); - expect(eventBus.dispatchEvent).toHaveBeenCalled(); - }); - - it('should throw when blockIndex is undefined', async () => { - const event = new BlockAddedEvent( - {} as Index, - { - name: 'tool', - data: {} - }, - USER_ID, - ); - - const eventPromise = changedListener(event); - - try { - await eventPromise; - } catch (error: unknown) { - expect((error as Error).message).toContain('[BlockManager] Block index should be defined'); - } - }); - - it('should throw when tool is not found', async () => { - jest.spyOn(toolsManager.blockTools, 'get').mockReturnValue(undefined); - - const event = new BlockAddedEvent( - { - blockIndex: 0 - } as Index, - { - name: 'missing-tool', - data: {} - }, - USER_ID, - ); - - try { - await changedListener(event); - } catch (error: unknown) { - expect((error as Error).message).toContain('[BlockManager] Block Tool missing-tool not found'); - } - }); - - it('should log error when tool render fails', async () => { - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); - const createMock = jest.fn(() => ({ - render: jest.fn(() => Promise.reject(new Error('render failed'))) - })); - - jest.spyOn(toolsManager.blockTools, 'get').mockReturnValue({ - name: 'tool', - create: createMock - } as unknown as BlockToolFacade); - - const event = new BlockAddedEvent( - { - blockIndex: 0 - } as Index, - { - name: 'tool', - data: {} - }, - USER_ID, - ); - - await changedListener(event); - - expect(errorSpy).toHaveBeenCalled(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('[BlockManager] Block Tool tool failed to render'), - expect.any(Error) - ); - errorSpy.mockRestore(); - }); - }); - - describe('BlockRemovedEvent handling', () => { - it('should dispatch BlockRemovedCoreEvent via EventBus', () => { - const event = new BlockRemovedEvent( - { - blockIndex: 1 - } as Index, - { - name: 'tool', - data: {} - }, - USER_ID, - ); - - void changedListener(event); - - expect(eventBus.dispatchEvent).toHaveBeenCalled(); - }); - - it('should throw when blockIndex is undefined', () => { - const event = new BlockRemovedEvent( - {} as Index, - { - name: 'tool', - data: {} - }, - USER_ID, - ); - - expect(() => { - void changedListener(event); - }).toThrow('Block index should be defined'); - }); - }); - }); }); diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index f3ceb336..bd4ebb86 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -1,17 +1,18 @@ import { - BlockAddedEvent, type BlockNodeSerialized, - BlockRemovedEvent, + type BlockNodeSerialized, type EditorDocumentSerialized, - EditorJSModel, - EventType, - ModelEvents + EditorJSModel } from '@editorjs/model'; import 'reflect-metadata'; -import { Inject, Service } from 'typedi'; -import { BlockToolAdapter, CaretAdapter, FormattingAdapter } from '@editorjs/dom-adapters'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens.js'; import ToolsManager from '../tools/ToolsManager.js'; -import { BlockAPI, BlockToolData } from '@editorjs/editorjs'; -import { CoreConfigValidated, EventBus, BlockAddedCoreEvent, BlockRemovedCoreEvent } from '@editorjs/sdk'; +import { BlockToolData } from '@editorjs/editorjs'; +import { + CoreConfigValidated, + EventBus +} from '@editorjs/sdk'; + /** * Parameters for the BlocksManager.insert() method */ @@ -43,11 +44,15 @@ interface InsertBlockParameters { } /** - * BlocksManager is responsible for - * - handling block adding and removing events - * - updating the Model blocks data on user actions + * BlocksManager is responsible for block lifecycle operations: + * - insert, delete, move, render, clear + * + * Model event handling (BlockAddedEvent / BlockRemovedEvent) and rendering + * are intentionally delegated to BlockRenderer, keeping this class free of + * any Adapter dependency and avoiding the circular dependency: + * BlocksManager → Adapter → EditorAPI → BlocksAPI → BlocksManager */ -@Service() +@injectable() export class BlocksManager { /** * Editor's Document Model instance to get and update blocks data @@ -59,12 +64,6 @@ export class BlocksManager { */ #eventBus: EventBus; - /** - * Caret Adapter instance - * Required here to create BlockToolAdapter - */ - #caretAdapter: CaretAdapter; - /** * Tools manager instance to get block tools */ @@ -75,18 +74,6 @@ export class BlocksManager { */ #config: CoreConfigValidated; - /** - * Will be passed to BlockToolAdapter for rendering inputs` formatted text - */ - #formattingAdapter: FormattingAdapter; - /** - * Local registry of block adapters maintained by BlocksManager. - * This allows us to update adapter indices synchronously when blocks are - * added/removed to ensure adapters reflect the current model state before - * any nested model events are processed. - */ - #adapters: BlockToolAdapter[] = []; - /** * Returns Blocks count */ @@ -96,36 +83,27 @@ export class BlocksManager { /** * BlocksManager constructor - * All parameters are injected thorugh the IoC container + * All parameters are injected through the IoC container * @param model - Editor's Document Model instance * @param eventBus - Editor's EventBus instance - * @param caretAdapter - Caret Adapter instance * @param toolsManager - Tools manager instance - * @param formattingAdapter - will be passed to BlockToolAdapter for rendering inputs` formatted text * @param config - Editor validated configuration */ constructor( model: EditorJSModel, eventBus: EventBus, - caretAdapter: CaretAdapter, toolsManager: ToolsManager, - formattingAdapter: FormattingAdapter, - @Inject('EditorConfig') config: CoreConfigValidated + @inject(TOKENS.EditorConfig) config: CoreConfigValidated ) { this.#model = model; this.#eventBus = eventBus; - this.#caretAdapter = caretAdapter; this.#toolsManager = toolsManager; - this.#formattingAdapter = formattingAdapter; this.#config = config; - - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Need to bubble the promise up in case of errors - this.#model.addEventListener(EventType.Changed, event => this.#handleModelUpdate(event)); } /** * Inserts a new block to the editor at the specified index - * @param parameters - method paramaters object + * @param parameters - method parameters object * @param parameters.type - block tool name to insert * @param parameters.data - block's initial data * @param parameters.index - index to insert block at @@ -229,136 +207,9 @@ export class BlocksManager { * Returns block index where user caret is placed */ #getCurrentBlockIndex(): number | undefined { - const caretIndex = this.#caretAdapter.userCaretIndex; + const userCaret = this.#model.getCaret(this.#config.userId); + const caretIndex = userCaret?.index; return caretIndex?.blockIndex; } - - /** - * Handles model update events - * Filters only BlockAddedEvent and BlockRemovedEvent - * @param event - Model update event - */ - #handleModelUpdate(event: ModelEvents): Promise | void { - switch (true) { - case event instanceof BlockAddedEvent: - return this.#handleBlockAddedEvent(event); - case event instanceof BlockRemovedEvent: - this.#handleBlockRemovedEvent(event); - break; - default: - } - } - - /** - * Handles BlockAddedEvent - * - creates BlockTool instance - * - renders its content - * - calls UI module to render the block - * @param event - BlockAddedEvent - */ - async #handleBlockAddedEvent(event: BlockAddedEvent): Promise { - const { index, data } = event.detail; - - if (index.blockIndex === undefined) { - throw new Error('[BlockManager] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); - } - - // Shift existing adapters indices to make room for the new block. - // This must happen synchronously before we create and render the new - // block adapter so that any nested model events produced during tool - // rendering will see correct adapter indices. - for (const adapter of this.#adapters) { - const current = adapter.getBlockIndex().blockIndex; - - if (current !== undefined && current >= index.blockIndex) { - adapter.setBlockIndex(current + 1); - } - } - - const blockToolAdapter = new BlockToolAdapter( - this.#config, - this.#model, - this.#eventBus, - this.#caretAdapter, - index.blockIndex, - this.#formattingAdapter - ); - - /** - * We store blocks managers in caret adapter to give it access to blocks` inputs - * without additional storing inputs in the caret adapter - * Thus, it won't care about block index change (block removed, block added, block moved) - */ - // Register new adapter locally and attach it to caret adapter. - this.#adapters.splice(index.blockIndex, 0, blockToolAdapter); - this.#caretAdapter.attachBlock(blockToolAdapter); - - const tool = this.#toolsManager.blockTools.get(data.name); - - if (tool === undefined) { - throw new Error(`[BlockManager] Block Tool ${event.detail.data.name} not found`); - } - - const block = tool.create({ - adapter: blockToolAdapter, - data: data.data, - block: {} as BlockAPI, - readOnly: false, - }); - - try { - const blockElement = await block.render(); - - this.#eventBus.dispatchEvent(new BlockAddedCoreEvent({ - tool: tool.name, - data: data.data, - ui: blockElement, - index: index.blockIndex, - })); - } catch (error) { - console.error(`[BlockManager] Block Tool ${data.name} failed to render`, error); - } - } - - /** - * Handles BlockRemovedEvent - * - callse UI module to remove the block - * @param event - BlockRemovedEvent - */ - #handleBlockRemovedEvent(event: BlockRemovedEvent): void { - const { data, index } = event.detail; - - if (index.blockIndex === undefined) { - throw new Error('Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); - } - - // Remove and detach adapter related to the removed block, then shift - // indices of adapters that were after the removed one. - const removedIndex = index.blockIndex; - - const adapterIndex = this.#adapters.findIndex(a => a.getBlockIndex().blockIndex === removedIndex); - - if (adapterIndex !== -1) { - const [removedAdapter] = this.#adapters.splice(adapterIndex, 1); - this.#caretAdapter.detachBlock(removedAdapter.getBlockIndex()); - } - - for (const adapter of this.#adapters) { - const current = adapter.getBlockIndex().blockIndex; - - if (current !== undefined && current > removedIndex) { - adapter.setBlockIndex(current - 1); - } - } - - this.#eventBus.dispatchEvent(new BlockRemovedCoreEvent({ - tool: data.name, - index: index.blockIndex, - })); - - /** - * @todo Detach block tool adapter from caret adapter to clear memory - */ - } } diff --git a/packages/core/src/components/BlockRenderer.spec.ts b/packages/core/src/components/BlockRenderer.spec.ts new file mode 100644 index 00000000..111c041d --- /dev/null +++ b/packages/core/src/components/BlockRenderer.spec.ts @@ -0,0 +1,220 @@ +/* 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'; + +const USER_ID = 'user'; + +/** + * Mock console.error to suppress expected error logs + */ +console.error = jest.fn(); + +jest.unstable_mockModule('@editorjs/sdk', () => ({ + BlockAddedCoreEvent: jest.fn(), + BlockRemovedCoreEvent: jest.fn(), + EventBus: jest.fn(), +})); + +jest.unstable_mockModule('@editorjs/model', () => { + const EditorJSModel = jest.fn(() => ({ + addEventListener: jest.fn(), + })); + + const EventBus = jest.fn(() => ({ dispatchEvent: jest.fn() })); + + const BlockAddedEvent = function (this: { detail: unknown }, index: Index, data: unknown): void { + this.detail = { + index, + data + }; + }; + + const BlockRemovedEvent = function (this: { detail: unknown }, index: Index, data: unknown): void { + this.detail = { + index, + data + }; + }; + + const EventType = { Changed: 'changed' }; + + return { + EditorJSModel, + EventBus, + BlockAddedEvent, + BlockRemovedEvent, + EventType, + }; +}); + +jest.unstable_mockModule('../tools/ToolsManager', () => ({ + default: jest.fn(() => ({ + blockTools: { + get: jest.fn(() => ({ + name: 'tool', + create: jest.fn(() => ({ render: jest.fn(() => Promise.resolve({})) })) + })), + }, + })), +})); + +// Now import the modules (they will receive the mocks registered above) +const { EditorJSModel, EventBus, BlockAddedEvent, BlockRemovedEvent } = await import('@editorjs/model'); +const ToolsManager = (await import('../tools/ToolsManager')).default; +const { BlockRenderer } = await import('./BlockRenderer.js'); + +describe('BlockRenderer (unit, mocked deps)', () => { + // @ts-expect-error - mock object, dont need to pass any arguments + const model = new EditorJSModel(); + let changedListener: (event: unknown) => void | Promise = () => undefined; + + // capture model change listener so tests can invoke it + model.addEventListener = jest.fn((type: string, callback: (event: unknown) => void) => { + if (type === 'changed') { + changedListener = callback; + } + }); + + const eventBus = new EventBus(); + // @ts-expect-error - Mock instance + const toolsManager = new ToolsManager(); + + const adapter: EditorJSAdapterPlugin = { + createBlockToolAdapter: jest.fn(() => ({})), + } as unknown as EditorJSAdapterPlugin; + + new BlockRenderer( + model, + eventBus, + toolsManager, + adapter + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('#handleModelUpdate()', () => { + it('should ignore unknown model events', () => { + void changedListener({ type: 'unknown-event' }); + + expect(eventBus.dispatchEvent).not.toHaveBeenCalled(); + }); + + describe('BlockAddedEvent handling', () => { + it('should create tool and dispatch BlockAddedCoreEvent via EventBus', async () => { + const createMock = jest.fn(() => ({ render: jest.fn(() => Promise.resolve({})) })); + + jest.spyOn(toolsManager.blockTools, 'get').mockReturnValue({ + name: 'tool', + create: createMock + } as unknown as BlockToolFacade); + + const event = new BlockAddedEvent( + { blockIndex: 0 } as Index, + { name: 'tool', + data: {} }, + USER_ID, + ); + + await changedListener(event); + + expect(toolsManager.blockTools.get).toHaveBeenCalledWith('tool'); + expect(createMock).toHaveBeenCalled(); + expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ + readOnly: false + })); + expect(eventBus.dispatchEvent).toHaveBeenCalled(); + }); + + it('should throw when blockIndex is undefined', async () => { + const event = new BlockAddedEvent( + {} as Index, + { name: 'tool', + data: {} }, + USER_ID, + ); + + try { + await changedListener(event); + } catch (error: unknown) { + expect((error as Error).message).toContain('[BlockRenderer] Block index should be defined'); + } + }); + + it('should throw when tool is not found', async () => { + jest.spyOn(toolsManager.blockTools, 'get').mockReturnValue(undefined); + + const event = new BlockAddedEvent( + { blockIndex: 0 } as Index, + { name: 'missing-tool', + data: {} }, + USER_ID, + ); + + try { + await changedListener(event); + } catch (error: unknown) { + expect((error as Error).message).toContain('[BlockRenderer] Block Tool missing-tool not found'); + } + }); + + it('should log error when tool render fails', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const createMock = jest.fn(() => ({ + render: jest.fn(() => Promise.reject(new Error('render failed'))) + })); + + jest.spyOn(toolsManager.blockTools, 'get').mockReturnValue({ + name: 'tool', + create: createMock + } as unknown as BlockToolFacade); + + const event = new BlockAddedEvent( + { blockIndex: 0 } as Index, + { name: 'tool', + data: {} }, + USER_ID, + ); + + await changedListener(event); + + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('[BlockRenderer] Block Tool tool failed to render'), + expect.any(Error) + ); + errorSpy.mockRestore(); + }); + }); + + describe('BlockRemovedEvent handling', () => { + it('should dispatch BlockRemovedCoreEvent via EventBus', () => { + const event = new BlockRemovedEvent( + { blockIndex: 1 } as Index, + { name: 'tool', + data: {} }, + USER_ID, + ); + + void changedListener(event); + + expect(eventBus.dispatchEvent).toHaveBeenCalled(); + }); + + it('should throw when blockIndex is undefined', () => { + const event = new BlockRemovedEvent( + {} as Index, + { name: 'tool', + data: {} }, + USER_ID, + ); + + expect(() => { + void changedListener(event); + }).toThrow('Block index should be defined'); + }); + }); + }); +}); diff --git a/packages/core/src/components/BlockRenderer.ts b/packages/core/src/components/BlockRenderer.ts new file mode 100644 index 00000000..ca567e4e --- /dev/null +++ b/packages/core/src/components/BlockRenderer.ts @@ -0,0 +1,158 @@ +import { + BlockAddedEvent, + BlockRemovedEvent, + EditorJSModel, + EventType, + ModelEvents +} from '@editorjs/model'; +import 'reflect-metadata'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens.js'; +import ToolsManager from '../tools/ToolsManager.js'; +import { BlockAPI } from '@editorjs/editorjs'; +import { + EventBus, + BlockAddedCoreEvent, + BlockRemovedCoreEvent, + EditorJSAdapterPlugin +} from '@editorjs/sdk'; + +/** + * BlockRenderer subscribes to model block events and is responsible for: + * - creating a BlockToolAdapter for each added block + * - instantiating and rendering the BlockTool + * - dispatching BlockAddedCoreEvent / BlockRemovedCoreEvent to the UI layer + * + * It is intentionally separate from BlocksManager so that BlocksManager + * has no dependency on the Adapter, breaking the circular dependency: + * BlocksManager → Adapter → EditorAPI → BlocksAPI → BlocksManager + * + * BlockRenderer is resolved in Core.initialize() *after* #initializeAdapter() + * has registered the Adapter, so the @Inject('Adapter') here is always safe. + */ +@injectable() +export class BlockRenderer { + /** + * Editor's Document Model instance to listen for block events + */ + #model: EditorJSModel; + + /** + * Editor's EventBus instance to dispatch core events + */ + #eventBus: EventBus; + + /** + * Tools manager instance to look up block tools by name + */ + #toolsManager: ToolsManager; + + /** + * Adapter plugin instance used to create per-block BlockToolAdapters + */ + #adapter: EditorJSAdapterPlugin; + + /** + * BlockRenderer constructor. + * All parameters are injected through the IoC container. + * @param model - Editor's Document Model instance + * @param eventBus - Editor's EventBus instance + * @param toolsManager - Tools manager instance + * @param adapter - Adapter plugin instance + */ + constructor( + model: EditorJSModel, + eventBus: EventBus, + toolsManager: ToolsManager, + @inject(TOKENS.Adapter) adapter: EditorJSAdapterPlugin + ) { + this.#model = model; + this.#eventBus = eventBus; + this.#toolsManager = toolsManager; + this.#adapter = adapter; + + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Need to bubble the promise up in case of errors + this.#model.addEventListener(EventType.Changed, event => this.#handleModelUpdate(event)); + } + + /** + * Handles model update events. + * Filters only BlockAddedEvent and BlockRemovedEvent. + * @param event - Model update event + */ + #handleModelUpdate(event: ModelEvents): Promise | void { + switch (true) { + case event instanceof BlockAddedEvent: + return this.#handleBlockAddedEvent(event); + case event instanceof BlockRemovedEvent: + this.#handleBlockRemovedEvent(event); + break; + default: + } + } + + /** + * Handles BlockAddedEvent: + * - creates a BlockToolAdapter for the block + * - instantiates and renders the BlockTool + * - dispatches BlockAddedCoreEvent with the rendered UI element + * @param event - BlockAddedEvent + */ + async #handleBlockAddedEvent(event: BlockAddedEvent): Promise { + const { index, data } = event.detail; + + if (index.blockIndex === undefined) { + throw new Error('[BlockRenderer] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); + } + + const tool = this.#toolsManager.blockTools.get(data.name); + + if (tool === undefined) { + throw new Error(`[BlockRenderer] Block Tool ${data.name} not found`); + } + + const blockToolAdapter = this.#adapter.createBlockToolAdapter(index.blockIndex, tool.name); + + const block = tool.create({ + adapter: blockToolAdapter, + data: data.data, + block: {} as BlockAPI, + readOnly: false, + }); + + try { + const blockElement = await block.render(); + + this.#eventBus.dispatchEvent(new BlockAddedCoreEvent({ + tool: tool.name, + data: data.data, + ui: blockElement, + index: index.blockIndex, + })); + } catch (error) { + console.error(`[BlockRenderer] Block Tool ${data.name} failed to render`, error); + } + } + + /** + * Handles BlockRemovedEvent: + * - dispatches BlockRemovedCoreEvent so the UI layer removes the block + * @param event - BlockRemovedEvent + */ + #handleBlockRemovedEvent(event: BlockRemovedEvent): void { + const { data, index } = event.detail; + + if (index.blockIndex === undefined) { + throw new Error('[BlockRenderer] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); + } + + this.#eventBus.dispatchEvent(new BlockRemovedCoreEvent({ + tool: data.name, + index: index.blockIndex, + })); + + /** + * @todo clear block tool adapter memory + */ + } +} diff --git a/packages/core/src/components/SelectionManager.spec.ts b/packages/core/src/components/SelectionManager.spec.ts index c9afaae5..4f718074 100644 --- a/packages/core/src/components/SelectionManager.spec.ts +++ b/packages/core/src/components/SelectionManager.spec.ts @@ -1,18 +1,11 @@ -/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @stylistic/comma-dangle,@typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc,@typescript-eslint/naming-convention */ import { jest } from '@jest/globals'; -import type { CoreConfig, ToolLoadedCoreEvent } from '@editorjs/sdk'; +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 { CaretManagerEvents, InlineFragment, InlineToolName, EventType, Index } from '@editorjs/model'; // Register ESM mocks before importing the module under test -jest.unstable_mockModule('@editorjs/dom-adapters', () => ({ - FormattingAdapter: jest.fn(() => ({ - attachTool: jest.fn(), - applyFormat: jest.fn(), - })), -})); - jest.unstable_mockModule('@editorjs/model', () => { const caretManagerCaretUpdatedEvent = function ( this: { detail: Record }, @@ -28,6 +21,9 @@ jest.unstable_mockModule('@editorjs/model', () => { const EditorJSModel = jest.fn(() => ({ addEventListener: jest.fn(), getFragments: jest.fn(() => []), + getCaret: jest.fn(() => undefined), + format: jest.fn(), + unformat: jest.fn(), serialized: { blocks: [] }, })); @@ -38,6 +34,8 @@ jest.unstable_mockModule('@editorjs/model', () => { EventType: eventType, createInlineToolData: (data: Record) => data, createInlineToolName: (name: string) => name, + FormattingAction: { Format: 'format', + Unformat: 'unformat' }, }; }); @@ -47,11 +45,18 @@ jest.unstable_mockModule('@editorjs/sdk', () => ({ this.detail = detail; }), EventBus: jest.fn(() => ({ dispatchEvent: jest.fn() })), + IndexError: class IndexError extends Error {}, +})); + +jest.unstable_mockModule('../tools/ToolsManager', () => ({ + default: jest.fn(() => ({ + inlineTools: new Map(), + })), })); const { EditorJSModel, EventType, CaretManagerCaretUpdatedEvent, Index } = await import('@editorjs/model'); -const { SelectionChangedCoreEvent, CoreEventType, EventBus } = await import('@editorjs/sdk'); -const { FormattingAdapter } = await import('@editorjs/dom-adapters'); +const { SelectionChangedCoreEvent, EventBus } = await import('@editorjs/sdk'); +const ToolsManager = (await import('../tools/ToolsManager')).default; const { SelectionManager } = await import('./SelectionManager.js'); describe('SelectionManager', () => { @@ -66,56 +71,22 @@ describe('SelectionManager', () => { } }; - // @ts-expect-error - Mocked instance - const formattingAdapter = new FormattingAdapter(); const eventBus = new EventBus(); - let toolLoadedListener: (e: CustomEvent) => void; - - eventBus.addEventListener = (type: string, callback: (e: Event) => void) => { - if (type === `core:${CoreEventType.ToolLoaded}`) { - toolLoadedListener = callback; - } - }; + // @ts-expect-error - Mocked instance + const toolsManager = new ToolsManager(); - const selectionManager = new SelectionManager({ userId: 'user' } as CoreConfig, model, formattingAdapter, eventBus); + const selectionManager = new SelectionManager( + { userId: 'user' } as unknown as CoreConfigValidated, + model, + eventBus, + toolsManager + ); beforeEach(() => { jest.resetAllMocks(); }); - describe('tool registration (ToolLoaded event)', () => { - it('should ignore non-inline loaded tool', () => { - const tool = { - name: 'paragraph', - isInline: jest.fn(() => false), - create: jest.fn(), - }; - - toolLoadedListener({ - detail: { tool }, - } as unknown as ToolLoadedCoreEvent); - - expect(formattingAdapter.attachTool).not.toHaveBeenCalled(); - }); - - it('should register inline tool and attach it to formatting adapter', () => { - const inlineToolInstance = { render: jest.fn() }; - const tool = { - name: 'bold', - isInline: jest.fn(() => true), - create: jest.fn(() => inlineToolInstance), - }; - - toolLoadedListener({ - detail: { tool }, - } as unknown as ToolLoadedCoreEvent); - - expect(tool.create).toHaveBeenCalled(); - expect(formattingAdapter.attachTool).toHaveBeenCalledWith('bold', inlineToolInstance); - }); - }); - describe('Caret Events handling', () => { it('should ignore caret events of other users', () => { const event = new CaretManagerCaretUpdatedEvent({ @@ -132,8 +103,7 @@ describe('SelectionManager', () => { const event = new CaretManagerCaretUpdatedEvent({ userId: 'user', index: null, - } - ); + }); caretEventsListener(event); @@ -210,16 +180,11 @@ describe('SelectionManager', () => { expect(eventBus.dispatchEvent).not.toHaveBeenCalled(); }); - it('should keep loaded inline tools map in selection changed payload', () => { - const tool = { - name: 'italic', - isInline: jest.fn(() => true), - create: jest.fn(() => ({ render: jest.fn() })), - }; + it('should include inline tools from toolsManager in availableInlineTools', () => { + const toolInstance = { render: jest.fn() }; + const facadeMock = { create: jest.fn(() => toolInstance) }; - toolLoadedListener({ - detail: { tool }, - } as unknown as ToolLoadedCoreEvent); + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['italic', facadeMock]]); const event = new CaretManagerCaretUpdatedEvent({ userId: 'user', @@ -227,41 +192,167 @@ describe('SelectionManager', () => { }); jest.spyOn(model, 'getFragments').mockReturnValue([]); - jest.spyOn(Index, 'parse').mockReturnValue({ blockIndex: 1, dataKey: 'text', textRange: [1, 3], getTextSegments() { - return [ - { - blockIndex: 1, - dataKey: 'text', - textRange: [1, 3], - }, - ]; + return [{ blockIndex: 1, + dataKey: 'text', + textRange: [1, 3] }]; }, } as unknown as Index); caretEventsListener(event); - expect(SelectionChangedCoreEvent).toHaveBeenCalledWith(expect.objectContaining({ - availableInlineTools: new Map([['italic', jest.fn()]]) - })); + const callArg = (SelectionChangedCoreEvent as jest.MockedClass).mock.calls[0][0] as { availableInlineTools: Map }; + + expect(callArg.availableInlineTools.has('italic')).toBe(true); }); }); describe('.applyInlineToolForCurrentSelection()', () => { - it('should apply inline tool format with default data', () => { + it('should throw when caret is not set', () => { + jest.spyOn(model, 'getCaret').mockReturnValue(undefined); + + expect(() => { + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + }).toThrow(); + }); + + it('should throw when caret index is null', () => { + jest.spyOn(model, 'getCaret').mockReturnValue({ index: null } as unknown as ReturnType); + + expect(() => { + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + }).toThrow(); + }); + + it('should throw when caret has no text segments', () => { + const indexMock = { getTextSegments: jest.fn(() => []) }; + + jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); + + expect(() => { + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + }).toThrow(); + }); + + it('should throw when tool is not found', () => { + const indexMock = { + getTextSegments: jest.fn(() => [{ blockIndex: 0, + dataKey: 'text', + textRange: [0, 3] }]), + }; + + jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map(); + + expect(() => { + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + }).toThrow('SelectionManager[applyInlineToolForCurrentSelection]: tool bold is not attached'); + }); + + it('should call model.format when tool getFormattingOptions returns Format action', () => { + const mockFormat = jest.spyOn(model, 'format').mockImplementation(() => undefined); + const toolMock = { + getFormattingOptions: jest.fn(() => ({ action: 'format', + range: [0, 3] })), + }; + const facadeMock = { create: jest.fn(() => toolMock) }; + + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['bold', facadeMock]]); + + const indexMock = { + getTextSegments: jest.fn(() => [{ blockIndex: 0, + dataKey: 'text', + textRange: [0, 3] }]), + }; + + jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); + jest.spyOn(model, 'getFragments').mockReturnValue([]); + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); - expect(formattingAdapter.applyFormat).toHaveBeenCalledWith('bold', {}); + expect(mockFormat).toHaveBeenCalled(); }); - it('should apply inline tool format with provided data', () => { - selectionManager.applyInlineToolForCurrentSelection('link' as InlineToolName, { href: 'https://example.com' }); + it('should call model.unformat when tool getFormattingOptions returns Unformat action', () => { + const mockUnformat = jest.spyOn(model, 'unformat').mockImplementation(() => undefined); + const toolMock = { + getFormattingOptions: jest.fn(() => ({ action: 'unformat', + range: [0, 3] })), + }; + const facadeMock = { create: jest.fn(() => toolMock) }; + + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['bold', facadeMock]]); + + const indexMock = { + getTextSegments: jest.fn(() => [{ blockIndex: 0, + dataKey: 'text', + textRange: [0, 3] }]), + }; + + jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); + jest.spyOn(model, 'getFragments').mockReturnValue([]); + + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + + expect(mockUnformat).toHaveBeenCalled(); + }); + + it('should throw when segment has no textRange', () => { + const toolMock = { getFormattingOptions: jest.fn() }; + const facadeMock = { create: jest.fn(() => toolMock) }; + + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['bold', facadeMock]]); + + const indexMock = { + getTextSegments: jest.fn(() => [{ blockIndex: 0, + dataKey: 'text' }]), + }; + + jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); + + expect(() => { + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + }).toThrow('TextRange of the index should be defined'); + }); + + it('should throw when segment has no blockIndex', () => { + const toolMock = { getFormattingOptions: jest.fn() }; + const facadeMock = { create: jest.fn(() => toolMock) }; + + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['bold', facadeMock]]); + + const indexMock = { + getTextSegments: jest.fn(() => [{ dataKey: 'text', + textRange: [0, 3] }]), + }; + + jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); + + expect(() => { + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + }).toThrow('BlockIndex should be defined'); + }); + + it('should throw when segment has no dataKey', () => { + const toolMock = { getFormattingOptions: jest.fn() }; + const facadeMock = { create: jest.fn(() => toolMock) }; + + (toolsManager as unknown as { inlineTools: Map }).inlineTools = new Map([['bold', facadeMock]]); + + const indexMock = { + getTextSegments: jest.fn(() => [{ blockIndex: 0, + textRange: [0, 3] }]), + }; + + jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType); - expect(formattingAdapter.applyFormat).toHaveBeenCalledWith('link', { href: 'https://example.com' },); + expect(() => { + selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName); + }).toThrow('DataKey of the index should be defined'); }); }); }); diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 72d69a90..91402e4a 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -1,21 +1,26 @@ import 'reflect-metadata'; -import { FormattingAdapter } from '@editorjs/dom-adapters'; -import type { CaretManagerEvents, InlineFragment, InlineToolName } from '@editorjs/model'; -import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel, createInlineToolData, createInlineToolName } from '@editorjs/model'; +import { + CaretManagerEvents, + createInlineToolData, + FormattingAction, + InlineFragment, + InlineToolName +} from '@editorjs/model'; +import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel, createInlineToolName } from '@editorjs/model'; import { EventType } from '@editorjs/model'; import { - CoreEventType, - ToolLoadedCoreEvent, EventBus, - SelectionChangedCoreEvent + SelectionChangedCoreEvent, IndexError, CoreConfigValidated } from '@editorjs/sdk'; -import { Inject, Service } from 'typedi'; -import { type CoreConfig, InlineTool, InlineToolFormatData } from '@editorjs/sdk'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens.js'; +import { InlineToolFormatData } from '@editorjs/sdk'; +import ToolsManager from '../tools/ToolsManager'; /** * SelectionManager responsible for handling selection changes and applying inline tools formatting */ -@Service() +@injectable() export class SelectionManager { /** * Editor model instance @@ -23,58 +28,34 @@ export class SelectionManager { */ #model: EditorJSModel; - /** - * FormattingAdapter instance - * Used for inline tools attaching and format apply - */ - #formattingAdapter: FormattingAdapter; - /** * EventBus instance to exchange events between components */ #eventBus: EventBus; - /** - * Inline Tools instances available for use - */ - #inlineTools: Map = new Map(); - /** * Editor's config */ - #config: CoreConfig; + #config: CoreConfigValidated; + + #toolsManager: ToolsManager; /** * @param config - Editor's config * @param model - editor model instance - * @param formattingAdapter - needed for applying format to the model * @param eventBus - EventBus instance to exchange events between components + * @param toolsManager - Editor's tools manager */ constructor( - @Inject('EditorConfig') config: CoreConfig, + @inject(TOKENS.EditorConfig) config: CoreConfigValidated, model: EditorJSModel, - formattingAdapter: FormattingAdapter, - eventBus: EventBus + eventBus: EventBus, + toolsManager: ToolsManager ) { this.#config = config; this.#model = model; - this.#formattingAdapter = formattingAdapter; this.#eventBus = eventBus; - - this.#eventBus.addEventListener(`core:${CoreEventType.ToolLoaded}`, (event: ToolLoadedCoreEvent) => { - const { tool } = event.detail; - - if ('isInline' in tool && tool.isInline() === false) { - return; - } - - const toolInstance = tool.create(); - const name = createInlineToolName(tool.name); - - this.#inlineTools.set(name, toolInstance); - - this.#formattingAdapter.attachTool(name, toolInstance); - }); + this.#toolsManager = toolsManager; this.#model.addEventListener(EventType.CaretManagerUpdated, (event: CaretManagerEvents) => this.#handleCaretManagerUpdate(event)); } @@ -110,7 +91,12 @@ export class SelectionManager { /** * @todo implement filter by current BlockTool configuration */ - availableInlineTools: this.#inlineTools, + availableInlineTools: new Map( + this.#toolsManager + .inlineTools + .entries() + .map(([name, facade]) => [createInlineToolName(name), facade.create()]) + ), fragments, })); @@ -126,8 +112,66 @@ export class SelectionManager { */ public applyInlineToolForCurrentSelection(toolName: InlineToolName, data: InlineToolFormatData = {}): void { /** - * @todo pass to applyFormat inline tool data formed in toolbar + * @todo use inline tool data formed in toolbar + */ + const userCaret = this.#model.getCaret(this.#config.userId); + + const index = userCaret?.index ?? null; + + if (index === null) { + throw new IndexError('SelectionManager[applyInlineToolForCurrentSelection]: caret index is outside of the input'); + } + + /** + * @todo do not store middle segments in the index, use only the first and last segments + * Also, we need to sort inputs inside first/last block by document order to restore selection */ - this.#formattingAdapter.applyFormat(toolName, createInlineToolData(data)); + const segments = index.getTextSegments(); + + if (segments.length === 0) { + throw new IndexError('SelectionManager[applyInlineToolForCurrentSelection]: caret index is outside of the input'); + } + + const tool = this.#toolsManager.inlineTools.get(toolName)?.create(); + + /** + * @todo think of config synchronisation. If remote user has some tools current user doesn't there's going to be mismatch in the data + */ + if (tool === undefined) { + throw new Error(`SelectionManager[applyInlineToolForCurrentSelection]: tool ${toolName} is not attached`); + } + + for (const segment of segments) { + const textRange = segment.textRange; + const blockIndex = segment.blockIndex; + const dataKey = segment.dataKey; + + if (textRange === undefined) { + throw new IndexError('TextRange of the index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); + } + + if (blockIndex === undefined) { + throw new IndexError('BlockIndex should be defined. Probably something wrong with the Editor Model. Please, report this issue'); + } + + if (dataKey === undefined) { + throw new IndexError('DataKey of the index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); + } + + const fragments = this.#model.getFragments(blockIndex, dataKey, ...textRange, toolName); + + const { action, range } = tool.getFormattingOptions(textRange, fragments); + + switch (action) { + case FormattingAction.Format: + this.#model.format(this.#config.userId, blockIndex, dataKey, toolName, ...range, createInlineToolData(data)); + + break; + case FormattingAction.Unformat: + this.#model.unformat(this.#config.userId, blockIndex, dataKey, toolName, ...range); + + break; + } + } }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c5f7aa6f..b1fc594a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,6 @@ import { CollaborationManager } from '@editorjs/collaboration-manager'; import { type DocumentId, EditorJSModel, EventType } from '@editorjs/model'; -import type { ContainerInstance } from 'typedi'; -import { Container } from 'typedi'; +import { Container } from 'inversify'; import { type BlockToolConstructor, CoreEventType, @@ -13,15 +12,16 @@ import { import type { ToolSettings } from './tools/ToolsFactory'; import { composeDataFromVersion2 } from './utils/composeDataFromVersion2.js'; import ToolsManager from './tools/ToolsManager.js'; -import { CaretAdapter, FormattingAdapter } from '@editorjs/dom-adapters'; -import type { CoreConfigValidated, CoreConfig, EditorjsPluginConstructor, BlockTuneConstructor, ToolConstructable } from '@editorjs/sdk'; -import { BlocksManager } from './components/BlockManager.js'; -import { SelectionManager } from './components/SelectionManager.js'; +import type { CoreConfigValidated, CoreConfig, EditorjsPluginConstructor, BlockTuneConstructor, ToolConstructable, EditorjsAdapterPluginConstructor } from '@editorjs/sdk'; import { EditorAPI } from './api/index.js'; import { generateId } from './utils/uid.js'; import { Paragraph, BoldInlineTool, LinkInlineTool, ItalicInlineTool } from './tools/internal'; import { ShortcutsPlugin } from './plugins/ShortcutsPlugin.js'; - +import { DOMAdapters } from '@editorjs/dom-adapters'; +import { BlocksManager } from './components/BlockManager.js'; +import { BlockRenderer } from './components/BlockRenderer.js'; +import { SelectionManager } from './components/SelectionManager.js'; +import { TOKENS } from './tokens.js'; /** * If no holder is provided via config, the editor will be appended to the element with this id */ @@ -50,22 +50,12 @@ export default class Core { */ #config: CoreConfigValidated; - /** - * Caret adapter is responsible for handling caret position and selection - */ - #caretAdapter: CaretAdapter; - /** * Inversion of Control container for dependency injections */ - #iocContainer: ContainerInstance; + #iocContainer: Container; - /** - * Inline tool adapter is responsible for handling model formatting updates - * Applies format, got from inline toolbar to the model - * When model changed with formatting event, it renders related fragment - */ - #formattingAdapter: FormattingAdapter; + #plugins: Container; /** * Collaboration manager @@ -76,8 +66,9 @@ export default class Core { * @param config - Editor configuration */ constructor(config: CoreConfig) { - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - this.#iocContainer = Container.of(Math.floor(Math.random() * 1e10).toString()); + this.#iocContainer = new Container({ autobind: true, + defaultScope: 'Singleton' }); + this.#plugins = new Container(); this.#validateConfig(config); @@ -91,30 +82,21 @@ export default class Core { this.#config.documentId = generateId(); } - this.#iocContainer.set('EditorConfig', this.#config); + this.#iocContainer.bind(TOKENS.EditorConfig).toConstantValue(this.#config); const eventBus = new EventBus(); - this.#iocContainer.set(EventBus, eventBus); + this.#iocContainer.bind(EventBus).toConstantValue(eventBus); this.#model = new EditorJSModel(this.#config.userId, { identifier: this.#config.documentId as DocumentId }); - this.#iocContainer.set(EditorJSModel, this.#model); + this.#iocContainer.bind(EditorJSModel).toConstantValue(this.#model); this.#toolsManager = this.#iocContainer.get(ToolsManager); - this.#caretAdapter = new CaretAdapter(this.#config, this.#config.holder, this.#model); - this.#iocContainer.set(CaretAdapter, this.#caretAdapter); - this.#collaborationManager = new CollaborationManager(this.#config, this.#model); - this.#iocContainer.set(CollaborationManager, this.#collaborationManager); - - this.#formattingAdapter = new FormattingAdapter(this.#config, this.#model, this.#caretAdapter); - - this.#iocContainer.set(FormattingAdapter, this.#formattingAdapter); - this.#iocContainer.get(SelectionManager); - this.#iocContainer.get(BlocksManager); + this.#iocContainer.bind(CollaborationManager).toConstantValue(this.#collaborationManager); if (config.onModelUpdate !== undefined) { this.#model.addEventListener(EventType.Changed, () => { @@ -135,6 +117,7 @@ export default class Core { this.use(ItalicInlineTool); this.use(LinkInlineTool); this.use(ShortcutsPlugin); + this.use(DOMAdapters); } /** @@ -147,14 +130,14 @@ export default class Core { * Injects Plugin into the container to initialize on Editor's init * @param plugin - allows to pass any implementation of editor plugins */ - public use(plugin: EditorjsPluginConstructor): Core; + public use(plugin: EditorjsPluginConstructor | EditorjsAdapterPluginConstructor): Core; /** * Overloaded method to register Editor.js Plugins/Tools/etc * @param pluginOrTool - entity to register * @param options - second argument of `use(Tool, options)` when registering a tool */ public use( - pluginOrTool: ToolConstructable | EditorjsPluginConstructor, + pluginOrTool: ToolConstructable | EditorjsPluginConstructor | EditorjsAdapterPluginConstructor, options?: Omit ): Core { const pluginType = pluginOrTool.type; @@ -163,21 +146,13 @@ export default class Core { case ToolType.Block: case ToolType.Inline: case ToolType.Tune: - this.#iocContainer.set({ - id: pluginType, - multiple: true, - value: [pluginOrTool, options], - }); + this.#plugins.bind<[ToolConstructable, ToolSettings | undefined]>(pluginType).toConstantValue([pluginOrTool as ToolConstructable, options as ToolSettings | undefined]); + break; + case PluginType.Adapter: + this.#plugins.bind(PluginType.Adapter).toConstantValue(pluginOrTool); break; default: - this.#iocContainer.set({ - id: PluginType.Plugin, - multiple: true, - value: pluginOrTool, - /** - * @todo support plugin "options" - */ - }); + this.#plugins.bind(PluginType.Plugin).toConstantValue(pluginOrTool); } return this; @@ -190,10 +165,16 @@ export default class Core { try { const { blocks } = composeDataFromVersion2(this.#config.data ?? { blocks: [] }); + this.#initializeAdapter(); + this.#initializePlugins(); await this.#initializeTools(); + this.#iocContainer.get(SelectionManager); + this.#iocContainer.get(BlocksManager); + this.#iocContainer.get(BlockRenderer); + this.#model.initializeDocument({ blocks }); this.#collaborationManager.connect(); } catch (error) { @@ -205,9 +186,9 @@ export default class Core { * Initalizes loaded tools */ async #initializeTools(): Promise { - const blockTools = this.#iocContainer.getMany<[ BlockToolConstructor, ToolSettings]>(ToolType.Block); - const inlineTools = this.#iocContainer.getMany<[ InlineToolConstructor, ToolSettings]>(ToolType.Inline); - const blockTunes = this.#iocContainer.getMany<[ BlockTuneConstructor, ToolSettings]>(ToolType.Tune); + const blockTools = this.#plugins.getAll<[BlockToolConstructor, ToolSettings]>(ToolType.Block); + const inlineTools = this.#plugins.getAll<[InlineToolConstructor, ToolSettings]>(ToolType.Inline); + const blockTunes = this.#plugins.getAll<[BlockTuneConstructor, ToolSettings]>(ToolType.Tune); return this.#toolsManager.prepareTools([...blockTools, ...inlineTools, ...blockTunes]); } @@ -216,7 +197,9 @@ export default class Core { * Initialize all registered UI plugins (see {@link PluginType.Plugin}). */ #initializePlugins(): void { - const plugins = this.#iocContainer.getMany(PluginType.Plugin); + const plugins = this.#plugins.isBound(PluginType.Plugin) + ? this.#plugins.getAll(PluginType.Plugin) + : []; for (const PluginCtor of plugins) { this.#initializePlugin(PluginCtor); @@ -238,6 +221,27 @@ export default class Core { }); } + /** + * Adds adapter factory to the IoC + */ + #initializeAdapter(): void { + const Adapter = this.#plugins.get(PluginType.Adapter); + + this.#iocContainer.bind(TOKENS.Adapter) + .toDynamicValue((ctx) => { + const eventBus = ctx.get(EventBus); + const api = ctx.get(EditorAPI); + + return new Adapter({ + model: this.#model, + config: this.#config, + api, + eventBus, + }); + }) + .inSingletonScope(); + } + /** * Validate configuration * @param config - Editor configuration diff --git a/packages/core/src/tokens.ts b/packages/core/src/tokens.ts new file mode 100644 index 00000000..13fee1b9 --- /dev/null +++ b/packages/core/src/tokens.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Injection tokens for the core IoC container. + * Using Symbol.for() so the same token is always equal across module instances. + */ +export const TOKENS = { + /** + * Configuration token + */ + EditorConfig: Symbol.for('EditorConfig'), + /** + * Adapter token + */ + Adapter: Symbol.for('Adapter'), +} as const; diff --git a/packages/core/src/tools/ToolsManager.ts b/packages/core/src/tools/ToolsManager.ts index 72459613..d58a09df 100644 --- a/packages/core/src/tools/ToolsManager.ts +++ b/packages/core/src/tools/ToolsManager.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; import { isFunction, isObject, PromiseQueue } from '@editorjs/helpers'; -import { Inject, Service } from 'typedi'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens.js'; import { type ToolSettings, ToolsFactory } from './ToolsFactory.js'; import type { EditorConfig @@ -20,7 +21,7 @@ import { * Works with tools * @todo - validate tools configurations */ -@Service() +@injectable() export default class ToolsManager { /** * ToolsFactory instance @@ -89,7 +90,7 @@ export default class ToolsManager { * @param eventBus - EventBus instance to exchange events between components */ constructor( - @Inject('EditorConfig') editorConfig: EditorConfig, + @inject(TOKENS.EditorConfig) editorConfig: EditorConfig, eventBus: EventBus ) { this.#config = this.#prepareConfig(editorConfig.tools ?? {}); 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 401a8fd7..d51fbfee 100644 --- a/packages/core/src/tools/internal/block-tools/paragraph/index.ts +++ b/packages/core/src/tools/internal/block-tools/paragraph/index.ts @@ -2,13 +2,15 @@ import type { ToolConfig } from '@editorjs/editorjs'; import type { TextNodeSerialized } from '@editorjs/model'; import type { BlockTool, - BlockToolAdapter, BlockToolConstructor, BlockToolConstructorOptions, - BlockToolData + BlockToolData, + KeyAddedEvent } from '@editorjs/sdk'; +import { KeyRemovedEvent } from '@editorjs/sdk'; import { ToolType } from '@editorjs/sdk'; import { IconText } from '@codexteam/icons'; +import type { DOMBlockToolAdapter } from '@editorjs/dom-adapters'; /** * Data structure describing the tool's input/output data @@ -48,54 +50,69 @@ export class Paragraph implements BlockTool { /** * Adapter for linking block data with the DOM */ - #adapter: BlockToolAdapter; + #adapter: DOMBlockToolAdapter; - /** - * Tool's input/output data - */ - #data: ParagraphData; + #wrapper: HTMLDivElement | undefined; - #wrapper!: HTMLDivElement; + #paragraph: HTMLDivElement | undefined; /** - * @param options - Block tool constructor options + * */ - constructor({ adapter, data }: BlockToolConstructorOptions) { - this.#adapter = adapter; - this.#data = data; + private get wrapper(): HTMLDivElement { + if (this.#wrapper !== undefined) { + return this.#wrapper; + } - this.#buildUI(); + this.#wrapper = document.createElement('div'); - this.#adapter.init(this, this.onUpdate); - this.#adapter.registerKey('text', 'text'); + this.#wrapper.classList.add('editorjs-paragraph'); + + return this.#wrapper; } - #buildUI(): void { - const wrapper = document.createElement('div'); + /** + * @param options - Block tool constructor options + */ + constructor({ adapter }: BlockToolConstructorOptions) { + this.#adapter = adapter; - wrapper.classList.add('editorjs-paragraph'); + adapter.addEventListener('adapter:updated', this.#onUpdate); - this.#wrapper = wrapper; + this.#adapter.registerTextInputKey('text'); } /** * Creates tool element */ public render(): HTMLElement { - return this.#wrapper; + return this.wrapper; } - onUpdate = (key: string, type: 'text' | 'value'): HTMLElement => { + #onUpdate = (event: KeyAddedEvent | KeyRemovedEvent): void => { + const { key } = event.detail; + + if (event instanceof KeyRemovedEvent) { + this.#paragraph?.remove(); + this.#paragraph = undefined; + + this.#adapter.setInput(key, undefined); + + return; + } + const paragraph = document.createElement('div'); paragraph.contentEditable = 'true'; paragraph.style.outline = 'none'; paragraph.style.whiteSpace = 'pre-wrap'; - this.#wrapper.append(paragraph); + this.wrapper.append(paragraph); + + this.#adapter.setInput(key, paragraph); - return paragraph; + this.#paragraph = paragraph; }; } -Paragraph satisfies BlockToolConstructor; +Paragraph satisfies BlockToolConstructor; diff --git a/packages/dom-adapters/package.json b/packages/dom-adapters/package.json index 82cd438e..e1337a89 100644 --- a/packages/dom-adapters/package.json +++ b/packages/dom-adapters/package.json @@ -36,6 +36,8 @@ "dependencies": { "@editorjs/dom": "^1.1.0", "@editorjs/model": "workspace:^", - "@editorjs/sdk": "workspace:^" + "@editorjs/sdk": "workspace:^", + "inversify": "^8.1.0", + "reflect-metadata": "^0.2.2" } } diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index b1ae4961..f56f5514 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -1,98 +1,52 @@ -import { isNativeInput } from '@editorjs/dom'; import { - BlockAddedEvent, - BlockRemovedEvent, createDataKey, - type DataKey, DataNodeAddedEvent, DataNodeRemovedEvent, - type EditorJSModel, + type DataKey, + EditorJSModel, EventAction, - EventType, IndexBuilder, type ModelEvents, TextAddedEvent, - TextRemovedEvent, - type Index, - ValueModifiedEvent + TextRemovedEvent } from '@editorjs/model'; import type { - EventBus, - BlockToolAdapter as BlockToolAdapterInterface, - CoreConfig, BeforeInputUIEvent, BeforeInputUIEventPayload, - BlockTool + CoreConfig } from '@editorjs/sdk'; -import { BeforeInputUIEventName } from '@editorjs/sdk'; -import type { CaretAdapter } from '../CaretAdapter/index.js'; -import type { FormattingAdapter } from '../FormattingAdapter/index.js'; +import { BeforeInputUIEventName, BlockToolAdapter, + EventBus +} from '@editorjs/sdk'; +import { CaretAdapter } from '../CaretAdapter/index.js'; +import { FormattingAdapter } from '../FormattingAdapter/index.js'; import { - findNextHardLineBoundary, - findNextWordBoundary, - findPreviousHardLineBoundary, - findPreviousWordBoundary, getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset, getClippedTextRangeForInput, isInputContainsOnlyEndOfSelection, isInputContainsOnlyStartOfSelection, isInputContainsWholeSelection, - isInputInBetweenSelection, - isNonTextInput + isInputInBetweenSelection } from '../utils/index.js'; import { InputType } from './types/InputType.js'; - -type ToolOnUpdateCallback = (key: string, type: 'text' | 'value') => HTMLElement; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens.js'; +import { InputsRegistry } from '../InputsRegistry/index.js'; /** * BlockToolAdapter is using inside Block tools to connect browser DOM elements to the model * It can handle beforeinput events and update model data * It can handle model's change events and update DOM */ -export class BlockToolAdapter implements BlockToolAdapterInterface { - /** - * Model instance - */ - #model: EditorJSModel; - +@injectable('Transient') +export class DOMBlockToolAdapter extends BlockToolAdapter { /** - * Index of the block that this adapter is connected to + * Name of the tool that this adapter is connected to */ - #blockIndex: number; + #toolName: string = ''; - /** - * Caret adapter instance - */ #caretAdapter: CaretAdapter; - - /** - * Formatting adapter instance - */ #formattingAdapter: FormattingAdapter; - - /** - * Tool instance - */ - #tool!: BlockTool; - - /** - * Editor's config - */ - #config: Required; - - /** - * Callback registered by the tool to create DOM elements when data nodes are added - */ - #toolOnUpdateCallback: ToolOnUpdateCallback | null = null; - - /** - * Inputs that bound to the model - */ - #attachedInputs = new Map(); - - /** - * Values that bound to the model - */ - #attachedValues = new Map void>(); + #inputsRegistry: InputsRegistry; /** * BlockToolAdapter constructor @@ -101,131 +55,76 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @param model - EditorJSModel instance * @param eventBus - Editor EventBus instance * @param caretAdapter - CaretAdapter instance - * @param blockIndex - index of the block that this adapter is connected to * @param formattingAdapter - needed to render formatted text + * @param registry - shared inputs registry */ constructor( - config: Required, - model: EditorJSModel, - eventBus: EventBus, - caretAdapter: CaretAdapter, - blockIndex: number, - formattingAdapter: FormattingAdapter + @inject(TOKENS.EditorConfig) config: Required, + model: EditorJSModel, + eventBus: EventBus, + caretAdapter: CaretAdapter, + formattingAdapter: FormattingAdapter, + registry: InputsRegistry ) { - this.#config = config; - this.#model = model; - this.#blockIndex = blockIndex; + super(config, model, eventBus); + this.#caretAdapter = caretAdapter; this.#formattingAdapter = formattingAdapter; + this.#inputsRegistry = registry; - this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event)); + /** + * @todo Needs to be documented. If UI module is replaced and doesn't dispatch the event nothing would work + */ eventBus.addEventListener(`ui:${BeforeInputUIEventName}`, (event: BeforeInputUIEvent) => { this.#processDelegatedBeforeInput(event); }); } /** - * Registers a callback that will be called when a text or value node is added to the model. + * Sets tool name for the adapter * - * @param callback - receives the data key and node type, should create, mount and return the DOM element + * @todo think how to remove the name dependency + * @param name - tool name */ - public onUpdate(callback: ToolOnUpdateCallback): void { - this.#toolOnUpdateCallback = callback; + public setToolName(name: string): void { + this.#toolName = name; } /** - * Initializes the adapter with the tool instance and the update callback. - * Scans existing model data and renders inputs for all existing text nodes. + * Attaches or re-attaches input to the model using key + * It handles beforeinput events and updates model data * - * @param tool - the block tool instance - * @param onUpdateCallback - callback to create DOM elements for data nodes + * @param keyRaw - tools data key to attach input to + * @param input - input element */ - public init(tool: BlockTool, onUpdateCallback: ToolOnUpdateCallback): void { - this.#tool = tool; - this.#toolOnUpdateCallback = onUpdateCallback; - - const blockData = this.#model.serialized.blocks[this.#blockIndex]; - - Object.entries(blockData.data).forEach(([key, value]) => { - if (this.#attachedInputs.has(createDataKey(key))) { - return; - } - - if (typeof value === 'object' && value !== null && '$t' in value && (value as { $t: unknown }).$t === 't') { - this.onTextNodeAdded(key); - } - }); - } + public setInput(keyRaw: string, input: HTMLElement | undefined): void { + const key = createDataKey(keyRaw); - /** - * Called when a text DataNode is added to the model for this block. - * Creates and attaches the DOM input via the registered onUpdate callback. - * - * @param dataKey - key of the added text node - */ - public onTextNodeAdded(dataKey: string): void { - if (this.#attachedInputs.has(createDataKey(dataKey))) { - return; - } + if (input === undefined) { + this.#inputsRegistry.unregister(this.blockIndex, key); - if (this.#toolOnUpdateCallback === null) { return; } - const input = this.#toolOnUpdateCallback(dataKey, 'text'); - - this.attachInput(dataKey, input); - } - - public registerKey(keyRaw: string, type: 'text' | 'value', initialData?: unknown): void { - if (this.#model.getDataNode(this.#config.userId, this.#blockIndex, createDataKey(keyRaw)) !== undefined) { - return; + if (!(input instanceof HTMLElement)) { + throw new Error('Input should be an HTML element'); } - let data; - - switch (type) { - case 'text': - data = { - $t: 't', - text: initialData as string ?? '', - }; - break; - case 'value': - if (typeof initialData === 'object') { - data = { $t: 'v', ...initialData }; - } else { - data = initialData; - } - break; + if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) { + throw new Error('Native inputs such as HTMLInput or HTMLTextArea are not supported. Please provide an HTMLElement with contendEditable property set to \'true\''); } - this.#model.createDataNode(this.#config.userId, this.#blockIndex, keyRaw, data); - } + const existingInput = this.#attachedInputs.get(key); - /** - * Attaches input to the model using key - * It handles beforeinput events and updates model data - * - * @param keyRaw - tools data key to attach input to - * @param input - input element - */ - public attachInput(keyRaw: string, input: HTMLElement): void { - if (input instanceof HTMLInputElement && isNonTextInput(input)) { - throw new Error('Cannot attach non-text input'); + if (existingInput === input) { + return; } - const key = createDataKey(keyRaw); - - this.#attachedInputs.set(key, input); - - const builder = new IndexBuilder(); - - builder.addBlockIndex(this.#blockIndex).addDataKey(key); + const value = this.model.getText(this.blockIndex, key); + const fragments = this.model.getFragments(this.blockIndex, key); - const value = this.#model.getText(this.#blockIndex, key); - const fragments = this.#model.getFragments(this.#blockIndex, key); + this.#inputsRegistry.register(this.blockIndex, key, input); input.textContent = value; @@ -235,48 +134,10 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { } /** - * Removes the input from the DOM by key - * - * @param keyRaw - key of the input to remove + * Returns the (dataKey → element) map for this block from the shared registry. */ - public detachInput(keyRaw: string): void { - const key = createDataKey(keyRaw); - const input = this.#attachedInputs.get(key); - - if (!input) { - return; - } - - /** - * @todo Let BlockTool handle DOM update - */ - input.remove(); - - this.#attachedInputs.delete(key); - - this.#model.removeDataNode(this.#config.userId, this.#blockIndex, key); - } - - /** - * @todo - move to sdk BlockToolAdapter interface if it would be used - * Public getter for block index. - * Can be used to find a particular block, for example, in caret adapter - */ - public getBlockIndex(): Index { - return new IndexBuilder() - .addBlockIndex(this.#blockIndex) - .build(); - } - - /** - * Updates the internal block index. - * Should only be called by the CaretAdapter registry when blocks are inserted or removed, - * guaranteeing the update happens before any nested model events fire. - * - * @param index - new block index value - */ - public setBlockIndex(index: number): void { - this.#blockIndex = index; + get #attachedInputs(): Map { + return this.#inputsRegistry.getBlockInputs(this.blockIndex) ?? new Map(); } /** @@ -298,77 +159,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { return this.#attachedInputs.get(key); } - /** - * Attaches value to the model using raw data key - * - * @template T - type of the value node - * @param keyRaw - string data key used for data node identification - * @param initialValue - initial value of the value node - * @param callback - callback function that should be used for DOM rerendering - * @returns {(newValue: T) => void} function that should be used to update the model - */ - public attachValue(keyRaw: string, initialValue: T, callback: (value: T) => void): (newValue: T) => void { - const key = createDataKey(keyRaw); - - /** - * Cast callback to allow saving - */ - this.#attachedValues.set(key, callback as (value: unknown) => void); - - /** - * Create data node in the model with initial value - */ - this.#model.createDataNode( - this.#config.userId, - this.#blockIndex, - key, - { - $t: 'v', - value: initialValue, - } - ); - - return (newValue: T) => { - this.#model.updateValue(this.#config.userId, this.#blockIndex, key, newValue); - }; - }; - - /** - * Removes the data node from the model by key - * - * @param keyRaw - string data key used for value node identification - */ - public detachValue(keyRaw: string): void { - const key = createDataKey(keyRaw); - - const value = this.#attachedValues.get(key); - - if (!value) { - return; - } - - /** - * Remove value update callback - */ - this.#attachedValues.delete(key); - - /** - * Remove data node from the model - */ - this.#model.removeDataNode(this.#config.userId, this.#blockIndex, key); - } - - /** - * Calls detach input and detach value methods - * If no value or no input for this dataKey — pass silently - * - * @param key - data key to detach - */ - #detachDataNode(key: DataKey): void { - this.detachInput(key.toString()); - this.detachValue(key.toString()); - } - /** * Check current selection and find all inputs that contain target ranges * @@ -462,106 +252,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { }); } - /** - * Handles delete events in native input - * - * @param payload - beforeinput event payload - * @param input - input element - * @param key - data key input is attached to - * @param range - target range for this input - * @private - * @deprecated - */ - #handleDeleteInNativeInput( - payload: BeforeInputUIEventPayload, - input: HTMLInputElement | HTMLTextAreaElement, - key: DataKey, - range: StaticRange - ): void { - const inputType = payload.inputType; - const inputValue = input.value; - const inputLength = inputValue.length; - - let start = 0; - let end = inputLength; - - /** - * If range is fully contained within this input - */ - if (input.contains(range.startContainer) && input.contains(range.endContainer)) { - start = range.startOffset; - end = range.endOffset; - } else if (input.contains(range.startContainer)) { - /** - * If only start is in this input, delete from start to end of input - */ - start = range.startOffset; - } else if (input.contains(range.endContainer)) { - /** - * If only end is in this input, delete from start of input to end - */ - end = range.endOffset; - } - - /** - * If selection is not collapsed, just remove selected text - */ - if (start !== end) { - this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - - return; - } - - switch (inputType) { - case InputType.DeleteContentForward: { - /** - * If selection end is already after the last element, then there is nothing to delete - */ - end = end !== inputValue.length ? end + 1 : end; - break; - } - case InputType.DeleteContentBackward: { - /** - * If start is already 0, then there is nothing to delete - */ - start = start !== 0 ? start - 1 : start; - break; - } - case InputType.DeleteWordBackward: { - start = findPreviousWordBoundary(inputValue, start); - break; - } - case InputType.DeleteWordForward: { - end = findNextWordBoundary(inputValue, start); - break; - } - case InputType.DeleteHardLineBackward: { - start = findPreviousHardLineBoundary(inputValue, start); - break; - } - case InputType.DeleteHardLineForward: { - end = findNextHardLineBoundary(inputValue, start); - break; - } - case InputType.DeleteSoftLineBackward: - case InputType.DeleteSoftLineForward: - case InputType.DeleteEntireSoftLine: - /** - * @todo Think of how to find soft line boundaries - */ - break; - case InputType.DeleteByDrag: - case InputType.DeleteByCut: - case InputType.DeleteContent: - default: - /** - * do nothing, use start and end from range - */ - } - - this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); - } - /** * Handles delete events in contenteditable element * @@ -582,7 +272,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * 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.blockIndex); return; } @@ -610,20 +300,32 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { } 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.blockIndex, key, start, end); let newCaretIndex: number | null = null; - if (isInputContainsOnlyStartOfSelection(input, range) && !isRestoreCaretToTheEnd) { + if (!isRestoreCaretToTheEnd) { + /** + * Default mode: place the caret where the deletion started. + * Applies when the input owns the start of the selection, or when + * the entire selection falls inside this input (whole-selection delete). + * Also covers the case where the input owns only the end of a cross-input + * selection — the remaining text has shifted to `start` (= 0 for a + * leading-edge clip), so that is the correct landing position. + */ newCaretIndex = start; - } else if (isInputContainsOnlyEndOfSelection(input, range) && isRestoreCaretToTheEnd) { + } else if (isInputContainsOnlyEndOfSelection(input, range)) { + /** + * InsertParagraph / split mode: the caller wants the caret at the end + * of the surviving text in the input that held the selection end. + */ newCaretIndex = end - removedText.length; } if (newCaretIndex !== null) { this.#caretAdapter.updateIndex( new IndexBuilder() - .addBlockIndex(this.#blockIndex) + .addBlockIndex(this.blockIndex) .addDataKey(key) .addTextRange([newCaretIndex, newCaretIndex]) .build() @@ -645,7 +347,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { const range = targetRanges[0]; const isFormattingInputType = inputType.startsWith('format'); - const isInputNative = isNativeInput(input); let start: number; /** @@ -656,11 +357,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * In all cases (except formatting commands) we need to handle delete selected text if range is not collapsed. */ if (range.collapsed === false && !isFormattingInputType) { - if (isInputNative) { - this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key, range); - } else { - this.#handleDeleteInContentEditable(input, key, range); - } + this.#handleDeleteInContentEditable(input, key, range); } switch (inputType) { @@ -668,11 +365,9 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { case InputType.InsertFromDrop: case InputType.InsertFromPaste: { if (data !== undefined && input.contains(range.startContainer)) { - start = isInputNative ? - (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : - getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + 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.blockIndex, key, data, start); } break; } @@ -682,11 +377,9 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ case InputType.InsertCompositionText: { if (data !== undefined && input.contains(range.startContainer)) { - start = isInputNative ? - (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : - getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + 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.blockIndex, key, data, start); } break; } @@ -718,21 +411,15 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { (isInputContainsOnlyStartOfSelection(input, range) || isInputContainsWholeSelection(input, range)) && payload.isCrossInputSelection === false ) { - start = isInputNative ? - (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number : - getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); + start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); this.#handleSplit(key, start, start); } break; case InputType.InsertLineBreak: /** - * @todo Think if we need to keep that or not + * @todo hanlde insert linebreak for content editable elements */ - if (isInputNative && input.contains(range.startContainer)) { - start = (input as HTMLInputElement | HTMLTextAreaElement).selectionStart as number; - this.#model.insertText(this.#config.userId, this.#blockIndex, key, '\n', start); - } break; default: } @@ -748,10 +435,10 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @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 currentValue = this.model.getText(this.blockIndex, key); const newValueAfter = currentValue.slice(end); - const relatedFragments = this.#model.getFragments(this.#blockIndex, key, end, currentValue.length); + const relatedFragments = this.model.getFragments(this.blockIndex, 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 @@ -761,11 +448,14 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { fragment.range[1] -= end; }); - this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, currentValue.length); - this.#model.addBlock( - this.#config.userId, + this.model.removeText(this.config.userId, this.blockIndex, key, start, currentValue.length); + this.model.addBlock( + this.config.userId, { - name: this.#tool.constructor.name, + /** + * @todo when implementing split/merge, think of how to not use toolname here + */ + name: this.#toolName, data: { [key]: { $t: 't', @@ -774,7 +464,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { }, }, }, - this.#blockIndex + 1 + this.blockIndex + 1 ); /** @@ -783,7 +473,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { requestAnimationFrame(() => { this.#caretAdapter.updateIndex( new IndexBuilder() - .addBlockIndex(this.#blockIndex + 1) + .addBlockIndex(this.blockIndex + 1) .addDataKey(key) .addTextRange([0, 0]) .build() @@ -791,48 +481,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { }); } - /** - * Handles model update events for native inputs and updates DOM - * - * @param event - model update event - * @param input - input element - */ - #handleModelUpdateForNativeInput(event: ModelEvents, input: HTMLInputElement | HTMLTextAreaElement): void { - const { textRange } = event.detail.index; - - const currentElement = input; - const [start, end] = textRange!; - - const action = event.detail.action; - - const caretIndexBuilder = new IndexBuilder(); - - caretIndexBuilder.from(event.detail.index); - - switch (action) { - case EventAction.Added: { - const text = event.detail.data as string; - const prevValue = currentElement.value; - - currentElement.value = prevValue.slice(0, start) + text + prevValue.slice(start); - - caretIndexBuilder.addTextRange([start + text.length, start + text.length]); - - break; - } - case EventAction.Removed: { - currentElement.value = currentElement.value.slice(0, start) + - currentElement.value.slice(end); - - caretIndexBuilder.addTextRange([start, start]); - - break; - } - } - - this.#caretAdapter.updateIndex(caretIndexBuilder.build(), event.detail.userId); - }; - /** * Handles model update events for contenteditable elements and updates DOM * @@ -854,7 +502,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { const builder = new IndexBuilder(); - builder.addDataKey(key).addBlockIndex(this.#blockIndex); + builder.addDataKey(key).addBlockIndex(this.blockIndex); let newCaretIndex: number | null = null; @@ -885,99 +533,24 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { } }; - /** - * Handles model update events for value nodes and updates DOM via callback - * - * @param event - value modified event got from the model - * @param key - data key of the value node - */ - #handleModelUpdateForValue(event: ValueModifiedEvent, key: DataKey): void { - const { index, data } = event.detail; - const { dataKey, blockIndex } = index; - - const { value } = data; - - if (blockIndex !== this.#blockIndex || dataKey !== key) { - return; - } - - /** - * Get value update callback - */ - const valueUpdateCallback = this.#attachedValues.get(key); - - if (!valueUpdateCallback) { - return; - } - - valueUpdateCallback(value); - } - /** * Handles model update events and updates DOM * * @param event - model update event */ - #handleModelUpdate(event: ModelEvents): void { - if (event instanceof BlockAddedEvent || event instanceof BlockRemovedEvent) { - /** - * Block index shifting is now handled externally by CaretAdapter.shiftBlockIndices(), - * which is called synchronously by BlocksManager before any nested model events fire. - * No action needed here. - */ - return; - } - - const { textRange, dataKey, blockIndex } = event.detail.index; - - if (blockIndex !== this.#blockIndex) { - return; - } - - if (event instanceof DataNodeAddedEvent) { - if (dataKey === undefined) { - return; - } - - const data = event.detail.data; - - if (typeof data === 'object' && data !== null && '$t' in data && (data as { $t: unknown }).$t === 't') { - this.onTextNodeAdded(dataKey.toString()); - } - - return; - } - - if (event instanceof DataNodeRemovedEvent) { - if (dataKey === undefined) { - return; - } - - this.#detachDataNode(dataKey); - - return; - } - - if (event instanceof ValueModifiedEvent) { - this.#handleModelUpdateForValue(event, dataKey!); - } - + protected handleModelUpdate(event: ModelEvents): void { if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) { return; } + const { textRange, dataKey } = event.detail.index; + const input = this.#attachedInputs.get(dataKey!); if (!input || textRange === undefined) { return; } - const isInputNative = isNativeInput(input); - - if (isInputNative === true) { - this.#handleModelUpdateForNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement); - } else { - this.#handleModelUpdateForContentEditableElement(event, input, dataKey!); - } + this.#handleModelUpdateForContentEditableElement(event, input, dataKey!); }; } diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index c12da65d..92c94e68 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -1,10 +1,8 @@ import { isNativeInput } from '@editorjs/dom'; -import type { ModelEvents } from '@editorjs/model'; import { - BlockRemovedEvent, type Caret, type CaretManagerEvents, - type EditorJSModel, + EditorJSModel, EventType, Index, IndexBuilder, @@ -17,14 +15,17 @@ import { getClippedTextRangeForInput, useSelectionChange } from '../utils/index.js'; -import type { BlockToolAdapter } from '../BlockToolAdapter/index.ts'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens.js'; +import { InputsRegistry } from '../InputsRegistry/index.js'; /** * Caret adapter watches selection change and saves it to the model * * On model update, it updates the selection in the DOM */ -export class CaretAdapter extends EventTarget { +@injectable() +export class CaretAdapter { /** * Editor.js DOM container */ @@ -36,22 +37,16 @@ export class CaretAdapter extends EventTarget { #model: EditorJSModel; /** - * We store blocks in caret adapter to give it access to blocks` inputs - * without additional storing inputs in the caret adapter - * Thus, it won't care about block index change (block removed, block added, block moved) + * Shared inputs registry — single source of truth for (blockIndex, dataKey) → HTMLElement. + * Both BlockToolAdapter and CaretAdapter operate on the same registry instance. */ - #blocks: Array = []; + #inputsRegistry: InputsRegistry; /** * Current user's caret */ #currentUserCaret: Caret; - /** - * Map with users' carets by userId - */ - #userCarets = new Map(); - /** * Editor's config */ @@ -60,27 +55,28 @@ export class CaretAdapter extends EventTarget { /** * @class * @param config - Editor's config - * @param container - Editor.js DOM container * @param model - Editor.js model + * @param registry - shared inputs registry */ - constructor(config: Required, container: HTMLElement, model: EditorJSModel) { - super(); - + constructor( + @inject(TOKENS.EditorConfig) config: Required, + model: EditorJSModel, + registry: InputsRegistry + ) { this.#config = config; this.#model = model; - this.#container = container; + this.#inputsRegistry = registry; + this.#container = config.holder; this.#currentUserCaret = this.#model.createCaret(this.#config.userId); - this.#userCarets.set(this.#config.userId, this.#currentUserCaret); const { on } = useSelectionChange(); /** * @todo Unsubscribe on adapter destruction */ - on(container, (selection) => this.#onSelectionChange(selection), this); + on(this.#container, (selection) => this.#onSelectionChange(selection), this); this.#model.addEventListener(EventType.CaretManagerUpdated, (event) => this.#onModelCaretUpdate(event)); - this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event)); } /** @@ -90,33 +86,6 @@ export class CaretAdapter extends EventTarget { return this.#currentUserCaret.index; } - /** - * Adds block to the caret adapter - * - * @param block - block tool adapter - */ - public attachBlock(block: BlockToolAdapter): void { - this.#blocks.push(block); - } - - /** - * Removes block from the caret adapter - * - * @param index - index of the block to remove - */ - public detachBlock(index: Index): void { - const block = this.getBlock(index); - - if (!block) { - return; - } - - const blockIndex = this.#blocks.indexOf(block); - - if (blockIndex !== -1) { - this.#blocks.splice(blockIndex, 1); - } - } /** * Updates current user's caret index @@ -132,7 +101,7 @@ export class CaretAdapter extends EventTarget { } - const caretToUpdate = this.#userCarets.get(userId); + const caretToUpdate = this.#model.getCaret(userId); if (caretToUpdate === undefined) { return; @@ -141,28 +110,6 @@ export class CaretAdapter extends EventTarget { caretToUpdate.update(index); } - /** - * Finds block by index - * - * @param index - index of the block in the model tree - */ - public getBlock(index?: Index): BlockToolAdapter | undefined { - if (index === undefined) { - if (this.#currentUserCaret.index === null) { - throw new Error('[CaretManager] No index provided and no user caret index found'); - } - index = this.#currentUserCaret.index; - } - - const blockIndex = index.blockIndex; - - if (blockIndex === undefined) { - return undefined; - } - - return this.#blocks.find(block => block.getBlockIndex().blockIndex === blockIndex); - } - /** * Finds input by block index and data key * @@ -171,18 +118,7 @@ export class CaretAdapter extends EventTarget { * @returns input element or undefined if not found */ public findInput(blockIndex: number, dataKeyRaw: string): HTMLElement | undefined { - const builder = new IndexBuilder(); - - builder.addBlockIndex(blockIndex); - const block = this.getBlock(builder.build()); - - if (!block) { - return undefined; - } - - const dataKey = createDataKey(dataKeyRaw); - - return block.getInput(dataKey); + return this.#inputsRegistry.getInput(blockIndex, createDataKey(dataKeyRaw)); } /** @@ -211,15 +147,8 @@ export class CaretAdapter extends EventTarget { return; } - const startBlock = this.getBlock(first); - const endBlock = this.getBlock(last); - - if (startBlock === undefined || endBlock === undefined) { - return; - } - - const startInput = startBlock.getInput(first.dataKey); - const endInput = endBlock.getInput(last.dataKey); + const startInput = this.findInput(first.blockIndex, first.dataKey.toString()); + const endInput = this.findInput(last.blockIndex, last.dataKey.toString()); if (startInput === undefined || endInput === undefined) { return; @@ -271,31 +200,29 @@ export class CaretAdapter extends EventTarget { const selectionRange = selection.getRangeAt(0); const segments: Index[] = []; - for (const block of this.#blocks) { - for (const [key, input] of block.getAttachedInputs().entries()) { - if (isNativeInput(input) === true) { - continue; - } + for (const [blockIndex, dataKeyStr, input] of this.#inputsRegistry.entries()) { + if (isNativeInput(input) === true) { + continue; + } - const textRange = getClippedTextRangeForInput(selectionRange, input); + const textRange = getClippedTextRangeForInput(selectionRange, input); - if (textRange === null) { - continue; - } + if (textRange === null) { + continue; + } - const builder = new IndexBuilder(); + const builder = new IndexBuilder(); - builder - .from(block.getBlockIndex()) - .addDataKey(key) - .addTextRange(textRange); + builder + .addBlockIndex(blockIndex) + .addDataKey(createDataKey(dataKeyStr)) + .addTextRange(textRange); - segments.push(builder.build()); - } + segments.push(builder.build()); } /** - * {@link #blocks} order may not match document order after block moves; composite index and + * {@link #inputsRegistry} iteration order follows insertion order; composite index and * {@link #restoreDomSelectionFromCompositeIndex} require segments ordered from selection start * to end (by {@link Index.blockIndex}, then DOM order of inputs within a block). */ @@ -400,19 +327,13 @@ export class CaretAdapter extends EventTarget { return; } - const { textRange, dataKey } = index; - - if (textRange === undefined || dataKey === undefined) { - return; - } - - const block = this.getBlock(index); + const { textRange, dataKey, blockIndex } = index; - if (!block) { + if (textRange === undefined || dataKey === undefined || blockIndex === undefined) { return; } - const input = block.getInput(dataKey); + const input = this.findInput(blockIndex, dataKey.toString()); if (!input) { return; @@ -476,28 +397,4 @@ export class CaretAdapter extends EventTarget { selection.removeAllRanges(); selection.addRange(range); } - - /** - * Handles model update events - * - * @param event - model update event - */ - #handleModelUpdate(event: ModelEvents): void { - /** - * When block is removed, we need to remove it from this.#blocks - */ - if (event instanceof BlockRemovedEvent) { - const removedBlockIndex = event.detail.index.blockIndex; - - if (removedBlockIndex === undefined) { - return; - } - - const blocksToRemove = this.#blocks.find(block => block.getBlockIndex().blockIndex === removedBlockIndex); - - if (blocksToRemove) { - this.detachBlock(blocksToRemove.getBlockIndex()); - } - } - } } diff --git a/packages/dom-adapters/src/FormattingAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts index 3e0f2f0f..efac5042 100644 --- a/packages/dom-adapters/src/FormattingAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -1,27 +1,31 @@ import type { - EditorJSModel, InlineFragment, - InlineToolData, InlineToolName, ModelEvents } from '@editorjs/model'; +import { + createInlineToolName, + EditorJSModel +} from '@editorjs/model'; import { EventType, TextFormattedEvent, TextUnformattedEvent } from '@editorjs/model'; -import type { CaretAdapter } from '../CaretAdapter/index.js'; -import { FormattingAction } from '@editorjs/model'; -import { IndexError } from '@editorjs/sdk'; -import type { CoreConfig, InlineTool } from '@editorjs/sdk'; +import { CaretAdapter } from '../CaretAdapter/index.js'; +import type { CoreConfig, InlineTool, ToolLoadedCoreEvent } from '@editorjs/sdk'; +import { CoreEventType, EventBus } from '@editorjs/sdk'; import { surround } from '../utils/surround.js'; import { getBoundaryPointByAbsoluteOffset } from '../utils/getRelativeIndex.js'; import { expandRangeNodeBoundary } from '../utils/expandRangeNodeBoundary.js'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens.js'; /** * Class handles on format model events and renders inline tools * Applies format to the model */ +@injectable() export class FormattingAdapter { /** * Editor model instance @@ -48,12 +52,34 @@ export class FormattingAdapter { * @param config - Editor's config * @param model - editor model instance * @param caretAdapter - caret adapter instance + * @param eventBus - Editor's EventBus instance */ - constructor(config: Required, model: EditorJSModel, caretAdapter: CaretAdapter) { + constructor( + @inject(TOKENS.EditorConfig) config: Required, + model: EditorJSModel, + caretAdapter: CaretAdapter, + eventBus: EventBus + ) { this.#config = config; this.#model = model; this.#caretAdapter = caretAdapter; + /** + * @todo maybe expose some limited information about tools via API so we don't need to store tools in the formatting adapter + */ + eventBus.addEventListener(`core:${CoreEventType.ToolLoaded}`, (event: ToolLoadedCoreEvent) => { + const { tool } = event.detail; + + if ('isInline' in tool && tool.isInline() === false) { + return; + } + + const toolInstance = tool.create(); + const name = createInlineToolName(tool.name); + + this.attachTool(name, toolInstance); + }); + /** * Add event listener for model changes */ @@ -78,7 +104,7 @@ export class FormattingAdapter { if (tool === undefined) { throw new Error(`FormattingAdapter: tool ${toolName} is not attached`); - }; + } try { const inlineElement = tool.createWrapper(toolData); @@ -109,69 +135,6 @@ export class FormattingAdapter { this.#tools.delete(toolName); } - /** - * Format model according to action formed by inline tool instance - * - * @param toolName - name of the tool whose format will be applied - * @param data - data of the tool got from toolbar - */ - public applyFormat(toolName: InlineToolName, data: InlineToolData): void { - const index = this.#caretAdapter.userCaretIndex; - - if (index === null) { - throw new IndexError('FormattingAdapter: caret index is outside of the input'); - } - - /** - * @todo do not store middle segments in the index, use only the first and last segments - * Also, we need to sort inpus inside first/last block by document order to restore selection - */ - const segments = index.getTextSegments(); - - if (segments.length === 0) { - throw new IndexError('FormattingAdapter: caret index is outside of the input'); - } - - const tool = this.#tools.get(toolName); - - if (tool === undefined) { - throw new Error(`FormattingAdapter: tool ${toolName} is not attached`); - } - - for (const segment of segments) { - const textRange = segment.textRange; - const blockIndex = segment.blockIndex; - const dataKey = segment.dataKey; - - if (textRange === undefined) { - throw new IndexError('TextRange of the index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); - } - - if (blockIndex === undefined) { - throw new IndexError('BlockIndex should be defined. Probably something wrong with the Editor Model. Please, report this issue'); - } - - if (dataKey === undefined) { - throw new IndexError('DataKey of the index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); - } - - const fragments = this.#model.getFragments(blockIndex, dataKey, ...textRange, toolName); - - const { action, range } = tool.getFormattingOptions(textRange, fragments); - - switch (action) { - case FormattingAction.Format: - this.#model.format(this.#config.userId, blockIndex, dataKey, toolName, ...range, data); - - break; - case FormattingAction.Unformat: - this.#model.unformat(this.#config.userId, blockIndex, dataKey, toolName, ...range); - - break; - } - } - } - /** * Handles text format and unformat model events * diff --git a/packages/dom-adapters/src/InputsRegistry/index.ts b/packages/dom-adapters/src/InputsRegistry/index.ts new file mode 100644 index 00000000..411c0ec7 --- /dev/null +++ b/packages/dom-adapters/src/InputsRegistry/index.ts @@ -0,0 +1,107 @@ +import type { DataKey } from '@editorjs/model'; +import { injectable } from 'inversify'; + +/** + * A registry that maps (blockIndex, 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. + */ +@injectable() +export class InputsRegistry { + /** + * Index = block position. Each entry is a (dataKey → element) map for that block. + */ + #inputs: Map[] = []; + + /** + * Registers (or replaces) an input element for a given block + data key. + * + * @param blockIndex - position of the block in the document + * @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(); + } + + this.#inputs[blockIndex].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 dataKey - optional specific data key to unregister + */ + public unregister(blockIndex: number, dataKey?: DataKey): void { + if (dataKey === undefined) { + this.#inputs.splice(blockIndex, 1); + + return; + } + + this.#inputs[blockIndex]?.delete(dataKey); + } + + /** + * Looks up a single input by block index and data key. + * + * @param blockIndex - position of the block + * @param dataKey - data key of the input + */ + public getInput(blockIndex: number, dataKey: DataKey): HTMLElement | undefined { + return this.#inputs[blockIndex]?.get(dataKey); + } + + /** + * Returns all inputs for a block as a (dataKey → element) map. + * + * @param blockIndex - position of the block + */ + public getBlockInputs(blockIndex: number): Map | undefined { + return this.#inputs[blockIndex]; + } + + /** + * Returns all registered entries as an iterable of [blockIndex, 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; + } + + for (const [dataKey, element] of keyMap) { + yield [blockIndex, 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 + */ + public insertBlock(blockIndex: number): void { + this.#inputs.splice(blockIndex, 0, 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 + */ + public removeBlock(blockIndex: number): void { + this.#inputs.splice(blockIndex, 1); + } +} diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index f4d9f900..8954bbe8 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -1,3 +1,101 @@ +import type { + BlockToolAdapter, + EditorJSAdapterPlugin, EditorjsAdapterPluginConstructor, + EditorjsAdapterPluginParams +} from '@editorjs/sdk'; +import { EventBus } from '@editorjs/sdk'; +import { PluginType } from '@editorjs/sdk'; +import { DOMBlockToolAdapter } from './BlockToolAdapter/index.js'; +import { InputsRegistry } from './InputsRegistry/index.js'; +import { BlockRemovedEvent, EditorJSModel, EventType } from '@editorjs/model'; +import { Container } from 'inversify'; +import { TOKENS } from './tokens.js'; +import type { CoreConfig } from '@editorjs/sdk'; + export * from './CaretAdapter/index.js'; -export * from './BlockToolAdapter/index.js'; export * from './FormattingAdapter/index.js'; +export * from './BlockToolAdapter/index.js'; + +/** + * Plugin for the DOM adapters + */ +export class DOMAdapters implements EditorJSAdapterPlugin { + public static type = PluginType.Adapter as const; + + #iocContainer: Container = new Container({ + autobind: true, + defaultScope: 'Singleton', + }); + + /** + * All created block tool adapters, kept so their blockIndex can be updated + * when blocks are inserted or removed. + */ + #adapters: DOMBlockToolAdapter[] = []; + + /** + * @param params - Plugin parameters + * @param params.config - Editor's config + * @param params.model - Model instance + * @param params.eventBus - EventBus instance + */ + constructor({ config, model, eventBus }: EditorjsAdapterPluginParams) { + this.#iocContainer.bind>(TOKENS.EditorConfig).toConstantValue(config as Required); + this.#iocContainer.bind(EditorJSModel).toConstantValue(model); + this.#iocContainer.bind(EventBus).toConstantValue(eventBus); + + + const registry = this.#iocContainer.get(InputsRegistry); + + model.addEventListener(EventType.Changed, (event) => { + if (event instanceof BlockRemovedEvent) { + const removedBlockIndex = event.detail.index.blockIndex; + + if (removedBlockIndex === undefined) { + return; + } + + registry.removeBlock(removedBlockIndex); + + this.#adapters.splice(removedBlockIndex, 1); + + this.#adapters.forEach(adapter => { + if (adapter.getBlockIndex() > removedBlockIndex) { + adapter.setBlockIndex(adapter.getBlockIndex() - 1); + } + }); + } + }); + } + + /** + * 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 + * @param toolName - name of the tool for this block + */ + public createBlockToolAdapter(blockIndex: number, 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); + } + }); + + const adapter = this.#iocContainer.get(DOMBlockToolAdapter); + + adapter.setBlockIndex(blockIndex); + adapter.setToolName(toolName); + + this.#adapters.splice(blockIndex, 0, adapter); + + return adapter; + } +} + +DOMAdapters satisfies EditorjsAdapterPluginConstructor; diff --git a/packages/dom-adapters/src/tokens.ts b/packages/dom-adapters/src/tokens.ts new file mode 100644 index 00000000..31aa6818 --- /dev/null +++ b/packages/dom-adapters/src/tokens.ts @@ -0,0 +1,11 @@ +/** + * Injection tokens for the dom-adapters IoC container. + * Using Symbol.for() so the same token is always equal across module instances. + */ +export const TOKENS = { + /** + * Configuration token + */ + EditorConfig: Symbol.for('EditorConfig'), +} as const; + diff --git a/packages/dom-adapters/tsconfig.json b/packages/dom-adapters/tsconfig.json index c1429a27..592b3c75 100644 --- a/packages/dom-adapters/tsconfig.json +++ b/packages/dom-adapters/tsconfig.json @@ -9,6 +9,7 @@ "strict": true, "skipLibCheck": true, "experimentalDecorators": true, + "emitDecoratorMetadata": true, "types": ["jest"], "rootDir": "src", "outDir": "dist" diff --git a/packages/model/.eslintrc.yml b/packages/model/.eslintrc.yml index 0b55b726..f0035a91 100644 --- a/packages/model/.eslintrc.yml +++ b/packages/model/.eslintrc.yml @@ -8,6 +8,7 @@ ignorePatterns: # Eslint seems to not recognize WeakMap as a global globals: WeakMap: readonly + queueMicrotask: readonly plugins: - import diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index 0ecd7c72..519b5c4f 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -132,6 +132,15 @@ export class EditorJSModel extends EventBus { return this.#caretManager.createCaret(...parameters); } + /** + * Returns a caret by user id + * + * @param parameters - getCaret method parameters + */ + public getCaret(...parameters: Parameters): ReturnType { + return this.#caretManager.getCaret(...parameters); + } + /** * Updates caret instance in the model * @@ -277,6 +286,14 @@ export class EditorJSModel extends EventBus { return this.#document.removeDataNode(...parameters); } + /** + * Returns a data node by the block index and key + * + * @param _userId - user identifier which is being set to the context + * @param parameters - getDataNode method parameters + * @param parameters.blockIndex - index of the BlockNode where data node is stored + * @param parameters.dataKey - key of the node to get + */ @WithContext public getDataNode(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.getDataNode(...parameters); diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index f3ad86b7..00d33662 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -1,4 +1,3 @@ -import { DataNodeAddedEvent } from '../../EventBus/events/DataNodeAddedEvent.js'; import { getContext } from '../../utils/Context.js'; import type { EditorDocument } from '../EditorDocument'; import type { BlockTuneName, BlockTuneSerialized } from '../BlockTune'; @@ -29,6 +28,8 @@ import type { DeepReadonly } from '../../utils/DeepReadonly'; import { EventBus } from '../../EventBus/EventBus.js'; import { EventType } from '../../EventBus/types/EventType.js'; import { + DataNodeRemovedEvent, + DataNodeAddedEvent, TuneModifiedEvent, ValueModifiedEvent } from '../../EventBus/events/index.js'; @@ -148,8 +149,9 @@ export class BlockNode extends EventBus { } /** + * Returns data node by the key * - * @param dataKey + * @param dataKey - key of the node to get */ public getDataNode(dataKey: DataKey): ValueSerialized | TextNodeSerialized | undefined { const node = get(this.data, dataKey as string); @@ -182,7 +184,9 @@ export class BlockNode extends EventBus { .addDataKey(dataKey) .build(); - this.dispatchEvent(new DataNodeAddedEvent(index, data, getContext()!)); + queueMicrotask(() => { + this.dispatchEvent(new DataNodeAddedEvent(index, data, getContext()!)); + }); }; /** @@ -203,7 +207,9 @@ export class BlockNode extends EventBus { .addDataKey(dataKey) .build(); - this.dispatchEvent(new DataNodeAddedEvent(index, nodeData, getContext()!)); + queueMicrotask(() => { + this.dispatchEvent(new DataNodeRemovedEvent(index, nodeData, getContext()!)); + }); } /** @@ -355,9 +361,9 @@ export class BlockNode extends EventBus { * @param data - block data */ #initialize(data: BlockNodeDataSerialized): void { - this.#data = mapObject( + mapObject( data, - (value, key) => this.#mapSerializedDataToNodes(value, key) + (value, key) => this.createDataNode(createDataKey(key), value) ); } @@ -572,3 +578,6 @@ export { createBlockToolName, createDataKey }; + +export { NODE_TYPE_HIDDEN_PROP } from './consts.js'; +export { BlockChildType }; diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index aafe0831..ae1c7ee4 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -151,6 +151,11 @@ export class EditorDocument extends EventBus { builder.addBlockIndex(index); + /** + * Dispatch BlockAddedEvent synchronously so it fires before any child DataNodeAddedEvents + * (which are queued as microtasks during BlockNode construction), preserving root → leaves order + * for add events. + */ this.dispatchEvent(new BlockAddedEvent(builder.build(), blockNode.serialized, getContext()!)); } @@ -169,7 +174,9 @@ export class EditorDocument extends EventBus { builder.addBlockIndex(index); - this.dispatchEvent(new BlockRemovedEvent(builder.build(), blockNode.serialized, getContext()!)); + queueMicrotask(() => { + this.dispatchEvent(new BlockRemovedEvent(builder.build(), blockNode.serialized, getContext()!)); + }); } /** @@ -212,9 +219,10 @@ export class EditorDocument extends EventBus { } /** + * Returns data node by the block index and data key * - * @param index - * @param key + * @param index - block index where data node is stored + * @param key - data key of the data node */ public getDataNode(index: number, key: DataKey | string): ValueSerialized | TextNodeSerialized { this.#checkIndexOutOfBounds(index, this.length - 1); diff --git a/packages/playground/src/components/Input.vue b/packages/playground/src/components/Input.vue deleted file mode 100644 index 2b67b989..00000000 --- a/packages/playground/src/components/Input.vue +++ /dev/null @@ -1,67 +0,0 @@ - - - - - diff --git a/packages/playground/src/components/index.ts b/packages/playground/src/components/index.ts index cd16f46b..0441128a 100644 --- a/packages/playground/src/components/index.ts +++ b/packages/playground/src/components/index.ts @@ -1,11 +1,9 @@ import Node from './Node.vue'; import Indent from './Indent.vue'; import Value from './Value.vue'; -import Input from './Input.vue'; export { Node, Indent, - Value, - Input + Value }; diff --git a/packages/sdk/eslint.config.mjs b/packages/sdk/eslint.config.mjs index 676ac0ea..aa9d01da 100644 --- a/packages/sdk/eslint.config.mjs +++ b/packages/sdk/eslint.config.mjs @@ -18,6 +18,9 @@ export default [ }, }, rules: { + 'n/no-unsupported-features/node-builtins': ['error', { + ignores: ['CustomEvent'], + }], 'n/no-unpublished-import': ['error', { allowModules: [ '@jest/globals', diff --git a/packages/sdk/src/entities/BlockTool.ts b/packages/sdk/src/entities/BlockTool.ts index c305c3a4..ee08c993 100644 --- a/packages/sdk/src/entities/BlockTool.ts +++ b/packages/sdk/src/entities/BlockTool.ts @@ -20,12 +20,18 @@ export interface BlockToolConstructorOptions< /** * User-end configuration for the tool */ - Config extends ToolConfig = ToolConfig + Config extends ToolConfig = ToolConfig, + /** + * Adapter type — defaults to the base BlockToolAdapter. + * Override with a more specific adapter type (e.g. DOMBlockToolAdapter) + * if your tool needs access to DOM-specific methods such as setInput. + */ + Adapter extends BlockToolAdapter = BlockToolAdapter > extends BlockToolConstructorOptionsVersion2 { /** * Block tool adapter will be passed to the tool to connect data with the DOM */ - adapter: BlockToolAdapter; + adapter: Adapter; /** * Tool's input/output data @@ -71,9 +77,15 @@ export interface BlockToolConstructor< /** * User-end configuration for the tool */ - Config extends ToolConfig = ToolConfig + Config extends ToolConfig = ToolConfig, + /** + * Adapter type — defaults to the base BlockToolAdapter. + * Override with a more specific adapter type if your tool needs + * access to adapter methods beyond the base interface. + */ + Adapter extends BlockToolAdapter = BlockToolAdapter > extends BaseToolConstructor { - new(options: BlockToolConstructorOptions): BlockTool; + new(options: BlockToolConstructorOptions): BlockTool; /** * Property specifies that the class is a Block Tool diff --git a/packages/sdk/src/entities/BlockToolAdapter.ts b/packages/sdk/src/entities/BlockToolAdapter.ts index c52e6e71..09a33593 100644 --- a/packages/sdk/src/entities/BlockToolAdapter.ts +++ b/packages/sdk/src/entities/BlockToolAdapter.ts @@ -1,60 +1,163 @@ +import type { DataKey, EditorJSModel, EventBus, ModelEvents, TextNodeSerialized, ValueSerialized } from '@editorjs/model'; +import { + createDataKey, + DataNodeAddedEvent, + DataNodeRemovedEvent, + EventType, + NODE_TYPE_HIDDEN_PROP, + ValueModifiedEvent, + BlockChildType +} from '@editorjs/model'; +import type { CoreConfig } from '@/entities/Config'; +import { KeyAddedEvent, KeyRemovedEvent, ValueNodeChangedEvent } from './EventBus/events/adapter/index.js'; + /** - * Links Document Model to DOM + * Abstract BlockToolAdapter class implementing core functionality of the block adapter */ -export interface BlockToolAdapter { +export abstract class BlockToolAdapter extends EventTarget { + /** + * Model instance + */ + protected model: EditorJSModel; + + /** + * Index of the block that this adapter is connected to + */ + protected blockIndex: number = 0; + + /** + * Editor's config + */ + protected config: Required; + /** - * Registers a callback that will be called when a text or value node is added to the model. - * The callback should create, mount, and return the corresponding DOM element. - * - * @param callback - receives the data key and node type, returns the created element + * Editor's global EventBus */ - onUpdate(callback: (key: string, type: 'text' | 'value') => HTMLElement): void; + protected eventBus: EventBus; /** - * Initializes the adapter with the tool instance and the update callback. - * Scans existing model data and calls onTextNodeAdded for each existing text node. - * - * @param tool - the block tool instance - * @param onUpdateCallback - callback to create DOM elements for data nodes + * @param config - editor's configuration + * @param model - model instance + * @param eventBus - global event bus instance */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - init(tool: any, onUpdateCallback: (key: string, type: 'text' | 'value') => HTMLElement): void; + constructor(config: Required, model: EditorJSModel, eventBus: EventBus) { + super(); + + this.model = model; + this.config = config; + this.eventBus = eventBus; + + this.model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event)); + } /** - * Attaches input to the model using key. - * @param keyRaw - tools data key to attach input to - * @param input - input element + * Updates the internal block index. + * @param index - new block index value */ - attachInput(keyRaw: string, input: HTMLElement): void; + public setBlockIndex(index: number): void { + this.blockIndex = index; + } /** - * Removes the input from the DOM by key - * @param keyRaw - key of the input to remove + * Returns block index of the adapter */ - detachInput(keyRaw: string): void; + public getBlockIndex(): number { + return this.blockIndex; + } /** - * Attaches value to the model using raw data key - * @param keyRaw - string data key used for value identification - * @param initialValue - initial value of the value node - * @param callback - callback function that receives the updated value and should be used for DOM rerendering - * @returns — function that should be used to update model + * Creates data node for the text input key + * @param keyRaw - input key within the block + * @param initialData - optional initial data for the block */ - attachValue(keyRaw: string, initialValue: T, callback: (value: T) => void): (newValue: T) => void; + public registerTextInputKey(keyRaw: string, initialData?: Pick & Partial): void { + const data: TextNodeSerialized = { + value: initialData?.value ?? '', + fragments: initialData?.fragments ?? [], + [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, + }; + + this.#createDataNode(createDataKey(keyRaw), data); + } /** - * Removes the value node from the model by key - * @param keyRaw - string data key used for value identification + * Creates data node for the value key + * @param keyRaw - value key within the block + * @param initialData - optional initial data for the value */ - detachValue(keyRaw: string): void; + public registerValueKey(keyRaw: string, initialData?: ValueSerialized): void { + this.#createDataNode(createDataKey(keyRaw), initialData); + } /** - * Registers a data key with the model, creating a DataNode if it doesn't already exist. - * Use this to declare inputs/values that the tool owns before rendering. - * - * @param keyRaw - string data key to register - * @param type - node type: 'text' for contenteditable inputs, 'value' for arbitrary values + * Remove data node by the key + * @param keyRaw - key of the node to remove + */ + public removeKey(keyRaw: string): void { + if (this.model.getDataNode(this.config.userId, this.blockIndex, keyRaw) === undefined) { + return; + } + + this.model.removeDataNode(this.config.userId, this.blockIndex, createDataKey(keyRaw)); + } + + /** + * Creates data node in the model + * @param key - key of the node * @param initialData - optional initial data for the node */ - registerKey(keyRaw: string, type: 'text' | 'value', initialData?: unknown): void; + #createDataNode(key: DataKey, initialData?: TextNodeSerialized | ValueSerialized): void { + if (this.model.getDataNode(this.config.userId, this.blockIndex, key) !== undefined) { + return; + } + + this.model.createDataNode(this.config.userId, this.blockIndex, key, initialData); + } + + /** + * Handles model updates. Dispatches adapter events and calls the abstract method for the child classes + * @param event - Model event + */ + #handleModelUpdate(event: ModelEvents): void { + const { blockIndex } = event.detail.index; + + if (blockIndex !== this.blockIndex) { + return; + } + + switch (true) { + case event instanceof DataNodeAddedEvent: { + const { dataKey } = event.detail.index; + + this.dispatchEvent(new KeyAddedEvent(dataKey as string)); + + break; + } + + case event instanceof DataNodeRemovedEvent: { + const { dataKey } = event.detail.index; + + this.dispatchEvent(new KeyRemovedEvent(dataKey as string)); + + break; + } + + case event instanceof ValueModifiedEvent: { + const { dataKey } = event.detail.index; + const value = event.detail.data; + + this.dispatchEvent(new ValueNodeChangedEvent(dataKey as string, value)); + + break; + } + } + + this.handleModelUpdate(event); + } + + /** + * Abstract method for the child classes to handle model updates + * @param event - event object + */ + protected abstract handleModelUpdate(event: ModelEvents): void; } diff --git a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts new file mode 100644 index 00000000..831a6f2a --- /dev/null +++ b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts @@ -0,0 +1,37 @@ +import type { EditorjsPlugin, EditorjsPluginConstructor, EditorjsPluginParams } from '@/entities/EditorjsPlugin'; +import type { EditorJSModel } from '@editorjs/model'; +import type { PluginType } from '@/entities/EntityType'; +import type { BlockToolAdapter } from '@/entities/BlockToolAdapter'; + +/** + * Parameters for adapter plugin constructor. + * Extends standard plugin params with direct model access for low-level operations. + */ +export interface EditorjsAdapterPluginParams extends EditorjsPluginParams { + /** + * Editor's document model instance + */ + model: EditorJSModel; +} + +/** + * Base interface for adapter plugins + */ +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 name - tool name + */ + createBlockToolAdapter(blockIndex: number, name: string): BlockToolAdapter; +} + +/** + * Constructor type for adapter plugins + */ +export interface EditorjsAdapterPluginConstructor extends EditorjsPluginConstructor { + /** + * Marks the plugin as a singleton adapter, replaceable via core.use() + */ + type: PluginType.Adapter; +} diff --git a/packages/sdk/src/entities/EditorjsPlugin.ts b/packages/sdk/src/entities/EditorjsPlugin.ts index a8dde687..96d264e1 100644 --- a/packages/sdk/src/entities/EditorjsPlugin.ts +++ b/packages/sdk/src/entities/EditorjsPlugin.ts @@ -36,11 +36,14 @@ export interface EditorjsPlugin { /** * Constructor type for EditorjsPlugin */ -export interface EditorjsPluginConstructor { +export interface EditorjsPluginConstructor< + Params extends EditorjsPluginParams = EditorjsPluginParams, + Instance extends EditorjsPlugin = EditorjsPlugin +> { /** * Create new EditorjsPlugin instance */ - new (params: EditorjsPluginParams): EditorjsPlugin; + new (params: Params): Instance; /** * Plugin's entity type: UI plugin, Tool, etc. diff --git a/packages/sdk/src/entities/EntityType.ts b/packages/sdk/src/entities/EntityType.ts index 1dc39103..02c8a2e0 100644 --- a/packages/sdk/src/entities/EntityType.ts +++ b/packages/sdk/src/entities/EntityType.ts @@ -55,7 +55,13 @@ export enum PluginType { /** * Default plugin type */ - Plugin = 'Plugin' + Plugin = 'Plugin', + + /** + * Adapter plugin type + * Guaranteed to be initialized before any block is created. + */ + Adapter = 'Adapter' } export type EntityType = ToolType | UiComponentType | PluginType; diff --git a/packages/sdk/src/entities/EventBus/EventBus.ts b/packages/sdk/src/entities/EventBus/EventBus.ts index 20793624..092ff4d7 100644 --- a/packages/sdk/src/entities/EventBus/EventBus.ts +++ b/packages/sdk/src/entities/EventBus/EventBus.ts @@ -14,7 +14,6 @@ declare global { * @param callback - the function to call when the event is triggered * @param options - an options object that specifies characteristics about the event listener */ - // eslint-disable-next-line n/no-unsupported-features/node-builtins addEventListener(type: Event, callback: ((event: CustomEvent) => void) | null, options?: AddEventListenerOptions | boolean): void; /** * Removes an event listener for the specified event type @@ -22,7 +21,6 @@ declare global { * @param callback - the event callback to remove * @param options - an options object that specifies characteristics about the event listener */ - // eslint-disable-next-line n/no-unsupported-features/node-builtins removeEventListener(type: Event, callback: ((event: CustomEvent) => void) | null, options?: EventListenerOptions | boolean): void; } } diff --git a/packages/sdk/src/entities/EventBus/events/adapter/AdapterEventType.ts b/packages/sdk/src/entities/EventBus/events/adapter/AdapterEventType.ts new file mode 100644 index 00000000..1b712d32 --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/adapter/AdapterEventType.ts @@ -0,0 +1,9 @@ +/** + * BlockTool Adapter events + */ +export enum AdapterEventType { + /** + * Updated event fired when text or value key is added/removed, or when the value is updated + */ + Updated = 'adapter:updated' +} diff --git a/packages/sdk/src/entities/EventBus/events/adapter/KeyAdded.ts b/packages/sdk/src/entities/EventBus/events/adapter/KeyAdded.ts new file mode 100644 index 00000000..beeb68e4 --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/adapter/KeyAdded.ts @@ -0,0 +1,28 @@ +import { AdapterEventType } from './AdapterEventType.js'; + +/** + * Payload of the KeyAdded event + */ +interface KeyAddedPayload { + /** + * Added key + */ + key: string; +} + +/** + * KeyAddedEvent custom event + */ +export class KeyAddedEvent extends CustomEvent { + /** + * Constructor function + * @param key - key of the added data node + */ + constructor(key: string) { + super(AdapterEventType.Updated, { + detail: { + key, + }, + }); + } +} diff --git a/packages/sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts b/packages/sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts new file mode 100644 index 00000000..e36e597e --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts @@ -0,0 +1,28 @@ +import { AdapterEventType } from './AdapterEventType.js'; + +/** + * Payload of the KeyRemoved event + */ +interface KeyRemovedPayload { + /** + * Removed key + */ + key: string; +} + +/** + * KeyRemovedEvent adapter event + */ +export class KeyRemovedEvent extends CustomEvent { + /** + * Cosntructor function + * @param key - key of the removed data node + */ + constructor(key: string) { + super(AdapterEventType.Updated, { + detail: { + key, + }, + }); + } +} diff --git a/packages/sdk/src/entities/EventBus/events/adapter/ValueNodeChanged.ts b/packages/sdk/src/entities/EventBus/events/adapter/ValueNodeChanged.ts new file mode 100644 index 00000000..05c5f9ab --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/adapter/ValueNodeChanged.ts @@ -0,0 +1,36 @@ +import type { ValueSerialized } from '@editorjs/model'; +import { AdapterEventType } from './AdapterEventType.js'; + +/** + * Payload of the ValueNodeChanged event + */ +interface ValueNodeChangedPayload { + /** + * Changed value key + */ + key: string; + + /** + * New value + */ + value: ValueSerialized; +} + +/** + * ValueNodeChangedEvent adapterr event + */ +export class ValueNodeChangedEvent extends CustomEvent> { + /** + * ValueNodeChangedEvent constructor function + * @param key - the key of the value node that has changed + * @param value - changed value node new value + */ + constructor(key: string, value: ValueSerialized) { + super(AdapterEventType.Updated, { + detail: { + key, + value, + }, + }); + } +} diff --git a/packages/sdk/src/entities/EventBus/events/adapter/index.ts b/packages/sdk/src/entities/EventBus/events/adapter/index.ts new file mode 100644 index 00000000..2652a796 --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/adapter/index.ts @@ -0,0 +1,4 @@ +export { AdapterEventType } from './AdapterEventType.js'; +export { KeyAddedEvent } from './KeyAdded.js'; +export { KeyRemovedEvent } from './KeyRemoved'; +export { ValueNodeChangedEvent } from './ValueNodeChanged.js'; diff --git a/packages/sdk/src/entities/EventBus/events/core/CoreEventBase.ts b/packages/sdk/src/entities/EventBus/events/core/CoreEventBase.ts index c26acb5a..6fc38f84 100644 --- a/packages/sdk/src/entities/EventBus/events/core/CoreEventBase.ts +++ b/packages/sdk/src/entities/EventBus/events/core/CoreEventBase.ts @@ -2,7 +2,6 @@ * Represents a base class for core events. * @template Payload - The type of the event payload. */ -// eslint-disable-next-line n/no-unsupported-features/node-builtins export class CoreEventBase extends CustomEvent { /** * CoreEventBase constructor function diff --git a/packages/sdk/src/entities/EventBus/events/index.ts b/packages/sdk/src/entities/EventBus/events/index.ts index f1a3049c..90aabe47 100644 --- a/packages/sdk/src/entities/EventBus/events/index.ts +++ b/packages/sdk/src/entities/EventBus/events/index.ts @@ -1,2 +1,3 @@ export * from './core/index.js'; export * from './ui/index.js'; +export * from './adapter/index.js'; diff --git a/packages/sdk/src/entities/EventBus/events/ui/UIEventBase.ts b/packages/sdk/src/entities/EventBus/events/ui/UIEventBase.ts index a33e0c0e..f406ad1a 100644 --- a/packages/sdk/src/entities/EventBus/events/ui/UIEventBase.ts +++ b/packages/sdk/src/entities/EventBus/events/ui/UIEventBase.ts @@ -2,7 +2,6 @@ * Represents a base class for UI events. * @template Payload - The type of the event payload. */ -// eslint-disable-next-line n/no-unsupported-features/node-builtins export class UIEventBase extends CustomEvent { /** * UIEventBase constructor function diff --git a/packages/sdk/src/entities/index.ts b/packages/sdk/src/entities/index.ts index 9190d883..89ba9d0d 100644 --- a/packages/sdk/src/entities/index.ts +++ b/packages/sdk/src/entities/index.ts @@ -2,8 +2,9 @@ export type * from './InlineTool.js'; export type * from './BlockTool.js'; export type * from './BlockTune.js'; export type * from './Config.js'; -export type * from './BlockToolAdapter.js'; +export * from './BlockToolAdapter.js'; export * from './EventBus/index.js'; export type * from './EditorjsPlugin.js'; export * from './EntityType.js'; export * from './IndexError.js'; +export type * from './EditorjsAdapterPlugin.js'; diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.ts b/packages/sdk/src/tools/facades/BaseToolFacade.ts index d76c67f9..a6c5ba45 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.ts @@ -11,7 +11,8 @@ import { ToolType } from '../../entities/EntityType.js'; import { type BlockTuneFacade } from './BlockTuneFacade.js'; import type { BlockTool, BlockToolConstructor, InlineTool, InlineToolConstructor, BlockTuneConstructor } from '../../entities'; -export type ToolConstructable = BlockToolConstructor | InlineToolConstructor | BlockTuneConstructor; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- need to allow any type here so extended interfaces pass +export type ToolConstructable = BlockToolConstructor | InlineToolConstructor | BlockTuneConstructor; /** * Enum of Tool options provided by user diff --git a/packages/sdk/src/tools/facades/InlineToolFacade.ts b/packages/sdk/src/tools/facades/InlineToolFacade.ts index 8d9411a4..ae4e8ebb 100644 --- a/packages/sdk/src/tools/facades/InlineToolFacade.ts +++ b/packages/sdk/src/tools/facades/InlineToolFacade.ts @@ -16,6 +16,12 @@ export class InlineToolFacade extends BaseToolFacade Date: Mon, 27 Apr 2026 23:20:12 +0100 Subject: [PATCH 03/14] Fix tests --- packages/model/jest.config.ts | 4 +- packages/model/src/EditorJSModel.spec.ts | 2 + .../src/entities/BlockNode/BlockNode.spec.ts | 87 +++- .../src/entities/BlockNode/__mocks__/index.ts | 7 + .../model/src/entities/BlockNode/index.ts | 4 +- .../EditorDocument/EditorDocument.spec.ts | 106 ++++ .../src/entities/EditorDocument/index.ts | 6 +- .../TextNode/TextNode.spec.ts | 20 + packages/sdk/jest.config.ts | 4 +- packages/sdk/package.json | 1 + yarn.lock | 473 +++++++++++++++++- 11 files changed, 682 insertions(+), 32 deletions(-) diff --git a/packages/model/jest.config.ts b/packages/model/jest.config.ts index 3d0af6c9..caf62a9d 100644 --- a/packages/model/jest.config.ts +++ b/packages/model/jest.config.ts @@ -3,8 +3,8 @@ import type { JestConfigWithTsJest } from 'ts-jest'; export default { preset: 'ts-jest', testEnvironment: 'node', - testMatch: [ '/src/**/*.spec.ts' ], - modulePathIgnorePatterns: [ '/.*/__mocks__', '/.*/mocks' ], + testMatch: ['/src/**/*.spec.ts'], + modulePathIgnorePatterns: ['/.*/__mocks__', '/.*/mocks'], extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index 6b744d19..d461b739 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -28,6 +28,8 @@ describe('EditorJSModel', () => { 'insertText', 'removeText', 'format', + 'getCaret', + 'getDataNode', 'unformat', 'getFragments', 'createCaret', diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index afe257e7..2c917b5e 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -10,7 +10,7 @@ import { ValueNode } from '../ValueNode/index.js'; import type { EditorDocument } from '../EditorDocument/index.js'; import type { ValueNodeConstructorParameters } from '../ValueNode/index.js'; -import type { InlineFragment, InlineToolData, InlineToolName } from '../inline-fragments/index.js'; +import type { InlineFragment, InlineToolData, InlineToolName, TextNodeSerialized } from '../inline-fragments/index.js'; import { TextNode } from '../inline-fragments/index.js'; import type { BlockNodeData, BlockNodeDataSerialized } from './types/index.js'; import { BlockChildType } from './types/index.js'; @@ -19,6 +19,13 @@ import { TextAddedEvent, TuneModifiedEvent, ValueModifiedEvent } from '../../Eve import { EventType } from '../../EventBus/types/EventType.js'; import { createBlockTuneName } from '../BlockTune/index.js'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed to spy on conditional-typed getter with @jest/globals strict types +const ValueNodeProto = ValueNode.prototype as unknown as { + serialized: unknown; + value: unknown; + update: () => void; +}; + jest.mock('../BlockTune'); jest.mock('../inline-fragments/TextNode'); @@ -117,7 +124,7 @@ describe('BlockNode', () => { [createDataKey(`data-key-${index}c${index}d`)]: index, }), {}); - const spy = jest.spyOn(ValueNode.prototype, 'serialized', 'get'); + const spy = jest.spyOn(ValueNodeProto, 'serialized', 'get'); const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), @@ -164,7 +171,7 @@ describe('BlockNode', () => { }); it('should call .serialized getter of ValueNodes in an array', () => { - const spy = jest.spyOn(ValueNode.prototype, 'serialized', 'get'); + const spy = jest.spyOn(ValueNodeProto, 'serialized', 'get'); const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), data: { @@ -202,7 +209,7 @@ describe('BlockNode', () => { }); it('should call .serialized getter of ValueNodes in a nested object', () => { - const spy = jest.spyOn(ValueNode.prototype, 'serialized', 'get'); + const spy = jest.spyOn(ValueNodeProto, 'serialized', 'get'); const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), data: { @@ -244,7 +251,7 @@ describe('BlockNode', () => { }); it('should call .serialized getter of ValueNodes in an array inside an object', () => { - const spy = jest.spyOn(ValueNode.prototype, 'serialized', 'get'); + const spy = jest.spyOn(ValueNodeProto, 'serialized', 'get'); const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), data: { @@ -286,7 +293,7 @@ describe('BlockNode', () => { }); it('should call .serialized getter of ValueNodes in an object inside an array', () => { - const spy = jest.spyOn(ValueNode.prototype, 'serialized', 'get'); + const spy = jest.spyOn(ValueNodeProto, 'serialized', 'get'); const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), data: { @@ -326,7 +333,7 @@ describe('BlockNode', () => { }); it('should call .serialized getter of ValueNodes in a nested array', () => { - const spy = jest.spyOn(ValueNode.prototype, 'serialized', 'get'); + const spy = jest.spyOn(ValueNodeProto, 'serialized', 'get'); const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), data: { @@ -364,7 +371,7 @@ describe('BlockNode', () => { }); it('should call .serialized getter of object marked as value node', () => { - const spy = jest.spyOn(ValueNode.prototype, 'serialized', 'get'); + const spy = jest.spyOn(ValueNodeProto, 'serialized', 'get'); const blockNode = new BlockNode({ name: createBlockToolName('paragraph'), data: { @@ -467,7 +474,7 @@ describe('BlockNode', () => { expect(blockNode.data[key]).toBeInstanceOf(TextNode); }); - it('should emit DataNodeAddedEvent', () => { + it('should emit DataNodeAddedEvent', async () => { const blockNodeName = createBlockToolName('paragraph'); const blockNode = new BlockNode({ @@ -488,6 +495,10 @@ describe('BlockNode', () => { blockNode.createDataNode(key, value); + // createDataNode dispatches the event inside a queueMicrotask, flush + // microtasks before asserting that the listener was called. + await Promise.resolve(); + expect(listener).toBeCalledWith(expect.objectContaining({ detail: { action: EventAction.Added, @@ -523,6 +534,60 @@ describe('BlockNode', () => { }); }); + describe('.getDataNode()', () => { + it('should return undefined if the key does not exist', () => { + const blockNode = createBlockNodeWithData({}); + const key = createDataKey('nonexistent'); + + const result = blockNode.getDataNode(key); + + expect(result).toBeUndefined(); + }); + + it('should return serialized ValueNode for a value key', () => { + const key = createDataKey('url'); + const value = 'https://editorjs.io'; + const blockNode = createBlockNodeWithData({ [key]: value }); + + jest.spyOn(ValueNodeProto, 'serialized', 'get').mockReturnValueOnce(value); + + const result = blockNode.getDataNode(key); + + expect(result).toBe(value); + }); + + it('should return serialized TextNode for a text key', () => { + const key = createDataKey('text'); + const value = { + $t: 't', + value: 'some text', + }; + const blockNode = createBlockNodeWithData({ [key]: value }); + + const serialized = { $t: 't' as const, + value: 'some text', + fragments: [] } as unknown as TextNodeSerialized; + + jest.spyOn(TextNode.prototype, 'serialized', 'get').mockReturnValueOnce(serialized); + + const result = blockNode.getDataNode(key); + + expect(result).toBe(serialized); + }); + + it('should throw InvalidNodeTypeError if the key holds a nested object (not a leaf node)', () => { + const blockNode = createBlockNodeWithData({ + nested: { + value: 'some-value', + }, + }); + const key = createDataKey('nested'); + + expect(() => blockNode.getDataNode(key)) + .toThrow(`BlockNode: data with key "${key}" is not a text or a value`); + }); + }); + describe('.removeDataNode()', () => { it('should remove data from the block', () => { const key = createDataKey('url'); @@ -539,7 +604,7 @@ describe('BlockNode', () => { const blockNode = createBlockNodeWithData({ [key]: value }); const listener = jest.fn(); - jest.spyOn(ValueNode.prototype, 'serialized', 'get').mockReturnValueOnce(value); + jest.spyOn(ValueNodeProto, 'serialized', 'get').mockReturnValueOnce(value); blockNode.addEventListener(EventType.Changed, listener); @@ -547,7 +612,7 @@ describe('BlockNode', () => { expect(listener).toBeCalledWith(expect.objectContaining({ detail: { - action: EventAction.Added, + action: EventAction.Removed, index: expect.objectContaining({ dataKey: key }), data: value, }, diff --git a/packages/model/src/entities/BlockNode/__mocks__/index.ts b/packages/model/src/entities/BlockNode/__mocks__/index.ts index 0b8ff530..28724161 100644 --- a/packages/model/src/entities/BlockNode/__mocks__/index.ts +++ b/packages/model/src/entities/BlockNode/__mocks__/index.ts @@ -22,6 +22,13 @@ export class BlockNode extends EventBus { return; } + /** + * Mock method + */ + public getDataNode(): void { + return; + } + /** * Mock method */ diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index 625e81ed..89d78ad9 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -203,9 +203,7 @@ export class BlockNode extends EventBus { .addDataKey(dataKey) .build(); - queueMicrotask(() => { - this.dispatchEvent(new DataNodeRemovedEvent(index, nodeData, getContext()!)); - }); + this.dispatchEvent(new DataNodeRemovedEvent(index, nodeData, getContext()!)); } /** diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index 032a37a3..155a991c 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -707,6 +707,112 @@ describe('EditorDocument', () => { }); }); + describe('.getDataNode()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call .getDataNode() method of the BlockNode at the specific index', () => { + const blocksData = [ + { + name: 'header' as BlockToolName, + data: {}, + }, + { + name: 'header' as BlockToolName, + data: {}, + }, + { + name: 'header' as BlockToolName, + data: {}, + }, + ]; + const document = new EditorDocument({ + identifier: 'document', + }); + + document.initialize({ blocks: blocksData }); + + blocksData.forEach((_, i) => { + const blockNode = document.getBlock(i); + + jest + .spyOn(blockNode, 'getDataNode') + .mockImplementation(() => undefined); + }); + + const blockIndexToQuery = 1; + const dataKey = 'data-key-1a2b'; + + document.getDataNode(blockIndexToQuery, dataKey); + + expect(document.getBlock(blockIndexToQuery).getDataNode) + .toHaveBeenCalledWith(dataKey); + }); + + it('should not call .getDataNode() method of other BlockNodes', () => { + const blocksData = [ + { + name: 'header' as BlockToolName, + data: {}, + }, + { + name: 'header' as BlockToolName, + data: {}, + }, + { + name: 'header' as BlockToolName, + data: {}, + }, + ]; + const document = new EditorDocument({ + identifier: 'document', + }); + + document.initialize({ blocks: blocksData }); + + const blockNodes = blocksData.map((_, i) => { + const blockNode = document.getBlock(i); + + jest + .spyOn(blockNode, 'getDataNode') + .mockImplementation(() => undefined); + + return blockNode; + }); + + const blockIndexToQuery = 1; + const dataKey = 'data-key-1a2b'; + + document.getDataNode(blockIndexToQuery, dataKey); + + blockNodes.forEach((blockNode, index) => { + if (index === blockIndexToQuery) { + return; + } + + expect(blockNode.getDataNode) + .not + .toHaveBeenCalled(); + }); + }); + + it('should throw an error if the index is out of bounds', () => { + const document = new EditorDocument({ + identifier: 'document', + }); + const blockIndexOutOfBound = document.length + 1; + const dataKey = 'data-key-1a2b'; + + const action = (): void => { + document.getDataNode(blockIndexOutOfBound, dataKey); + }; + + expect(action) + .toThrowError('Index out of bounds'); + }); + }); + describe('.removeDataNode()', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index 2e63c962..5d1caae8 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -168,9 +168,7 @@ export class EditorDocument extends EventBus { builder.addBlockIndex(index); - queueMicrotask(() => { - this.dispatchEvent(new BlockRemovedEvent(builder.build(), blockNode.serialized, getContext()!)); - }); + this.dispatchEvent(new BlockRemovedEvent(builder.build(), blockNode.serialized, getContext()!)); } /** @@ -214,7 +212,7 @@ export class EditorDocument extends EventBus { * @param index - block index where data node is stored * @param key - data key of the data node */ - public getDataNode(index: number, key: DataKey | string): ValueSerialized | TextNodeSerialized { + public getDataNode(index: number, key: DataKey | string): ValueSerialized | TextNodeSerialized | undefined { this.#checkIndexOutOfBounds(index, this.length - 1); return this.#children[index].getDataNode(createDataKey(key)); diff --git a/packages/model/src/entities/inline-fragments/TextNode/TextNode.spec.ts b/packages/model/src/entities/inline-fragments/TextNode/TextNode.spec.ts index 942283b1..9e2801a5 100644 --- a/packages/model/src/entities/inline-fragments/TextNode/TextNode.spec.ts +++ b/packages/model/src/entities/inline-fragments/TextNode/TextNode.spec.ts @@ -2,10 +2,30 @@ import type { InlineToolName } from '../FormattingInlineNode/index.js'; import type { InlineFragment } from '../InlineNode/index.js'; import { TextNode } from './index.js'; import { ParentInlineNode } from '../ParentInlineNode/index.js'; +import { BlockChildType } from '../../BlockNode/types/index.js'; +import { NODE_TYPE_HIDDEN_PROP } from '../../BlockNode/consts.js'; jest.mock('../ParentInlineNode'); describe('TextNode', () => { + describe('.serialized', () => { + it('should add NODE_TYPE_HIDDEN_PROP with BlockChildType.Text to the parent serialized output', () => { + const superSerialized = { + value: 'hello', + fragments: [], + }; + + jest.spyOn(ParentInlineNode.prototype, 'serialized', 'get').mockReturnValueOnce(superSerialized); + + const node = new TextNode(); + const result = node.serialized; + + expect(result[NODE_TYPE_HIDDEN_PROP]).toBe(BlockChildType.Text); + expect(result.value).toBe('hello'); + expect(result.fragments).toEqual([]); + }); + }); + describe('.getFragments()', () => { it('should return all fragments for the specific range', () => { const boldFragmentStart = 0; diff --git a/packages/sdk/jest.config.ts b/packages/sdk/jest.config.ts index f755c1fa..3756ad18 100644 --- a/packages/sdk/jest.config.ts +++ b/packages/sdk/jest.config.ts @@ -2,7 +2,9 @@ import type { JestConfigWithTsJest } from 'ts-jest'; export default { preset: 'ts-jest', - testEnvironment: 'node', + // SDK tests require a browser-like environment ('codex-tooltip' accesses `window`), use jsdom + // @todo refactor tooltip package to not use window on the root level + testEnvironment: 'jsdom', testMatch: [ '/src/**/*.spec.ts' ], extensionsToTreatAsEsm: [ '.ts' ], moduleNameMapper: { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f4f45332..c1988c3d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -23,6 +23,7 @@ "eslint-config-codex": "^2.0.3", "eslint-plugin-import": "^2.31.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript": "^5.5.4" diff --git a/yarn.lock b/yarn.lock index 24d07cc6..729c9890 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2406,6 +2406,7 @@ __metadata: eslint-config-codex: "npm:^2.0.3" eslint-plugin-import: "npm:^2.31.0" jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" ts-jest: "npm:^29.2.5" ts-node: "npm:^10.9.2" typescript: "npm:^5.5.4" @@ -3953,6 +3954,13 @@ __metadata: languageName: node linkType: hard +"@tootallnate/once@npm:2": + version: 2.0.0 + resolution: "@tootallnate/once@npm:2.0.0" + checksum: ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8 + languageName: node + linkType: hard + "@tsconfig/node10@npm:^1.0.7": version: 1.0.11 resolution: "@tsconfig/node10@npm:1.0.11" @@ -4109,6 +4117,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^20.0.0": + version: 20.0.1 + resolution: "@types/jsdom@npm:20.0.1" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 15fbb9a0bfb4a5845cf6e795f2fd12400aacfca53b8c7e5bca4a3e5e8fa8629f676327964d64258aefb127d2d8a2be86dad46359efbfca0e8c9c2b790e7f8a88 + languageName: node + linkType: hard + "@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -4158,6 +4177,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 01fd82efc8202670865928629697b62fe9bf0c0dcbc5b1c115831caeb073a2c0abb871ff393d7df1ae94ea41e256cb87d2a5a91fd03cdb1b0b4384e08d4ee482 + languageName: node + linkType: hard + "@types/web-bluetooth@npm:^0.0.20": version: 0.0.20 resolution: "@types/web-bluetooth@npm:0.0.20" @@ -4564,6 +4590,13 @@ __metadata: languageName: node linkType: hard +"abab@npm:^2.0.6": + version: 2.0.6 + resolution: "abab@npm:2.0.6" + checksum: ebe95d7278999e605823fc515a3b05d689bc72e7f825536e73c95ebf621636874c6de1b749b3c4bf866b96ccd4b3a2802efa313d0e45ad51a413c8c73247db20 + languageName: node + linkType: hard + "abbrev@npm:^3.0.0": version: 3.0.0 resolution: "abbrev@npm:3.0.0" @@ -4571,6 +4604,16 @@ __metadata: languageName: node linkType: hard +"acorn-globals@npm:^7.0.0": + version: 7.0.1 + resolution: "acorn-globals@npm:7.0.1" + dependencies: + acorn: "npm:^8.1.0" + acorn-walk: "npm:^8.0.2" + checksum: 2a2998a547af6d0db5f0cdb90acaa7c3cbca6709010e02121fb8b8617c0fbd8bab0b869579903fde358ac78454356a14fadcc1a672ecb97b04b1c2ccba955ce8 + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -4580,6 +4623,15 @@ __metadata: languageName: node linkType: hard +"acorn-walk@npm:^8.0.2": + version: 8.3.5 + resolution: "acorn-walk@npm:8.3.5" + dependencies: + acorn: "npm:^8.11.0" + checksum: f52a158a1c1f00c82702c7eb9b8ae8aad79748a7689241dcc2d797dce680f1dcb15c78f312f687eeacdfb3a4cac4b87d04af470f0201bd56c6661fca6f94b195 + languageName: node + linkType: hard + "acorn-walk@npm:^8.1.1": version: 8.3.4 resolution: "acorn-walk@npm:8.3.4" @@ -4589,6 +4641,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.1.0, acorn@npm:^8.8.1": + version: 8.16.0 + resolution: "acorn@npm:8.16.0" + bin: + acorn: bin/acorn + checksum: 690c673bb4d61b38ef82795fab58526471ad7f7e67c0e40c4ff1e10ecd80ce5312554ef633c9995bfc4e6d170cef165711f9ca9e49040b62c0c66fbf2dd3df2b + languageName: node + linkType: hard + "acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.4.1, acorn@npm:^8.9.0": version: 8.14.1 resolution: "acorn@npm:8.14.1" @@ -4598,6 +4659,15 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 21fb903e0917e5cb16591b4d0ef6a028a54b83ac30cd1fca58dece3d4e0990512a8723f9f83130d88a41e2af8b1f7be1386fda3ea2d181bb1a62155e75e95e23 + languageName: node + linkType: hard + "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": version: 7.1.3 resolution: "agent-base@npm:7.1.3" @@ -4853,6 +4923,13 @@ __metadata: languageName: node linkType: hard +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 3ce727cbc78f69d6a4722517a58ee926c8c21083633b1d3fdf66fd688f6c127a53a592141bd4866f9b63240a86e9d8e974b13919450bd17fa33c2d22c4558ad8 + languageName: node + linkType: hard + "autoprefixer@npm:^10.4.19": version: 10.4.21 resolution: "autoprefixer@npm:10.4.21" @@ -5506,6 +5583,15 @@ __metadata: languageName: node linkType: hard +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 2e969e637d05d09fa50b02d74c83a1186f6914aae89e6653b62595cc75a221464f884f55f231b8f4df7a49537fba60bdc0427acd2bf324c09a1dbb84837e36e4 + languageName: node + linkType: hard + "commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -5786,6 +5872,29 @@ __metadata: languageName: node linkType: hard +"cssom@npm:^0.5.0": + version: 0.5.0 + resolution: "cssom@npm:0.5.0" + checksum: b502a315b1ce020a692036cc38cb36afa44157219b80deadfa040ab800aa9321fcfbecf02fd2e6ec87db169715e27978b4ab3701f916461e9cf7808899f23b54 + languageName: node + linkType: hard + +"cssom@npm:~0.3.6": + version: 0.3.8 + resolution: "cssom@npm:0.3.8" + checksum: 49eacc88077555e419646c0ea84ddc73c97e3a346ad7cb95e22f9413a9722d8964b91d781ce21d378bd5ae058af9a745402383fa4e35e9cdfd19654b63f892a9 + languageName: node + linkType: hard + +"cssstyle@npm:^2.3.0": + version: 2.3.0 + resolution: "cssstyle@npm:2.3.0" + dependencies: + cssom: "npm:~0.3.6" + checksum: 46f7f05a153446c4018b0454ee1464b50f606cb1803c90d203524834b7438eb52f3b173ba0891c618f380ced34ee12020675dc0052a7f1be755fe4ebc27ee977 + languageName: node + linkType: hard + "csstype@npm:^3.1.3": version: 3.1.3 resolution: "csstype@npm:3.1.3" @@ -5793,6 +5902,17 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^3.0.2": + version: 3.0.2 + resolution: "data-urls@npm:3.0.2" + dependencies: + abab: "npm:^2.0.6" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + checksum: 033fc3dd0fba6d24bc9a024ddcf9923691dd24f90a3d26f6545d6a2f71ec6956f93462f2cdf2183cc46f10dc01ed3bcb36731a8208456eb1a08147e571fe2a76 + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.2": version: 1.0.2 resolution: "data-view-buffer@npm:1.0.2" @@ -5882,6 +6002,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.4.2": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69 + languageName: node + linkType: hard + "dedent@npm:^1.0.0": version: 1.5.3 resolution: "dedent@npm:1.5.3" @@ -5939,6 +6066,13 @@ __metadata: languageName: node linkType: hard +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 46fe6e83e2cb1d85ba50bd52803c68be9bd953282fa7096f51fc29edd5d67ff84ff753c51966061e5ba7cb5e47ef6d36a91924eddb7f3f3483b1c560f77a0020 + languageName: node + linkType: hard + "detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -6003,6 +6137,15 @@ __metadata: languageName: node linkType: hard +"domexception@npm:^4.0.0": + version: 4.0.0 + resolution: "domexception@npm:4.0.0" + dependencies: + webidl-conversions: "npm:^7.0.0" + checksum: 4ed443227d2871d76c58d852b2e93c68e0443815b2741348f20881bedee8c1ad4f9bfc5d30c7dec433cd026b57da63407c010260b1682fef4c8847e7181ea43f + languageName: node + linkType: hard + "domhandler@npm:^4.2.0, domhandler@npm:^4.3.1": version: 4.3.1 resolution: "domhandler@npm:4.3.1" @@ -6134,6 +6277,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 62af1307202884349d2867f0aac5c60d8b57102ea0b0e768b16246099512c28e239254ad772d6834e7e14cb1b6f153fc3d0c031934e3183b086c86d3838d874a + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -6463,6 +6613,24 @@ __metadata: languageName: node linkType: hard +"escodegen@npm:^2.0.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" + dependencies: + esprima: "npm:^4.0.1" + estraverse: "npm:^5.2.0" + esutils: "npm:^2.0.2" + source-map: "npm:~0.6.1" + dependenciesMeta: + source-map: + optional: true + bin: + escodegen: bin/escodegen.js + esgenerate: bin/esgenerate.js + checksum: 47719a65b2888b4586e3fa93769068b275961c13089e90d5d01a96a6e8e95871b1c3893576814c8fbf08a4a31a496f37e7b2c937cf231270f4d81de012832c7c + languageName: node + linkType: hard + "eslint-compat-utils@npm:^0.5.1": version: 0.5.1 resolution: "eslint-compat-utils@npm:0.5.1" @@ -6827,7 +6995,7 @@ __metadata: languageName: node linkType: hard -"esprima@npm:^4.0.0": +"esprima@npm:^4.0.0, esprima@npm:^4.0.1": version: 4.0.1 resolution: "esprima@npm:4.0.1" bin: @@ -7162,6 +7330,19 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.0": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 52ecd6e927c8c4e215e68a7ad5e0f7c1031397439672fd9741654b4a94722c4182e74cc815b225dcb5be3f4180f36428f67c6dd39eaa98af0dcfdd26c00c19cd + languageName: node + linkType: hard + "fraction.js@npm:^4.3.7": version: 4.3.7 resolution: "fraction.js@npm:4.3.7" @@ -7550,6 +7731,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^3.0.0": + version: 3.0.0 + resolution: "html-encoding-sniffer@npm:3.0.0" + dependencies: + whatwg-encoding: "npm:^2.0.0" + checksum: 707a812ec2acaf8bb5614c8618dc81e2fb6b4399d03e95ff18b65679989a072f4e919b9bef472039301a1bbfba64063ba4c79ea6e851c653ac9db80dbefe8fe5 + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -7564,6 +7754,17 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^5.0.0": + version: 5.0.0 + resolution: "http-proxy-agent@npm:5.0.0" + dependencies: + "@tootallnate/once": "npm:2" + agent-base: "npm:6" + debug: "npm:4" + checksum: 5ee19423bc3e0fd5f23ce991b0755699ad2a46a440ce9cec99e8126bb98448ad3479d2c0ea54be5519db5b19a4ffaa69616bac01540db18506dd4dac3dc418f0 + languageName: node + linkType: hard + "http-proxy-agent@npm:^7.0.0": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" @@ -7574,6 +7775,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: f0dce7bdcac5e8eaa0be3c7368bb8836ed010fb5b6349ffb412b172a203efe8f807d9a6681319105ea1b6901e1972c7b5ea899672a7b9aad58309f766dcbe0df + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.1": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" @@ -7598,6 +7809,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 24e3292dd3dadaa81d065c6f8c41b274a47098150d444b96e5f53b4638a9a71482921ea6a91a1f59bb71d9796de25e04afd05919fa64c360347ba65d3766f10f + languageName: node + linkType: hard + "iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -7607,15 +7827,6 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": - version: 0.6.3 - resolution: "iconv-lite@npm:0.6.3" - dependencies: - safer-buffer: "npm:>= 2.1.2 < 3.0.0" - checksum: 24e3292dd3dadaa81d065c6f8c41b274a47098150d444b96e5f53b4638a9a71482921ea6a91a1f59bb71d9796de25e04afd05919fa64c360347ba65d3766f10f - languageName: node - linkType: hard - "icss-replace-symbols@npm:^1.1.0": version: 1.1.0 resolution: "icss-replace-symbols@npm:1.1.0" @@ -7976,6 +8187,13 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: ced7bbbb6433a5b684af581872afe0e1767e2d1146b2207ca0068a648fb5cab9d898495d1ac0583524faaf24ca98176a7d9876363097c2d14fee6dd324f3a1ab + languageName: node + linkType: hard + "is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" @@ -8331,6 +8549,27 @@ __metadata: languageName: node linkType: hard +"jest-environment-jsdom@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-jsdom@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/jsdom": "npm:^20.0.0" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jsdom: "npm:^20.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 23bbfc9bca914baef4b654f7983175a4d49b0f515a5094ebcb8f819f28ec186f53c0ba06af1855eac04bab1457f4ea79dae05f70052cf899863e8096daa6e0f5 + languageName: node + linkType: hard + "jest-environment-node@npm:^29.7.0": version: 29.7.0 resolution: "jest-environment-node@npm:29.7.0" @@ -8740,6 +8979,45 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^20.0.0": + version: 20.0.3 + resolution: "jsdom@npm:20.0.3" + dependencies: + abab: "npm:^2.0.6" + acorn: "npm:^8.8.1" + acorn-globals: "npm:^7.0.0" + cssom: "npm:^0.5.0" + cssstyle: "npm:^2.3.0" + data-urls: "npm:^3.0.2" + decimal.js: "npm:^10.4.2" + domexception: "npm:^4.0.0" + escodegen: "npm:^2.0.0" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^3.0.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.2" + parse5: "npm:^7.1.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^4.1.2" + w3c-xmlserializer: "npm:^4.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^2.0.0" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + ws: "npm:^8.11.0" + xml-name-validator: "npm:^4.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: a4cdcff5b07eed87da90b146b82936321533b5efe8124492acf7160ebd5b9cf2b3c2435683592bf1cffb479615245756efb6c173effc1906f845a86ed22af985 + languageName: node + linkType: hard + "jsesc@npm:^2.5.1": version: 2.5.2 resolution: "jsesc@npm:2.5.2" @@ -9106,6 +9384,22 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 54bb60bf39e6f8689f6622784e668a3d7f8bed6b0d886f5c3c446cb3284be28b30bf707ed05d0fe44a036f8469976b2629bbea182684977b084de9da274694d7 + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 89aa9651b67644035de2784a6e665fc685d79aba61857e02b9c8758da874a754aed4a9aced9265f5ed1171fd934331e5516b84a7f0218031b6fa0270eca1e51a + languageName: node + linkType: hard + "mimic-fn@npm:^1.0.0": version: 1.2.0 resolution: "mimic-fn@npm:1.2.0" @@ -9445,6 +9739,13 @@ __metadata: languageName: node linkType: hard +"nwsapi@npm:^2.2.2": + version: 2.2.23 + resolution: "nwsapi@npm:2.2.23" + checksum: aa4a570039c33d70b51436d1bb533f3e2c33c488ccbe9b09285c46a6cee5ef266fd60103461085c6954ba52460786a8138f042958328c7c1b4763898eb3dadfa + languageName: node + linkType: hard + "object-inspect@npm:^1.13.3": version: 1.13.4 resolution: "object-inspect@npm:1.13.4" @@ -9707,6 +10008,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.0.0, parse5@npm:^7.1.1": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: "npm:^6.0.0" + checksum: b0e48be20b820c655b138b86fa6fb3a790de6c891aa2aba536524f8027b4dca4fe538f11a0e5cf2f6f847d120dbb9e4822dcaeb933ff1e10850a2ef0154d1d88 + languageName: node + linkType: hard + "path-browserify@npm:^1.0.1": version: 1.0.1 resolution: "path-browserify@npm:1.0.1" @@ -10718,7 +11028,16 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"psl@npm:^1.1.33": + version: 1.15.0 + resolution: "psl@npm:1.15.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 5e7467eb5196eb7900d156783d12907d445c0122f76c73203ce96b148a6ccf8c5450cc805887ffada38ff92d634afcf33720c24053cb01d5b6598d1c913c5caf + languageName: node + linkType: hard + +"punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: febdc4362bead22f9e2608ff0171713230b57aff9dddc1c273aa2a651fbd366f94b7d6a71d78342a7c0819906750351ca7f2edd26ea41b626d87d6a13d1bd059 @@ -10741,6 +11060,13 @@ __metadata: languageName: node linkType: hard +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 46ab16f252fd892fc29d6af60966d338cdfeea68a231e9457631ffd22d67cec1e00141e0a5236a2eb16c0d7d74175d9ec1d6f963660c6f2b1c2fc85b194c5680 + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -10872,6 +11198,13 @@ __metadata: languageName: node linkType: hard +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 878880ee78ccdce372784f62f52a272048e2d0827c29ae31e7f99da18b62a2b9463ea03a75f277352f4697c100183debb0532371ad515a2d49d4bfe596dd4c20 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -11259,6 +11592,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 97b50daf6ca3a153e89842efa18a862e446248296622b7473c169c84c823ee8a16e4a43bac2f73f11fc8cb9168c73fbb0d73340f26552bac17970e9052367aa9 + languageName: node + linkType: hard + "semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -11835,6 +12177,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: c09a00aadf279d47d0c5c46ca3b6b2fbaeb45f0a184976d599637d412d3a70bbdc043ff33effe1206dea0e36e0ad226cb957112e7ce9a4bf2daedf7fa4f85c53 + languageName: node + linkType: hard + "synckit@npm:^0.9.1": version: 0.9.2 resolution: "synckit@npm:0.9.2" @@ -11926,6 +12275,27 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^4.1.2": + version: 4.1.4 + resolution: "tough-cookie@npm:4.1.4" + dependencies: + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: 75663f4e2cd085f16af0b217e4218772adf0617fb3227171102618a54ce0187a164e505d61f773ed7d65988f8ff8a8f935d381f87da981752c1171b076b4afac + languageName: node + linkType: hard + +"tr46@npm:^3.0.0": + version: 3.0.0 + resolution: "tr46@npm:3.0.0" + dependencies: + punycode: "npm:^2.1.1" + checksum: b09a15886cbfaee419a3469081223489051ce9dca3374dd9500d2378adedbee84a3c73f83bfdd6bb13d53657753fc0d4e20a46bfcd3f1b9057ef528426ad7ce4 + languageName: node + linkType: hard + "tree-kill@npm:^1.2.2, tree-kill@npm:~1.2.2": version: 1.2.2 resolution: "tree-kill@npm:1.2.2" @@ -12311,6 +12681,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.2.0": + version: 0.2.0 + resolution: "universalify@npm:0.2.0" + checksum: e86134cb12919d177c2353196a4cc09981524ee87abf621f7bc8d249dbbbebaec5e7d1314b96061497981350df786e4c5128dbf442eba104d6e765bc260678b5 + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.1.1": version: 1.1.3 resolution: "update-browserslist-db@npm:1.1.3" @@ -12348,6 +12725,16 @@ __metadata: languageName: node linkType: hard +"url-parse@npm:^1.5.3": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: c9e96bc8c5b34e9f05ddfeffc12f6aadecbb0d971b3cc26015b58d5b44676a99f50d5aeb1e5c9e61fa4d49961ae3ab1ae997369ed44da51b2f5ac010d188e6ad + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -12590,6 +12977,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^4.0.0": + version: 4.0.0 + resolution: "w3c-xmlserializer@npm:4.0.0" + dependencies: + xml-name-validator: "npm:^4.0.0" + checksum: 9a00c412b5496f4f040842c9520bc0aaec6e0c015d06412a91a723cd7d84ea605ab903965f546b4ecdb3eae267f5145ba08565222b1d6cb443ee488cda9a0aee + languageName: node + linkType: hard + "walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" @@ -12615,6 +13011,39 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 4c4f65472c010eddbe648c11b977d048dd96956a625f7f8b9d64e1b30c3c1f23ea1acfd654648426ce5c743c2108a5a757c0592f02902cf7367adb7d14e67721 + languageName: node + linkType: hard + +"whatwg-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "whatwg-encoding@npm:2.0.0" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 162d712d88fd134a4fe587e53302da812eb4215a1baa4c394dfd86eff31d0a079ff932c05233857997de07481093358d6e7587997358f49b8a580a777be22089 + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 96f9f628c663c2ae05412c185ca81b3df54bcb921ab52fe9ebc0081c1720f25d770665401eb2338ab7f48c71568133845638e18a81ed52ab5d4dcef7d22b40ef + languageName: node + linkType: hard + +"whatwg-url@npm:^11.0.0": + version: 11.0.0 + resolution: "whatwg-url@npm:11.0.0" + dependencies: + tr46: "npm:^3.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: dfcd51c6f4bfb54685528fb10927f3fd3d7c809b5671beef4a8cdd7b1408a7abf3343a35bc71dab83a1424f1c1e92cc2700d7930d95d231df0fac361de0c7648 + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" @@ -12765,6 +13194,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.11.0": + version: 8.20.0 + resolution: "ws@npm:8.20.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: b7ab934b21ffdea9f25a5af5097e8c1ec7625db553bca026c5a23e35b7c236f3fb89782f2b57fab9da553864512f9aa7d245827ef998d26ffa1b2187a19a6d10 + languageName: node + linkType: hard + "ws@npm:^8.18.1": version: 8.18.1 resolution: "ws@npm:8.18.1" @@ -12787,6 +13231,13 @@ __metadata: languageName: node linkType: hard +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 4ad5924974efd004a47cce6acf5c0269aee0e62f9a805a426db3337af7bcbd331099df174b024ace4fb18971b8a56de386d2e73a1c4b020e3abd63a4a9b917f1 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" From 40c428406cf4dbd51d2b6ef66ebd2d080496696a Mon Sep 17 00:00:00 2001 From: gohabereg Date: Mon, 27 Apr 2026 23:51:20 +0100 Subject: [PATCH 04/14] Review comments --- packages/core/report.json | 1 + packages/core/src/index.ts | 6 +++++- packages/dom-adapters/src/BlockToolAdapter/index.ts | 6 +++--- packages/dom-adapters/src/index.ts | 4 ++++ packages/model/src/entities/BlockNode/index.ts | 7 +++---- packages/sdk/src/entities/BlockToolAdapter.ts | 6 ++++++ .../sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts | 2 +- .../entities/EventBus/events/adapter/ValueNodeChanged.ts | 2 +- packages/sdk/src/entities/EventBus/events/adapter/index.ts | 2 +- 9 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 packages/core/report.json diff --git a/packages/core/report.json b/packages/core/report.json new file mode 100644 index 00000000..bca7468a --- /dev/null +++ b/packages/core/report.json @@ -0,0 +1 @@ +{"numFailedTestSuites":0,"numFailedTests":0,"numPassedTestSuites":7,"numPassedTests":73,"numPendingTestSuites":0,"numPendingTests":0,"numRuntimeErrorTestSuites":0,"numTodoTests":0,"numTotalTestSuites":7,"numTotalTests":73,"openHandles":[],"snapshot":{"added":0,"didUpdate":false,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0},"startTime":1777328802976,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() should ignore unknown model events","invocations":1,"location":{"column":5,"line":99},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should ignore unknown model events"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockAddedEvent handling"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockAddedEvent handling should create tool and dispatch BlockAddedCoreEvent via EventBus","invocations":1,"location":{"column":7,"line":106},"numPassingAsserts":4,"retryReasons":[],"status":"passed","title":"should create tool and dispatch BlockAddedCoreEvent via EventBus"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockAddedEvent handling"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockAddedEvent handling should throw when blockIndex is undefined","invocations":1,"location":{"column":7,"line":131},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when blockIndex is undefined"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockAddedEvent handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockAddedEvent handling should throw when tool is not found","invocations":1,"location":{"column":7,"line":146},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when tool is not found"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockAddedEvent handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockAddedEvent handling should log error when tool render fails","invocations":1,"location":{"column":7,"line":163},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should log error when tool render fails"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockRemovedEvent handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockRemovedEvent handling should dispatch BlockRemovedCoreEvent via EventBus","invocations":1,"location":{"column":7,"line":193},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should dispatch BlockRemovedCoreEvent via EventBus"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockRemovedEvent handling"],"duration":5,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockRemovedEvent handling should throw when blockIndex is undefined","invocations":1,"location":{"column":7,"line":206},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when blockIndex is undefined"}],"endTime":1777328803872,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockRenderer.spec.ts","startTime":1777328803007,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insert()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insert() should add a block to an empty document and model.length becomes 1","invocations":1,"location":{"column":5,"line":93},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should add a block to an empty document and model.length becomes 1"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insert() should insert a block at the specified index","invocations":1,"location":{"column":5,"line":102},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should insert a block at the specified index"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insert() should use the default block type when type is omitted","invocations":1,"location":{"column":5,"line":114},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should use the default block type when type is omitted"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insert() should replace a block at the given index when replace flag is set","invocations":1,"location":{"column":5,"line":123},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should replace a block at the given index when replace flag is set"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insertMany() should insert multiple blocks at the specified index","invocations":1,"location":{"column":5,"line":137},"numPassingAsserts":4,"retryReasons":[],"status":"passed","title":"should insert multiple blocks at the specified index"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insertMany()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insertMany() should append blocks at the end when index is omitted","invocations":1,"location":{"column":5,"line":160},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should append blocks at the end when index is omitted"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","delete()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) delete() should remove a block at the given index","invocations":1,"location":{"column":5,"line":181},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should remove a block at the given index"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","delete()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) delete() should remove the first block when index is 0","invocations":1,"location":{"column":5,"line":193},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should remove the first block when index is 0"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","delete()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) delete() should throw when no index is provided and no caret is set","invocations":1,"location":{"column":5,"line":203},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when no index is provided and no caret is set"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","move()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) move() should move a block from fromIndex to toIndex (forward)","invocations":1,"location":{"column":5,"line":211},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should move a block from fromIndex to toIndex (forward)"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) move() should move a block from fromIndex to toIndex (backward)","invocations":1,"location":{"column":5,"line":222},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should move a block from fromIndex to toIndex (backward)"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) move() should not change anything when fromIndex equals toIndex","invocations":1,"location":{"column":5,"line":232},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should not change anything when fromIndex equals toIndex"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) move() should throw when no fromIndex is provided and no caret is set","invocations":1,"location":{"column":5,"line":241},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when no fromIndex is provided and no caret is set"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","getBlocksCount()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) getBlocksCount() should return 0 for an empty document","invocations":1,"location":{"column":5,"line":249},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should return 0 for an empty document"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","getBlocksCount()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) getBlocksCount() should return the correct count after insertions and deletions","invocations":1,"location":{"column":5,"line":253},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should return the correct count after insertions and deletions"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","render()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) render() should replace document content with the provided serialized data","invocations":1,"location":{"column":5,"line":267},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should replace document content with the provided serialized data"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","render()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) render() should result in an empty document when empty blocks array is passed","invocations":1,"location":{"column":5,"line":284},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should result in an empty document when empty blocks array is passed"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","clear()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) clear() should remove all blocks from the document","invocations":1,"location":{"column":5,"line":298},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should remove all blocks from the document"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","clear()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) clear() should be safe to call on an already empty document","invocations":1,"location":{"column":5,"line":309},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should be safe to call on an already empty document"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","model events"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) model events should emit BlockAddedEvent on model when insert is called","invocations":1,"location":{"column":5,"line":317},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should emit BlockAddedEvent on model when insert is called"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","model events"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) model events should emit BlockRemovedEvent on model when delete is called","invocations":1,"location":{"column":5,"line":329},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should emit BlockRemovedEvent on model when delete is called"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","combined operations"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) combined operations should handle a sequence of insert, move, delete, and clear","invocations":1,"location":{"column":5,"line":347},"numPassingAsserts":5,"retryReasons":[],"status":"passed","title":"should handle a sequence of insert, move, delete, and clear"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","combined operations"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) combined operations should support render after clear and then further mutations","invocations":1,"location":{"column":5,"line":371},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should support render after clear and then further mutations"}],"endTime":1777328804602,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/BlocksAPI.integration.spec.ts","startTime":1777328803875,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should ignore caret events of other users","invocations":1,"location":{"column":5,"line":91},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should ignore caret events of other users"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should dispatch empty selection info when index is null","invocations":1,"location":{"column":5,"line":102},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should dispatch empty selection info when index is null"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should dispatch selection with empty fragments when parsed index is incomplete","invocations":1,"location":{"column":5,"line":119},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should dispatch selection with empty fragments when parsed index is incomplete"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should dispatch selection with fragments when parsed index has text range","invocations":1,"location":{"column":5,"line":140},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should dispatch selection with fragments when parsed index has text range"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should ignore unknown caret manager event types","invocations":1,"location":{"column":5,"line":172},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should ignore unknown caret manager event types"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should include inline tools from toolsManager in availableInlineTools","invocations":1,"location":{"column":5,"line":183},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should include inline tools from toolsManager in availableInlineTools"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when caret is not set","invocations":1,"location":{"column":5,"line":215},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when caret is not set"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when caret index is null","invocations":1,"location":{"column":5,"line":223},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when caret index is null"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when caret has no text segments","invocations":1,"location":{"column":5,"line":231},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when caret has no text segments"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when tool is not found","invocations":1,"location":{"column":5,"line":241},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when tool is not found"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should call model.format when tool getFormattingOptions returns Format action","invocations":1,"location":{"column":5,"line":256},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.format when tool getFormattingOptions returns Format action"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should call model.unformat when tool getFormattingOptions returns Unformat action","invocations":1,"location":{"column":5,"line":280},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.unformat when tool getFormattingOptions returns Unformat action"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when segment has no textRange","invocations":1,"location":{"column":5,"line":304},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when segment has no textRange"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when segment has no blockIndex","invocations":1,"location":{"column":5,"line":322},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when segment has no blockIndex"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when segment has no dataKey","invocations":1,"location":{"column":5,"line":340},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when segment has no dataKey"}],"endTime":1777328804762,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/SelectionManager.spec.ts","startTime":1777328804610,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["BlocksManager (unit, mocked deps)",".blocksCount"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .blocksCount should proxy model.length","invocations":1,"location":{"column":5,"line":73},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should proxy model.length"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should call model.addBlock with default tool name and computed index","invocations":1,"location":{"column":5,"line":79},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should call model.addBlock with default tool name and computed index"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should use explicit index when provided","invocations":1,"location":{"column":5,"line":93},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should use explicit index when provided"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should call removeBlock then addBlock when replace is true and index is provided","invocations":1,"location":{"column":5,"line":108},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should call removeBlock then addBlock when replace is true and index is provided"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should call model.addBlock when focus is true","invocations":1,"location":{"column":5,"line":125},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should call model.addBlock when focus is true"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should use model.length as insertion/removal index when replace is true and index is omitted","invocations":1,"location":{"column":5,"line":136},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should use model.length as insertion/removal index when replace is true and index is omitted"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insertMany() should call model.addBlock for each block with increasing indexes","invocations":1,"location":{"column":5,"line":153},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should call model.addBlock for each block with increasing indexes"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insertMany() should use model.length as start index when index is omitted","invocations":1,"location":{"column":5,"line":186},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should use model.length as start index when index is omitted"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".render()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .render() should call model.initializeDocument with provided document","invocations":1,"location":{"column":5,"line":220},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.initializeDocument with provided document"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".clear()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .clear() should call model.clearBlocks","invocations":1,"location":{"column":5,"line":239},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.clearBlocks"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".deleteBlock()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .deleteBlock() should throw when no caret and no index is provided","invocations":1,"location":{"column":5,"line":247},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when no caret and no index is provided"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".deleteBlock()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .deleteBlock() should call model.removeBlock with provided index","invocations":1,"location":{"column":5,"line":253},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.removeBlock with provided index"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".move()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .move() should call removeBlock and addBlock when moving current block forward","invocations":1,"location":{"column":5,"line":261},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should call removeBlock and addBlock when moving current block forward"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .move() should throw when there is no current block and no index provided","invocations":1,"location":{"column":5,"line":279},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when there is no current block and no index provided"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .move() should pass toIndex directly when toIndex is less than fromIndex","invocations":1,"location":{"column":5,"line":285},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should pass toIndex directly when toIndex is less than fromIndex"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .move() should do nothing when toIndex equals fromIndex","invocations":1,"location":{"column":5,"line":301},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should do nothing when toIndex equals fromIndex"}],"endTime":1777328804935,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockManager.spec.ts","startTime":1777328804769,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["DocumentAPI",".data"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"DocumentAPI .data should return serialized model","invocations":1,"location":{"column":5,"line":28},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should return serialized model"}],"endTime":1777328805045,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts","startTime":1777328804937,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["BlocksAPI",".clear()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .clear() should call blocksManager.clear","invocations":1,"location":{"column":5,"line":28},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call blocksManager.clear"},{"ancestorTitles":["BlocksAPI",".render()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .render() should call blocksManager.render with provided document","invocations":1,"location":{"column":5,"line":38},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call blocksManager.render with provided document"},{"ancestorTitles":["BlocksAPI",".delete()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .delete() should pass explicit index to blocksManager.deleteBlock","invocations":1,"location":{"column":5,"line":51},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass explicit index to blocksManager.deleteBlock"},{"ancestorTitles":["BlocksAPI",".delete()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .delete() should pass undefined when index is omitted","invocations":1,"location":{"column":5,"line":59},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass undefined when index is omitted"},{"ancestorTitles":["BlocksAPI",".move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .move() should call blocksManager.move with toIndex and fromIndex","invocations":1,"location":{"column":5,"line":69},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call blocksManager.move with toIndex and fromIndex"},{"ancestorTitles":["BlocksAPI",".getBlocksCount()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .getBlocksCount() should return blocksManager.blocksCount","invocations":1,"location":{"column":5,"line":79},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should return blocksManager.blocksCount"},{"ancestorTitles":["BlocksAPI",".insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .insertMany() should pass blocks and index to blocksManager.insertMany","invocations":1,"location":{"column":5,"line":90},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass blocks and index to blocksManager.insertMany"},{"ancestorTitles":["BlocksAPI",".insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .insertMany() should pass undefined index to blocksManager.insertMany when omitted","invocations":1,"location":{"column":5,"line":101},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass undefined index to blocksManager.insertMany when omitted"},{"ancestorTitles":["BlocksAPI",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .insert() should use defaults and pass payload to blocksManager.insert","invocations":1,"location":{"column":5,"line":114},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should use defaults and pass payload to blocksManager.insert"},{"ancestorTitles":["BlocksAPI",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .insert() should pass provided params to blocksManager.insert and ignore compatibility args","invocations":1,"location":{"column":5,"line":127},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass provided params to blocksManager.insert and ignore compatibility args"}],"endTime":1777328805155,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/BlocksAPI.spec.ts","startTime":1777328805047,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["SelectionAPI",".applyInlineToolForCurrentSelection()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionAPI .applyInlineToolForCurrentSelection() should convert toolName and delegate to SelectionManager","invocations":1,"location":{"column":5,"line":24},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should convert toolName and delegate to SelectionManager"}],"endTime":1777328805262,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/SelectionAPI.spec.ts","startTime":1777328805157,"status":"passed","summary":""}],"wasInterrupted":false,"coverageMap":{"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/tokens.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/tokens.ts","statementMap":{"0":{"start":{"line":6,"column":22},"end":{"line":15,"column":11}}},"fnMap":{},"branchMap":{},"s":{"0":5},"f":{},"b":{}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockRenderer.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockRenderer.ts","statementMap":{"0":{"start":{"line":34,"column":7},"end":{"line":158,"column":null}},"1":{"start":{"line":69,"column":4},"end":{"line":69,"column":24}},"2":{"start":{"line":70,"column":4},"end":{"line":70,"column":30}},"3":{"start":{"line":71,"column":4},"end":{"line":71,"column":38}},"4":{"start":{"line":72,"column":4},"end":{"line":72,"column":28}},"5":{"start":{"line":75,"column":4},"end":{"line":75,"column":93}},"6":{"start":{"line":75,"column":61},"end":{"line":75,"column":91}},"7":{"start":{"line":84,"column":4},"end":{"line":91,"column":5}},"8":{"start":{"line":86,"column":8},"end":{"line":86,"column":50}},"9":{"start":{"line":88,"column":8},"end":{"line":88,"column":45}},"10":{"start":{"line":89,"column":8},"end":{"line":89,"column":14}},"11":{"start":{"line":102,"column":28},"end":{"line":102,"column":40}},"12":{"start":{"line":104,"column":4},"end":{"line":106,"column":5}},"13":{"start":{"line":105,"column":6},"end":{"line":105,"column":146}},"14":{"start":{"line":108,"column":17},"end":{"line":108,"column":61}},"15":{"start":{"line":110,"column":4},"end":{"line":112,"column":5}},"16":{"start":{"line":111,"column":6},"end":{"line":111,"column":75}},"17":{"start":{"line":114,"column":29},"end":{"line":114,"column":94}},"18":{"start":{"line":116,"column":18},"end":{"line":121,"column":6}},"19":{"start":{"line":123,"column":4},"end":{"line":134,"column":5}},"20":{"start":{"line":124,"column":27},"end":{"line":124,"column":47}},"21":{"start":{"line":126,"column":6},"end":{"line":131,"column":10}},"22":{"start":{"line":133,"column":6},"end":{"line":133,"column":87}},"23":{"start":{"line":143,"column":28},"end":{"line":143,"column":40}},"24":{"start":{"line":145,"column":4},"end":{"line":147,"column":5}},"25":{"start":{"line":146,"column":6},"end":{"line":146,"column":146}},"26":{"start":{"line":149,"column":4},"end":{"line":152,"column":8}},"27":{"start":{"line":34,"column":13},"end":{"line":158,"column":null}}},"fnMap":{"0":{"name":"(anonymous_4)","decl":{"start":{"line":63,"column":2},"end":{"line":63,"column":null}},"loc":{"start":{"line":67,"column":58},"end":{"line":76,"column":3}}},"1":{"name":"(anonymous_5)","decl":{"start":{"line":75,"column":52},"end":{"line":75,"column":57}},"loc":{"start":{"line":75,"column":61},"end":{"line":75,"column":91}}}},"branchMap":{"0":{"loc":{"start":{"line":84,"column":4},"end":{"line":91,"column":5}},"type":"switch","locations":[{"start":{"line":85,"column":6},"end":{"line":86,"column":50}},{"start":{"line":87,"column":6},"end":{"line":89,"column":14}},{"start":{"line":90,"column":6},"end":{"line":90,"column":14}}]},"1":{"loc":{"start":{"line":104,"column":4},"end":{"line":106,"column":5}},"type":"if","locations":[{"start":{"line":104,"column":4},"end":{"line":106,"column":5}}]},"2":{"loc":{"start":{"line":110,"column":4},"end":{"line":112,"column":5}},"type":"if","locations":[{"start":{"line":110,"column":4},"end":{"line":112,"column":5}}]},"3":{"loc":{"start":{"line":145,"column":4},"end":{"line":147,"column":5}},"type":"if","locations":[{"start":{"line":145,"column":4},"end":{"line":147,"column":5}}]}},"s":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":7,"7":7,"8":4,"9":2,"10":1,"11":4,"12":4,"13":1,"14":3,"15":3,"16":1,"17":2,"18":2,"19":2,"20":2,"21":1,"22":1,"23":2,"24":2,"25":1,"26":1,"27":1},"f":{"0":1,"1":7},"b":{"0":[4,2,1],"1":[1],"2":[1],"3":[1]}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockManager.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockManager.ts","statementMap":{"0":{"start":{"line":56,"column":7},"end":{"line":215,"column":null}},"1":{"start":{"line":81,"column":4},"end":{"line":81,"column":30}},"2":{"start":{"line":98,"column":4},"end":{"line":98,"column":24}},"3":{"start":{"line":99,"column":4},"end":{"line":99,"column":30}},"4":{"start":{"line":100,"column":4},"end":{"line":100,"column":38}},"5":{"start":{"line":101,"column":4},"end":{"line":101,"column":26}},"6":{"start":{"line":122,"column":19},"end":{"line":122,"column":24}},"7":{"start":{"line":124,"column":4},"end":{"line":126,"column":5}},"8":{"start":{"line":125,"column":6},"end":{"line":125,"column":57}},"9":{"start":{"line":128,"column":4},"end":{"line":130,"column":5}},"10":{"start":{"line":129,"column":6},"end":{"line":129,"column":61}},"11":{"start":{"line":132,"column":4},"end":{"line":135,"column":17}},"12":{"start":{"line":137,"column":4},"end":{"line":141,"column":5}},"13":{"start":{"line":150,"column":4},"end":{"line":150,"column":94}},"14":{"start":{"line":150,"column":33},"end":{"line":150,"column":92}},"15":{"start":{"line":158,"column":4},"end":{"line":158,"column":45}},"16":{"start":{"line":165,"column":4},"end":{"line":165,"column":30}},"17":{"start":{"line":173,"column":4},"end":{"line":178,"column":5}},"18":{"start":{"line":177,"column":6},"end":{"line":177,"column":53}},"19":{"start":{"line":180,"column":4},"end":{"line":180,"column":56}},"20":{"start":{"line":189,"column":4},"end":{"line":191,"column":5}},"21":{"start":{"line":190,"column":6},"end":{"line":190,"column":51}},"22":{"start":{"line":196,"column":4},"end":{"line":198,"column":5}},"23":{"start":{"line":197,"column":6},"end":{"line":197,"column":13}},"24":{"start":{"line":200,"column":18},"end":{"line":200,"column":58}},"25":{"start":{"line":202,"column":4},"end":{"line":202,"column":60}},"26":{"start":{"line":203,"column":4},"end":{"line":203,"column":62}},"27":{"start":{"line":210,"column":22},"end":{"line":210,"column":63}},"28":{"start":{"line":211,"column":23},"end":{"line":211,"column":39}},"29":{"start":{"line":213,"column":4},"end":{"line":213,"column":34}},"30":{"start":{"line":56,"column":13},"end":{"line":215,"column":null}}},"fnMap":{"0":{"name":"(anonymous_4)","decl":{"start":{"line":80,"column":2},"end":{"line":80,"column":13}},"loc":{"start":{"line":80,"column":24},"end":{"line":82,"column":3}}},"1":{"name":"(anonymous_5)","decl":{"start":{"line":92,"column":2},"end":{"line":92,"column":null}},"loc":{"start":{"line":96,"column":60},"end":{"line":102,"column":3}}},"2":{"name":"(anonymous_6)","decl":{"start":{"line":113,"column":9},"end":{"line":113,"column":15}},"loc":{"start":{"line":121,"column":31},"end":{"line":142,"column":3}}},"3":{"name":"(anonymous_7)","decl":{"start":{"line":149,"column":9},"end":{"line":149,"column":19}},"loc":{"start":{"line":149,"column":85},"end":{"line":151,"column":3}}},"4":{"name":"(anonymous_8)","decl":{"start":{"line":150,"column":19},"end":{"line":150,"column":20}},"loc":{"start":{"line":150,"column":33},"end":{"line":150,"column":92}}},"5":{"name":"(anonymous_9)","decl":{"start":{"line":157,"column":9},"end":{"line":157,"column":15}},"loc":{"start":{"line":157,"column":50},"end":{"line":159,"column":3}}},"6":{"name":"(anonymous_10)","decl":{"start":{"line":164,"column":9},"end":{"line":164,"column":14}},"loc":{"start":{"line":164,"column":14},"end":{"line":166,"column":3}}},"7":{"name":"(anonymous_11)","decl":{"start":{"line":172,"column":9},"end":{"line":172,"column":20}},"loc":{"start":{"line":172,"column":77},"end":{"line":181,"column":3}}},"8":{"name":"(anonymous_12)","decl":{"start":{"line":188,"column":9},"end":{"line":188,"column":13}},"loc":{"start":{"line":188,"column":91},"end":{"line":204,"column":3}}}},"branchMap":{"0":{"loc":{"start":{"line":113,"column":16},"end":{"line":121,"column":31}},"type":"default-arg","locations":[{"start":{"line":121,"column":29},"end":{"line":121,"column":31}}]},"1":{"loc":{"start":{"line":115,"column":4},"end":{"line":115,"column":36}},"type":"default-arg","locations":[{"start":{"line":115,"column":11},"end":{"line":115,"column":36}}]},"2":{"loc":{"start":{"line":116,"column":4},"end":{"line":116,"column":13}},"type":"default-arg","locations":[{"start":{"line":116,"column":11},"end":{"line":116,"column":13}}]},"3":{"loc":{"start":{"line":118,"column":4},"end":{"line":118,"column":17}},"type":"default-arg","locations":[{"start":{"line":118,"column":12},"end":{"line":118,"column":17}}]},"4":{"loc":{"start":{"line":119,"column":4},"end":{"line":119,"column":19}},"type":"default-arg","locations":[{"start":{"line":119,"column":14},"end":{"line":119,"column":19}}]},"5":{"loc":{"start":{"line":124,"column":4},"end":{"line":126,"column":5}},"type":"if","locations":[{"start":{"line":124,"column":4},"end":{"line":126,"column":5}}]},"6":{"loc":{"start":{"line":125,"column":39},"end":{"line":125,"column":55}},"type":"cond-expr","locations":[{"start":{"line":125,"column":49},"end":{"line":125,"column":51}},{"start":{"line":125,"column":54},"end":{"line":125,"column":55}}]},"7":{"loc":{"start":{"line":128,"column":4},"end":{"line":130,"column":5}},"type":"if","locations":[{"start":{"line":128,"column":4},"end":{"line":130,"column":5}}]},"8":{"loc":{"start":{"line":137,"column":4},"end":{"line":141,"column":5}},"type":"if","locations":[{"start":{"line":137,"column":4},"end":{"line":141,"column":5}}]},"9":{"loc":{"start":{"line":149,"column":51},"end":{"line":149,"column":85}},"type":"default-arg","locations":[{"start":{"line":149,"column":67},"end":{"line":149,"column":85}}]},"10":{"loc":{"start":{"line":172,"column":21},"end":{"line":172,"column":77}},"type":"default-arg","locations":[{"start":{"line":172,"column":49},"end":{"line":172,"column":77}}]},"11":{"loc":{"start":{"line":173,"column":4},"end":{"line":178,"column":5}},"type":"if","locations":[{"start":{"line":173,"column":4},"end":{"line":178,"column":5}}]},"12":{"loc":{"start":{"line":188,"column":31},"end":{"line":188,"column":91}},"type":"default-arg","locations":[{"start":{"line":188,"column":63},"end":{"line":188,"column":91}}]},"13":{"loc":{"start":{"line":189,"column":4},"end":{"line":191,"column":5}},"type":"if","locations":[{"start":{"line":189,"column":4},"end":{"line":191,"column":5}}]},"14":{"loc":{"start":{"line":196,"column":4},"end":{"line":198,"column":5}},"type":"if","locations":[{"start":{"line":196,"column":4},"end":{"line":198,"column":5}}]}},"s":{"0":2,"1":4,"2":24,"3":24,"4":24,"5":24,"6":46,"7":46,"8":29,"9":46,"10":3,"11":46,"12":46,"13":4,"14":8,"15":4,"16":5,"17":9,"18":2,"19":7,"20":9,"21":2,"22":7,"23":2,"24":5,"25":5,"26":5,"27":5,"28":5,"29":5,"30":2},"f":{"0":4,"1":24,"2":46,"3":4,"4":8,"5":4,"6":5,"7":9,"8":9},"b":{"0":[1],"1":[3],"2":[5],"3":[45],"4":[43],"5":[29],"6":[1,28],"7":[3],"8":[1],"9":[2],"10":[2],"11":[2],"12":[3],"13":[2],"14":[2]}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/BlocksAPI.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/BlocksAPI.ts","statementMap":{"0":{"start":{"line":15,"column":7},"end":{"line":116,"column":null}},"1":{"start":{"line":35,"column":4},"end":{"line":35,"column":40}},"2":{"start":{"line":36,"column":4},"end":{"line":36,"column":26}},"3":{"start":{"line":43,"column":4},"end":{"line":43,"column":39}},"4":{"start":{"line":51,"column":4},"end":{"line":51,"column":48}},"5":{"start":{"line":59,"column":4},"end":{"line":59,"column":50}},"6":{"start":{"line":68,"column":4},"end":{"line":68,"column":56}},"7":{"start":{"line":75,"column":4},"end":{"line":75,"column":43}},"8":{"start":{"line":84,"column":4},"end":{"line":84,"column":57}},"9":{"start":{"line":105,"column":22},"end":{"line":105,"column":55}},"10":{"start":{"line":106,"column":22},"end":{"line":106,"column":32}},"11":{"start":{"line":108,"column":4},"end":{"line":114,"column":7}},"12":{"start":{"line":15,"column":13},"end":{"line":116,"column":null}}},"fnMap":{"0":{"name":"(anonymous_4)","decl":{"start":{"line":31,"column":2},"end":{"line":31,"column":null}},"loc":{"start":{"line":33,"column":60},"end":{"line":37,"column":3}}},"1":{"name":"(anonymous_5)","decl":{"start":{"line":42,"column":9},"end":{"line":42,"column":14}},"loc":{"start":{"line":42,"column":14},"end":{"line":44,"column":3}}},"2":{"name":"(anonymous_6)","decl":{"start":{"line":50,"column":9},"end":{"line":50,"column":15}},"loc":{"start":{"line":50,"column":50},"end":{"line":52,"column":3}}},"3":{"name":"(anonymous_7)","decl":{"start":{"line":58,"column":9},"end":{"line":58,"column":15}},"loc":{"start":{"line":58,"column":30},"end":{"line":60,"column":3}}},"4":{"name":"(anonymous_8)","decl":{"start":{"line":67,"column":9},"end":{"line":67,"column":13}},"loc":{"start":{"line":67,"column":49},"end":{"line":69,"column":3}}},"5":{"name":"(anonymous_9)","decl":{"start":{"line":74,"column":9},"end":{"line":74,"column":23}},"loc":{"start":{"line":74,"column":23},"end":{"line":76,"column":3}}},"6":{"name":"(anonymous_10)","decl":{"start":{"line":83,"column":9},"end":{"line":83,"column":19}},"loc":{"start":{"line":83,"column":65},"end":{"line":85,"column":3}}},"7":{"name":"(anonymous_11)","decl":{"start":{"line":96,"column":9},"end":{"line":96,"column":15}},"loc":{"start":{"line":103,"column":15},"end":{"line":115,"column":3}}}},"branchMap":{"0":{"loc":{"start":{"line":105,"column":22},"end":{"line":105,"column":55}},"type":"binary-expr","locations":[{"start":{"line":105,"column":22},"end":{"line":105,"column":26}},{"start":{"line":105,"column":30},"end":{"line":105,"column":55}}]},"1":{"loc":{"start":{"line":106,"column":22},"end":{"line":106,"column":32}},"type":"binary-expr","locations":[{"start":{"line":106,"column":22},"end":{"line":106,"column":26}},{"start":{"line":106,"column":30},"end":{"line":106,"column":32}}]}},"s":{"0":2,"1":33,"2":33,"3":5,"4":4,"5":9,"6":6,"7":4,"8":4,"9":43,"10":43,"11":43,"12":2},"f":{"0":33,"1":5,"2":4,"3":9,"4":6,"5":4,"6":4,"7":43},"b":{"0":[43,2],"1":[43,26]}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/SelectionManager.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/SelectionManager.ts","statementMap":{"0":{"start":{"line":24,"column":7},"end":{"line":177,"column":null}},"1":{"start":{"line":55,"column":4},"end":{"line":55,"column":26}},"2":{"start":{"line":56,"column":4},"end":{"line":56,"column":24}},"3":{"start":{"line":57,"column":4},"end":{"line":57,"column":30}},"4":{"start":{"line":58,"column":4},"end":{"line":58,"column":38}},"5":{"start":{"line":60,"column":4},"end":{"line":60,"column":134}},"6":{"start":{"line":60,"column":95},"end":{"line":60,"column":132}},"7":{"start":{"line":68,"column":4},"end":{"line":70,"column":5}},"8":{"start":{"line":69,"column":6},"end":{"line":69,"column":13}},"9":{"start":{"line":72,"column":4},"end":{"line":105,"column":5}},"10":{"start":{"line":74,"column":43},"end":{"line":74,"column":55}},"11":{"start":{"line":76,"column":22},"end":{"line":76,"column":84}},"12":{"start":{"line":77,"column":42},"end":{"line":77,"column":44}},"13":{"start":{"line":79,"column":8},"end":{"line":87,"column":9}},"14":{"start":{"line":80,"column":10},"end":{"line":86,"column":11}},"15":{"start":{"line":81,"column":12},"end":{"line":85,"column":13}},"16":{"start":{"line":82,"column":14},"end":{"line":84,"column":16}},"17":{"start":{"line":89,"column":8},"end":{"line":101,"column":12}},"18":{"start":{"line":98,"column":39},"end":{"line":98,"column":84}},"19":{"start":{"line":103,"column":8},"end":{"line":103,"column":14}},"20":{"start":{"line":117,"column":22},"end":{"line":117,"column":63}},"21":{"start":{"line":119,"column":18},"end":{"line":119,"column":42}},"22":{"start":{"line":121,"column":4},"end":{"line":123,"column":5}},"23":{"start":{"line":122,"column":6},"end":{"line":122,"column":120}},"24":{"start":{"line":129,"column":21},"end":{"line":129,"column":44}},"25":{"start":{"line":131,"column":4},"end":{"line":133,"column":5}},"26":{"start":{"line":132,"column":6},"end":{"line":132,"column":120}},"27":{"start":{"line":135,"column":17},"end":{"line":135,"column":71}},"28":{"start":{"line":140,"column":4},"end":{"line":142,"column":5}},"29":{"start":{"line":141,"column":6},"end":{"line":141,"column":112}},"30":{"start":{"line":144,"column":4},"end":{"line":175,"column":5}},"31":{"start":{"line":145,"column":24},"end":{"line":145,"column":41}},"32":{"start":{"line":146,"column":25},"end":{"line":146,"column":43}},"33":{"start":{"line":147,"column":22},"end":{"line":147,"column":37}},"34":{"start":{"line":149,"column":6},"end":{"line":151,"column":7}},"35":{"start":{"line":150,"column":8},"end":{"line":150,"column":148}},"36":{"start":{"line":153,"column":6},"end":{"line":155,"column":7}},"37":{"start":{"line":154,"column":8},"end":{"line":154,"column":136}},"38":{"start":{"line":157,"column":6},"end":{"line":159,"column":7}},"39":{"start":{"line":158,"column":8},"end":{"line":158,"column":146}},"40":{"start":{"line":161,"column":24},"end":{"line":161,"column":93}},"41":{"start":{"line":163,"column":32},"end":{"line":163,"column":79}},"42":{"start":{"line":165,"column":6},"end":{"line":174,"column":7}},"43":{"start":{"line":167,"column":10},"end":{"line":167,"column":119}},"44":{"start":{"line":169,"column":10},"end":{"line":169,"column":16}},"45":{"start":{"line":171,"column":10},"end":{"line":171,"column":93}},"46":{"start":{"line":173,"column":10},"end":{"line":173,"column":16}},"47":{"start":{"line":24,"column":13},"end":{"line":177,"column":null}}},"fnMap":{"0":{"name":"(anonymous_4)","decl":{"start":{"line":49,"column":2},"end":{"line":49,"column":null}},"loc":{"start":{"line":53,"column":30},"end":{"line":61,"column":3}}},"1":{"name":"(anonymous_5)","decl":{"start":{"line":60,"column":64},"end":{"line":60,"column":65}},"loc":{"start":{"line":60,"column":95},"end":{"line":60,"column":132}}},"2":{"name":"(anonymous_6)","decl":{"start":{"line":98,"column":19},"end":{"line":98,"column":20}},"loc":{"start":{"line":98,"column":39},"end":{"line":98,"column":84}}},"3":{"name":"(anonymous_7)","decl":{"start":{"line":113,"column":9},"end":{"line":113,"column":43}},"loc":{"start":{"line":113,"column":101},"end":{"line":176,"column":3}}}},"branchMap":{"0":{"loc":{"start":{"line":68,"column":4},"end":{"line":70,"column":5}},"type":"if","locations":[{"start":{"line":68,"column":4},"end":{"line":70,"column":5}}]},"1":{"loc":{"start":{"line":72,"column":4},"end":{"line":105,"column":5}},"type":"switch","locations":[{"start":{"line":73,"column":6},"end":{"line":104,"column":7}}]},"2":{"loc":{"start":{"line":76,"column":22},"end":{"line":76,"column":84}},"type":"cond-expr","locations":[{"start":{"line":76,"column":49},"end":{"line":76,"column":77}},{"start":{"line":76,"column":80},"end":{"line":76,"column":84}}]},"3":{"loc":{"start":{"line":79,"column":8},"end":{"line":87,"column":9}},"type":"if","locations":[{"start":{"line":79,"column":8},"end":{"line":87,"column":9}}]},"4":{"loc":{"start":{"line":81,"column":12},"end":{"line":85,"column":13}},"type":"if","locations":[{"start":{"line":81,"column":12},"end":{"line":85,"column":13}}]},"5":{"loc":{"start":{"line":81,"column":16},"end":{"line":81,"column":116}},"type":"binary-expr","locations":[{"start":{"line":81,"column":16},"end":{"line":81,"column":48}},{"start":{"line":81,"column":52},"end":{"line":81,"column":81}},{"start":{"line":81,"column":85},"end":{"line":81,"column":116}}]},"6":{"loc":{"start":{"line":113,"column":70},"end":{"line":113,"column":101}},"type":"default-arg","locations":[{"start":{"line":113,"column":99},"end":{"line":113,"column":101}}]},"7":{"loc":{"start":{"line":119,"column":18},"end":{"line":119,"column":42}},"type":"binary-expr","locations":[{"start":{"line":119,"column":18},"end":{"line":119,"column":34}},{"start":{"line":119,"column":38},"end":{"line":119,"column":42}}]},"8":{"loc":{"start":{"line":121,"column":4},"end":{"line":123,"column":5}},"type":"if","locations":[{"start":{"line":121,"column":4},"end":{"line":123,"column":5}}]},"9":{"loc":{"start":{"line":131,"column":4},"end":{"line":133,"column":5}},"type":"if","locations":[{"start":{"line":131,"column":4},"end":{"line":133,"column":5}}]},"10":{"loc":{"start":{"line":140,"column":4},"end":{"line":142,"column":5}},"type":"if","locations":[{"start":{"line":140,"column":4},"end":{"line":142,"column":5}}]},"11":{"loc":{"start":{"line":149,"column":6},"end":{"line":151,"column":7}},"type":"if","locations":[{"start":{"line":149,"column":6},"end":{"line":151,"column":7}}]},"12":{"loc":{"start":{"line":153,"column":6},"end":{"line":155,"column":7}},"type":"if","locations":[{"start":{"line":153,"column":6},"end":{"line":155,"column":7}}]},"13":{"loc":{"start":{"line":157,"column":6},"end":{"line":159,"column":7}},"type":"if","locations":[{"start":{"line":157,"column":6},"end":{"line":159,"column":7}}]},"14":{"loc":{"start":{"line":165,"column":6},"end":{"line":174,"column":7}},"type":"switch","locations":[{"start":{"line":166,"column":8},"end":{"line":169,"column":16}},{"start":{"line":170,"column":8},"end":{"line":173,"column":16}}]}},"s":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":6,"7":6,"8":1,"9":5,"10":4,"11":4,"12":4,"13":4,"14":3,"15":2,"16":2,"17":4,"18":1,"19":4,"20":9,"21":9,"22":9,"23":2,"24":7,"25":7,"26":1,"27":6,"28":6,"29":1,"30":5,"31":5,"32":5,"33":5,"34":5,"35":1,"36":4,"37":1,"38":3,"39":1,"40":2,"41":2,"42":2,"43":1,"44":1,"45":1,"46":1,"47":1},"f":{"0":1,"1":6,"2":1,"3":9},"b":{"0":[1],"1":[4],"2":[3,1],"3":[3],"4":[2],"5":[2,2,2],"6":[9],"7":[9,2],"8":[2],"9":[1],"10":[1],"11":[1],"12":[1],"13":[1],"14":[1,1]}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/DocumentAPI/DocumentAPI.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/DocumentAPI/DocumentAPI.ts","statementMap":{"0":{"start":{"line":12,"column":7},"end":{"line":33,"column":null}},"1":{"start":{"line":24,"column":4},"end":{"line":24,"column":24}},"2":{"start":{"line":31,"column":4},"end":{"line":31,"column":34}},"3":{"start":{"line":12,"column":13},"end":{"line":33,"column":null}}},"fnMap":{"0":{"name":"(anonymous_2)","decl":{"start":{"line":23,"column":2},"end":{"line":23,"column":14}},"loc":{"start":{"line":23,"column":34},"end":{"line":25,"column":3}}},"1":{"name":"(anonymous_3)","decl":{"start":{"line":30,"column":2},"end":{"line":30,"column":13}},"loc":{"start":{"line":30,"column":17},"end":{"line":32,"column":3}}}},"branchMap":{},"s":{"0":1,"1":1,"2":1,"3":1},"f":{"0":1,"1":1},"b":{}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/SelectionAPI.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/SelectionAPI.ts","statementMap":{"0":{"start":{"line":14,"column":7},"end":{"line":35,"column":null}},"1":{"start":{"line":24,"column":4},"end":{"line":24,"column":46}},"2":{"start":{"line":33,"column":4},"end":{"line":33,"column":100}},"3":{"start":{"line":14,"column":13},"end":{"line":35,"column":null}}},"fnMap":{"0":{"name":"(anonymous_2)","decl":{"start":{"line":21,"column":2},"end":{"line":21,"column":null}},"loc":{"start":{"line":22,"column":38},"end":{"line":25,"column":3}}},"1":{"name":"(anonymous_3)","decl":{"start":{"line":32,"column":9},"end":{"line":32,"column":43}},"loc":{"start":{"line":32,"column":89},"end":{"line":34,"column":3}}}},"branchMap":{},"s":{"0":1,"1":1,"2":1,"3":1},"f":{"0":1,"1":1},"b":{}}}} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b1fc594a..e24f7722 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -149,7 +149,11 @@ export default class Core { this.#plugins.bind<[ToolConstructable, ToolSettings | undefined]>(pluginType).toConstantValue([pluginOrTool as ToolConstructable, options as ToolSettings | undefined]); break; case PluginType.Adapter: - this.#plugins.bind(PluginType.Adapter).toConstantValue(pluginOrTool); + if (this.#plugins.isBound(PluginType.Adapter)) { + this.#plugins.rebind(PluginType.Adapter).toConstantValue(pluginOrTool); + } else { + this.#plugins.bind(PluginType.Adapter).toConstantValue(pluginOrTool); + } break; default: this.#plugins.bind(PluginType.Plugin).toConstantValue(pluginOrTool); diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 8754ed14..d070d5ec 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -36,7 +36,7 @@ import { InputsRegistry } from '../InputsRegistry/index.js'; * It can handle beforeinput events and update model data * It can handle model's change events and update DOM */ -@injectable('Transient') +@injectable() export class DOMBlockToolAdapter extends BlockToolAdapter { /** * Name of the tool that this adapter is connected to @@ -107,7 +107,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { } if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) { - throw new Error('Native inputs such as HTMLInput or HTMLTextArea are not supported. Please provide an HTMLElement with contendEditable property set to \'true\''); + throw new Error('Native inputs such as HTMLInput or HTMLTextArea are not supported. Please provide a non-native HTMLElement (e.g. a div with contentEditable set to \'true\')'); } const existingInput = this.#attachedInputs.get(key); @@ -408,7 +408,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { break; case InputType.InsertLineBreak: /** - * @todo hanlde insert linebreak for content editable elements + * @todo handle insert linebreak for content editable elements */ break; default: diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index b577a1fb..7e4e605c 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -43,6 +43,10 @@ export class DOMAdapters implements EditorJSAdapterPlugin { this.#iocContainer.bind>(TOKENS.EditorConfig).toConstantValue(config as Required); this.#iocContainer.bind(EditorJSModel).toConstantValue(model); this.#iocContainer.bind(EventBus).toConstantValue(eventBus); + this.#iocContainer + .bind(DOMBlockToolAdapter) + .toSelf() + .inTransientScope(); const registry = this.#iocContainer.get(InputsRegistry); diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index 89d78ad9..ed15e92b 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -346,10 +346,9 @@ export class BlockNode extends EventBus { * @param data - block data */ #initialize(data: BlockNodeDataSerialized): void { - mapObject( - data, - (value, key) => this.createDataNode(createDataKey(key), value) - ); + for (const [key, value] of Object.entries(data)) { + this.createDataNode(createDataKey(key), value); + } } /** diff --git a/packages/sdk/src/entities/BlockToolAdapter.ts b/packages/sdk/src/entities/BlockToolAdapter.ts index 09a33593..f289c49b 100644 --- a/packages/sdk/src/entities/BlockToolAdapter.ts +++ b/packages/sdk/src/entities/BlockToolAdapter.ts @@ -105,6 +105,12 @@ export abstract class BlockToolAdapter extends EventTarget { * Creates data node in the model * @param key - key of the node * @param initialData - optional initial data for the node + * @example + * // Register a text input key with initial content + * this.#createDataNode(createDataKey('content'), { $t: 't', value: 'Hello', fragments: [] }); + * + * // Register a value key in an array (e.g. for items[0].content) + * 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) { diff --git a/packages/sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts b/packages/sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts index e36e597e..a8ff9ab8 100644 --- a/packages/sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts +++ b/packages/sdk/src/entities/EventBus/events/adapter/KeyRemoved.ts @@ -15,7 +15,7 @@ interface KeyRemovedPayload { */ export class KeyRemovedEvent extends CustomEvent { /** - * Cosntructor function + * Constructor function * @param key - key of the removed data node */ constructor(key: string) { diff --git a/packages/sdk/src/entities/EventBus/events/adapter/ValueNodeChanged.ts b/packages/sdk/src/entities/EventBus/events/adapter/ValueNodeChanged.ts index 05c5f9ab..ce36f661 100644 --- a/packages/sdk/src/entities/EventBus/events/adapter/ValueNodeChanged.ts +++ b/packages/sdk/src/entities/EventBus/events/adapter/ValueNodeChanged.ts @@ -17,7 +17,7 @@ interface ValueNodeChangedPayload { } /** - * ValueNodeChangedEvent adapterr event + * ValueNodeChangedEvent adapter event */ export class ValueNodeChangedEvent extends CustomEvent> { /** diff --git a/packages/sdk/src/entities/EventBus/events/adapter/index.ts b/packages/sdk/src/entities/EventBus/events/adapter/index.ts index 2652a796..941c938e 100644 --- a/packages/sdk/src/entities/EventBus/events/adapter/index.ts +++ b/packages/sdk/src/entities/EventBus/events/adapter/index.ts @@ -1,4 +1,4 @@ export { AdapterEventType } from './AdapterEventType.js'; export { KeyAddedEvent } from './KeyAdded.js'; -export { KeyRemovedEvent } from './KeyRemoved'; +export { KeyRemovedEvent } from './KeyRemoved.js'; export { ValueNodeChangedEvent } from './ValueNodeChanged.js'; From ba5077de9675c2558de9f4755b7a52efa27e333a Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 28 Apr 2026 00:04:31 +0100 Subject: [PATCH 05/14] (try to) fix test run for core package --- .github/actions/unit-tests/action.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index c6760569..44e5daab 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -15,10 +15,6 @@ runs: node-version-file: .nvmrc - uses: ./.github/actions/setup - - uses: ./.github/actions/build - with: - package-name: ${{ inputs.package-name }} - # Find current PR's number - uses: jwalton/gh-find-current-pr@v1 id: findPr @@ -28,6 +24,6 @@ runs: with: custom-title: Coverage report for `${{ inputs.working-directory }}` working-directory: ${{ inputs.working-directory }} - test-script: yarn workspace ${{ inputs.package-name }} test + test-script: yarn workspace ${{ inputs.package-name }} run build && yarn workspace ${{ inputs.package-name }} test package-manager: yarn prnumber: ${{ steps.findPr.outputs.number }} From e424775be33076e1748cf3849d484d9641d972cb Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 28 Apr 2026 00:25:36 +0100 Subject: [PATCH 06/14] Run base branch coverage manually --- .github/actions/unit-tests/action.yml | 47 +++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index 44e5daab..8cc890f7 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -15,15 +15,48 @@ runs: node-version-file: .nvmrc - uses: ./.github/actions/setup + # ---- Base branch coverage (for comparison) ---- + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + + - uses: ./.github/actions/build + with: + package-name: ${{ inputs.package-name }} + + - name: Run base tests with coverage + shell: bash + run: yarn workspace ${{ inputs.package-name }} test:coverage --coverageReporters=json-summary + continue-on-error: true + + - name: Save base coverage + shell: bash + run: | + [ -f "${{ inputs.working-directory }}/coverage/coverage-summary.json" ] && \ + cp "${{ inputs.working-directory }}/coverage/coverage-summary.json" /tmp/base-coverage-summary.json || true + + # ---- PR HEAD coverage ---- + - name: Checkout PR HEAD + uses: actions/checkout@v4 + + - uses: ./.github/actions/build + with: + package-name: ${{ inputs.package-name }} + + - name: Run unit tests with coverage + shell: bash + run: yarn workspace ${{ inputs.package-name }} test:coverage --coverageReporters=json-summary --coverageReporters=text + # Find current PR's number - uses: jwalton/gh-find-current-pr@v1 id: findPr - - name: Run unit tests - uses: ArtiomTr/jest-coverage-report-action@v2 + - name: Post coverage report + uses: MishaKav/jest-coverage-comment@main with: - custom-title: Coverage report for `${{ inputs.working-directory }}` - working-directory: ${{ inputs.working-directory }} - test-script: yarn workspace ${{ inputs.package-name }} run build && yarn workspace ${{ inputs.package-name }} test - package-manager: yarn - prnumber: ${{ steps.findPr.outputs.number }} + title: Coverage report for `${{ inputs.working-directory }}` + coverage-summary-path: ${{ inputs.working-directory }}/coverage/coverage-summary.json + base-coverage-summary-path: /tmp/base-coverage-summary.json + pr-number: ${{ steps.findPr.outputs.number }} + github-token: ${{ github.token }} From 5990de3c26acc4c4d066f52ac0d0154f25e051d3 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 28 Apr 2026 00:46:26 +0100 Subject: [PATCH 07/14] Fix unit-tests action: use ArtiomTr with pre-generated coverage files --- .github/actions/unit-tests/action.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index 8cc890f7..82981fc6 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -27,14 +27,14 @@ runs: - name: Run base tests with coverage shell: bash - run: yarn workspace ${{ inputs.package-name }} test:coverage --coverageReporters=json-summary + run: yarn workspace ${{ inputs.package-name }} test:coverage continue-on-error: true - name: Save base coverage shell: bash run: | - [ -f "${{ inputs.working-directory }}/coverage/coverage-summary.json" ] && \ - cp "${{ inputs.working-directory }}/coverage/coverage-summary.json" /tmp/base-coverage-summary.json || true + [ -f "${{ inputs.working-directory }}/coverage/coverage-final.json" ] && \ + cp "${{ inputs.working-directory }}/coverage/coverage-final.json" /tmp/base-coverage.json || true # ---- PR HEAD coverage ---- - name: Checkout PR HEAD @@ -46,17 +46,19 @@ runs: - name: Run unit tests with coverage shell: bash - run: yarn workspace ${{ inputs.package-name }} test:coverage --coverageReporters=json-summary --coverageReporters=text + run: yarn workspace ${{ inputs.package-name }} test:coverage # Find current PR's number - uses: jwalton/gh-find-current-pr@v1 id: findPr - name: Post coverage report - uses: MishaKav/jest-coverage-comment@main + uses: ArtiomTr/jest-coverage-report-action@v2 + continue-on-error: true with: - title: Coverage report for `${{ inputs.working-directory }}` - coverage-summary-path: ${{ inputs.working-directory }}/coverage/coverage-summary.json - base-coverage-summary-path: /tmp/base-coverage-summary.json - pr-number: ${{ steps.findPr.outputs.number }} - github-token: ${{ github.token }} + custom-title: Coverage report for `${{ inputs.working-directory }}` + working-directory: ${{ inputs.working-directory }} + coverage-file: ${{ inputs.working-directory }}/coverage/coverage-final.json + base-coverage-file: /tmp/base-coverage.json + skip-step: all + prnumber: ${{ steps.findPr.outputs.number }} From 208c95d079ff602aca1097ba124918ae4bf6bd65 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 28 Apr 2026 17:01:30 +0100 Subject: [PATCH 08/14] Try just PR branch tests --- .github/actions/unit-tests/action.yml | 66 +++++++++++++-------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index 82981fc6..edfd694f 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -29,36 +29,36 @@ runs: shell: bash run: yarn workspace ${{ inputs.package-name }} test:coverage continue-on-error: true - - - name: Save base coverage - shell: bash - run: | - [ -f "${{ inputs.working-directory }}/coverage/coverage-final.json" ] && \ - cp "${{ inputs.working-directory }}/coverage/coverage-final.json" /tmp/base-coverage.json || true - - # ---- PR HEAD coverage ---- - - name: Checkout PR HEAD - uses: actions/checkout@v4 - - - uses: ./.github/actions/build - with: - package-name: ${{ inputs.package-name }} - - - name: Run unit tests with coverage - shell: bash - run: yarn workspace ${{ inputs.package-name }} test:coverage - - # Find current PR's number - - uses: jwalton/gh-find-current-pr@v1 - id: findPr - - - name: Post coverage report - uses: ArtiomTr/jest-coverage-report-action@v2 - continue-on-error: true - with: - custom-title: Coverage report for `${{ inputs.working-directory }}` - working-directory: ${{ inputs.working-directory }} - coverage-file: ${{ inputs.working-directory }}/coverage/coverage-final.json - base-coverage-file: /tmp/base-coverage.json - skip-step: all - prnumber: ${{ steps.findPr.outputs.number }} +# +# - name: Save base coverage +# shell: bash +# run: | +# [ -f "${{ inputs.working-directory }}/coverage/coverage-final.json" ] && \ +# cp "${{ inputs.working-directory }}/coverage/coverage-final.json" /tmp/base-coverage.json || true +# +# # ---- PR HEAD coverage ---- +# - name: Checkout PR HEAD +# uses: actions/checkout@v4 +# +# - uses: ./.github/actions/build +# with: +# package-name: ${{ inputs.package-name }} +# +# - name: Run unit tests with coverage +# shell: bash +# run: yarn workspace ${{ inputs.package-name }} test:coverage +# +# # Find current PR's number +# - uses: jwalton/gh-find-current-pr@v1 +# id: findPr +# +# - name: Post coverage report +# uses: ArtiomTr/jest-coverage-report-action@v2 +# continue-on-error: true +# with: +# custom-title: Coverage report for `${{ inputs.working-directory }}` +# working-directory: ${{ inputs.working-directory }} +# coverage-file: ${{ inputs.working-directory }}/coverage/coverage-final.json +# base-coverage-file: /tmp/base-coverage.json +# skip-step: all +# prnumber: ${{ steps.findPr.outputs.number }} From 0996eacf8a8f3ea0de110b53b1414c95b5c5b591 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 28 Apr 2026 17:29:47 +0100 Subject: [PATCH 09/14] Try clear before build --- .github/actions/build/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 7ce0aca8..364bb637 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -14,6 +14,10 @@ runs: - name: Setup environment uses: ./.github/actions/setup + - name: Clear workspaces + shell: bash + run: yarn workspaces foreach --all run clear + - name: Build the package with dependencies shell: bash run: yarn workspace ${{ inputs.package-name }} run build From 8958a511a0263b5e1b2cf3f8eabf606de44919ab Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 28 Apr 2026 17:35:11 +0100 Subject: [PATCH 10/14] Try no cache --- .github/actions/build/action.yml | 4 ---- .github/actions/setup/action.yml | 16 ++++++++-------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 364bb637..7ce0aca8 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -14,10 +14,6 @@ runs: - name: Setup environment uses: ./.github/actions/setup - - name: Clear workspaces - shell: bash - run: yarn workspaces foreach --all run clear - - name: Build the package with dependencies shell: bash run: yarn workspace ${{ inputs.package-name }} run build diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 076996d2..f8711cfc 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -7,14 +7,14 @@ runs: run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT shell: bash - - name: Restore yarn cache folder - uses: actions/cache@v3 - id: yarn-cache - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- +# - name: Restore yarn cache folder +# uses: actions/cache@v3 +# id: yarn-cache +# with: +# path: ${{ steps.yarn-cache-dir-path.outputs.dir }} +# key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} +# restore-keys: | +# ${{ runner.os }}-yarn- - name: Install dependencies run: yarn install From 0ea33bca802b95a10ce8e3bb73c44e8a3c214da6 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 28 Apr 2026 17:44:22 +0100 Subject: [PATCH 11/14] Use latest node --- .github/actions/setup/action.yml | 16 ++++---- .github/actions/unit-tests/action.yml | 55 ++++++--------------------- .nvmrc | 2 +- 3 files changed, 21 insertions(+), 52 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index f8711cfc..076996d2 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -7,14 +7,14 @@ runs: run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT shell: bash -# - name: Restore yarn cache folder -# uses: actions/cache@v3 -# id: yarn-cache -# with: -# path: ${{ steps.yarn-cache-dir-path.outputs.dir }} -# key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} -# restore-keys: | -# ${{ runner.os }}-yarn- + - name: Restore yarn cache folder + uses: actions/cache@v3 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- - name: Install dependencies run: yarn install diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index edfd694f..c6760569 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -15,50 +15,19 @@ runs: node-version-file: .nvmrc - uses: ./.github/actions/setup - # ---- Base branch coverage (for comparison) ---- - - name: Checkout base branch - uses: actions/checkout@v4 - with: - ref: ${{ github.base_ref }} - - uses: ./.github/actions/build with: package-name: ${{ inputs.package-name }} - - name: Run base tests with coverage - shell: bash - run: yarn workspace ${{ inputs.package-name }} test:coverage - continue-on-error: true -# -# - name: Save base coverage -# shell: bash -# run: | -# [ -f "${{ inputs.working-directory }}/coverage/coverage-final.json" ] && \ -# cp "${{ inputs.working-directory }}/coverage/coverage-final.json" /tmp/base-coverage.json || true -# -# # ---- PR HEAD coverage ---- -# - name: Checkout PR HEAD -# uses: actions/checkout@v4 -# -# - uses: ./.github/actions/build -# with: -# package-name: ${{ inputs.package-name }} -# -# - name: Run unit tests with coverage -# shell: bash -# run: yarn workspace ${{ inputs.package-name }} test:coverage -# -# # Find current PR's number -# - uses: jwalton/gh-find-current-pr@v1 -# id: findPr -# -# - name: Post coverage report -# uses: ArtiomTr/jest-coverage-report-action@v2 -# continue-on-error: true -# with: -# custom-title: Coverage report for `${{ inputs.working-directory }}` -# working-directory: ${{ inputs.working-directory }} -# coverage-file: ${{ inputs.working-directory }}/coverage/coverage-final.json -# base-coverage-file: /tmp/base-coverage.json -# skip-step: all -# prnumber: ${{ steps.findPr.outputs.number }} + # Find current PR's number + - uses: jwalton/gh-find-current-pr@v1 + id: findPr + + - name: Run unit tests + uses: ArtiomTr/jest-coverage-report-action@v2 + with: + custom-title: Coverage report for `${{ inputs.working-directory }}` + working-directory: ${{ inputs.working-directory }} + test-script: yarn workspace ${{ inputs.package-name }} test + package-manager: yarn + prnumber: ${{ steps.findPr.outputs.number }} diff --git a/.nvmrc b/.nvmrc index 805b5a4e..f3c88209 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.9.0 +v24.15.0 From d265bd3c02a548d7ac23e38e29c1f82aa867829b Mon Sep 17 00:00:00 2001 From: gohabereg Date: Wed, 29 Apr 2026 18:29:00 +0100 Subject: [PATCH 12/14] Review comments resolved --- packages/core/report.json | 1 - packages/core/src/components/BlockRenderer.ts | 3 +-- packages/core/src/components/SelectionManager.ts | 3 +++ packages/core/src/index.ts | 3 +++ .../src/tools/internal/block-tools/paragraph/index.ts | 8 +++++++- packages/model/.eslintrc.yml | 0 packages/sdk/src/entities/EditorjsPlugin.ts | 6 ++++++ packages/sdk/src/tools/facades/InlineToolFacade.ts | 8 ++++---- 8 files changed, 24 insertions(+), 8 deletions(-) delete mode 100644 packages/core/report.json delete mode 100644 packages/model/.eslintrc.yml diff --git a/packages/core/report.json b/packages/core/report.json deleted file mode 100644 index bca7468a..00000000 --- a/packages/core/report.json +++ /dev/null @@ -1 +0,0 @@ -{"numFailedTestSuites":0,"numFailedTests":0,"numPassedTestSuites":7,"numPassedTests":73,"numPendingTestSuites":0,"numPendingTests":0,"numRuntimeErrorTestSuites":0,"numTodoTests":0,"numTotalTestSuites":7,"numTotalTests":73,"openHandles":[],"snapshot":{"added":0,"didUpdate":false,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0},"startTime":1777328802976,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() should ignore unknown model events","invocations":1,"location":{"column":5,"line":99},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should ignore unknown model events"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockAddedEvent handling"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockAddedEvent handling should create tool and dispatch BlockAddedCoreEvent via EventBus","invocations":1,"location":{"column":7,"line":106},"numPassingAsserts":4,"retryReasons":[],"status":"passed","title":"should create tool and dispatch BlockAddedCoreEvent via EventBus"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockAddedEvent handling"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockAddedEvent handling should throw when blockIndex is undefined","invocations":1,"location":{"column":7,"line":131},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when blockIndex is undefined"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockAddedEvent handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockAddedEvent handling should throw when tool is not found","invocations":1,"location":{"column":7,"line":146},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when tool is not found"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockAddedEvent handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockAddedEvent handling should log error when tool render fails","invocations":1,"location":{"column":7,"line":163},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should log error when tool render fails"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockRemovedEvent handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockRemovedEvent handling should dispatch BlockRemovedCoreEvent via EventBus","invocations":1,"location":{"column":7,"line":193},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should dispatch BlockRemovedCoreEvent via EventBus"},{"ancestorTitles":["BlockRenderer (unit, mocked deps)","#handleModelUpdate()","BlockRemovedEvent handling"],"duration":5,"failureDetails":[],"failureMessages":[],"fullName":"BlockRenderer (unit, mocked deps) #handleModelUpdate() BlockRemovedEvent handling should throw when blockIndex is undefined","invocations":1,"location":{"column":7,"line":206},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when blockIndex is undefined"}],"endTime":1777328803872,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockRenderer.spec.ts","startTime":1777328803007,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insert()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insert() should add a block to an empty document and model.length becomes 1","invocations":1,"location":{"column":5,"line":93},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should add a block to an empty document and model.length becomes 1"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insert() should insert a block at the specified index","invocations":1,"location":{"column":5,"line":102},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should insert a block at the specified index"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insert() should use the default block type when type is omitted","invocations":1,"location":{"column":5,"line":114},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should use the default block type when type is omitted"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insert() should replace a block at the given index when replace flag is set","invocations":1,"location":{"column":5,"line":123},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should replace a block at the given index when replace flag is set"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insertMany() should insert multiple blocks at the specified index","invocations":1,"location":{"column":5,"line":137},"numPassingAsserts":4,"retryReasons":[],"status":"passed","title":"should insert multiple blocks at the specified index"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","insertMany()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) insertMany() should append blocks at the end when index is omitted","invocations":1,"location":{"column":5,"line":160},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should append blocks at the end when index is omitted"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","delete()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) delete() should remove a block at the given index","invocations":1,"location":{"column":5,"line":181},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should remove a block at the given index"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","delete()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) delete() should remove the first block when index is 0","invocations":1,"location":{"column":5,"line":193},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should remove the first block when index is 0"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","delete()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) delete() should throw when no index is provided and no caret is set","invocations":1,"location":{"column":5,"line":203},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when no index is provided and no caret is set"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","move()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) move() should move a block from fromIndex to toIndex (forward)","invocations":1,"location":{"column":5,"line":211},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should move a block from fromIndex to toIndex (forward)"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) move() should move a block from fromIndex to toIndex (backward)","invocations":1,"location":{"column":5,"line":222},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should move a block from fromIndex to toIndex (backward)"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) move() should not change anything when fromIndex equals toIndex","invocations":1,"location":{"column":5,"line":232},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should not change anything when fromIndex equals toIndex"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) move() should throw when no fromIndex is provided and no caret is set","invocations":1,"location":{"column":5,"line":241},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when no fromIndex is provided and no caret is set"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","getBlocksCount()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) getBlocksCount() should return 0 for an empty document","invocations":1,"location":{"column":5,"line":249},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should return 0 for an empty document"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","getBlocksCount()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) getBlocksCount() should return the correct count after insertions and deletions","invocations":1,"location":{"column":5,"line":253},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should return the correct count after insertions and deletions"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","render()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) render() should replace document content with the provided serialized data","invocations":1,"location":{"column":5,"line":267},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should replace document content with the provided serialized data"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","render()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) render() should result in an empty document when empty blocks array is passed","invocations":1,"location":{"column":5,"line":284},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should result in an empty document when empty blocks array is passed"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","clear()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) clear() should remove all blocks from the document","invocations":1,"location":{"column":5,"line":298},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should remove all blocks from the document"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","clear()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) clear() should be safe to call on an already empty document","invocations":1,"location":{"column":5,"line":309},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should be safe to call on an already empty document"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","model events"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) model events should emit BlockAddedEvent on model when insert is called","invocations":1,"location":{"column":5,"line":317},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should emit BlockAddedEvent on model when insert is called"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","model events"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) model events should emit BlockRemovedEvent on model when delete is called","invocations":1,"location":{"column":5,"line":329},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should emit BlockRemovedEvent on model when delete is called"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","combined operations"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) combined operations should handle a sequence of insert, move, delete, and clear","invocations":1,"location":{"column":5,"line":347},"numPassingAsserts":5,"retryReasons":[],"status":"passed","title":"should handle a sequence of insert, move, delete, and clear"},{"ancestorTitles":["BlocksAPI integration (real model, mocked DOM adapters)","combined operations"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI integration (real model, mocked DOM adapters) combined operations should support render after clear and then further mutations","invocations":1,"location":{"column":5,"line":371},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should support render after clear and then further mutations"}],"endTime":1777328804602,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/BlocksAPI.integration.spec.ts","startTime":1777328803875,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should ignore caret events of other users","invocations":1,"location":{"column":5,"line":91},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should ignore caret events of other users"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should dispatch empty selection info when index is null","invocations":1,"location":{"column":5,"line":102},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should dispatch empty selection info when index is null"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should dispatch selection with empty fragments when parsed index is incomplete","invocations":1,"location":{"column":5,"line":119},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should dispatch selection with empty fragments when parsed index is incomplete"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should dispatch selection with fragments when parsed index has text range","invocations":1,"location":{"column":5,"line":140},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should dispatch selection with fragments when parsed index has text range"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should ignore unknown caret manager event types","invocations":1,"location":{"column":5,"line":172},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should ignore unknown caret manager event types"},{"ancestorTitles":["SelectionManager","Caret Events handling"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager Caret Events handling should include inline tools from toolsManager in availableInlineTools","invocations":1,"location":{"column":5,"line":183},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should include inline tools from toolsManager in availableInlineTools"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when caret is not set","invocations":1,"location":{"column":5,"line":215},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when caret is not set"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when caret index is null","invocations":1,"location":{"column":5,"line":223},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when caret index is null"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when caret has no text segments","invocations":1,"location":{"column":5,"line":231},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when caret has no text segments"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when tool is not found","invocations":1,"location":{"column":5,"line":241},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when tool is not found"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should call model.format when tool getFormattingOptions returns Format action","invocations":1,"location":{"column":5,"line":256},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.format when tool getFormattingOptions returns Format action"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should call model.unformat when tool getFormattingOptions returns Unformat action","invocations":1,"location":{"column":5,"line":280},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.unformat when tool getFormattingOptions returns Unformat action"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when segment has no textRange","invocations":1,"location":{"column":5,"line":304},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when segment has no textRange"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when segment has no blockIndex","invocations":1,"location":{"column":5,"line":322},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when segment has no blockIndex"},{"ancestorTitles":["SelectionManager",".applyInlineToolForCurrentSelection()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"SelectionManager .applyInlineToolForCurrentSelection() should throw when segment has no dataKey","invocations":1,"location":{"column":5,"line":340},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when segment has no dataKey"}],"endTime":1777328804762,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/SelectionManager.spec.ts","startTime":1777328804610,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["BlocksManager (unit, mocked deps)",".blocksCount"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .blocksCount should proxy model.length","invocations":1,"location":{"column":5,"line":73},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should proxy model.length"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should call model.addBlock with default tool name and computed index","invocations":1,"location":{"column":5,"line":79},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should call model.addBlock with default tool name and computed index"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should use explicit index when provided","invocations":1,"location":{"column":5,"line":93},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should use explicit index when provided"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should call removeBlock then addBlock when replace is true and index is provided","invocations":1,"location":{"column":5,"line":108},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should call removeBlock then addBlock when replace is true and index is provided"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should call model.addBlock when focus is true","invocations":1,"location":{"column":5,"line":125},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should call model.addBlock when focus is true"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insert() should use model.length as insertion/removal index when replace is true and index is omitted","invocations":1,"location":{"column":5,"line":136},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should use model.length as insertion/removal index when replace is true and index is omitted"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insertMany() should call model.addBlock for each block with increasing indexes","invocations":1,"location":{"column":5,"line":153},"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"should call model.addBlock for each block with increasing indexes"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .insertMany() should use model.length as start index when index is omitted","invocations":1,"location":{"column":5,"line":186},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should use model.length as start index when index is omitted"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".render()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .render() should call model.initializeDocument with provided document","invocations":1,"location":{"column":5,"line":220},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.initializeDocument with provided document"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".clear()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .clear() should call model.clearBlocks","invocations":1,"location":{"column":5,"line":239},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.clearBlocks"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".deleteBlock()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .deleteBlock() should throw when no caret and no index is provided","invocations":1,"location":{"column":5,"line":247},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when no caret and no index is provided"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".deleteBlock()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .deleteBlock() should call model.removeBlock with provided index","invocations":1,"location":{"column":5,"line":253},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call model.removeBlock with provided index"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".move()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .move() should call removeBlock and addBlock when moving current block forward","invocations":1,"location":{"column":5,"line":261},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should call removeBlock and addBlock when moving current block forward"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .move() should throw when there is no current block and no index provided","invocations":1,"location":{"column":5,"line":279},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should throw when there is no current block and no index provided"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .move() should pass toIndex directly when toIndex is less than fromIndex","invocations":1,"location":{"column":5,"line":285},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should pass toIndex directly when toIndex is less than fromIndex"},{"ancestorTitles":["BlocksManager (unit, mocked deps)",".move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksManager (unit, mocked deps) .move() should do nothing when toIndex equals fromIndex","invocations":1,"location":{"column":5,"line":301},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should do nothing when toIndex equals fromIndex"}],"endTime":1777328804935,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockManager.spec.ts","startTime":1777328804769,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["DocumentAPI",".data"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"DocumentAPI .data should return serialized model","invocations":1,"location":{"column":5,"line":28},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should return serialized model"}],"endTime":1777328805045,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts","startTime":1777328804937,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["BlocksAPI",".clear()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .clear() should call blocksManager.clear","invocations":1,"location":{"column":5,"line":28},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call blocksManager.clear"},{"ancestorTitles":["BlocksAPI",".render()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .render() should call blocksManager.render with provided document","invocations":1,"location":{"column":5,"line":38},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call blocksManager.render with provided document"},{"ancestorTitles":["BlocksAPI",".delete()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .delete() should pass explicit index to blocksManager.deleteBlock","invocations":1,"location":{"column":5,"line":51},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass explicit index to blocksManager.deleteBlock"},{"ancestorTitles":["BlocksAPI",".delete()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .delete() should pass undefined when index is omitted","invocations":1,"location":{"column":5,"line":59},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass undefined when index is omitted"},{"ancestorTitles":["BlocksAPI",".move()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .move() should call blocksManager.move with toIndex and fromIndex","invocations":1,"location":{"column":5,"line":69},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should call blocksManager.move with toIndex and fromIndex"},{"ancestorTitles":["BlocksAPI",".getBlocksCount()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .getBlocksCount() should return blocksManager.blocksCount","invocations":1,"location":{"column":5,"line":79},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should return blocksManager.blocksCount"},{"ancestorTitles":["BlocksAPI",".insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .insertMany() should pass blocks and index to blocksManager.insertMany","invocations":1,"location":{"column":5,"line":90},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass blocks and index to blocksManager.insertMany"},{"ancestorTitles":["BlocksAPI",".insertMany()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .insertMany() should pass undefined index to blocksManager.insertMany when omitted","invocations":1,"location":{"column":5,"line":101},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass undefined index to blocksManager.insertMany when omitted"},{"ancestorTitles":["BlocksAPI",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .insert() should use defaults and pass payload to blocksManager.insert","invocations":1,"location":{"column":5,"line":114},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should use defaults and pass payload to blocksManager.insert"},{"ancestorTitles":["BlocksAPI",".insert()"],"duration":0,"failureDetails":[],"failureMessages":[],"fullName":"BlocksAPI .insert() should pass provided params to blocksManager.insert and ignore compatibility args","invocations":1,"location":{"column":5,"line":127},"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"should pass provided params to blocksManager.insert and ignore compatibility args"}],"endTime":1777328805155,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/BlocksAPI.spec.ts","startTime":1777328805047,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["SelectionAPI",".applyInlineToolForCurrentSelection()"],"duration":1,"failureDetails":[],"failureMessages":[],"fullName":"SelectionAPI .applyInlineToolForCurrentSelection() should convert toolName and delegate to SelectionManager","invocations":1,"location":{"column":5,"line":24},"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"should convert toolName and delegate to SelectionManager"}],"endTime":1777328805262,"message":"","name":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/SelectionAPI.spec.ts","startTime":1777328805157,"status":"passed","summary":""}],"wasInterrupted":false,"coverageMap":{"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/tokens.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/tokens.ts","statementMap":{"0":{"start":{"line":6,"column":22},"end":{"line":15,"column":11}}},"fnMap":{},"branchMap":{},"s":{"0":5},"f":{},"b":{}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockRenderer.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockRenderer.ts","statementMap":{"0":{"start":{"line":34,"column":7},"end":{"line":158,"column":null}},"1":{"start":{"line":69,"column":4},"end":{"line":69,"column":24}},"2":{"start":{"line":70,"column":4},"end":{"line":70,"column":30}},"3":{"start":{"line":71,"column":4},"end":{"line":71,"column":38}},"4":{"start":{"line":72,"column":4},"end":{"line":72,"column":28}},"5":{"start":{"line":75,"column":4},"end":{"line":75,"column":93}},"6":{"start":{"line":75,"column":61},"end":{"line":75,"column":91}},"7":{"start":{"line":84,"column":4},"end":{"line":91,"column":5}},"8":{"start":{"line":86,"column":8},"end":{"line":86,"column":50}},"9":{"start":{"line":88,"column":8},"end":{"line":88,"column":45}},"10":{"start":{"line":89,"column":8},"end":{"line":89,"column":14}},"11":{"start":{"line":102,"column":28},"end":{"line":102,"column":40}},"12":{"start":{"line":104,"column":4},"end":{"line":106,"column":5}},"13":{"start":{"line":105,"column":6},"end":{"line":105,"column":146}},"14":{"start":{"line":108,"column":17},"end":{"line":108,"column":61}},"15":{"start":{"line":110,"column":4},"end":{"line":112,"column":5}},"16":{"start":{"line":111,"column":6},"end":{"line":111,"column":75}},"17":{"start":{"line":114,"column":29},"end":{"line":114,"column":94}},"18":{"start":{"line":116,"column":18},"end":{"line":121,"column":6}},"19":{"start":{"line":123,"column":4},"end":{"line":134,"column":5}},"20":{"start":{"line":124,"column":27},"end":{"line":124,"column":47}},"21":{"start":{"line":126,"column":6},"end":{"line":131,"column":10}},"22":{"start":{"line":133,"column":6},"end":{"line":133,"column":87}},"23":{"start":{"line":143,"column":28},"end":{"line":143,"column":40}},"24":{"start":{"line":145,"column":4},"end":{"line":147,"column":5}},"25":{"start":{"line":146,"column":6},"end":{"line":146,"column":146}},"26":{"start":{"line":149,"column":4},"end":{"line":152,"column":8}},"27":{"start":{"line":34,"column":13},"end":{"line":158,"column":null}}},"fnMap":{"0":{"name":"(anonymous_4)","decl":{"start":{"line":63,"column":2},"end":{"line":63,"column":null}},"loc":{"start":{"line":67,"column":58},"end":{"line":76,"column":3}}},"1":{"name":"(anonymous_5)","decl":{"start":{"line":75,"column":52},"end":{"line":75,"column":57}},"loc":{"start":{"line":75,"column":61},"end":{"line":75,"column":91}}}},"branchMap":{"0":{"loc":{"start":{"line":84,"column":4},"end":{"line":91,"column":5}},"type":"switch","locations":[{"start":{"line":85,"column":6},"end":{"line":86,"column":50}},{"start":{"line":87,"column":6},"end":{"line":89,"column":14}},{"start":{"line":90,"column":6},"end":{"line":90,"column":14}}]},"1":{"loc":{"start":{"line":104,"column":4},"end":{"line":106,"column":5}},"type":"if","locations":[{"start":{"line":104,"column":4},"end":{"line":106,"column":5}}]},"2":{"loc":{"start":{"line":110,"column":4},"end":{"line":112,"column":5}},"type":"if","locations":[{"start":{"line":110,"column":4},"end":{"line":112,"column":5}}]},"3":{"loc":{"start":{"line":145,"column":4},"end":{"line":147,"column":5}},"type":"if","locations":[{"start":{"line":145,"column":4},"end":{"line":147,"column":5}}]}},"s":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":7,"7":7,"8":4,"9":2,"10":1,"11":4,"12":4,"13":1,"14":3,"15":3,"16":1,"17":2,"18":2,"19":2,"20":2,"21":1,"22":1,"23":2,"24":2,"25":1,"26":1,"27":1},"f":{"0":1,"1":7},"b":{"0":[4,2,1],"1":[1],"2":[1],"3":[1]}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockManager.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/BlockManager.ts","statementMap":{"0":{"start":{"line":56,"column":7},"end":{"line":215,"column":null}},"1":{"start":{"line":81,"column":4},"end":{"line":81,"column":30}},"2":{"start":{"line":98,"column":4},"end":{"line":98,"column":24}},"3":{"start":{"line":99,"column":4},"end":{"line":99,"column":30}},"4":{"start":{"line":100,"column":4},"end":{"line":100,"column":38}},"5":{"start":{"line":101,"column":4},"end":{"line":101,"column":26}},"6":{"start":{"line":122,"column":19},"end":{"line":122,"column":24}},"7":{"start":{"line":124,"column":4},"end":{"line":126,"column":5}},"8":{"start":{"line":125,"column":6},"end":{"line":125,"column":57}},"9":{"start":{"line":128,"column":4},"end":{"line":130,"column":5}},"10":{"start":{"line":129,"column":6},"end":{"line":129,"column":61}},"11":{"start":{"line":132,"column":4},"end":{"line":135,"column":17}},"12":{"start":{"line":137,"column":4},"end":{"line":141,"column":5}},"13":{"start":{"line":150,"column":4},"end":{"line":150,"column":94}},"14":{"start":{"line":150,"column":33},"end":{"line":150,"column":92}},"15":{"start":{"line":158,"column":4},"end":{"line":158,"column":45}},"16":{"start":{"line":165,"column":4},"end":{"line":165,"column":30}},"17":{"start":{"line":173,"column":4},"end":{"line":178,"column":5}},"18":{"start":{"line":177,"column":6},"end":{"line":177,"column":53}},"19":{"start":{"line":180,"column":4},"end":{"line":180,"column":56}},"20":{"start":{"line":189,"column":4},"end":{"line":191,"column":5}},"21":{"start":{"line":190,"column":6},"end":{"line":190,"column":51}},"22":{"start":{"line":196,"column":4},"end":{"line":198,"column":5}},"23":{"start":{"line":197,"column":6},"end":{"line":197,"column":13}},"24":{"start":{"line":200,"column":18},"end":{"line":200,"column":58}},"25":{"start":{"line":202,"column":4},"end":{"line":202,"column":60}},"26":{"start":{"line":203,"column":4},"end":{"line":203,"column":62}},"27":{"start":{"line":210,"column":22},"end":{"line":210,"column":63}},"28":{"start":{"line":211,"column":23},"end":{"line":211,"column":39}},"29":{"start":{"line":213,"column":4},"end":{"line":213,"column":34}},"30":{"start":{"line":56,"column":13},"end":{"line":215,"column":null}}},"fnMap":{"0":{"name":"(anonymous_4)","decl":{"start":{"line":80,"column":2},"end":{"line":80,"column":13}},"loc":{"start":{"line":80,"column":24},"end":{"line":82,"column":3}}},"1":{"name":"(anonymous_5)","decl":{"start":{"line":92,"column":2},"end":{"line":92,"column":null}},"loc":{"start":{"line":96,"column":60},"end":{"line":102,"column":3}}},"2":{"name":"(anonymous_6)","decl":{"start":{"line":113,"column":9},"end":{"line":113,"column":15}},"loc":{"start":{"line":121,"column":31},"end":{"line":142,"column":3}}},"3":{"name":"(anonymous_7)","decl":{"start":{"line":149,"column":9},"end":{"line":149,"column":19}},"loc":{"start":{"line":149,"column":85},"end":{"line":151,"column":3}}},"4":{"name":"(anonymous_8)","decl":{"start":{"line":150,"column":19},"end":{"line":150,"column":20}},"loc":{"start":{"line":150,"column":33},"end":{"line":150,"column":92}}},"5":{"name":"(anonymous_9)","decl":{"start":{"line":157,"column":9},"end":{"line":157,"column":15}},"loc":{"start":{"line":157,"column":50},"end":{"line":159,"column":3}}},"6":{"name":"(anonymous_10)","decl":{"start":{"line":164,"column":9},"end":{"line":164,"column":14}},"loc":{"start":{"line":164,"column":14},"end":{"line":166,"column":3}}},"7":{"name":"(anonymous_11)","decl":{"start":{"line":172,"column":9},"end":{"line":172,"column":20}},"loc":{"start":{"line":172,"column":77},"end":{"line":181,"column":3}}},"8":{"name":"(anonymous_12)","decl":{"start":{"line":188,"column":9},"end":{"line":188,"column":13}},"loc":{"start":{"line":188,"column":91},"end":{"line":204,"column":3}}}},"branchMap":{"0":{"loc":{"start":{"line":113,"column":16},"end":{"line":121,"column":31}},"type":"default-arg","locations":[{"start":{"line":121,"column":29},"end":{"line":121,"column":31}}]},"1":{"loc":{"start":{"line":115,"column":4},"end":{"line":115,"column":36}},"type":"default-arg","locations":[{"start":{"line":115,"column":11},"end":{"line":115,"column":36}}]},"2":{"loc":{"start":{"line":116,"column":4},"end":{"line":116,"column":13}},"type":"default-arg","locations":[{"start":{"line":116,"column":11},"end":{"line":116,"column":13}}]},"3":{"loc":{"start":{"line":118,"column":4},"end":{"line":118,"column":17}},"type":"default-arg","locations":[{"start":{"line":118,"column":12},"end":{"line":118,"column":17}}]},"4":{"loc":{"start":{"line":119,"column":4},"end":{"line":119,"column":19}},"type":"default-arg","locations":[{"start":{"line":119,"column":14},"end":{"line":119,"column":19}}]},"5":{"loc":{"start":{"line":124,"column":4},"end":{"line":126,"column":5}},"type":"if","locations":[{"start":{"line":124,"column":4},"end":{"line":126,"column":5}}]},"6":{"loc":{"start":{"line":125,"column":39},"end":{"line":125,"column":55}},"type":"cond-expr","locations":[{"start":{"line":125,"column":49},"end":{"line":125,"column":51}},{"start":{"line":125,"column":54},"end":{"line":125,"column":55}}]},"7":{"loc":{"start":{"line":128,"column":4},"end":{"line":130,"column":5}},"type":"if","locations":[{"start":{"line":128,"column":4},"end":{"line":130,"column":5}}]},"8":{"loc":{"start":{"line":137,"column":4},"end":{"line":141,"column":5}},"type":"if","locations":[{"start":{"line":137,"column":4},"end":{"line":141,"column":5}}]},"9":{"loc":{"start":{"line":149,"column":51},"end":{"line":149,"column":85}},"type":"default-arg","locations":[{"start":{"line":149,"column":67},"end":{"line":149,"column":85}}]},"10":{"loc":{"start":{"line":172,"column":21},"end":{"line":172,"column":77}},"type":"default-arg","locations":[{"start":{"line":172,"column":49},"end":{"line":172,"column":77}}]},"11":{"loc":{"start":{"line":173,"column":4},"end":{"line":178,"column":5}},"type":"if","locations":[{"start":{"line":173,"column":4},"end":{"line":178,"column":5}}]},"12":{"loc":{"start":{"line":188,"column":31},"end":{"line":188,"column":91}},"type":"default-arg","locations":[{"start":{"line":188,"column":63},"end":{"line":188,"column":91}}]},"13":{"loc":{"start":{"line":189,"column":4},"end":{"line":191,"column":5}},"type":"if","locations":[{"start":{"line":189,"column":4},"end":{"line":191,"column":5}}]},"14":{"loc":{"start":{"line":196,"column":4},"end":{"line":198,"column":5}},"type":"if","locations":[{"start":{"line":196,"column":4},"end":{"line":198,"column":5}}]}},"s":{"0":2,"1":4,"2":24,"3":24,"4":24,"5":24,"6":46,"7":46,"8":29,"9":46,"10":3,"11":46,"12":46,"13":4,"14":8,"15":4,"16":5,"17":9,"18":2,"19":7,"20":9,"21":2,"22":7,"23":2,"24":5,"25":5,"26":5,"27":5,"28":5,"29":5,"30":2},"f":{"0":4,"1":24,"2":46,"3":4,"4":8,"5":4,"6":5,"7":9,"8":9},"b":{"0":[1],"1":[3],"2":[5],"3":[45],"4":[43],"5":[29],"6":[1,28],"7":[3],"8":[1],"9":[2],"10":[2],"11":[2],"12":[3],"13":[2],"14":[2]}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/BlocksAPI.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/BlocksAPI.ts","statementMap":{"0":{"start":{"line":15,"column":7},"end":{"line":116,"column":null}},"1":{"start":{"line":35,"column":4},"end":{"line":35,"column":40}},"2":{"start":{"line":36,"column":4},"end":{"line":36,"column":26}},"3":{"start":{"line":43,"column":4},"end":{"line":43,"column":39}},"4":{"start":{"line":51,"column":4},"end":{"line":51,"column":48}},"5":{"start":{"line":59,"column":4},"end":{"line":59,"column":50}},"6":{"start":{"line":68,"column":4},"end":{"line":68,"column":56}},"7":{"start":{"line":75,"column":4},"end":{"line":75,"column":43}},"8":{"start":{"line":84,"column":4},"end":{"line":84,"column":57}},"9":{"start":{"line":105,"column":22},"end":{"line":105,"column":55}},"10":{"start":{"line":106,"column":22},"end":{"line":106,"column":32}},"11":{"start":{"line":108,"column":4},"end":{"line":114,"column":7}},"12":{"start":{"line":15,"column":13},"end":{"line":116,"column":null}}},"fnMap":{"0":{"name":"(anonymous_4)","decl":{"start":{"line":31,"column":2},"end":{"line":31,"column":null}},"loc":{"start":{"line":33,"column":60},"end":{"line":37,"column":3}}},"1":{"name":"(anonymous_5)","decl":{"start":{"line":42,"column":9},"end":{"line":42,"column":14}},"loc":{"start":{"line":42,"column":14},"end":{"line":44,"column":3}}},"2":{"name":"(anonymous_6)","decl":{"start":{"line":50,"column":9},"end":{"line":50,"column":15}},"loc":{"start":{"line":50,"column":50},"end":{"line":52,"column":3}}},"3":{"name":"(anonymous_7)","decl":{"start":{"line":58,"column":9},"end":{"line":58,"column":15}},"loc":{"start":{"line":58,"column":30},"end":{"line":60,"column":3}}},"4":{"name":"(anonymous_8)","decl":{"start":{"line":67,"column":9},"end":{"line":67,"column":13}},"loc":{"start":{"line":67,"column":49},"end":{"line":69,"column":3}}},"5":{"name":"(anonymous_9)","decl":{"start":{"line":74,"column":9},"end":{"line":74,"column":23}},"loc":{"start":{"line":74,"column":23},"end":{"line":76,"column":3}}},"6":{"name":"(anonymous_10)","decl":{"start":{"line":83,"column":9},"end":{"line":83,"column":19}},"loc":{"start":{"line":83,"column":65},"end":{"line":85,"column":3}}},"7":{"name":"(anonymous_11)","decl":{"start":{"line":96,"column":9},"end":{"line":96,"column":15}},"loc":{"start":{"line":103,"column":15},"end":{"line":115,"column":3}}}},"branchMap":{"0":{"loc":{"start":{"line":105,"column":22},"end":{"line":105,"column":55}},"type":"binary-expr","locations":[{"start":{"line":105,"column":22},"end":{"line":105,"column":26}},{"start":{"line":105,"column":30},"end":{"line":105,"column":55}}]},"1":{"loc":{"start":{"line":106,"column":22},"end":{"line":106,"column":32}},"type":"binary-expr","locations":[{"start":{"line":106,"column":22},"end":{"line":106,"column":26}},{"start":{"line":106,"column":30},"end":{"line":106,"column":32}}]}},"s":{"0":2,"1":33,"2":33,"3":5,"4":4,"5":9,"6":6,"7":4,"8":4,"9":43,"10":43,"11":43,"12":2},"f":{"0":33,"1":5,"2":4,"3":9,"4":6,"5":4,"6":4,"7":43},"b":{"0":[43,2],"1":[43,26]}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/SelectionManager.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/components/SelectionManager.ts","statementMap":{"0":{"start":{"line":24,"column":7},"end":{"line":177,"column":null}},"1":{"start":{"line":55,"column":4},"end":{"line":55,"column":26}},"2":{"start":{"line":56,"column":4},"end":{"line":56,"column":24}},"3":{"start":{"line":57,"column":4},"end":{"line":57,"column":30}},"4":{"start":{"line":58,"column":4},"end":{"line":58,"column":38}},"5":{"start":{"line":60,"column":4},"end":{"line":60,"column":134}},"6":{"start":{"line":60,"column":95},"end":{"line":60,"column":132}},"7":{"start":{"line":68,"column":4},"end":{"line":70,"column":5}},"8":{"start":{"line":69,"column":6},"end":{"line":69,"column":13}},"9":{"start":{"line":72,"column":4},"end":{"line":105,"column":5}},"10":{"start":{"line":74,"column":43},"end":{"line":74,"column":55}},"11":{"start":{"line":76,"column":22},"end":{"line":76,"column":84}},"12":{"start":{"line":77,"column":42},"end":{"line":77,"column":44}},"13":{"start":{"line":79,"column":8},"end":{"line":87,"column":9}},"14":{"start":{"line":80,"column":10},"end":{"line":86,"column":11}},"15":{"start":{"line":81,"column":12},"end":{"line":85,"column":13}},"16":{"start":{"line":82,"column":14},"end":{"line":84,"column":16}},"17":{"start":{"line":89,"column":8},"end":{"line":101,"column":12}},"18":{"start":{"line":98,"column":39},"end":{"line":98,"column":84}},"19":{"start":{"line":103,"column":8},"end":{"line":103,"column":14}},"20":{"start":{"line":117,"column":22},"end":{"line":117,"column":63}},"21":{"start":{"line":119,"column":18},"end":{"line":119,"column":42}},"22":{"start":{"line":121,"column":4},"end":{"line":123,"column":5}},"23":{"start":{"line":122,"column":6},"end":{"line":122,"column":120}},"24":{"start":{"line":129,"column":21},"end":{"line":129,"column":44}},"25":{"start":{"line":131,"column":4},"end":{"line":133,"column":5}},"26":{"start":{"line":132,"column":6},"end":{"line":132,"column":120}},"27":{"start":{"line":135,"column":17},"end":{"line":135,"column":71}},"28":{"start":{"line":140,"column":4},"end":{"line":142,"column":5}},"29":{"start":{"line":141,"column":6},"end":{"line":141,"column":112}},"30":{"start":{"line":144,"column":4},"end":{"line":175,"column":5}},"31":{"start":{"line":145,"column":24},"end":{"line":145,"column":41}},"32":{"start":{"line":146,"column":25},"end":{"line":146,"column":43}},"33":{"start":{"line":147,"column":22},"end":{"line":147,"column":37}},"34":{"start":{"line":149,"column":6},"end":{"line":151,"column":7}},"35":{"start":{"line":150,"column":8},"end":{"line":150,"column":148}},"36":{"start":{"line":153,"column":6},"end":{"line":155,"column":7}},"37":{"start":{"line":154,"column":8},"end":{"line":154,"column":136}},"38":{"start":{"line":157,"column":6},"end":{"line":159,"column":7}},"39":{"start":{"line":158,"column":8},"end":{"line":158,"column":146}},"40":{"start":{"line":161,"column":24},"end":{"line":161,"column":93}},"41":{"start":{"line":163,"column":32},"end":{"line":163,"column":79}},"42":{"start":{"line":165,"column":6},"end":{"line":174,"column":7}},"43":{"start":{"line":167,"column":10},"end":{"line":167,"column":119}},"44":{"start":{"line":169,"column":10},"end":{"line":169,"column":16}},"45":{"start":{"line":171,"column":10},"end":{"line":171,"column":93}},"46":{"start":{"line":173,"column":10},"end":{"line":173,"column":16}},"47":{"start":{"line":24,"column":13},"end":{"line":177,"column":null}}},"fnMap":{"0":{"name":"(anonymous_4)","decl":{"start":{"line":49,"column":2},"end":{"line":49,"column":null}},"loc":{"start":{"line":53,"column":30},"end":{"line":61,"column":3}}},"1":{"name":"(anonymous_5)","decl":{"start":{"line":60,"column":64},"end":{"line":60,"column":65}},"loc":{"start":{"line":60,"column":95},"end":{"line":60,"column":132}}},"2":{"name":"(anonymous_6)","decl":{"start":{"line":98,"column":19},"end":{"line":98,"column":20}},"loc":{"start":{"line":98,"column":39},"end":{"line":98,"column":84}}},"3":{"name":"(anonymous_7)","decl":{"start":{"line":113,"column":9},"end":{"line":113,"column":43}},"loc":{"start":{"line":113,"column":101},"end":{"line":176,"column":3}}}},"branchMap":{"0":{"loc":{"start":{"line":68,"column":4},"end":{"line":70,"column":5}},"type":"if","locations":[{"start":{"line":68,"column":4},"end":{"line":70,"column":5}}]},"1":{"loc":{"start":{"line":72,"column":4},"end":{"line":105,"column":5}},"type":"switch","locations":[{"start":{"line":73,"column":6},"end":{"line":104,"column":7}}]},"2":{"loc":{"start":{"line":76,"column":22},"end":{"line":76,"column":84}},"type":"cond-expr","locations":[{"start":{"line":76,"column":49},"end":{"line":76,"column":77}},{"start":{"line":76,"column":80},"end":{"line":76,"column":84}}]},"3":{"loc":{"start":{"line":79,"column":8},"end":{"line":87,"column":9}},"type":"if","locations":[{"start":{"line":79,"column":8},"end":{"line":87,"column":9}}]},"4":{"loc":{"start":{"line":81,"column":12},"end":{"line":85,"column":13}},"type":"if","locations":[{"start":{"line":81,"column":12},"end":{"line":85,"column":13}}]},"5":{"loc":{"start":{"line":81,"column":16},"end":{"line":81,"column":116}},"type":"binary-expr","locations":[{"start":{"line":81,"column":16},"end":{"line":81,"column":48}},{"start":{"line":81,"column":52},"end":{"line":81,"column":81}},{"start":{"line":81,"column":85},"end":{"line":81,"column":116}}]},"6":{"loc":{"start":{"line":113,"column":70},"end":{"line":113,"column":101}},"type":"default-arg","locations":[{"start":{"line":113,"column":99},"end":{"line":113,"column":101}}]},"7":{"loc":{"start":{"line":119,"column":18},"end":{"line":119,"column":42}},"type":"binary-expr","locations":[{"start":{"line":119,"column":18},"end":{"line":119,"column":34}},{"start":{"line":119,"column":38},"end":{"line":119,"column":42}}]},"8":{"loc":{"start":{"line":121,"column":4},"end":{"line":123,"column":5}},"type":"if","locations":[{"start":{"line":121,"column":4},"end":{"line":123,"column":5}}]},"9":{"loc":{"start":{"line":131,"column":4},"end":{"line":133,"column":5}},"type":"if","locations":[{"start":{"line":131,"column":4},"end":{"line":133,"column":5}}]},"10":{"loc":{"start":{"line":140,"column":4},"end":{"line":142,"column":5}},"type":"if","locations":[{"start":{"line":140,"column":4},"end":{"line":142,"column":5}}]},"11":{"loc":{"start":{"line":149,"column":6},"end":{"line":151,"column":7}},"type":"if","locations":[{"start":{"line":149,"column":6},"end":{"line":151,"column":7}}]},"12":{"loc":{"start":{"line":153,"column":6},"end":{"line":155,"column":7}},"type":"if","locations":[{"start":{"line":153,"column":6},"end":{"line":155,"column":7}}]},"13":{"loc":{"start":{"line":157,"column":6},"end":{"line":159,"column":7}},"type":"if","locations":[{"start":{"line":157,"column":6},"end":{"line":159,"column":7}}]},"14":{"loc":{"start":{"line":165,"column":6},"end":{"line":174,"column":7}},"type":"switch","locations":[{"start":{"line":166,"column":8},"end":{"line":169,"column":16}},{"start":{"line":170,"column":8},"end":{"line":173,"column":16}}]}},"s":{"0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":6,"7":6,"8":1,"9":5,"10":4,"11":4,"12":4,"13":4,"14":3,"15":2,"16":2,"17":4,"18":1,"19":4,"20":9,"21":9,"22":9,"23":2,"24":7,"25":7,"26":1,"27":6,"28":6,"29":1,"30":5,"31":5,"32":5,"33":5,"34":5,"35":1,"36":4,"37":1,"38":3,"39":1,"40":2,"41":2,"42":2,"43":1,"44":1,"45":1,"46":1,"47":1},"f":{"0":1,"1":6,"2":1,"3":9},"b":{"0":[1],"1":[4],"2":[3,1],"3":[3],"4":[2],"5":[2,2,2],"6":[9],"7":[9,2],"8":[2],"9":[1],"10":[1],"11":[1],"12":[1],"13":[1],"14":[1,1]}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/DocumentAPI/DocumentAPI.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/DocumentAPI/DocumentAPI.ts","statementMap":{"0":{"start":{"line":12,"column":7},"end":{"line":33,"column":null}},"1":{"start":{"line":24,"column":4},"end":{"line":24,"column":24}},"2":{"start":{"line":31,"column":4},"end":{"line":31,"column":34}},"3":{"start":{"line":12,"column":13},"end":{"line":33,"column":null}}},"fnMap":{"0":{"name":"(anonymous_2)","decl":{"start":{"line":23,"column":2},"end":{"line":23,"column":14}},"loc":{"start":{"line":23,"column":34},"end":{"line":25,"column":3}}},"1":{"name":"(anonymous_3)","decl":{"start":{"line":30,"column":2},"end":{"line":30,"column":13}},"loc":{"start":{"line":30,"column":17},"end":{"line":32,"column":3}}}},"branchMap":{},"s":{"0":1,"1":1,"2":1,"3":1},"f":{"0":1,"1":1},"b":{}},"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/SelectionAPI.ts":{"path":"/Users/gohabereg/Projects/CodeX/document-model/packages/core/src/api/SelectionAPI.ts","statementMap":{"0":{"start":{"line":14,"column":7},"end":{"line":35,"column":null}},"1":{"start":{"line":24,"column":4},"end":{"line":24,"column":46}},"2":{"start":{"line":33,"column":4},"end":{"line":33,"column":100}},"3":{"start":{"line":14,"column":13},"end":{"line":35,"column":null}}},"fnMap":{"0":{"name":"(anonymous_2)","decl":{"start":{"line":21,"column":2},"end":{"line":21,"column":null}},"loc":{"start":{"line":22,"column":38},"end":{"line":25,"column":3}}},"1":{"name":"(anonymous_3)","decl":{"start":{"line":32,"column":9},"end":{"line":32,"column":43}},"loc":{"start":{"line":32,"column":89},"end":{"line":34,"column":3}}}},"branchMap":{},"s":{"0":1,"1":1,"2":1,"3":1},"f":{"0":1,"1":1},"b":{}}}} diff --git a/packages/core/src/components/BlockRenderer.ts b/packages/core/src/components/BlockRenderer.ts index ca567e4e..47872c44 100644 --- a/packages/core/src/components/BlockRenderer.ts +++ b/packages/core/src/components/BlockRenderer.ts @@ -85,8 +85,7 @@ export class BlockRenderer { case event instanceof BlockAddedEvent: return this.#handleBlockAddedEvent(event); case event instanceof BlockRemovedEvent: - this.#handleBlockRemovedEvent(event); - break; + return this.#handleBlockRemovedEvent(event); default: } } diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 91402e4a..91a76bac 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -38,6 +38,9 @@ export class SelectionManager { */ #config: CoreConfigValidated; + /** + * Editor's Tools manager instance + */ #toolsManager: ToolsManager; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e24f7722..29974bf9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,6 +55,9 @@ export default class Core { */ #iocContainer: Container; + /** + * Inversion of Control container for loaded plugins + */ #plugins: Container; /** 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 d51fbfee..b4feaee3 100644 --- a/packages/core/src/tools/internal/block-tools/paragraph/index.ts +++ b/packages/core/src/tools/internal/block-tools/paragraph/index.ts @@ -52,12 +52,18 @@ export class Paragraph implements BlockTool { */ #adapter: DOMBlockToolAdapter; + /** + * Tool's wrapper + */ #wrapper: HTMLDivElement | undefined; + /** + * Paragraph input — contenteditable DIV element + */ #paragraph: HTMLDivElement | undefined; /** - * + * Returns tool's wrapper, creates one if it doesn't exist yet */ private get wrapper(): HTMLDivElement { if (this.#wrapper !== undefined) { diff --git a/packages/model/.eslintrc.yml b/packages/model/.eslintrc.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/sdk/src/entities/EditorjsPlugin.ts b/packages/sdk/src/entities/EditorjsPlugin.ts index 96d264e1..84045a57 100644 --- a/packages/sdk/src/entities/EditorjsPlugin.ts +++ b/packages/sdk/src/entities/EditorjsPlugin.ts @@ -37,7 +37,13 @@ export interface EditorjsPlugin { * Constructor type for EditorjsPlugin */ export interface EditorjsPluginConstructor< + /** + * Plugin's params. Has to be a generic param as constructor can not be overloaded + */ Params extends EditorjsPluginParams = EditorjsPluginParams, + /** + * Plugin's instance interface. Has to be a generic param as constructor can not be overloaded + */ Instance extends EditorjsPlugin = EditorjsPlugin > { /** diff --git a/packages/sdk/src/tools/facades/InlineToolFacade.ts b/packages/sdk/src/tools/facades/InlineToolFacade.ts index ae4e8ebb..27c983fd 100644 --- a/packages/sdk/src/tools/facades/InlineToolFacade.ts +++ b/packages/sdk/src/tools/facades/InlineToolFacade.ts @@ -1,11 +1,11 @@ import { BaseToolFacade, InternalInlineToolSettings } from './BaseToolFacade.js'; -import type { InlineTool as IInlineTool, InlineToolConstructor } from '../../entities'; +import type { InlineTool, InlineToolConstructor } from '../../entities'; import { ToolType } from '../../entities'; /** * InlineTool object to work with Inline Tools constructables */ -export class InlineToolFacade extends BaseToolFacade { +export class InlineToolFacade extends BaseToolFacade { /** * Tool type for InlineToolFacade tools — Inline */ @@ -20,7 +20,7 @@ export class InlineToolFacade extends BaseToolFacade Date: Thu, 30 Apr 2026 00:21:23 +0100 Subject: [PATCH 13/14] Docs --- .github/agents/docs-updater.md | 189 +++++++++++++++++++++ README.md | 46 +++++ docs/README.md | 67 ++++++++ docs/architecture.md | 41 +++++ docs/collaboration.md | 61 +++++++ docs/diagrams/architecture-overview.mmd | 124 ++++++++++++++ docs/diagrams/block-adapter-input-flow.mmd | 53 ++++++ docs/diagrams/caret-selection-flow.mmd | 45 +++++ docs/diagrams/collaboration-ot-flow.mmd | 90 ++++++++++ docs/diagrams/events-catalog.mmd | 178 +++++++++++++++++++ docs/diagrams/inline-formatting-flow.mmd | 58 +++++++ docs/diagrams/model-tree-structure.mmd | 149 ++++++++++++++++ docs/diagrams/plugin-lifecycle-flow.mmd | 66 +++++++ docs/diagrams/undo-redo-flow.mmd | 62 +++++++ docs/events.md | 68 ++++++++ docs/input-handling.md | 42 +++++ docs/model.md | 49 ++++++ docs/plugins.md | 69 ++++++++ 18 files changed, 1457 insertions(+) create mode 100644 .github/agents/docs-updater.md create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/collaboration.md create mode 100644 docs/diagrams/architecture-overview.mmd create mode 100644 docs/diagrams/block-adapter-input-flow.mmd create mode 100644 docs/diagrams/caret-selection-flow.mmd create mode 100644 docs/diagrams/collaboration-ot-flow.mmd create mode 100644 docs/diagrams/events-catalog.mmd create mode 100644 docs/diagrams/inline-formatting-flow.mmd create mode 100644 docs/diagrams/model-tree-structure.mmd create mode 100644 docs/diagrams/plugin-lifecycle-flow.mmd create mode 100644 docs/diagrams/undo-redo-flow.mmd create mode 100644 docs/events.md create mode 100644 docs/input-handling.md create mode 100644 docs/model.md create mode 100644 docs/plugins.md diff --git a/.github/agents/docs-updater.md b/.github/agents/docs-updater.md new file mode 100644 index 00000000..813dc434 --- /dev/null +++ b/.github/agents/docs-updater.md @@ -0,0 +1,189 @@ +# Docs Updater Agent Instructions + +## Purpose + +You are a documentation maintenance agent for the `@editorjs/document-model` monorepo. + +Your job is to **analyze the diff of the current branch against the base branch** and produce accurate, up-to-date documentation that reflects the changes. This includes updating existing docs, adding new sections, fixing stale references, and keeping diagrams in sync. + +--- + +## Workflow + +Follow this sequence for every run: + +1. **Get the diff.** Run `git diff main...HEAD -- '*.ts' '*.tsx'` (or the appropriate base branch) to identify what changed. Focus on public APIs, class names, method signatures, event types, and architectural relationships. +2. **Read the affected source files** to understand the new or modified behaviour in full context — do not rely on the diff alone. +3. **Identify which docs are affected** using the mapping below. +4. **Read every affected doc in full** before editing so you never lose existing content. +5. **Edit or add** — prefer targeted edits over full rewrites. If a section is accurate, leave it alone. +6. **Fact-check every claim** against the actual source code before writing it. Never infer or speculate. +7. **Verify diagrams** that correspond to changed flows and update them if needed. + +--- + +## Documentation map + +| Changed area | Primary doc(s) | Diagram(s) | +|---|---|---| +| Package list, dependencies, overall structure | `docs/architecture.md`, root `README.md` | `diagrams/architecture-overview.mmd` | +| `EditorJSModel`, `EditorDocument`, `BlockNode`, `TextNode`, `ValueNode`, `BlockTune`, `Index`, `CaretManager` | `docs/model.md` | `diagrams/model-tree-structure.mmd` | +| Event classes, `EventType`, `EventBus` | `docs/events.md` | `diagrams/events-catalog.mmd` | +| `Core`, `BlocksManager`, `BlockRenderer`, `SelectionManager`, `ToolsManager`, `EditorAPI`, plugin/tool lifecycle | `docs/plugins.md` | `diagrams/plugin-lifecycle-flow.mmd` | +| `DOMBlockToolAdapter`, `CaretAdapter`, `FormattingAdapter`, `InputsRegistry`, `BeforeInputUIEvent` | `docs/input-handling.md` | `diagrams/block-adapter-input-flow.mmd`, `diagrams/caret-selection-flow.mmd`, `diagrams/inline-formatting-flow.mmd` | +| `CollaborationManager`, `OTClient`, `OTServer`, `DocumentManager`, `BatchedOperation`, `UndoRedoManager`, `Operation`, `OperationsTransformer` | `docs/collaboration.md` | `diagrams/collaboration-ot-flow.mmd`, `diagrams/undo-redo-flow.mmd` | +| `docs/README.md` mental model, lifecycle overview, glossary | `docs/README.md` | — | + +When in doubt, update `docs/README.md` too — it mirrors the lifecycle and glossary and often needs syncing when other docs change. + +--- + +## Style guide + +Strict rules — match the existing voice and structure at all times. + +### Prose +- **Short, declarative sentences.** No filler words ("simply", "easily", "just"). +- **One concern per page.** If a change belongs to a different concern, put it in the right file. +- **Present tense.** "X does Y", not "X will do Y". +- **Class/method names in backticks.** Always. File paths in backticks too. +- **No implementation speculation.** Only document what the code actually does. +- **Avoid "Note:", "Please note:", "It is important to".** State the fact directly. + +### Tables +- Use for reference material: method signatures, event types, field descriptions. +- Column order: thing being described → type/location → description. +- Keep descriptions short (one clause). + +### Section headers +- `##` for top-level sections inside a page. +- `###` for sub-sections (e.g. sub-API namespaces, sub-event categories). +- Do not add a header unless there are at least two items under it. + +### Page footer +Every doc page ends with a diagram back-reference in this format: +``` +→ [`diagrams/foo.mmd`](diagrams/foo.mmd) + +_One-line description of what the diagram shows._ +``` +If there is no diagram, omit the block entirely. Do not add a diagram reference for a diagram that does not exist. + +--- + +## Diagram conventions + +All diagrams are Mermaid files in `docs/diagrams/`. Every diagram must: + +1. Have a `title:` in the YAML front-matter. +2. Have a `%% See: ../xxx.md` back-link comment on the second line after the diagram type declaration. +3. Use `theme: neutral` in the config block. + +Template for a new diagram: +``` +--- +title: +config: + theme: neutral +--- +%% See: ../relevant-doc.md +sequenceDiagram (or classDiagram, etc.) + ... +``` + +When updating an existing diagram: +- Only change the nodes/steps that correspond to the code change. +- Preserve existing comments (`%%`) that explain non-obvious steps. +- Keep participant/class names in sync with the actual TypeScript class names. +- **Never** use fictional method names, callbacks, or properties. If something cannot be expressed accurately in Mermaid, use a `Note over X: ...` to describe the real behaviour in plain text. + +--- + +## Fact-checking rules + +These rules are absolute. Break none of them. + +1. **Class names must match source.** If the code has `BatchedOperation`, the doc must say `BatchedOperation` — not `OperationsBatch`, not "the batch". +2. **Method signatures must be accurate.** Check parameter names, order, and optionality. If a method takes `userId` as its first argument, show it. +3. **Return types must be accurate.** E.g. `EditorJSModel.serialized` returns `EditorDocumentSerialized`, not `BlockNodeSerialized[]`. +4. **Event dispatchers must be correct.** Always verify *who* dispatches an event. Do not attribute dispatch to a class that only *listens*. +5. **Package membership must be correct.** Don't list a class under the wrong package. +6. **Initialization order must match code.** In `Core.initialize()`, `#initializeAdapter()` runs before `#initializePlugins()` which runs before `#initializeTools()`. +7. **No fictional APIs.** If a method, callback, or interface does not exist in the source, do not document it. + +Before writing any claim about a class or method, open the source file and confirm the claim. Use `grep` or file reads — never assume. + +--- + +## When to add vs update + +| Situation | Action | +|---|---| +| Existing method signature changed | Update the relevant table row and any code examples | +| New public method added to an existing class | Add a row to the relevant table in the correct doc | +| New event class added | Add a row to the event reference table in `docs/events.md` and a node in `diagrams/events-catalog.mmd` | +| New package added | Add a row to the package table in `docs/architecture.md` and `README.md`; create a `## role` section in `docs/architecture.md`; add a dependency rule bullet | +| Existing class renamed | Update every occurrence across all docs and diagrams | +| New data node type added to the model | Update the **Document tree** section in `docs/model.md` and the `model-tree-structure.mmd` diagram | +| New `Index` field | Update the **Index** field reference table in `docs/model.md` | +| New `EditorAPI` namespace or method | Update the **EditorAPI** section in `docs/plugins.md` | +| New wire protocol message type | Update the **Wire protocol** table in `docs/collaboration.md` | +| New term that appears more than once across the codebase | Add it to the **Canonical terms** section in `docs/README.md` | + +### When NOT to touch a doc +- If a change is purely internal (private method, test helper, implementation detail that is not observable through a public interface or event), do not surface it in docs. +- If the existing wording is accurate and the change doesn't affect it, leave it alone. + +--- + +## Glossary maintenance (`docs/README.md` — Canonical terms) + +Add an entry when a new term: +- is a TypeScript class/interface that appears in more than one package, **or** +- is used in a doc page but not defined there, **or** +- is frequently confused with another term. + +Entry format: +``` +- `TermName`: one or two sentences. What it is, where it lives, and why it matters. +``` + +Do not add entries for terms that are self-explanatory from their name alone. + +--- + +## Packages reference + +| Package | Path | Description | +|---|---|---| +| `@editorjs/sdk` | `packages/sdk` | Contracts, interfaces, `EventBus`, event base classes | +| `@editorjs/model` | `packages/model` | Document model, `EditorJSModel`, nodes, `Index`, caret | +| `@editorjs/dom-adapters` | `packages/dom-adapters` | DOM↔model bridge, `DOMAdapters`, adapters, `InputsRegistry` | +| `@editorjs/collaboration-manager` | `packages/collaboration-manager` | OT client, batching, undo/redo, `Operation` | +| `@editorjs/core` | `packages/core` | Orchestrator, IoC, `EditorAPI`, managers | +| `@editorjs/ui` | `packages/ui` | UI shell, `BlocksUI` (dispatches `BeforeInputUIEvent`) | +| `@editorjs/ot-server` | `packages/ot-server` | WebSocket OT server, `OTServer`, `DocumentManager` | +| `playground` | `packages/playground` | Dev sandbox, not published | + +--- + +## Key architectural invariants + +These must never be contradicted by the docs: + +- **`BlockRenderer`** (not `BlocksManager`) creates `BlockToolAdapter` instances in response to `BlockAddedEvent`. +- **`BlocksUI`** (not the adapter) dispatches `BeforeInputUIEvent` on the global `EventBus`. +- **`SelectionManager.applyInlineToolForCurrentSelection()`** calls `model.format()` / `model.unformat()` directly — it does not delegate to `FormattingAdapter`. `FormattingAdapter` handles DOM re-rendering only. +- **`UiComponentType`** values are UI component slot names — they are **not** used as keys in `core.use()`. `core.use()` uses `ToolType` and `PluginType` values. +- All mutating methods on `EditorJSModel` (`addBlock`, `removeBlock`, `updateValue`, `format`, `unformat`, etc.) require `userId` as their **first** argument. +- `EditorJSModel.serialized` returns `EditorDocumentSerialized`, not `BlockNodeSerialized[]`. +- `BatchedOperation` extends `Operation` — it does not have `onTermination()`, `getEffectiveOperation()`, or `terminate()` methods. + +--- + +## Output expectations + +- Only edit files that need changing. Do not reformat or rewrite sections that are already correct. +- Commit message (if applicable): `docs: update for `. +- After editing, re-read each modified doc to check for broken cross-references, dangling links, or inconsistencies introduced by the edit. + diff --git a/README.md b/README.md index ff0450bf..f21ce042 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ # @editorjs/document-model + +A model-driven, collaboration-ready Editor.js engine split into focused packages. + +## Packages + +| Package | Description | +|---|---| +| [`@editorjs/sdk`](packages/sdk) | Shared contracts — interfaces, base event classes, `EventBus` | +| [`@editorjs/model`](packages/model) | In-memory document model (`EditorJSModel`, `BlockNode`, `TextNode`, caret management) | +| [`@editorjs/dom-adapters`](packages/dom-adapters) | Binds model nodes to DOM inputs (`DOMBlockToolAdapter`, `CaretAdapter`, `FormattingAdapter`) | +| [`@editorjs/collaboration-manager`](packages/collaboration-manager) | Operational transformation, batching, undo/redo, OT WebSocket client | +| [`@editorjs/core`](packages/core) | Orchestrator — IoC container, plugin/tool lifecycle, `EditorAPI` | +| [`@editorjs/ui`](packages/ui) | Default UI shell (`EditorjsUI`, `BlocksUI`, `Toolbar`, `InlineToolbar`, `Toolbox`) | +| [`@editorjs/ot-server`](packages/ot-server) | Standalone WebSocket OT server (`OTServer`, `DocumentManager`) | +| [`playground`](packages/playground) | Vite dev sandbox for manual testing | + +## Documentation + +In-depth architecture, flow, and API docs live in [`docs/`](docs/README.md). + +Quick links: +- [Architecture overview](docs/architecture.md) +- [Data model](docs/model.md) +- [Input handling & caret](docs/input-handling.md) +- [Plugins & Tools](docs/plugins.md) +- [Collaboration & Undo/Redo](docs/collaboration.md) +- [Event system](docs/events.md) + +## Development + +```bash +# Install all package dependencies +yarn install + +# Build all packages +yarn workspaces run build + +# Run tests for a specific package (e.g. model) +cd packages/model && yarn test + +# Start the playground +cd packages/playground && yarn dev + +# Start the OT server (Docker) +docker compose up +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..891489e3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,67 @@ +# How the Editor Works + +This folder documents how the editor is wired end to end, with short pages that keep one concern each. + +Read by goal: +- System boundaries: [Architecture](architecture.md) +- Document structures and mutation API: [Data Model](model.md) +- Typing, caret, formatting pipeline: [Input Handling](input-handling.md) +- Registration and lifecycle contracts: [Plugins & Tools](plugins.md) +- OT, batching, undo/redo: [Collaboration](collaboration.md) +- Which event bus to listen to: [Events](events.md) + +--- + +## Mental model in 90 seconds + +Five core parts: +1. `Core` owns startup and dependency wiring. +2. `EditorJSModel` is the source of truth for document state. +3. DOM adapters map model changes to concrete DOM inputs. +4. Tools/plugins add behavior through stable interfaces. +5. `CollaborationManager` translates model changes into OT operations. + +Two event transports (never mixed): +- Model events on `EditorJSModel` +- Core/UI events on the `EventBus` held by the IoC container + +--- + +## Lifecycle (from `new Core()` to live editor) + +1. `new Core(config)` binds IoC services and built-ins. +2. `core.use(...)` registers UI components/plugins by `plugin.type`. +3. `core.initialize()` initializes the adapter plugin (`DOMAdapters` or a custom replacement) first. +4. UI plugins are instantiated (`EditorjsPlugin` instances, e.g. `EditorjsUI`). +5. Tools are prepared and announced with `ToolLoadedCoreEvent`. +6. Initial document is inserted into `EditorJSModel`; `BlockRenderer` reacts to each `BlockAddedEvent` to create a `BlockToolAdapter`, render the tool, and emit `BlockAddedCoreEvent`. +7. Collaboration manager connects (if server config is provided). + +--- + +## One keystroke, full path + +1. Browser fires `beforeinput` inside the `contenteditable` blocks holder. +2. `BlocksUI` (the `@editorjs/ui` blocks component) intercepts it, wraps it in `BeforeInputUIEvent`, and dispatches it on the global `EventBus`. +3. `DOMBlockToolAdapter` listens on the `EventBus` for `BeforeInputUIEvent` and calls `model.insertText(...)`. +4. Model mutates and emits `TextAddedEvent`. +5. `DOMBlockToolAdapter` updates the affected DOM range. +6. `CollaborationManager` converts the event to an `Operation`, adds it to the current `BatchedOperation`, and resets the debounce timer. +7. Browser `selectionchange` fires; `CaretAdapter` builds an `Index` and updates the model caret. +8. `SelectionManager` emits `SelectionChangedCoreEvent`; `CaretAdapter` restores DOM selection from the model index if needed. + +The system stays decoupled because each step communicates through interfaces and events, not direct cross-component calls. + +--- + +## Canonical terms + +- `EditorjsPlugin`: general UI/behavior plugin registered via `core.use()` with `PluginType.Plugin`. +- `UiComponentType`: reserved string keys for UI component slots (`shell`, `blocks`, `inline-toolbar`, `toolbox`, `toolbar`). These name components in the UI layer but are **not** used as arguments to `core.use()` — plugins are registered by `PluginType` or `ToolType` values. +- `BlockTool` / `InlineTool` / `BlockTune`: tool contracts provided via config and prepared during `initialize()`. +- `Index`: serializable location in the document tree, independent of DOM nodes. Fields: `documentId`, `blockIndex`, `dataKey`, `textRange`, `tuneName`, `tuneKey`, `propertyName`. A `compositeSegments` array holds multiple per-input text indices for cross-block selections. Built with `IndexBuilder`; serialized to a compact string for caret storage and OT operations. +- `DataKey`: branded string identifying a data slot inside a `BlockNode` (e.g. `"text"`, `"caption"`). Created via `createDataKey()`. +- `BatchedOperation`: groups rapid single-character inserts or deletes on the same data key into one logical edit for undo/redo. Lives in `@editorjs/collaboration-manager`. +- `InputsRegistry`: shared map of `(blockIndex, dataKey) → HTMLElement` maintained by `DOMAdapters`. Both `DOMBlockToolAdapter` and `CaretAdapter` read from it. +- `BlockRenderer`: internal `@editorjs/core` component that subscribes to `BlockAddedEvent`/`BlockRemovedEvent` and creates/tears down `BlockToolAdapter` instances. Not to be confused with `BlocksManager` which handles the programmatic insert/delete/move API. +- `CaretManager`: owns one `Caret` per collaborating user. Dispatches `CaretManagerCaretUpdatedEvent` on `EditorJSModel` when any caret changes. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..1eb66ba0 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,41 @@ +# Architecture Overview + +The editor is split into eight packages in a layered dependency direction. + +| Package | Role | +|---|---| +| `@editorjs/sdk` | Shared contracts — interfaces, base event classes, `EventBus` | +| `@editorjs/model` | In-memory document model (`EditorJSModel`) | +| `@editorjs/dom-adapters` | Binds model nodes to DOM inputs; default adapter implementation | +| `@editorjs/collaboration-manager` | Operational transformation, batching, undo/redo, OT WebSocket client | +| `@editorjs/core` | Orchestrator — IoC container, plugin/tool lifecycle, `EditorAPI` | +| `@editorjs/ui` | Default UI shell — `EditorjsUI`, `BlocksUI`, `Toolbar`, `InlineToolbar`, `Toolbox` | +| `@editorjs/ot-server` | Standalone WebSocket OT server — `OTServer`, `DocumentManager` | +| `playground` | Vite dev sandbox; not published | + +## Dependency rules + +- `sdk` is the contract layer all other packages depend on. +- `core` wires runtime dependencies; it should be the only orchestrator. +- `model` does not depend on DOM concerns. +- `dom-adapters` and `collaboration-manager` observe/apply model changes through public APIs and events. +- `ui` depends on `sdk` only; it is registered as an `EditorjsPlugin` via `core.use()`. +- `ot-server` depends on `collaboration-manager` (for `Operation` / message types) and `model`; it runs server-side only. + +## Runtime ownership + +`Core` is the entry point and owner of service wiring. Most services are wired in the constructor; `core.use(...)` registers UI plugins and tools; `initialize()` prepares tools, initializes the model, and starts collaboration. + +### `@editorjs/ui` role + +`BlocksUI` owns the `contenteditable` blocks holder. It intercepts browser `beforeinput` events, normalises them into `BeforeInputUIEvent`, and dispatches them on the global `EventBus`. It also listens for `BlockAddedCoreEvent` / `BlockRemovedCoreEvent` to insert/remove rendered block elements in the DOM. + +### `@editorjs/ot-server` role + +`OTServer` is a standalone Node.js WebSocket server. It maintains one `DocumentManager` per `documentId`. On each incoming `Operation` message it transforms the operation against any conflicting operations (ops with a higher or equal revision number), bumps the revision, applies the result to its own `EditorJSModel` copy, and broadcasts the transformed operation to all connected clients for that document. + +Direct cross-layer coupling should be avoided: use interfaces/events from `sdk` and mutation APIs from `EditorJSModel`. + +→ [`diagrams/architecture-overview.mmd`](diagrams/architecture-overview.mmd) + +_Package boundaries and integration contracts. Keep this as the high-level map; see other docs for per-subsystem flow details._ diff --git a/docs/collaboration.md b/docs/collaboration.md new file mode 100644 index 00000000..ff7fbcef --- /dev/null +++ b/docs/collaboration.md @@ -0,0 +1,61 @@ +# Collaboration & Undo/Redo + +## Architecture + +`CollaborationManager` bridges `EditorJSModel` and `OTClient` (WebSocket client): + +1. Converts local model changes into `Operation` and sends them. +2. Applies incoming remote operations back to the model. +3. Batches rapid operations and forwards completed batches to `UndoRedoManager`. + +## Operational transformation + +When concurrent edits start from revision `N`, reconciliation is two-step: + +- **Step 1**: transform each pending local op against the incoming remote op. +- **Step 2**: transform the remote op against pending local ops before applying locally. + +This preserves convergence regardless of arrival order. + + +→ [`diagrams/collaboration-ot-flow.mmd`](diagrams/collaboration-ot-flow.mmd) + +_Handshake, local send/ack path, remote OT transform path, then local apply._ + +## Wire protocol + +`OTClient` and `OTServer` exchange JSON messages over WebSocket. Every message has the shape: + +```json +{ "type": "", "payload": { ... } } +``` + +### Message types + +| `type` | Direction | Payload fields | Description | +|---|---|---|---| +| `Handshake` | Client → Server | `document` (DocumentId), `userId`, `rev`, `data?` (EditorDocumentSerialized) | Connect to a document. Client sends its current state on first connect. | +| `Handshake` | Server → Client | `document`, `userId`, `rev`, `data?` | Server echoes back. If the server already has the document, `data` contains the authoritative state and the client should call `initializeDocument(data)`. | +| `Operation` | Client → Server | Serialized `Operation` (`type`, `index`, `data`, `userId`, `rev`) | A local operation to apply. | +| `Operation` | Server → All clients | Serialized transformed `Operation` | Server broadcasts the transformed operation to every client in the document room (including the author — the author's copy serves as an ack). | + +### Server-side OT + +`OTServer` maintains one `DocumentManager` per `documentId`. On receiving an `Operation`: +1. If `operation.rev` is ahead of the server's current revision the operation is rejected (WebSocket closed with code `4400`). +2. Conflicting operations (all ops with `rev >= operation.rev`) are fetched and the incoming op is transform-reduced through them. +3. The transformed op is applied to the server's `EditorJSModel` copy and the revision counter is incremented. +4. The transformed op (with the new `rev`) is broadcast to all clients in the document room. + +Operations are processed sequentially per document — `DocumentManager` queues them so a new op always awaits the previous one. + +## Undo / Redo + +`BatchedOperation` groups rapid edits with debounce and terminates on timeout or incompatible operation type. + +`UndoRedoManager` stores completed batches. Undo/redo invert and apply operations while event re-recording is disabled (`shouldHandleEvents = false`) to avoid stack pollution. + + +→ [`diagrams/undo-redo-flow.mmd`](diagrams/undo-redo-flow.mmd) + +_Rapid edits are merged into one logical batch; undo/redo replay inverses of that effective operation._ diff --git a/docs/diagrams/architecture-overview.mmd b/docs/diagrams/architecture-overview.mmd new file mode 100644 index 00000000..33d1b981 --- /dev/null +++ b/docs/diagrams/architecture-overview.mmd @@ -0,0 +1,124 @@ +--- +title: Architecture Overview +config: + theme: neutral +--- +%% See: ../architecture.md +classDiagram + direction LR + + + %% Layer 0 — Contracts (@editorjs/sdk) + namespace sdk { + + class EventBus { + <> + +addEventListener(type, callback) + +removeEventListener(type, callback) + +dispatchEvent(event) + } + + class BlockToolAdapter { + <> + +attachInput(keyRaw, input: HTMLElement) + } + + class BlockTool { + <> + +render(): HTMLElement + +validate?(data): boolean + +destroy?() + } + + class EditorjsPlugin { + <> + +destroy?() + } + + class EditorjsPluginConstructor { + <> + +type: string + +new(params: EditorjsPluginParams): EditorjsPlugin + } + + class EditorJSAdapterPlugin { + <> + +createBlockToolAdapter(blockIndex, toolName): BlockToolAdapter + } + + class EditorjsAdapterPluginConstructor { + <> + +type: string + +new(params: EditorjsAdapterPluginParams): EditorJSAdapterPlugin + } + } + + %% Layer 1 — Data model (@editorjs/model) + namespace model { + class EditorJSModel { + +serialized: EditorDocumentSerialized + +initializeDocument(config) + +addBlock(userId, blockData, index?) + +removeBlock(userId, index) + +insertText(userId, blockIndex, dataKey, text, start?) + +removeText(userId, blockIndex, dataKey, start, end?) + +format(userId, blockIndex, dataKey, tool, start, end, data?) + +getFragments(blockIndex, dataKey, start?, end?, tool?) + +createCaret(userId): Caret + +getCaret(userId): Caret + +updateCaret(userId, caret) + } + } + + %% Layer 2a — DOM binding (@editorjs/dom-adapters) + namespace domAdapters { + class DOMAdapters { + +createBlockToolAdapter(blockIndex, toolName): BlockToolAdapter + } + } + + %% Layer 2b — Collaboration (@editorjs/collaboration-manager) + namespace collaborationManager { + class CollaborationManager { + +connect() + +applyOperation(operation) + +undo() + +redo() + } + } + + %% Layer 3 — Orchestrator (@editorjs/core) + namespace core { + class Core { + +constructor(config) + +initialize() + +use(plugin): Core + } + } + + %% Infrastructure relations + EditorJSModel --|> EventBus : extends + Core *-- EventBus : creates & holds + + %% Core — owns & wires + Core *-- EditorJSModel + Core *-- CollaborationManager + Core ..> EditorjsPluginConstructor : registers via use() + Core ..> EditorjsAdapterPluginConstructor : registers via use() + + %% Model interactions + CollaborationManager --> EditorJSModel : listens / applies ops + BlockToolAdapter --> EditorJSModel : syncs DOM↔model + + %% Tool contracts + BlockTool ..> BlockToolAdapter : receives via constructor + + %% Plugin contracts + EditorjsPluginConstructor ..> EditorjsPlugin : creates + EditorjsPlugin ..> EventBus : receives + + %% Adapter plugin contracts + EditorjsAdapterPluginConstructor ..> EditorJSAdapterPlugin : creates + DOMAdapters ..|> EditorJSAdapterPlugin : implements + EditorJSAdapterPlugin ..> BlockToolAdapter : creates + EditorJSAdapterPlugin ..> EventBus : receives diff --git a/docs/diagrams/block-adapter-input-flow.mmd b/docs/diagrams/block-adapter-input-flow.mmd new file mode 100644 index 00000000..5b05a727 --- /dev/null +++ b/docs/diagrams/block-adapter-input-flow.mmd @@ -0,0 +1,53 @@ +--- +title: Block Adapter Input Flow +config: + theme: neutral +--- +%% See: ../input-handling.md +sequenceDiagram + actor User + participant BlocksManager + participant EditorJSModel + participant BlockRenderer + participant DOMAdapters + participant InputsRegistry + participant DOMBlockToolAdapter + participant BlockTool + participant EventBus + + %% ── Block insertion & adapter creation ────────── + User->>BlocksManager: insert(toolName, data, index) + BlocksManager->>EditorJSModel: addBlock(userId, data, index) + EditorJSModel-->>BlockRenderer: BlockAddedEvent + BlockRenderer->>DOMAdapters: createBlockToolAdapter(blockIndex, toolName) + DOMAdapters->>InputsRegistry: insertBlock(blockIndex) + DOMAdapters->>DOMBlockToolAdapter: resolve from IoC container + DOMAdapters->>DOMBlockToolAdapter: setBlockIndex(blockIndex), setToolName(toolName) + BlockRenderer->>BlockTool: tool.create({ adapter, data }) + BlockTool->>BlockTool: render() + BlockTool->>DOMBlockToolAdapter: createDataNode(key, initialData) + DOMBlockToolAdapter->>EditorJSModel: createDataNode(userId, blockIndex, key, data) + BlockTool->>DOMBlockToolAdapter: attachInput(key, inputElement) + DOMBlockToolAdapter->>InputsRegistry: register(blockIndex, key, inputElement) + BlockRenderer->>EventBus: dispatch BlockAddedCoreEvent (ui element) + + %% ── User types (text node) ─────────────────────── + User->>EventBus: BeforeInputUIEvent (delegated from DOM) + EventBus-->>DOMBlockToolAdapter: BeforeInputUIEvent + DOMBlockToolAdapter->>EditorJSModel: insertText(userId, blockIndex, key, text, start) + EditorJSModel-->>DOMBlockToolAdapter: TextAddedEvent + DOMBlockToolAdapter->>DOMBlockToolAdapter: update DOM range + + %% ── Value node registration ────────────────────── + BlockTool->>DOMBlockToolAdapter: createDataNode(key, initialValue) + DOMBlockToolAdapter->>EditorJSModel: createDataNode(userId, blockIndex, key, data) + EditorJSModel-->>DOMBlockToolAdapter: DataNodeAddedEvent + DOMBlockToolAdapter-->>BlockTool: KeyAddedEvent + + %% ── Tool updates value node ────────────────────── + BlockTool->>DOMBlockToolAdapter: updateValue(key, newValue) + DOMBlockToolAdapter->>EditorJSModel: updateValue(userId, blockIndex, key, newValue) + EditorJSModel-->>DOMBlockToolAdapter: ValueModifiedEvent + DOMBlockToolAdapter-->>BlockTool: ValueNodeChangedEvent + + diff --git a/docs/diagrams/caret-selection-flow.mmd b/docs/diagrams/caret-selection-flow.mmd new file mode 100644 index 00000000..f330106d --- /dev/null +++ b/docs/diagrams/caret-selection-flow.mmd @@ -0,0 +1,45 @@ +--- +title: Caret & Selection Flow +config: + theme: neutral +--- +%% See: ../input-handling.md +sequenceDiagram + actor User + participant DOM + participant InputsRegistry + participant CaretAdapter + participant EditorJSModel + participant SelectionManager + participant ToolsManager + participant EventBus + + %% ── User moves caret / changes selection ──────────── + User->>DOM: click or keyboard navigation + DOM->>CaretAdapter: selectionchange event + CaretAdapter->>InputsRegistry: entries() — iterate all registered inputs + CaretAdapter->>CaretAdapter: clip selection range per input, build Index per segment + CaretAdapter->>CaretAdapter: sort segments in document order + CaretAdapter->>EditorJSModel: caret.update(index or compositeIndex) + + %% ── Model dispatches caret event ──────────────────── + EditorJSModel-->>SelectionManager: CaretManagerCaretUpdatedEvent (local userId) + EditorJSModel-->>CaretAdapter: CaretManagerCaretUpdatedEvent (local userId) + + %% ── SelectionManager computes available tools ─────── + SelectionManager->>EditorJSModel: getFragments per segment + EditorJSModel-->>SelectionManager: InlineFragment[] + SelectionManager->>ToolsManager: inlineTools.entries() — create instances on-demand + SelectionManager->>EventBus: dispatch SelectionChangedCoreEvent(index, availableInlineTools, fragments) + + %% ── CaretAdapter restores DOM selection (round-trip) ─ + CaretAdapter->>InputsRegistry: findInput(blockIndex, dataKey) + InputsRegistry-->>CaretAdapter: HTMLElement + CaretAdapter->>CaretAdapter: compare current DOM selection with model index + alt selection differs + CaretAdapter->>DOM: selection.addRange(range) + end + + %% ── Remote caret (collaborator) ───────────────────── + EditorJSModel-->>CaretAdapter: CaretManagerCaretUpdatedEvent (remote userId) + diff --git a/docs/diagrams/collaboration-ot-flow.mmd b/docs/diagrams/collaboration-ot-flow.mmd new file mode 100644 index 00000000..11565d42 --- /dev/null +++ b/docs/diagrams/collaboration-ot-flow.mmd @@ -0,0 +1,90 @@ +--- +title: Collaboration OT Flow +config: + theme: neutral +--- +%% See: ../collaboration.md +sequenceDiagram + actor LocalUser as Local User + participant EditorJSModel + participant CollaborationManager + participant UndoRedoManager + participant OTClient + participant OTServer as OT Server + actor RemoteUser as Remote User + + %% ── Connect ────────────────────────────────────── + LocalUser->>CollaborationManager: connect() + CollaborationManager->>OTClient: new OTClient(serverAddr, userId) + OTClient->>OTServer: WebSocket open + CollaborationManager->>OTClient: connectDocument(model.serialized) + OTClient->>OTServer: Handshake(documentId, userId, rev, data) + OTServer-->>OTClient: Handshake(data?) + alt server has newer document state + OTClient->>EditorJSModel: initializeDocument(data) + end + + %% ── Local edit → server ────────────────────────── + LocalUser->>EditorJSModel: insertText, addBlock, etc. + EditorJSModel-->>CollaborationManager: TextAddedEvent, BlockAddedEvent, etc. + CollaborationManager->>CollaborationManager: build Operation from event + CollaborationManager->>OTClient: send(operation) + OTClient->>OTServer: Operation(type, index, payload, userId, rev) + OTServer-->>OTClient: Ack(userId, rev) + + %% ── Batch & undo stack ─────────────────────────── + CollaborationManager->>CollaborationManager: create/grow BatchedOperation, reset debounce timer + Note over CollaborationManager: debounce timeout fires (or incompatible op / remote op received) + CollaborationManager->>UndoRedoManager: put(currentBatch) + + %% ── Remote edit → local model ──────────────────── + RemoteUser->>OTServer: Operation(type, index, payload, userId, rev) + OTServer-->>OTClient: Operation(type, index, payload, userId, rev) + OTClient->>OTClient: transform against pending operations (OT) + OTClient->>CollaborationManager: onRemoteOperation(transformedOp) + CollaborationManager->>EditorJSModel: insertData, removeData, modifyData + + %% ── Undo ───────────────────────────────────────── + LocalUser->>CollaborationManager: undo() + Note over CollaborationManager: #putBatchToUndo() — flush currentBatch + CollaborationManager->>UndoRedoManager: put(currentBatch) + CollaborationManager->>UndoRedoManager: undo() + UndoRedoManager-->>CollaborationManager: invertedOperation + CollaborationManager->>EditorJSModel: applyOperation(invertedOperation) + + %% ══════════════════════════════════════════════════ + %% Operational Transformation: concurrent edit scenario + %% ══════════════════════════════════════════════════ + %% Both users start at the same document revision N. + %% User A types before receiving User B's operation, + %% so their ops are based on the same state and must be transformed. + + rect rgb(235, 245, 255) + Note over LocalUser, RemoteUser: OT: concurrent edits (both at rev N) + + LocalUser->>OTClient: send Op-A (e.g. insert "x" at pos 5, rev N) + OTClient->>OTServer: Op-A(Insert, pos=5, rev=N) + Note over OTClient: Op-A is now in pendingOperations + + RemoteUser->>OTServer: Op-B(Insert, pos=3, rev=N) + + OTServer->>OTServer: apply Op-B first (wins the race), rev = N+1 + OTServer-->>OTClient: broadcast Op-B(Insert, pos=3, rev=N+1) + + Note over OTClient: Op-A is still pending (no Ack yet) + + Note over OTClient: Step 1 — transform each pending op against the remote op,
so pending ops stay valid on top of the new server state:
Op-A' = Op-A.transform(Op-B) → pos 5 shifts to 6 + OTClient->>OTClient: Op-A (pending) = Op-A.transform(Op-B) + + Note over OTClient: Step 2 — transform the remote op against each pending op
in sequence, so it applies correctly to local state:
Op-B' = Op-B.transform(Op-A) → pos 3 unchanged (before pos 5) + OTClient->>OTClient: Op-B' = Op-B.transform(Op-A) + + OTClient->>CollaborationManager: onRemoteOperation(Op-B') + CollaborationManager->>EditorJSModel: insertData at pos 3 (Op-B') + + OTServer->>OTServer: transform Op-A against Op-B, apply, rev = N+2 + OTServer-->>OTClient: Ack Op-A(rev=N+2) + Note over OTClient: pendingOperations cleared, rev = N+2 + end + + diff --git a/docs/diagrams/events-catalog.mmd b/docs/diagrams/events-catalog.mmd new file mode 100644 index 00000000..ce1fc572 --- /dev/null +++ b/docs/diagrams/events-catalog.mmd @@ -0,0 +1,178 @@ +--- +title: Events Catalog +config: + theme: neutral + layout: dagre +--- +%% See: ../events.md +classDiagram + + direction LR + + %% ── Base classes ───────────────────────────────── + class BaseDocumentEvent { + <> + detail.index: Index + detail.action: EventAction + detail.data: unknown + detail.userId: string or number + } + + class CoreEventBase { + <> + detail: Payload + } + + class UIEventBase { + <> + detail: Payload + } + + %% ── Event Dispatchers ──────────────────────────── + class EditorJSModel { + <> + } + + class CaretManager { + <> + } + + class CoreEventBus["Core EventBus"] { + <> + } + + class BlockToolAdapterBus["BlockToolAdapter"] { + <> + } + + %% ── @editorjs/model ────────────────────────────── + namespace model { + class BlockAddedEvent { + detail.data: BlockNodeSerialized + } + class BlockRemovedEvent { + detail.data: BlockNodeSerialized + } + class TextAddedEvent { + detail.data: string + } + class TextRemovedEvent { + detail.data: string + } + class TextFormattedEvent { + detail.data.tool: InlineToolName + detail.data.data: InlineToolData + } + class TextUnformattedEvent { + detail.data.tool: InlineToolName + detail.data.data: InlineToolData + } + class DataNodeAddedEvent { + detail.data: BlockNodeDataSerializedValue + } + class DataNodeRemovedEvent { + detail.data: BlockNodeDataSerializedValue + } + class ValueModifiedEvent { + detail.data.value: T + detail.data.previous: T + } + class TuneModifiedEvent { + detail.data.value: T + detail.data.previous: T + } + class CaretManagerCaretUpdatedEvent { + <> + detail.index: SerializedIndex or null + detail.userId: string or number + } + class CaretManagerCaretAddedEvent { + <> + detail.index: SerializedIndex + detail.userId: string or number + } + class CaretManagerCaretRemovedEvent { + <> + detail.userId: string or number + } + } + + %% ── @editorjs/sdk — EventBus ───────────────────── + namespace sdk { + class BlockAddedCoreEvent { + detail.tool: string + detail.data: BlockToolData + detail.index: number + detail.ui: HTMLElement + } + class BlockRemovedCoreEvent { + detail.tool: string + detail.index: number + } + class ToolLoadedCoreEvent { + detail.tool: ToolFacadeClass + } + class SelectionChangedCoreEvent { + detail.index: Index or null + detail.availableInlineTools: Map~InlineToolName, InlineTool~ + detail.fragments: InlineFragment[] + } + class UndoCoreEvent { + detail: undefined + } + class RedoCoreEvent { + detail: undefined + } + class BeforeInputUIEvent { + detail.data: string + detail.inputType: string + detail.targetRanges: StaticRange[] + detail.isCrossInputSelection: boolean + } + } + + %% ── @editorjs/sdk — BlockToolAdapter (per-block) ─ + namespace adapter { + class KeyAddedEvent { + detail: string + } + class KeyRemovedEvent { + detail: string + } + class ValueNodeChangedEvent { + detail.key: string + detail.value: unknown + } + } + + %% ── Inheritance ────────────────────────────────── + BlockAddedEvent --|> BaseDocumentEvent + BlockRemovedEvent --|> BaseDocumentEvent + TextAddedEvent --|> BaseDocumentEvent + TextRemovedEvent --|> BaseDocumentEvent + TextFormattedEvent --|> BaseDocumentEvent + TextUnformattedEvent --|> BaseDocumentEvent + DataNodeAddedEvent --|> BaseDocumentEvent + DataNodeRemovedEvent --|> BaseDocumentEvent + ValueModifiedEvent --|> BaseDocumentEvent + TuneModifiedEvent --|> BaseDocumentEvent + + BlockAddedCoreEvent --|> CoreEventBase + BlockRemovedCoreEvent --|> CoreEventBase + ToolLoadedCoreEvent --|> CoreEventBase + SelectionChangedCoreEvent --|> CoreEventBase + UndoCoreEvent --|> CoreEventBase + RedoCoreEvent --|> CoreEventBase + + BeforeInputUIEvent --|> UIEventBase + + %% ── Dispatchers ────────────────────────────────── + BaseDocumentEvent ..> EditorJSModel : dispatched on + CaretManagerCaretUpdatedEvent ..> EditorJSModel : dispatched on EventType.CaretManagerUpdated + CaretManagerCaretAddedEvent ..> EditorJSModel : dispatched on EventType.CaretManagerUpdated + CaretManagerCaretRemovedEvent ..> EditorJSModel : dispatched on EventType.CaretManagerUpdated + CoreEventBase ..> CoreEventBus : dispatched on + UIEventBase ..> CoreEventBus : dispatched on + KeyAddedEvent ..> BlockToolAdapterBus : dispatched on + KeyRemovedEvent ..> BlockToolAdapterBus : dispatched on + ValueNodeChangedEvent ..> BlockToolAdapterBus : dispatched on diff --git a/docs/diagrams/inline-formatting-flow.mmd b/docs/diagrams/inline-formatting-flow.mmd new file mode 100644 index 00000000..d38e1467 --- /dev/null +++ b/docs/diagrams/inline-formatting-flow.mmd @@ -0,0 +1,58 @@ +--- +title: Inline Formatting Flow +config: + theme: neutral +--- +%% See: ../input-handling.md +sequenceDiagram + participant EventBus + participant ToolsManager + participant FormattingAdapter + participant SelectionManager + participant EditorJSModel + participant CaretAdapter + participant InputsRegistry + participant InlineTool + participant DOM + actor User + + %% ── Setup: FormattingAdapter caches tool instances ── + EventBus-->>FormattingAdapter: ToolLoadedCoreEvent (inline tool) + FormattingAdapter->>InlineTool: tool.create() + FormattingAdapter->>FormattingAdapter: attachTool(name, instance) + + %% ── User applies inline tool ───────────────────────── + User->>SelectionManager: applyInlineToolForCurrentSelection(toolName, data) + SelectionManager->>EditorJSModel: getCaret(userId) + EditorJSModel-->>SelectionManager: Caret (index with segments) + SelectionManager->>ToolsManager: inlineTools.get(toolName).create() + ToolsManager-->>SelectionManager: InlineTool instance + + loop for each text segment in composite index + SelectionManager->>EditorJSModel: getFragments(blockIndex, dataKey, start, end, toolName) + EditorJSModel-->>SelectionManager: InlineFragment[] + SelectionManager->>InlineTool: getFormattingOptions(textRange, fragments) + InlineTool-->>SelectionManager: action (Format or Unformat), range + alt Format + SelectionManager->>EditorJSModel: format(userId, blockIndex, dataKey, toolName, start, end, data) + else Unformat + SelectionManager->>EditorJSModel: unformat(userId, blockIndex, dataKey, toolName, start, end) + end + end + + %% ── Model event → FormattingAdapter re-renders DOM ── + EditorJSModel-->>FormattingAdapter: TextFormattedEvent or TextUnformattedEvent + FormattingAdapter->>CaretAdapter: findInput(blockIndex, dataKey) + CaretAdapter->>InputsRegistry: getInput(blockIndex, dataKey) + InputsRegistry-->>CaretAdapter: HTMLElement + CaretAdapter-->>FormattingAdapter: HTMLElement (input) + FormattingAdapter->>EditorJSModel: getFragments(blockIndex, dataKey, rangeStart, rangeEnd) + EditorJSModel-->>FormattingAdapter: affected InlineFragment[] + FormattingAdapter->>FormattingAdapter: rerenderRange — clear range, re-wrap all fragments + loop for each affected fragment + FormattingAdapter->>InlineTool: createWrapper(toolData) + InlineTool-->>FormattingAdapter: HTMLElement wrapper + FormattingAdapter->>DOM: surround(wrapper, input, range) + end + FormattingAdapter->>CaretAdapter: updateIndex(index, userId) + CaretAdapter->>DOM: restore caret position diff --git a/docs/diagrams/model-tree-structure.mmd b/docs/diagrams/model-tree-structure.mmd new file mode 100644 index 00000000..5e092040 --- /dev/null +++ b/docs/diagrams/model-tree-structure.mmd @@ -0,0 +1,149 @@ +--- +title: Model Tree Structure +config: + theme: neutral +--- +%% See: ../model.md +classDiagram + direction TB + + + %% ── Document tree ──────────────────────────────── + class EditorJSModel { + <> + -document: EditorDocument + -caretManager: CaretManager + -currentUserId: string or number + +length: number + +serialized: EditorDocumentSerialized + +addBlock(userId, data, index?) + +removeBlock(userId, index) + +updateValue(userId, blockIndex, dataKey, value) + +format(userId, blockIndex, dataKey, ...) + +unformat(userId, blockIndex, dataKey, ...) + +createCaret(userId, index?): Caret + +getCaret(userId): Caret + +removeCaret(userId) + } + + class EditorDocument { + +identifier: DocumentId + +children: BlockNode[] + +length: number + +initialize(blocks) + +addBlock(data, index?) + +removeBlock(index) + +updateValue(blockIndex, dataKey, value) + } + + class BlockNode { + +name: BlockToolName + +data: BlockNodeData + +tunes: Record~BlockTuneName, BlockTune~ + +parent: EditorDocument + +createDataNode(key, data) + +removeDataNode(key) + +updateValue(key, value) + +getText(key): string + +getFragments(key, ...): InlineFragment[] + } + + class BlockTune { + +name: BlockTuneName + +data: Record~string, unknown~ + +update(key, value) + } + + class TextNode { + +length: number + +getText(start?, end?): string + +insertText(text, index?) + +removeText(start?, end?) + +format(tool, start, end, data?) + +unformat(tool, start, end) + +getFragments(start?, end?, tool?): InlineFragment[] + } + + class ValueNode~T~ { + +value: T + +update(value: T) + } + + class ParentInlineNode { + <> + +children: InlineNode[] + +insertText(text, index?) + +removeText(start?, end?) + +format(tool, start, end, data?) + +unformat(tool, start, end) + +getFragments(start?, end?): InlineFragment[] + } + + class FormattingInlineNode { + +tool: InlineToolName + +data?: InlineToolData + +format(tool, start, end, data?) + +unformat(tool, start, end) + +split(index): FormattingInlineNode + } + + class TextInlineNode { + +value: string + +length: number + +insertText(text, index?) + +removeText(start?, end?) + +format(tool, start, end, data?): InlineNode[] + } + + %% ── Caret management ───────────────────────────── + class CaretManager { + <> + -registry: Map~userId, Caret~ + +getCaret(userId): Caret + +createCaret(userId, index?): Caret + +updateCaret(caret) + +removeCaret(caret) + } + + class Caret { + <> + -userId: string or number + -index: Index or null + +update(index: Index or null) + +toJSON(): CaretSerialized + } + + %% ── Base ───────────────────────────────────────── + class EventBus { + <> + } + + %% EventBus inheritance + CaretManager --|> EventBus + Caret --|> EventBus + EditorJSModel --|> EventBus + EditorDocument --|> EventBus + BlockNode --|> EventBus + BlockTune --|> EventBus + ValueNode --|> EventBus + ParentInlineNode --|> EventBus + + %% Document composition + EditorJSModel "1" *-- "1" EditorDocument : document + + %% Tree structure + EditorDocument "1" *-- "0..*" BlockNode : children + BlockNode "1" *-- "0..*" TextNode : data[key] + BlockNode "1" *-- "0..*" ValueNode : data[key] + BlockNode "1" *-- "0..*" BlockTune : tunes[name] + + %% Inline tree + TextNode --|> ParentInlineNode + FormattingInlineNode --|> ParentInlineNode + ParentInlineNode "1" *-- "0..*" FormattingInlineNode : children + ParentInlineNode "1" *-- "0..*" TextInlineNode : children + + %% Caret management (declared last → placed right) + EditorJSModel "1" *-- "1" CaretManager : caretManager + CaretManager "1" *-- "0..*" Caret : registry + diff --git a/docs/diagrams/plugin-lifecycle-flow.mmd b/docs/diagrams/plugin-lifecycle-flow.mmd new file mode 100644 index 00000000..d42ab0fd --- /dev/null +++ b/docs/diagrams/plugin-lifecycle-flow.mmd @@ -0,0 +1,66 @@ +--- +title: Plugin Lifecycle Flow +config: + theme: neutral +--- +%% See: ../plugins.md +sequenceDiagram + actor Dev as Developer + participant Core + participant Plugins as plugins sub-container + participant IoC as IoC Container + participant ToolsManager + participant EventBus + participant EditorJSModel + participant BlockRenderer + participant Plugin as EditorjsPlugin + participant Adapter as EditorJSAdapterPlugin + participant Tool as BlockTool / InlineTool + + %% ── Constructor ────────────────────────────────── + Dev->>Core: new Core(config) + Core->>IoC: bind EditorConfig, EventBus, EditorJSModel, ToolsManager + Core->>Plugins: bind DOMAdapters, Paragraph, Bold, Italic, Link, ShortcutsPlugin + + %% ── Registration ───────────────────────────────── + + Dev->>Core: use(CustomAdapter) + Core->>Plugins: rebind PluginType.Adapter = CustomAdapter + + Dev->>Core: use(SomePlugin) + Core->>Plugins: bind PluginType.Plugin = SomePlugin + + Dev->>Core: use(SomeTool, settings) + Core->>Plugins: bind ToolType.Block / Inline / Tune = [SomeTool, settings] + + %% ── await initialize() ─────────────────────────── + Dev->>Core: await initialize() + + Core->>IoC: bind TOKENS.Adapter as toDynamicValue(ctx => new Adapter(model, config, api, eventBus)) + + Core->>Plugins: getAll(PluginType.Plugin) + Plugins-->>Core: [SomePlugin, ...] + Core->>Plugin: new SomePlugin(config, api, eventBus) + Plugin->>EventBus: wire event listeners + + Core->>Plugins: getAll(ToolType.Block / Inline / Tune) + Plugins-->>Core: [[SomeTool, settings], ...] + Core->>ToolsManager: prepareTools(blockTools, inlineTools, blockTunes) + loop for each tool + ToolsManager->>Tool: Tool.prepare(toolName, config) + ToolsManager->>ToolsManager: wrap in BlockToolFacade or InlineToolFacade + ToolsManager->>EventBus: dispatch ToolLoadedCoreEvent(facade) + end + + Core->>IoC: get SelectionManager, BlocksManager, BlockRenderer + + Core->>EditorJSModel: initializeDocument(blocks) + EditorJSModel-->>BlockRenderer: BlockAddedEvent (per block) + BlockRenderer->>Adapter: createBlockToolAdapter(blockIndex, toolName) + BlockRenderer->>Tool: facade.create(adapter, data) + + Core->>Core: collaborationManager.connect() + + %% ── Destroy ────────────────────────────────────── + Dev->>Plugin: plugin.destroy() + Plugin->>EventBus: removeEventListener (cleanup) diff --git a/docs/diagrams/undo-redo-flow.mmd b/docs/diagrams/undo-redo-flow.mmd new file mode 100644 index 00000000..06a31806 --- /dev/null +++ b/docs/diagrams/undo-redo-flow.mmd @@ -0,0 +1,62 @@ +--- +title: Undo / Redo Flow +config: + theme: neutral +--- +%% See: ../collaboration.md +sequenceDiagram + actor User + participant CollaborationManager + participant UndoRedoManager + participant OTClient + participant EditorJSModel + + %% ── Rapid typing — ops are batched ────────────── + User->>EditorJSModel: insertText "H" (op1) + EditorJSModel-->>CollaborationManager: TextAddedEvent + CollaborationManager->>OTClient: send(op1) + CollaborationManager->>CollaborationManager: #currentBatch = new BatchedOperation(op1), reset debounce + + User->>EditorJSModel: insertText "i" (op2) + EditorJSModel-->>CollaborationManager: TextAddedEvent + CollaborationManager->>OTClient: send(op2) + CollaborationManager->>CollaborationManager: currentBatch.add(op2), reset debounce + + User->>EditorJSModel: insertText "!" (op3) + EditorJSModel-->>CollaborationManager: TextAddedEvent + CollaborationManager->>OTClient: send(op3) + CollaborationManager->>CollaborationManager: currentBatch.add(op3), reset debounce + + %% ── Batch terminates on timeout ────────────────── + Note over CollaborationManager: debounce timeout fires + CollaborationManager->>UndoRedoManager: put(currentBatch) + + %% ── Batch terminated early by incompatible op ──── + User->>EditorJSModel: insertText "X" (opA — different block/key) + EditorJSModel-->>CollaborationManager: TextAddedEvent + Note over CollaborationManager: currentBatch.canAdd(opA) → false + CollaborationManager->>UndoRedoManager: put(currentBatch) + CollaborationManager->>CollaborationManager: #currentBatch = new BatchedOperation(opA) + + %% ── Undo ───────────────────────────────────────── + User->>CollaborationManager: undo() + Note over CollaborationManager: #putBatchToUndo() — flush currentBatch + CollaborationManager->>UndoRedoManager: put(currentBatch) + + CollaborationManager->>UndoRedoManager: undo() + UndoRedoManager->>UndoRedoManager: pop undoStack → op.inverse() → Delete "Hi!" + UndoRedoManager->>UndoRedoManager: push inverted op to redoStack + UndoRedoManager-->>CollaborationManager: invertedOp (Delete "Hi!") + + CollaborationManager->>EditorJSModel: applyOperation(invertedOp) + + %% ── Redo ───────────────────────────────────────── + User->>CollaborationManager: redo() + Note over CollaborationManager: #putBatchToUndo() — flush currentBatch + CollaborationManager->>UndoRedoManager: redo() + UndoRedoManager->>UndoRedoManager: pop redoStack → op.inverse() → Insert "Hi!" + UndoRedoManager->>UndoRedoManager: push inverted op back to undoStack + UndoRedoManager-->>CollaborationManager: invertedOp (Insert "Hi!") + + CollaborationManager->>EditorJSModel: applyOperation(invertedOp) + diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 00000000..5d2ea57f --- /dev/null +++ b/docs/events.md @@ -0,0 +1,68 @@ +# Event System + +The editor exposes two public event transports. + +| Transport | Dispatched on | Event types | +|---|---|---| +| `EditorJSModel` `EventType.Changed` | `EditorJSModel` instance | `BlockAddedEvent`, `BlockRemovedEvent`, `TextAddedEvent`, `TextRemovedEvent`, `TextFormattedEvent`, `TextUnformattedEvent`, `ValueModifiedEvent`, `TuneModifiedEvent`, `DataNodeAddedEvent`, `DataNodeRemovedEvent`, `PropertyModifiedEvent` | +| `EditorJSModel` `EventType.CaretManagerUpdated` | `EditorJSModel` instance | `CaretManagerCaretUpdatedEvent`, `CaretManagerCaretAddedEvent`, `CaretManagerCaretRemovedEvent` | +| Core `EventBus` (per editor instance) | `EventBus` held in the IoC container | `BlockAddedCoreEvent`, `BlockRemovedCoreEvent`, `ToolLoadedCoreEvent`, `SelectionChangedCoreEvent`, `UndoCoreEvent`, `RedoCoreEvent`, `BeforeInputUIEvent` | + +## Model events (`@editorjs/model`) + +All document mutation events extend `BaseDocumentEvent`: +- `detail.index` — location of the change +- `detail.action` — action type +- `detail.data` — changed value +- `detail.userId` — who made the change + +### Full model event reference + +| Event | `detail.data` type | When | +|---|---|---| +| `BlockAddedEvent` | `BlockNodeSerialized` | A block was inserted | +| `BlockRemovedEvent` | `BlockNodeSerialized` | A block was removed | +| `TextAddedEvent` | `string` | Characters inserted into a `TextNode` | +| `TextRemovedEvent` | `string` | Characters deleted from a `TextNode` | +| `TextFormattedEvent` | `{ tool, data }` | An inline tool was applied to a text range | +| `TextUnformattedEvent` | `{ tool, data }` | An inline tool was removed from a text range | +| `ValueModifiedEvent` | `{ value, previous }` | A `ValueNode`'s value was changed | +| `TuneModifiedEvent` | `{ value, previous }` | A `BlockTune`'s data was updated | +| `DataNodeAddedEvent` | `BlockNodeDataSerializedValue` | A data node (text or value) was created on a block | +| `DataNodeRemovedEvent` | `BlockNodeDataSerializedValue` | A data node was removed from a block | +| `PropertyModifiedEvent` | `{ value, previous }` | A top-level document property was set | + +Use model events for synchronization, collaboration, and persistence logic. + +## Core / UI events (`@editorjs/sdk`) + +Dispatched on the IoC-managed `EventBus` (one instance per editor) with prefixed type strings (`core:*`, `ui:*`). Higher-level signals for plugins and tools. + +| Event | Type string | `detail` shape | Who dispatches | +|---|---|---|---| +| `BlockAddedCoreEvent` | `core:block-added` | `{ tool, data, index, ui: HTMLElement }` | `BlockRenderer` | +| `BlockRemovedCoreEvent` | `core:block-removed` | `{ tool, index }` | `BlockRenderer` | +| `ToolLoadedCoreEvent` | `core:tool-loaded` | `{ tool: ToolFacadeClass }` | `ToolsManager` | +| `SelectionChangedCoreEvent` | `core:selection-changed` | `{ index, availableInlineTools, fragments }` | `SelectionManager` | +| `UndoCoreEvent` | `core:undo` | — | `BlocksUI` (Cmd/Ctrl+Z) | +| `RedoCoreEvent` | `core:redo` | — | `BlocksUI` (Cmd/Ctrl+Shift+Z) | +| `BeforeInputUIEvent` | `ui:before-input` | `{ data, inputType, targetRanges, isCrossInputSelection }` | `BlocksUI` | + +`BlockAddedCoreEvent` carries the rendered `HTMLElement` in `detail.ui`, while the model-level `BlockAddedEvent` carries serialised data — they are complementary. + +Use core/UI events for UI workflows and extension coordination. + +## Adapter internals + +`BlockToolAdapter` and `CaretAdapter` maintain per-block/per-input state. `BlockToolAdapter` dispatches `KeyAddedEvent`, `KeyRemovedEvent`, and `ValueNodeChangedEvent` on its own internal event bus — these are consumed by block tools, not by the rest of the system. + +## Quick choice + +- Need document truth? Listen on `EditorJSModel`. +- Need app-level UI signal? Listen on global `EventBus`. +- Need per-block behavior? Implement it in the tool/adapter path and rely on model/core events for cross-component signaling. + + +→ [`diagrams/events-catalog.mmd`](diagrams/events-catalog.mmd) + +_Event classes grouped by package and transport. Model events are dispatched on `EditorJSModel`; SDK core/UI events are dispatched on the global `EventBus`._ diff --git a/docs/input-handling.md b/docs/input-handling.md new file mode 100644 index 00000000..25724274 --- /dev/null +++ b/docs/input-handling.md @@ -0,0 +1,42 @@ +# Input Handling + +This page is the canonical typing and selection pipeline. + +## Block adapter creation + +When a block appears, `BlockRenderer` creates a per-block `BlockToolAdapter`. + +The block tool registers DOM inputs through that adapter. Inputs are tracked inside adapter internals (`BlockToolAdapter` and `CaretAdapter`) for selection and rendering lookups. + + +→ [`diagrams/block-adapter-input-flow.mmd`](diagrams/block-adapter-input-flow.mmd) + +_Block inserted → `BlockAddedEvent` → `BlockRenderer` creates adapter → tool attaches inputs. Typing then flows through events into model mutation and targeted DOM update._ + +## BeforeInput delegation + +The `contenteditable` blocks holder is owned by `BlocksUI` (`@editorjs/ui`). It intercepts the browser `beforeinput` event, prevents its default, and re-dispatches it as `BeforeInputUIEvent` on the global `EventBus`. `DOMBlockToolAdapter` listens for this event and performs the actual model mutation (`insertText`, `removeText`, etc.). + +## Caret & selection + +`CaretAdapter` listens to browser `selectionchange`, scans attached inputs, and builds an `Index` in document coordinates. + +That index is written to the model. `SelectionManager` reads it, resolves fragments/tools, and emits `SelectionChangedCoreEvent`. + +On the return path, `CaretAdapter` restores DOM selection from the model index after re-renders, so caret state stays stable. + + +→ [`diagrams/caret-selection-flow.mmd`](diagrams/caret-selection-flow.mmd) + +_Caret moved -> adapter builds `Index` -> model caret update -> tool availability computed -> UI event emitted -> DOM selection restored if needed._ + +## Inline formatting + +When an inline tool is applied, `SelectionManager.applyInlineToolForCurrentSelection()` reads the current caret index, queries the model for existing fragments in the selection range, and calls `model.format()` or `model.unformat()` directly depending on whether the range is already formatted. + +The model emits `TextFormattedEvent` / `TextUnformattedEvent`; `FormattingAdapter` listens to these events, re-renders only the affected DOM range, and then caret position is restored from the model index. + + +→ [`diagrams/inline-formatting-flow.mmd`](diagrams/inline-formatting-flow.mmd) + +_Inline tool activation -> model format/unformat -> formatting event -> targeted DOM rerender -> caret restore._ diff --git a/docs/model.md b/docs/model.md new file mode 100644 index 00000000..faeec305 --- /dev/null +++ b/docs/model.md @@ -0,0 +1,49 @@ +# Data Model + +`EditorJSModel` is the source of truth. All document mutations go through it. + +Internally it owns an `EditorDocument` (ordered `BlockNode[]`) and a `CaretManager` (one caret per `userId`). + +## Document tree + +Each `BlockNode` contains keyed data nodes: + +- `TextNode`: rich text with inline tree (`FormattingInlineNode` + `TextInlineNode`). +- `ValueNode`: non-text typed value for tools. +- `BlockTune`: per-block tune configuration. + +## Mutation and event invariant + +- Nodes dispatch internal change events when they mutate. +- `EditorJSModel` listens and re-dispatches a normalized stream for consumers. +- Consumers should subscribe to `EditorJSModel` events instead of listening to deep nodes. + + +→ [`diagrams/model-tree-structure.mmd`](diagrams/model-tree-structure.mmd) + +_Node hierarchy. Document tree (left): `EditorJSModel` → `EditorDocument` → `BlockNode` → data nodes. Caret (right): `CaretManager` holds per-user `Caret` instances. All nodes extend `EventBus`._ + +## Caret & selection + +`CaretManager` stores one `Caret` per collaborating user. Each `Caret` holds an `Index`: a serializable selection structure that can span blocks/data keys. + +When a caret changes, `EditorJSModel` exposes that update under `EventType.CaretManagerUpdated` so the rest of the system can react without reading DOM state directly. + +## Index + +`Index` is the universal address type used throughout the system — for event locations, caret positions, and OT operation targets. It is DOM-independent and fully serializable. + +| Field | Type | Meaning | +|---|---|---| +| `documentId` | `DocumentId?` | Which document the index belongs to | +| `blockIndex` | `number?` | Position of the block in `EditorDocument.children` | +| `dataKey` | `DataKey?` | Named data slot inside the block (e.g. `"text"`) | +| `textRange` | `[number, number]?` | Character-offset range `[start, end]` inside a `TextNode` | +| `tuneName` | `BlockTuneName?` | Identifies a `BlockTune` entry | +| `tuneKey` | `string?` | Key inside a tune's data object | +| `propertyName` | `string?` | Top-level document property | +| `compositeSegments` | `Index[]?` | For cross-input selections: one text index per covered input, in document order | + +An index that has `blockIndex + dataKey + textRange` (and no `compositeSegments`) is a **text index** (`isTextIndex === true`). An index with only `blockIndex` is a **block index** (`isBlockIndex === true`). + +Use `IndexBuilder` to construct indices incrementally, and `Index.parse(serialized)` / `index.serialize()` to round-trip through storage or the network. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..050f8cbe --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,69 @@ +# Plugins & Tools + +## Registration + +`core.use(...)` registers UI components/plugins by static `type` (values from `ToolType` for tools, `PluginType.Adapter` for adapters, and `PluginType.Plugin` for general plugins). + +Tools are configured through editor config (`tools`) and loaded by `ToolsManager` during `initialize()`. + +| Type | Interface / Source | Purpose | +|---|---|---| +| UI Plugin | `EditorjsPlugin` | UI component/behavior registered via `core.use(...)` | +| Block Tool | `BlockTool` (from config `tools`) | Block rendering and block-specific behavior | +| Inline Tool | `InlineTool` (from config `tools`) | Selection formatting actions | +| Block Tune | `BlockTune` (from config `tools`) | Per-block tune behavior | + +## Initialization sequence + +Canonical startup order: + +1. Initialize the adapter (`DOMAdapters` / `PluginType.Adapter`). +2. Instantiate registered UI plugins. +3. Prepare tools and emit `ToolLoadedCoreEvent` for each available tool. +4. Resolve `SelectionManager`, `BlocksManager`, and `BlockRenderer`. `BlockRenderer` subscribes to model block events and creates `BlockToolAdapter` instances per block when a `BlockAddedEvent` fires. +5. Initialize model with configured blocks (triggers `BlockAddedEvent` for each block). +6. Connect collaboration manager. + +## Lifecycle boundary + +- Plugins receive dependencies via constructor params (`config`, `api`, `eventBus`). +- Plugin instances may implement `destroy()`, but `Core` currently does not expose a global `destroy()` lifecycle hook. + +## EditorAPI + +Every plugin and tool receives an `api` object of type `EditorAPI` in its constructor. It is composed of three namespaces: + +### `api.blocks` + +Programmatic block management — delegates to `BlocksManager`. + +| Method | Description | +|---|---| +| `insert(type?, data?, index?, focus?, replace?)` | Insert a block of the given tool type | +| `insertMany(blocks, index?)` | Insert multiple serialised blocks | +| `delete(index?)` | Remove a block (defaults to caret block) | +| `move(toIndex, fromIndex?)` | Move a block to a new position | +| `render(document)` | Re-initialize the document from serialised data | +| `clear()` | Remove all blocks | +| `getBlocksCount()` | Return the total number of blocks | + +### `api.selection` + +Inline tool application — delegates to `SelectionManager`. + +| Method | Description | +|---|---| +| `applyInlineToolForCurrentSelection(toolName, data?)` | Apply or toggle an inline tool on the current caret selection | + +### `api.document` + +Read-only document access — delegates to `DocumentAPI`. + +| Property | Description | +|---|---| +| `data` | Returns `EditorDocumentSerialized` — the current serialised document state | + + +→ [`diagrams/plugin-lifecycle-flow.mmd`](diagrams/plugin-lifecycle-flow.mmd) + +_`new Core` wires services; `use()` registers UI plugins; `initialize()` prepares tools, initializes document, and starts collaboration._ From 8257fba1eb3dca741a002d6d3e668ec1c2866df2 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 7 May 2026 23:38:12 +0100 Subject: [PATCH 14/14] Remove conflicts --- .../src/entities/BlockNode/BlockNode.spec.ts | 61 ------------------- 1 file changed, 61 deletions(-) diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index fba8ec93..fad3e79c 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -28,13 +28,6 @@ const ValueNodeProto = ValueNode.prototype as unknown as { update: () => void; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed to spy on conditional-typed getter with @jest/globals strict types -const ValueNodeProto = ValueNode.prototype as unknown as { - serialized: unknown; - value: unknown; - update: () => void; -}; - jest.mock('../BlockTune'); jest.mock('../inline-fragments/TextNode'); @@ -759,60 +752,6 @@ describe('BlockNode', () => { }); }); - describe('.getDataNode()', () => { - it('should return undefined if the key does not exist', () => { - const blockNode = createBlockNodeWithData({}); - const key = createDataKey('nonexistent'); - - const result = blockNode.getDataNode(key); - - expect(result).toBeUndefined(); - }); - - it('should return serialized ValueNode for a value key', () => { - const key = createDataKey('url'); - const value = 'https://editorjs.io'; - const blockNode = createBlockNodeWithData({ [key]: value }); - - jest.spyOn(ValueNodeProto, 'serialized', 'get').mockReturnValueOnce(value); - - const result = blockNode.getDataNode(key); - - expect(result).toBe(value); - }); - - it('should return serialized TextNode for a text key', () => { - const key = createDataKey('text'); - const value = { - $t: 't', - value: 'some text', - }; - const blockNode = createBlockNodeWithData({ [key]: value }); - - const serialized = { $t: 't' as const, - value: 'some text', - fragments: [] } as unknown as TextNodeSerialized; - - jest.spyOn(TextNode.prototype, 'serialized', 'get').mockReturnValueOnce(serialized); - - const result = blockNode.getDataNode(key); - - expect(result).toBe(serialized); - }); - - it('should throw InvalidNodeTypeError if the key holds a nested object (not a leaf node)', () => { - const blockNode = createBlockNodeWithData({ - nested: { - value: 'some-value', - }, - }); - const key = createDataKey('nested'); - - expect(() => blockNode.getDataNode(key)) - .toThrow(`BlockNode: data with key "${key}" is not a text or a value`); - }); - }); - describe('.removeDataNode()', () => { it('should remove data from the block', () => { const key = createDataKey('url');