diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index bf3cab25..02104bbf 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -3,7 +3,7 @@ import { BlockAddedEvent, BlockRemovedEvent, createDataKey, - type DataKey, + type DataKey, DataNodeAddedEvent, DataNodeRemovedEvent, type EditorJSModel, EventAction, EventType, @@ -71,8 +71,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { /** * Inputs that bound to the model - * - * @todo handle inputs deletion — remove inputs from the map when they are removed from the DOM */ #attachedInputs = new Map(); @@ -103,6 +101,8 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#formattingAdapter = formattingAdapter; this.#toolName = toolName; + this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event)); + eventBus.addEventListener(`ui:${BeforeInputUIEventName}`, (event: BeforeInputUIEvent) => { this.#processDelegatedBeforeInput(event); }); @@ -124,7 +124,15 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#attachedInputs.set(key, input); - this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event, input, key)); + this.#model.createDataNode( + this.#config.userId, + this.#blockIndex, + key, + { + $t: 't', + value: '', + } + ); const builder = new IndexBuilder(); @@ -132,18 +140,43 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#caretAdapter.attachInput(input, builder.build()); - try { - 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); - input.textContent = value; + input.textContent = value; + + fragments.forEach(fragment => { + this.#formattingAdapter.formatElementContent(input, fragment); + }); + } + + /** + * Removes the input from the DOM by key + * + * @param keyRaw - key of the input to remove + */ + public detachInput(keyRaw: string): void { + const key = createDataKey(keyRaw); + const input = this.#attachedInputs.get(key); - fragments.forEach(fragment => { - this.#formattingAdapter.formatElementContent(input, fragment); - }); - } catch (_) { - // do nothing — TextNode is not created yet as there is no initial data in the model + if (!input) { + return; } + + /** + * @todo Let BlockTool handle DOM update + */ + input.remove(); + this.#caretAdapter.detachInput( + new IndexBuilder() + .addBlockIndex(this.#blockIndex) + .addDataKey(key) + .build() + ); + + this.#attachedInputs.delete(key); + + this.#model.removeDataNode(this.#config.userId, this.#blockIndex, key); } /** @@ -151,7 +184,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * * @returns tuple of data key and input element or null if no focused input is found */ - #findFocusedInput(): [DataKey, HTMLElement] | null { + #findFocusedInput(): [ DataKey, HTMLElement ] | null { const currentInput = Array.from(this.#attachedInputs.entries()).find(([_, input]) => { /** * Case 1: Input is a native input — check if it has selection @@ -421,7 +454,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { this.#config.userId, { name: this.#toolName, - data : { + data: { [key]: { $t: 't', value: newValueAfter, @@ -451,35 +484,12 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * * @param event - model update event * @param input - input element - * @param key - data key input is attached to */ - #handleModelUpdateForNativeInput(event: ModelEvents, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void { - if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) { - return; - } - - const { textRange, dataKey, blockIndex } = event.detail.index; - - if (textRange === undefined) { - return; - } - - /** - * Event is not related to the attached block - */ - if (blockIndex !== this.#blockIndex) { - return; - } - - /** - * Event is not related to the attached data key - */ - if (dataKey !== key) { - return; - } + #handleModelUpdateForNativeInput(event: ModelEvents, input: HTMLInputElement | HTMLTextAreaElement): void { + const { textRange } = event.detail.index; const currentElement = input; - const [start, end] = textRange; + const [start, end] = textRange!; const action = event.detail.action; @@ -519,31 +529,10 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @param key - data key input is attached to */ #handleModelUpdateForContentEditableElement(event: ModelEvents, input: HTMLElement, key: DataKey): void { - if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) { - return; - } - - const { textRange, dataKey, blockIndex } = event.detail.index; - - if (blockIndex !== this.#blockIndex) { - return; - } - - /** - * Event is not related to the attached data key - */ - if (dataKey !== key) { - return; - } - - if (textRange === undefined) { - return; - } - + const { textRange } = event.detail.index; const action = event.detail.action; - const start = textRange[0]; - const end = textRange[1]; + const [start, end] = textRange!; const [startNode, startOffset] = getBoundaryPointByAbsoluteOffset(input, start); const [endNode, endOffset] = getBoundaryPointByAbsoluteOffset(input, end); @@ -586,22 +575,51 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * Handles model update events and updates DOM * * @param event - model update event - * @param input - attached input element - * @param key - data key input is attached to */ - #handleModelUpdate(event: ModelEvents, input: HTMLElement, key: DataKey): void { + #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; } + + return; + } + + const { textRange, dataKey, blockIndex } = event.detail.index; + + if (blockIndex !== this.#blockIndex) { + return; + } + + + if (event instanceof DataNodeRemovedEvent) { + this.detachInput(dataKey as string); + + return; + } + + 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; + } + + 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, key); + this.#handleModelUpdateForNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement); } else { - this.#handleModelUpdateForContentEditableElement(event, input, key); + this.#handleModelUpdateForContentEditableElement(event, input, dataKey!); } }; } diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index 6f0e113c..5e8136fe 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -97,6 +97,15 @@ export class CaretAdapter extends EventTarget { this.#inputs.set(index.serialize(), input); } + /** + * Removes input from the caret adapter + * + * @param index - index of the input to remove + */ + public detachInput(index: Index): void { + this.#inputs.delete(index.serialize()); + } + /** * Updates current user's caret index * diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index 022db3e5..f1793a1e 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -22,6 +22,8 @@ describe('EditorJSModel', () => { 'updateValue', 'removeBlock', 'moveBlock', + 'createDataNode', + 'removeDataNode', 'getText', 'insertText', 'removeText', diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index d5ed474c..166b1f78 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -257,6 +257,35 @@ export class EditorJSModel extends EventBus { this.#document.modifyData(index, data); } + /** + * Creates a data node (ValueNode or TextNode) with the specified key in the BlockNode at the specified index. + * + * @param _userId - user identifier which is being set to the context + * @param parameters - updateValue method parameters + * @param parameters.blockIndex - The index of the BlockNode to update + * @param parameters.dataKey - The key of the data node to create + * @param parameters.value - The initial value of the data node + * @throws Error if the index is out of bounds + */ + @WithContext + public createDataNode(_userId: string | number, ...parameters: Parameters): ReturnType { + return this.#document.createDataNode(...parameters); + } + + /** + * Removes a data node (ValueNode or TextNode) with the specified key in the BlockNode at the specified index. + * + * @param _userId - user identifier which is being set to the context + * @param parameters - updateValue method parameters + * @param parameters.blockIndex - The index of the BlockNode to update + * @param parameters.dataKey - The key of the data node to remove + * @throws Error if the index is out of bounds + */ + @WithContext + public removeDataNode(_userId: string | number, ...parameters: Parameters): ReturnType { + return this.#document.removeDataNode(...parameters); + } + /** * Updates the ValueNode data associated with the BlockNode at the specified index. * diff --git a/packages/model/src/EventBus/events/DataNodeAddedEvent.ts b/packages/model/src/EventBus/events/DataNodeAddedEvent.ts new file mode 100644 index 00000000..3cd9d14a --- /dev/null +++ b/packages/model/src/EventBus/events/DataNodeAddedEvent.ts @@ -0,0 +1,22 @@ +import type { BlockNodeDataSerializedValue } from '../../entities/BlockNode/types/index.js'; +import type { Index } from '../../entities/Index/index.js'; +import { EventAction } from '../types/EventAction.js'; +import { BaseDocumentEvent } from './BaseEvent.js'; + + +/** + * DataNodeAdded Custom Event + */ +export class DataNodeAddedEvent extends BaseDocumentEvent { + /** + * DataNodeAdded class constructor + * + * @param index - index of the added BlockNode in the document + * @param data - data serialized value + * @param userId - user identifier + */ + constructor(index: Index, data: BlockNodeDataSerializedValue, userId: string | number) { + // Stryker disable next-line ObjectLiteral + super(index, EventAction.Added, data, userId); + } +} diff --git a/packages/model/src/EventBus/events/DataNodeRemovedEvent.ts b/packages/model/src/EventBus/events/DataNodeRemovedEvent.ts new file mode 100644 index 00000000..dd9874e3 --- /dev/null +++ b/packages/model/src/EventBus/events/DataNodeRemovedEvent.ts @@ -0,0 +1,22 @@ +import type { BlockNodeDataSerializedValue } from '../../entities/BlockNode/types/index.js'; +import type { Index } from '../../entities/Index/index.js'; +import { EventAction } from '../types/EventAction.js'; +import { BaseDocumentEvent } from './BaseEvent.js'; + + +/** + * DataNodeRemoved Custom Event + */ +export class DataNodeRemovedEvent extends BaseDocumentEvent { + /** + * DataNodeRemoved class constructor + * + * @param index - index of the added BlockNode in the document + * @param data - data serialized value + * @param userId - user identifier + */ + constructor(index: Index, data: BlockNodeDataSerializedValue, userId: string | number) { + // Stryker disable next-line ObjectLiteral + super(index, EventAction.Removed, data, userId); + } +} diff --git a/packages/model/src/EventBus/events/index.ts b/packages/model/src/EventBus/events/index.ts index 11a1e78d..e218bee3 100644 --- a/packages/model/src/EventBus/events/index.ts +++ b/packages/model/src/EventBus/events/index.ts @@ -10,3 +10,5 @@ export * from './ValueModifiedEvent.js'; export * from './CaretManagerCaretUpdatedEvent.js'; export * from './CaretManagerCaretAddedEvent.js'; export * from './CaretManagerCaretRemovedEvent.js'; +export * from './DataNodeAddedEvent.js'; +export * from './DataNodeRemovedEvent.js'; diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index 32eb8661..4dd862d6 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -1,4 +1,4 @@ -import { expect } from '@jest/globals'; +import { EventAction } from '../../EventBus/index.js'; import { Index } from '../Index/index.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; import { BlockNode, createBlockToolName, createDataKey } from './index.js'; @@ -421,6 +421,145 @@ describe('BlockNode', () => { }); }); + describe('.createDataNode()', () => { + it('should create value data node', () => { + const blockNodeName = createBlockToolName('paragraph'); + + const blockNode = new BlockNode({ + name: blockNodeName, + data: {}, + parent: {} as EditorDocument, + }); + + const key = createDataKey('url'); + const value = 'https://editorjs.io'; + + + blockNode.createDataNode(key, value); + + expect(blockNode.data[key]).toBeInstanceOf(ValueNode); + }); + + it('should create text data node', () => { + const blockNodeName = createBlockToolName('paragraph'); + + const blockNode = new BlockNode({ + name: blockNodeName, + data: {}, + parent: {} as EditorDocument, + }); + + const key = createDataKey('text'); + const value = { + $t: 't', + value: 'text', + }; + + blockNode.createDataNode(key, value); + + expect(blockNode.data[key]).toBeInstanceOf(TextNode); + }); + + it('should emit DataNodeAddedEvent', () => { + const blockNodeName = createBlockToolName('paragraph'); + + const blockNode = new BlockNode({ + name: blockNodeName, + data: {}, + parent: {} as EditorDocument, + }); + + const listener = jest.fn(); + + blockNode.addEventListener(EventType.Changed, listener); + + const key = createDataKey('text'); + const value = { + $t: 't', + value: 'text', + }; + + blockNode.createDataNode(key, value); + + expect(listener).toBeCalledWith(expect.objectContaining({ + detail: { + action: EventAction.Added, + index: expect.objectContaining({ dataKey: key }), + data: value, + }, + })); + }); + + it('should not change the node if key already exists', () => { + const key = createDataKey('url'); + const value = 'https://editorjs.io'; + const blockNode = createBlockNodeWithData({ [key]: value }); + + const currentNode = blockNode.data[key]; + + blockNode.createDataNode(key, 'another value'); + + expect(blockNode.data[key]).toStrictEqual(currentNode); + }); + + it('should not emit DataNodeAddedEvent if key already exists', () => { + const key = createDataKey('url'); + const value = 'https://editorjs.io'; + const blockNode = createBlockNodeWithData({ [key]: value }); + const listener = jest.fn(); + + blockNode.addEventListener(EventType.Changed, listener); + + blockNode.createDataNode(key, value); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('.removeDataNode()', () => { + it('should remove data from the block', () => { + const key = createDataKey('url'); + const blockNode = createBlockNodeWithData({ [key]: 'editorjs.io' }); + + blockNode.removeDataNode(key); + + expect(blockNode.data[key]).toBeUndefined(); + }); + + it('should emit DataNodeRemovedEvent', () => { + const key = createDataKey('url'); + const value = 'https://editorjs.io'; + const blockNode = createBlockNodeWithData({ [key]: value }); + const listener = jest.fn(); + + jest.spyOn(ValueNode.prototype, 'serialized', 'get').mockReturnValueOnce(value); + + blockNode.addEventListener(EventType.Changed, listener); + + blockNode.removeDataNode(key); + + expect(listener).toBeCalledWith(expect.objectContaining({ + detail: { + action: EventAction.Added, + index: expect.objectContaining({ dataKey: key }), + data: value, + }, + })); + }); + + it('should not emit DataNodeRemovedEvent if key doesnt exist', () => { + const key = createDataKey('url'); + const blockNode = createBlockNodeWithData({}); + const listener = jest.fn(); + + blockNode.addEventListener(EventType.Changed, listener); + + blockNode.removeDataNode(key); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('.updateTuneData()', () => { afterEach(() => { jest.clearAllMocks(); @@ -1139,7 +1278,7 @@ describe('BlockNode', () => { const dataKey = createDataKey('text'); const start = 0; const end = 5; - const range: [number, number] = [start, end]; + const range: [ number, number ] = [start, end]; beforeEach(() => { node = createBlockNodeWithData({ diff --git a/packages/model/src/entities/BlockNode/__mocks__/index.ts b/packages/model/src/entities/BlockNode/__mocks__/index.ts index be52ddde..83f80ef3 100644 --- a/packages/model/src/entities/BlockNode/__mocks__/index.ts +++ b/packages/model/src/entities/BlockNode/__mocks__/index.ts @@ -1,9 +1,26 @@ import { EventBus } from '../../../EventBus/EventBus.js'; +import { create } from '../../../utils/index.js'; +import type { DataKey } from '../types/index'; + +export const createDataKey = create(); /** * Mock for BlockNode class */ export class BlockNode extends EventBus { + /** + * Mock method + */ + public createDataNode(): void { + return; + } + /** + * Mock method + */ + public removeDataNode(): void { + return; + } + /** * Mock method */ diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index 64313030..0fc33ae5 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -1,3 +1,4 @@ +import { DataNodeAddedEvent } from '../../EventBus/events/DataNodeAddedEvent.js'; import { getContext } from '../../utils/Context.js'; import type { EditorDocument } from '../EditorDocument'; import type { BlockTuneName, BlockTuneSerialized } from '../BlockTune'; @@ -128,19 +129,10 @@ export class BlockNode extends EventBus { * Returns serialized object representing the BlockNode */ public get serialized(): BlockNodeSerialized { - const map = (data: BlockNodeDataValue): BlockNodeDataSerializedValue => { - if (Array.isArray(data)) { - return data.map(map) as BlockNodeDataSerialized[]; - } - - if (data instanceof ValueNode || data instanceof TextNode) { - return data.serialized; - } - - return mapObject(data, map); - }; - - const serializedData = mapObject(this.#data, map); + const serializedData = mapObject( + this.#data, + (entry) => this.#serializeData(entry) + ); const serializedTunes = mapObject( this.#tunes, @@ -154,6 +146,47 @@ export class BlockNode extends EventBus { }; } + /** + * Creates a node at passed key with initial data + * + * @param dataKey - key for the node + * @param data - initial data of the node + */ + public createDataNode(dataKey: DataKey, data: BlockNodeDataSerializedValue): void { + if (this.#data[dataKey] !== undefined) { + return; + } + + this.#data[dataKey] = this.#mapSerializedDataToNodes(data, dataKey as string); + + const index = new IndexBuilder() + .addDataKey(dataKey) + .build(); + + this.dispatchEvent(new DataNodeAddedEvent(index, data, getContext()!)); + }; + + /** + * Removes a node with the passed key + * + * @param dataKey - key of the node to remove + */ + public removeDataNode(dataKey: DataKey): void { + if (this.#data[dataKey] === undefined) { + return; + } + + const nodeData = this.#serializeData(this.#data[dataKey]); + + delete this.#data[dataKey]; + + const index = new IndexBuilder() + .addDataKey(dataKey) + .build(); + + this.dispatchEvent(new DataNodeAddedEvent(index, nodeData, getContext()!)); + } + /** * Updates data in the BlockTune by the BlockTuneName * @@ -303,48 +336,69 @@ export class BlockNode extends EventBus { * @param data - block data */ #initialize(data: BlockNodeDataSerialized): void { - /** - * Recursively maps serialized data to BlockNodeData - * - * 1. If value is an object with NODE_TYPE_HIDDEN_PROP, then it's a serialized node. - * a. If NODE_TYPE_HIDDEN_PROP is BlockChildType.Value, then it's a serialized ValueNode - * b. If NODE_TYPE_HIDDEN_PROP is BlockChildType.Text, then it's a serialized TextNode - * 2. If value is an array, then it's an array of serialized nodes, so map it recursively - * 3. If value is an object without NODE_TYPE_HIDDEN_PROP, then it's a JSON object, so map it recursively - * 4. Otherwise, it's a primitive value, so create a ValueNode with it - * - * @param value - serialized value - * @param key - keypath of the current value - */ - const mapSerializedToNodes = (value: BlockNodeDataSerializedValue, key: string): BlockNodeData | BlockNodeDataValue => { - if (Array.isArray(value)) { - return value.map((v, i) => mapSerializedToNodes(v, `${key}.${i}`)) as BlockNodeData[] | ChildNode[]; - } + this.#data = mapObject( + data, + (value, key) => this.#mapSerializedDataToNodes(value, key) + ); + } + + /** + * Recursively serializes data value + * + * @param data - data to serialize + */ + #serializeData(data: BlockNodeDataValue): BlockNodeDataSerializedValue { + if (Array.isArray(data)) { + return data.map((entry) => this.#serializeData(entry)) as BlockNodeDataSerialized[]; + } + + if (data instanceof ValueNode || data instanceof TextNode) { + return data.serialized; + } + + return mapObject(data, (entry) => this.#serializeData(entry)); + }; + + + /** + * Recursively maps serialized data to BlockNodeData + * + * 1. If value is an object with NODE_TYPE_HIDDEN_PROP, then it's a serialized node. + * a. If NODE_TYPE_HIDDEN_PROP is BlockChildType.Value, then it's a serialized ValueNode + * b. If NODE_TYPE_HIDDEN_PROP is BlockChildType.Text, then it's a serialized TextNode + * 2. If value is an array, then it's an array of serialized nodes, so map it recursively + * 3. If value is an object without NODE_TYPE_HIDDEN_PROP, then it's a JSON object, so map it recursively + * 4. Otherwise, it's a primitive value, so create a ValueNode with it + * + * @param value - serialized value + * @param key - keypath of the current value + */ + #mapSerializedDataToNodes(value: BlockNodeDataSerializedValue, key: string): BlockNodeData | BlockNodeDataValue { + if (Array.isArray(value)) { + return value.map((v, i) => this.#mapSerializedDataToNodes(v, `${key}.${i}`)) as BlockNodeData[] | ChildNode[]; + } - if (typeof value === 'object' && value !== null) { - if (NODE_TYPE_HIDDEN_PROP in value) { - switch (value[NODE_TYPE_HIDDEN_PROP]) { - case BlockChildType.Value: { - return this.#createValueNode(createDataKey(key), value); - } - case BlockChildType.Text: { - return this.#createTextNode(createDataKey(key), value as TextNodeSerialized); - } + if (typeof value === 'object' && value !== null) { + if (NODE_TYPE_HIDDEN_PROP in value) { + switch (value[NODE_TYPE_HIDDEN_PROP]) { + case BlockChildType.Value: { + return this.#createValueNode(createDataKey(key), value); + } + case BlockChildType.Text: { + return this.#createTextNode(createDataKey(key), value as TextNodeSerialized); } } - - return mapObject(value as BlockNodeDataSerialized, (v, k) => mapSerializedToNodes(v, `${key}.${k}`)); } - const node = new ValueNode({ value }); + return mapObject(value as BlockNodeDataSerialized, (v, k) => this.#mapSerializedDataToNodes(v, `${key}.${k}`)); + } - this.#listenAndBubbleValueNodeEvent(node, key as DataKey); + const node = new ValueNode({ value }); - return node; - }; + this.#listenAndBubbleValueNodeEvent(node, key as DataKey); - this.#data = mapObject(data, mapSerializedToNodes); - } + return node; + }; /** * Creates new text node with passed key and initial value diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index 119af1b6..2e8a9d24 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -595,6 +595,225 @@ describe('EditorDocument', () => { }); }); + describe('.createDataNode()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call .createDataNode() 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(blocksData); + + blocksData.forEach((_, i) => { + const blockNode = document.getBlock(i); + + jest + .spyOn(blockNode, 'createDataNode') + // eslint-disable-next-line @typescript-eslint/no-empty-function -- mock of the method + .mockImplementation(() => { + }); + }); + + const blockIndexToUpdate = 1; + const dataKey = 'data-key-1a2b'; + const value = 'Some value'; + + document.createDataNode(blockIndexToUpdate, dataKey, value); + + expect(document.getBlock(blockIndexToUpdate).createDataNode) + .toHaveBeenCalledWith(dataKey, value); + }); + + it('should not call .createDataNode() 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(blocksData); + + const blockNodes = blocksData.map((_, i) => { + const blockNode = document.getBlock(i); + + jest + .spyOn(blockNode, 'createDataNode') + // eslint-disable-next-line @typescript-eslint/no-empty-function -- mock of the method + .mockImplementation(() => { + }); + + return blockNode; + }); + + const blockIndexToUpdate = 1; + const dataKey = 'data-key-1a2b'; + const value = 'Some value'; + + document.createDataNode(blockIndexToUpdate, dataKey, value); + + blockNodes.forEach((blockNode, index) => { + if (index === blockIndexToUpdate) { + return; + } + + expect(blockNode.createDataNode) + .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 expectedValue = 'new value'; + + const action = (): void => document.createDataNode(blockIndexOutOfBound, dataKey, expectedValue); + + expect(action) + .toThrowError('Index out of bounds'); + }); + }); + + describe('.removeDataNode()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call .removeDataNode() 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(blocksData); + + blocksData.forEach((_, i) => { + const blockNode = document.getBlock(i); + + jest + .spyOn(blockNode, 'removeDataNode') + // eslint-disable-next-line @typescript-eslint/no-empty-function -- mock of the method + .mockImplementation(() => { + }); + }); + + const blockIndexToUpdate = 1; + const dataKey = 'data-key-1a2b'; + + document.removeDataNode(blockIndexToUpdate, dataKey); + + expect(document.getBlock(blockIndexToUpdate).removeDataNode) + .toHaveBeenCalledWith(dataKey); + }); + + it('should not call .removeDataNode() 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(blocksData); + + const blockNodes = blocksData.map((_, i) => { + const blockNode = document.getBlock(i); + + jest + .spyOn(blockNode, 'removeDataNode') + // eslint-disable-next-line @typescript-eslint/no-empty-function -- mock of the method + .mockImplementation(() => { + }); + + return blockNode; + }); + + const blockIndexToUpdate = 1; + const dataKey = 'data-key-1a2b'; + + document.removeDataNode(blockIndexToUpdate, dataKey); + + blockNodes.forEach((blockNode, index) => { + if (index === blockIndexToUpdate) { + return; + } + + expect(blockNode.removeDataNode) + .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.removeDataNode(blockIndexOutOfBound, dataKey); + + expect(action) + .toThrowError('Index out of bounds'); + }); + }); + describe('.updateValue()', () => { beforeEach(() => { jest.clearAllMocks(); @@ -951,6 +1170,22 @@ describe('EditorDocument', () => { .toHaveBeenCalledWith(blockIndex, dataKey, text, 0); }); + it('should call .createDatNode() if data key index is provided', () => { + const spy = jest.spyOn(document, 'createDataNode'); + const index = new IndexBuilder() + .addBlockIndex(blockIndex) + .addDataKey(dataKey) + .build(); + const value = { value: text, + $t: 't' }; + + + document.insertData(index, value); + + expect(spy) + .toHaveBeenCalledWith(blockIndex, dataKey, value); + }); + it('should call .addBlock() if block index is provided', () => { const spy = jest.spyOn(document, 'addBlock'); const index = new IndexBuilder() @@ -1003,6 +1238,19 @@ describe('EditorDocument', () => { .toHaveBeenCalledWith(blockIndex, dataKey, 0, rangeEnd); }); + it('should call .removeDatNode() if data key index is provided', () => { + const spy = jest.spyOn(document, 'removeDataNode'); + const index = new IndexBuilder() + .addBlockIndex(blockIndex) + .addDataKey(dataKey) + .build(); + + document.removeData(index, {}); + + expect(spy) + .toHaveBeenCalledWith(blockIndex, dataKey); + }); + it('should call .removeBlock() if block index is provided', () => { const spy = jest.spyOn(document, 'removeBlock'); const index = new IndexBuilder() diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index d8f35aaa..5c934ade 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -1,6 +1,6 @@ import type { DocumentId } from '../../EventBus/index'; import { getContext } from '../../utils/Context.js'; -import type { DataKey } from '../BlockNode'; +import { createDataKey, type DataKey } from '../BlockNode/index.js'; import { BlockNode } from '../BlockNode/index.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; import type { EditorDocumentSerialized, EditorDocumentConstructorParameters, Properties } from './types'; @@ -8,7 +8,7 @@ import type { BlockTuneName } from '../BlockTune'; 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 { BlockNodeSerialized } from '../BlockNode/types'; +import type { BlockNodeDataSerializedValue, BlockNodeSerialized } from '../BlockNode/types'; import type { DeepReadonly } from '../../utils/DeepReadonly'; import { EventBus } from '../../EventBus/EventBus.js'; import { EventType } from '../../EventBus/types/EventType.js'; @@ -184,6 +184,33 @@ export class EditorDocument extends EventBus { return this.#children[index]; } + /** + * Creates a data node with passed key with initial data for the BlockNode at specified index + * Throws an error if the index is out of bounds. + * + * @param index - block index + * @param key - key for the node + * @param data - initial data of the node + */ + public createDataNode(index: number, key: DataKey | string, data: BlockNodeDataSerializedValue): void { + this.#checkIndexOutOfBounds(index, this.length - 1); + + this.#children[index].createDataNode(createDataKey(key), data); + } + + /** + * Removes a data node with the passed key in the BlockNode at the specified index + * + * @param index - block index + * @param key - key of the node to remove + */ + public removeDataNode(index: number, key: DataKey | string): void { + this.#checkIndexOutOfBounds(index, this.length - 1); + + this.#children[index].removeDataNode(createDataKey(key)); + } + + /** * Returns the serialised properties of the EditorDocument. */ @@ -362,12 +389,16 @@ export class EditorDocument extends EventBus { * @param index - index to insert data * @param data - data to insert (text or blocks) */ - public insertData(index: Index, data: string | BlockNodeSerialized[]): void { + public insertData(index: Index, data: string | BlockNodeSerialized[] | BlockNodeDataSerializedValue): void { switch (true) { case index.isTextIndex: this.insertText(index.blockIndex!, index.dataKey!, data as string, index.textRange![0]); break; + case index.isDataIndex: + this.createDataNode(index.blockIndex!, index.dataKey!, data as BlockNodeDataSerializedValue); + break; + case index.isBlockIndex: (data as BlockNodeSerialized[]) .forEach((blockData, i) => this.addBlock(blockData, index.blockIndex! + i)); @@ -383,10 +414,14 @@ export class EditorDocument extends EventBus { * @param index - index to remove data from * @param data - text or blocks to remove */ - public removeData(index: Index, data: string | BlockNodeSerialized[]): void { + public removeData(index: Index, data: string | BlockNodeSerialized[] | BlockNodeDataSerializedValue): void { switch (true) { case index.isTextIndex: - this.removeText(index.blockIndex!, index.dataKey!, index.textRange![0], index.textRange![0] + data.length); + this.removeText(index.blockIndex!, index.dataKey!, index.textRange![0], index.textRange![0] + (data as string).length); + break; + + case index.isDataIndex: + this.removeDataNode(index.blockIndex!, index.dataKey!); break; case index.isBlockIndex: diff --git a/packages/model/src/entities/Index/Index.spec.ts b/packages/model/src/entities/Index/Index.spec.ts index 0eea4c0e..0e2989df 100644 --- a/packages/model/src/entities/Index/Index.spec.ts +++ b/packages/model/src/entities/Index/Index.spec.ts @@ -233,6 +233,43 @@ describe('Index', () => { }); }); + describe('.isDataIndex', () => { + const dataKey = 'key' as DataKey; + + it('should return true if index points to the data node', () => { + const index = new IndexBuilder() + .addBlockIndex(0) + .addDataKey(dataKey) + .build(); + + expect(index.isDataIndex).toBe(true); + }); + + it('should return false if index does not data key', () => { + const index = new Index(); + + expect(index.isDataIndex).toBe(false); + }); + + it('should return false if index points to the text range', () => { + const index = new IndexBuilder().addBlockIndex(0) + .addDataKey('dataKey' as DataKey) + .addTextRange([0, 0]) + .build(); + + expect(index.isDataIndex).toBe(false); + }); + + it('should return false if index points to the tune data', () => { + const index = new IndexBuilder().addBlockIndex(0) + .addTuneName('tuneName' as BlockTuneName) + .addTuneKey('tuneKey') + .build(); + + expect(index.isDataIndex).toBe(false); + }); + }); + describe('.isTextIndex', () => { it('should return true if index points to the text', () => { const index = new IndexBuilder().addBlockIndex(0) diff --git a/packages/model/src/entities/Index/index.ts b/packages/model/src/entities/Index/index.ts index 274024bf..f1208184 100644 --- a/packages/model/src/entities/Index/index.ts +++ b/packages/model/src/entities/Index/index.ts @@ -159,4 +159,11 @@ export class Index { public get isBlockIndex(): boolean { return this.blockIndex !== undefined && this.tuneName === undefined && this.dataKey === undefined && this.textRange === undefined; } + + /** + * Returns true if index points to the block node data key + */ + public get isDataIndex(): boolean { + return this.blockIndex !== undefined && this.tuneName === undefined && this.dataKey !== undefined && this.textRange === undefined; + } }