diff --git a/src/common/array.ts b/src/common/array.ts index 1404889..f3bfb9a 100644 --- a/src/common/array.ts +++ b/src/common/array.ts @@ -1,3 +1,10 @@ export function insert(arr: T[], index: number, slice: T[]): T[] { return [...arr.slice(0, index), ...slice, ...arr.slice(index)]; } + +export function remove(arr: T[], idx: number, count: number = 1): T[] { + return [ + ...arr.slice(0, idx), + ...arr.slice(idx + count) + ]; +} \ No newline at end of file diff --git a/src/components/Fluent.tsx b/src/components/Fluent.tsx index c8772ef..d3e1aeb 100644 --- a/src/components/Fluent.tsx +++ b/src/components/Fluent.tsx @@ -8,7 +8,7 @@ import { SelectionService } from '../services/selection.service'; export class Fluent extends Component { private editorStateService = new IEditorStateService(); private selectionService = new SelectionService(); - + public render() { return (
diff --git a/src/state/delete-text.action.test.ts b/src/state/delete-text.action.test.ts new file mode 100644 index 0000000..e4cb9ca --- /dev/null +++ b/src/state/delete-text.action.test.ts @@ -0,0 +1,381 @@ +import { IEditorState } from './editor.state'; +import { SegmentType } from '../entities/segment'; +import { DeleteTextAction } from './delete-text.action'; +import { deepCopy } from '../common/object'; + +describe('DeleteTextAction', () => { + interface ITestCase { + name: string; + currState: IEditorState; + expectedNewState: IEditorState; + } + + const testCases: ITestCase[] = [ + { + name: 'should delete 1 character at cursor position', + currState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'hello'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 2, + endSegmentIndex: 0, + endOffset: 2 + } + }, + expectedNewState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'ad'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'hello'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 1, + endSegmentIndex: 0, + endOffset: 1 + } + } + }, + { + name: 'should delete 1 character at cursor position and should delete the segment when segment is empty', + currState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'h'.split('') + }, + { + index: 2, + type: SegmentType.Text, + styles: [], + content: 'asd'.split('') + } + ], + cursor: { + startSegmentIndex: 1, + startOffset: 1, + endSegmentIndex: 1, + endOffset: 1 + } + }, + expectedNewState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'asd'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 3, + endSegmentIndex: 0, + endOffset: 3 + } + } + }, + { + name: 'should delete multiple characters at cursor selection', + currState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'hello'.split('') + } + ], + cursor: { + startSegmentIndex: 1, + startOffset: 3, + endSegmentIndex: 1, + endOffset: 5 + } + }, + expectedNewState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'hel'.split('') + } + ], + cursor: { + startSegmentIndex: 1, + startOffset: 3, + endSegmentIndex: 1, + endOffset: 3 + } + } + }, + { + name: 'should delete multiple characters at cursor selection among different start/end segment', + currState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'hello'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 2, + endSegmentIndex: 1, + endOffset: 4 + } + }, + expectedNewState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'ab'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'o'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 2, + endSegmentIndex: 0, + endOffset: 2 + } + } + }, + { + name: 'should delete text among more than 2 segments and have correct index', + currState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'hello'.split('') + }, + { + index: 2, + type: SegmentType.Text, + styles: [], + content: 'qwer'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 2, + endSegmentIndex: 2, + endOffset: 2 + } + }, + expectedNewState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'ab'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'er'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 2, + endSegmentIndex: 0, + endOffset: 2 + } + } + }, + { + name: 'edge case: test when delete all content among three segments', + currState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'hello'.split('') + }, + { + index: 2, + type: SegmentType.Text, + styles: [], + content: 'qwer'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 0, + endSegmentIndex: 2, + endOffset: 4 + } + }, + expectedNewState: { + options: [], + segments: [ + ], + cursor: { + startSegmentIndex: 0, + startOffset: 0, + endSegmentIndex: 0, + endOffset: 0 + } + } + }, + { + name: 'edge case: test when end segment\'s content is removed', + currState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'abd'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'hello'.split('') + }, + { + index: 2, + type: SegmentType.Text, + styles: [], + content: 'qwer'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 2, + endSegmentIndex: 1, + endOffset: 5 + } + }, + expectedNewState: { + options: [], + segments: [ + { + index: 0, + type: SegmentType.Text, + styles: [], + content: 'ab'.split('') + }, + { + index: 1, + type: SegmentType.Text, + styles: [], + content: 'qwer'.split('') + } + ], + cursor: { + startSegmentIndex: 0, + startOffset: 2, + endSegmentIndex: 0, + endOffset: 2 + } + } + } + ]; + + for (const testCase of testCases) { + it(testCase.name, () => { + const action = new DeleteTextAction(); + const state = deepCopy(testCase.currState); + const newState = action.perform(state); + + expect(state).toEqual(testCase.currState); + expect(newState).toStrictEqual(testCase.expectedNewState); + }); + } +}); diff --git a/src/state/delete-text.action.ts b/src/state/delete-text.action.ts new file mode 100644 index 0000000..eb57c0d --- /dev/null +++ b/src/state/delete-text.action.ts @@ -0,0 +1,185 @@ +import { Action } from './action'; +import { ISegment } from '../entities/segment'; +import { ICursor } from '../entities/cursor'; +import { getCurrSegment, IEditorState, findSegment } from './editor.state'; +import { remove } from '../common/array'; + +export class DeleteTextAction implements Action { + + private rematchIndex(segments: ISegment[]): ISegment[]{ + return segments.map((currSegment: ISegment, idx: number) => { + return Object.assign>( + {}, + currSegment, + { + index: idx + } + ); + }); + } + + private deleteSingleChar(state: IEditorState): IEditorState { + const { cursor, segments } = state; + let newState: IEditorState = state; + let newSegments: ISegment[] = segments; + let newCursor: ICursor = cursor; + + const segment = getCurrSegment(newState); + if (!segment) { + return state; + } + + const newSegment = Object.assign>( + {}, + segment, + { + // to remove the single char at the cursor + content: remove( + segment.content, + newCursor.startOffset - 1 + ) + } + ); + + newSegments = newSegments.map((currSegment: ISegment) => { + if (currSegment.index === segment.index) { + return newSegment; + } + return currSegment; + }); + + // If after the deletion, the segment is empty, we need to remove it, and set the cursor to the end of the previous segment + if (newSegment.content.length === 0) { + newSegments = remove(newSegments, newSegment.index); + + // set the cursor to the end of the previous segment + if (newSegment.index > 0) { + let prevSegment = findSegment(segment.index - 1, segments); + + newCursor = Object.assign({}, newCursor, { + startSegmentIndex: prevSegment.index, + startOffset: prevSegment.content.length, + endSegmentIndex: prevSegment.index, + endOffset: prevSegment.content.length + }); + + // rematch index here since we have removed a segment + newSegments = this.rematchIndex(newSegments); + + // If it's the first segment, we need to reset the cursor. + } else { + newCursor = Object.assign({}, newCursor, { + startSegmentIndex: 0, + startOffset: 0, + endSegmentIndex: 0, + endOffset: 0 + }); + } + + } else { + newCursor = Object.assign({}, newCursor, { + startOffset: newCursor.startOffset - 1, + endOffset: newCursor.endOffset - 1 + }); + } + + return Object.assign({}, state, { + segments: newSegments, + cursor: newCursor + }); + } + + private deleteMultiple(state: IEditorState): IEditorState { + const { cursor, segments } = state; + let newSegments: ISegment[] = segments; + let newCursor: ICursor = cursor; + + let startSegment = findSegment(cursor.startSegmentIndex, segments); + if (!startSegment) { + return state; + } + + let endSegment = findSegment(cursor.endSegmentIndex, segments); + if (!endSegment) { + return state; + } + + startSegment = Object.assign>( + {}, + startSegment, + { + // to remove the single char at the cursor + content: remove( + startSegment.content, + cursor.startOffset, + startSegment.content.length - cursor.startOffset + ) + } + ); + + endSegment = Object.assign>( + {}, + endSegment, + { + // to remove the single char at the cursor + content: remove( + endSegment.content, + 0, + cursor.endOffset + ) + } + ); + + newSegments = newSegments.map((currSegment: ISegment) => { + if (currSegment.index === startSegment.index) { + return startSegment; + } + if (currSegment.index === endSegment.index) { + return endSegment; + } + return currSegment; + }); + + let startIndex = startSegment.index; + let endIndex = endSegment.index; + if (startSegment.content.length === 0) { + startIndex = startIndex - 1; + } + if (endSegment.content.length === 0) { + endIndex = endIndex + 1; + } + + if (endIndex - startIndex - 1 > 0) { + newSegments = remove(newSegments, startIndex + 1, endIndex - startIndex - 1); + newSegments = this.rematchIndex(newSegments); + } + + newCursor = Object.assign({}, newCursor, { + endSegmentIndex: newCursor.startSegmentIndex, + endOffset: newCursor.startOffset + }); + + return Object.assign({}, state, { + segments: newSegments, + cursor: newCursor + }); + } + + public perform(state: IEditorState): IEditorState { + const { cursor, segments } = state; + + if (segments.length < 1) { + return state; + } + + if (cursor.startSegmentIndex >= segments.length || cursor.endSegmentIndex < 0 || cursor.startSegmentIndex > cursor.endSegmentIndex) { + return state; + } + + if (cursor.startSegmentIndex === cursor.endSegmentIndex && cursor.startOffset === cursor.endOffset) { + return this.deleteSingleChar(state); + } else { + return this.deleteMultiple(state); + } + } +}