Skip to content

Commit

Permalink
RootInlineNode (#12)
Browse files Browse the repository at this point in the history
* FormattingNode#unformat mehtod & some tests refactoring

* Add RootInlineNode & start integration tests

* build fixes

* Lint fixes

* Move common methods of RootInlineNode and FormattingNode to a separate class. Add normalization. Add more tests

* Apply suggestions from code review

Co-authored-by: Peter Savchenko <specc.dev@gmail.com>

* updates after review

* Update src/entities/BlockNode/index.ts

Co-authored-by: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com>

---------

Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
Co-authored-by: Ilya Maroz <37909603+ilyamore88@users.noreply.github.com>
  • Loading branch information
3 people authored Aug 29, 2023
1 parent 6cdd08d commit 76035db
Show file tree
Hide file tree
Showing 31 changed files with 1,046 additions and 278 deletions.
12 changes: 6 additions & 6 deletions src/entities/BlockNode/BlockNode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { BlockNode } from './index';
import { createBlockNodeName, createDataKey } from './types';

import { BlockTune, createBlockTuneName } from '../BlockTune';
import { TextNode } from '../TextNode';
import { TextInlineNode } from '../inline-fragments/TextInlineNode';
import { ValueNode } from '../ValueNode';

import type { EditorDocument } from '../EditorDocument';
import type { BlockTuneConstructorParameters } from '../BlockTune/types';
import type { TextNodeConstructorParameters } from '../TextNode';
import type { TextInlineNodeConstructorParameters } from '../inline-fragments/TextInlineNode';
import type { ValueNodeConstructorParameters } from '../ValueNode';

describe('BlockNode', () => {
Expand All @@ -18,8 +18,8 @@ describe('BlockNode', () => {
serialized: jest.fn(),
}));

jest.mock('../TextNode', () => ({
TextNode: jest.fn().mockImplementation(() => ({}) as TextNode),
jest.mock('../inline-fragments/TextInlineNode', () => ({
TextInlineNode: jest.fn().mockImplementation(() => ({}) as TextInlineNode),
serialized: jest.fn(),
}));

Expand Down Expand Up @@ -109,11 +109,11 @@ describe('BlockNode', () => {
});
});

it('should call .serialized getter of all child TextNodes associated with the BlockNode', () => {
it('should call .serialized getter of all child TextInlineNodes associated with the BlockNode', () => {
const countOfTextNodes = 3;

const textNodes = [ ...Array(countOfTextNodes).keys() ]
.map(() => new TextNode({} as TextNodeConstructorParameters));
.map(() => new TextInlineNode({} as TextInlineNodeConstructorParameters));

const spyArray = textNodes
.map((textNode) => {
Expand Down
6 changes: 3 additions & 3 deletions src/entities/BlockNode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ValueNode } from '../ValueNode';

/**
* BlockNode class represents a node in a tree-like structure used to store and manipulate Blocks in an editor document.
* A BlockNode can contain one or more child nodes of type TextNode, ValueNode or FormattingNode.
* A BlockNode can contain one or more child nodes of type TextInlineNode, ValueNode or FormattingInlineNode.
* It can also be associated with one or more BlockTunes, which can modify the behavior of the BlockNode.
*/
export class BlockNode {
Expand All @@ -39,7 +39,7 @@ export class BlockNode {
/**
* Constructor for BlockNode class.
*
* @param args - TextNode constructor arguments.
* @param args - BlockNode constructor arguments.
* @param args.name - The name of the BlockNode.
* @param args.data - The content of the BlockNode.
* @param args.parent - The parent EditorDocument of the BlockNode.
Expand All @@ -62,7 +62,7 @@ export class BlockNode {
(acc, [dataKey, value]) => {
/**
* If the value is an array, we need to serialize each node in the array
* Value is an array if the BlockNode contains TextNodes and FormattingNodes
* Value is an array if the BlockNode contains TextInlineNodes and FormattingInlineNodes
* After serializing there will be InlineNodeSerialized object
*/
if (value instanceof Array) {
Expand Down
5 changes: 2 additions & 3 deletions src/entities/BlockNode/types/BlockNodeData.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { DataKey } from './DataKey';
import { TextNode } from '../../TextNode';
import { ValueNode } from '../../ValueNode';
import { FormattingNode } from '../../FormattingNode';
import { TextInlineNode, FormattingInlineNode } from '../../inline-fragments';

/**
* Represents a record object containing the data of a block node.
* Each root node is associated with a specific data key.
*/
export type BlockNodeData = Record<DataKey, ValueNode | (FormattingNode | TextNode)[]>;
export type BlockNodeData = Record<DataKey, ValueNode | (FormattingInlineNode | TextInlineNode)[]>;
8 changes: 0 additions & 8 deletions src/entities/TextNode/types/TextNodeConstructorParameters.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/entities/TextNode/types/index.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/entities/ValueNode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ValueNodeConstructorParameters } from './types';

/**
* ValueNode class represents a node in a tree-like structure, used to store and manipulate data associated with a BlockNode.
* Unlike TextNode, changing the data of a ValueNode will replace the entire data in this node.
* Unlike TextInlineNode, changing the data of a ValueNode will replace the entire data in this node.
* This can be useful for storing data that needs to be updated in its entirety, such as a link or other metadata associated with a BlockNode.
*/
export class ValueNode<ValueType = unknown> {
Expand Down
247 changes: 247 additions & 0 deletions src/entities/inline-fragments/FormattingInlineNode/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import {
FormattingInlineNodeConstructorParameters,
InlineToolName,
InlineToolData
} from './types';
import type { InlineFragment, InlineNode } from '../InlineNode';
import { ParentNode } from '../mixins/ParentNode';
import { ChildNode } from '../mixins/ChildNode';
import { ParentInlineNode } from '../ParentInlineNode';

export * from './types';

/**
* We need to extend FormattingInlineNode interface with ChildNode and ParentNode ones to use the methods from mixins
*/
export interface FormattingInlineNode extends ChildNode {}

/**
* FormattingInlineNode class represents a node in a tree-like structure, used to store and manipulate formatted text content
*/
@ParentNode
@ChildNode
export class FormattingInlineNode extends ParentInlineNode implements InlineNode {
/**
* Property representing the name of the formatting tool applied to the content
*/
public readonly tool: InlineToolName;

/**
* Any additional data associated with the formatting tool
*/
public readonly data?: InlineToolData;

/**
* Constructor for FormattingInlineNode class.
*
* @param args - FormattingInlineNode constructor arguments.
* @param args.tool - The name of the formatting tool applied to the content.
* @param args.data - Any additional data associated with the formatting.
*/
// Stryker disable next-line BlockStatement -- Styker's bug, see https://github.com/stryker-mutator/stryker-js/issues/2474
constructor({ tool, data }: FormattingInlineNodeConstructorParameters) {
super();

this.tool = tool;
this.data = data;
}

/**
* Removes text from the specified range. If there is no text left in a node, removes a node from a parent.
*
* @param [start] - start char index of the range, by default 0
* @param [end] - end char index of the range, by default length of the text value
* @returns {string} removed text
*/
public removeText(start = 0, end = this.length): string {
const result = super.removeText(start, end);

if (this.length === 0) {
this.remove();
}

return result;
}

/**
* Returns inline fragments for node from the specified character range
*
* If start and/or end is specified, method will return partial fragments for the specified range
*
* @param [start] - start char index of the range, by default 0
* @param [end] - end char index of the range, by default length of the text value
*/
public getFragments(start = 0, end = this.length): InlineFragment[] {
const fragments = super.getFragments(start, end);

const currentFragment: InlineFragment = {
tool: this.tool,
range: [start, end],
};

if (this.data) {
currentFragment.data = this.data;
}

/**
* Current node is not processed in super.getFragments, so we need to add it manually at the beginning
*/
fragments.unshift(currentFragment);

return fragments;
}

/**
* Splits current node by the specified index
*
* @param index - char index where to split the node
* @returns {FormattingInlineNode | null} new node
*/
public split(index: number): FormattingInlineNode | null {
if (index === 0 || index === this.length) {
return null;
}

const newNode = new FormattingInlineNode({
tool: this.tool,
data: this.data,
});

const [child, offset] = this.findChildByIndex(index);

if (!child) {
return null;
}

// Have to save length as it is changed after split
const childLength = child.length;

const splitNode = child.split(index - offset);
let midNodeIndex = this.children.indexOf(child);

/**
* If node is split or if node is not split but index equals to child length, we should split children from the next node
*/
if (splitNode || (index - offset === childLength)) {
midNodeIndex += 1;
}

newNode.append(...this.children.slice(midNodeIndex));

this.parent?.insertAfter(this, newNode);

return newNode;
}

/**
* Applies formatting to the text with specified inline tool in the specified range
*
* @param tool - name of inline tool to apply
* @param start - char start index of the range
* @param end - char end index of the range
* @param [data] - inline tool data if applicable
*/
public format(tool: InlineToolName, start: number, end: number, data?: InlineToolData): InlineNode[] {
/**
* In case current tool is the same as new one, do nothing
*
* @todo Compare data as well
*/
if (tool === this.tool) {
return [];
}


return super.format(tool, start, end, data);
}

/**
* Removes formatting from the text for a specified inline tool in the specified range
*
* @param tool - name of inline tool to remove
* @param start - char start index of the range
* @param end - char end index of the range
* @todo Possibly pass data or some InlineTool identifier to relevant only required fragments
*/
public unformat(tool: InlineToolName, start: number, end: number): InlineNode[] {
if (this.tool === tool) {
const middleNode = this.split(start);
const endNode = middleNode?.split(end);

const result: ChildNode[] = [];

/**
* If start > 0, then there is a middle node, so we'll need to append its children to the parent
*/
if (middleNode) {
result.push(this, ...middleNode.children);
/**
* Else we'll need to append current nodes children to the parent
*/
} else {
result.push(...this.children);
}

/**
* If end < this.length, we just append it to the parent
*/
if (endNode) {
result.push(endNode);
}

this.parent?.insertAfter(this, ...result);

if (middleNode) {
middleNode.remove();
} else {
this.remove();
}

return result;
}

return super.unformat(tool, start, end);
}

/**
* Checks if node is equal to passed node
*
* @param node - node to check
*/
public isEqual(node: InlineNode): node is FormattingInlineNode {
if (!(node instanceof FormattingInlineNode)) {
return false;
}

if (this.tool !== node.tool) {
return false;
}

/**
* @todo check data equality
*/

return true;
}

/**
* Merges current node with passed node
*
* @param node - node to merge with
*/
public mergeWith(node: InlineNode): void {
if (!this.isEqual(node)) {
throw new Error('Can not merge unequal nodes');
}

/**
* @todo merge data
*/

node.children.forEach((child) => {
this.append(child);
});

node.remove();
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { InlineToolName } from './InlineToolName';
import { InlineToolData } from './InlineToolData';
import type { ChildNodeConstructorOptions, ParentNodeConstructorOptions } from '../../interfaces';
import type { ChildNodeConstructorOptions } from '../../mixins/ChildNode';
import type { ParentNodeConstructorOptions } from '../../mixins/ParentNode';

export interface FormattingNodeConstructorParameters extends ChildNodeConstructorOptions, ParentNodeConstructorOptions {
export interface FormattingInlineNodeConstructorParameters extends ChildNodeConstructorOptions, ParentNodeConstructorOptions {
/**
* The name of the formatting tool applied to the content
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { create, Nominal } from '../../../utils/Nominal';
import { create, Nominal } from '../../../../utils/Nominal';

/**
* Base type for Inline Tool data
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { create, Nominal } from '../../../utils/Nominal';
import { create, Nominal } from '../../../../utils/Nominal';

/**
* Base type of the formatting node tool field
Expand All @@ -11,6 +11,6 @@ type InlineToolNameBase = string;
export type InlineToolName = Nominal<InlineToolNameBase, 'InlineToolName'>;

/**
* Function returns a value with the nominal FormattingNodeName type
* Function returns a value with the nominal InlineToolName type
*/
export const createInlineToolName = create<InlineToolName>();
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { FormattingNodeConstructorParameters } from './FormattingNodeConstructorParameters';
export { FormattingInlineNodeConstructorParameters } from './FormattingInlineNodeConstructorParameters';
export { InlineToolName, createInlineToolName } from './InlineToolName';
export { InlineToolData, createInlineToolData } from './InlineToolData';
Loading

0 comments on commit 76035db

Please sign in to comment.