diff --git a/assets/design-charts/BackwardDeleteWord.png b/assets/design-charts/BackwardDeleteWord.png new file mode 100644 index 00000000000..8b9d18d9ac4 Binary files /dev/null and b/assets/design-charts/BackwardDeleteWord.png differ diff --git a/assets/design-charts/ForwardDeleteWord.png b/assets/design-charts/ForwardDeleteWord.png new file mode 100644 index 00000000000..4654dd0a150 Binary files /dev/null and b/assets/design-charts/ForwardDeleteWord.png differ diff --git a/packages/roosterjs-content-model/lib/domToModel/processors/textProcessor.ts b/packages/roosterjs-content-model/lib/domToModel/processors/textProcessor.ts index 8c57695072f..00efec8ed57 100644 --- a/packages/roosterjs-content-model/lib/domToModel/processors/textProcessor.ts +++ b/packages/roosterjs-content-model/lib/domToModel/processors/textProcessor.ts @@ -7,7 +7,7 @@ import { createText } from '../../modelApi/creators/createText'; import { DomToModelContext } from '../../publicTypes/context/DomToModelContext'; import { ElementProcessor } from '../../publicTypes/context/ElementProcessor'; import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; -import { hasSpacesOnly } from '../../domUtils/hasSpacesOnly'; +import { hasSpacesOnly } from '../../domUtils/stringUtil'; /** * @internal diff --git a/packages/roosterjs-content-model/lib/domUtils/hasSpacesOnly.ts b/packages/roosterjs-content-model/lib/domUtils/hasSpacesOnly.ts deleted file mode 100644 index c7b15e423cd..00000000000 --- a/packages/roosterjs-content-model/lib/domUtils/hasSpacesOnly.ts +++ /dev/null @@ -1,10 +0,0 @@ -// A regex to match text that only has space and CR -// We use real space char " " (\u0020) here but not "\s" since "\s" will also match " " (\u00A0) which is something we need to keep -const SPACE_TEXT_REGEX = /^[\r\n\t ]*$/; - -/** - * @internal - */ -export function hasSpacesOnly(txt: string): boolean { - return SPACE_TEXT_REGEX.test(txt); -} diff --git a/packages/roosterjs-content-model/lib/domUtils/stringUtil.ts b/packages/roosterjs-content-model/lib/domUtils/stringUtil.ts new file mode 100644 index 00000000000..ff939585025 --- /dev/null +++ b/packages/roosterjs-content-model/lib/domUtils/stringUtil.ts @@ -0,0 +1,35 @@ +// A regex to match text that only has space and CR +// We use real space char " " (\u0020) here but not "\s" since "\s" will also match " " (\u00A0) which is something we need to keep +const SPACE_TEXT_REGEX = /^[\r\n\t ]*$/; + +const SPACES_REGEX = /[\u2000\u2009\u200a​\u200b​\u202f\u205f​\u3000\s\t\r\n]/gm; +const PUNCTUATIONS = '.,?!:"()[]\\/'; + +/** + * @internal + */ +export function isPunctuation(char: string) { + return PUNCTUATIONS.indexOf(char) >= 0; +} + +/** + * @internal + */ +export function isSpace(char: string) { + const code = char?.charCodeAt(0) ?? 0; + return code == 160 || code == 32 || SPACES_REGEX.test(char); +} + +/** + * @internal + */ +export function hasSpacesOnly(txt: string): boolean { + return SPACE_TEXT_REGEX.test(txt); +} + +/** + * @internal + */ +export function normalizeText(txt: string, isForward: boolean): string { + return txt.replace(isForward ? /^\u0020+/ : /\u0020+$/, '\u00A0'); +} diff --git a/packages/roosterjs-content-model/lib/editor/utils/handleKeyboardEventCommon.ts b/packages/roosterjs-content-model/lib/editor/utils/handleKeyboardEventCommon.ts index 811d6c8449f..cb44e86c172 100644 --- a/packages/roosterjs-content-model/lib/editor/utils/handleKeyboardEventCommon.ts +++ b/packages/roosterjs-content-model/lib/editor/utils/handleKeyboardEventCommon.ts @@ -76,3 +76,20 @@ export function handleKeyboardEventResult( return true; } } + +/** + * @internal + */ +export function shouldDeleteWord(rawEvent: KeyboardEvent, isMac: boolean) { + return ( + (isMac && rawEvent.altKey && !rawEvent.metaKey) || + (!isMac && rawEvent.ctrlKey && !rawEvent.altKey) + ); +} + +/** + * @internal + */ +export function shouldDeleteAllSegmentsBefore(rawEvent: KeyboardEvent) { + return rawEvent.metaKey && !rawEvent.altKey; +} diff --git a/packages/roosterjs-content-model/lib/modelApi/common/normalizeSegment.ts b/packages/roosterjs-content-model/lib/modelApi/common/normalizeSegment.ts index 3d5aaccb20e..36bbf5c6913 100644 --- a/packages/roosterjs-content-model/lib/modelApi/common/normalizeSegment.ts +++ b/packages/roosterjs-content-model/lib/modelApi/common/normalizeSegment.ts @@ -1,6 +1,6 @@ import { ContentModelSegment } from '../../publicTypes/segment/ContentModelSegment'; import { ContentModelText } from '../../publicTypes/segment/ContentModelText'; -import { hasSpacesOnly } from '../../domUtils/hasSpacesOnly'; +import { hasSpacesOnly } from '../../domUtils/stringUtil'; const SPACE = '\u0020'; const NONE_BREAK_SPACE = '\u00A0'; diff --git a/packages/roosterjs-content-model/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts b/packages/roosterjs-content-model/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts new file mode 100644 index 00000000000..2586e2b7282 --- /dev/null +++ b/packages/roosterjs-content-model/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts @@ -0,0 +1,20 @@ +import { DeleteResult, DeleteSelectionStep } from '../utils/DeleteSelectionStep'; +import { deleteSegment } from '../utils/deleteSegment'; + +/** + * @internal + */ +export const deleteAllSegmentBefore: DeleteSelectionStep = (context, onDeleteEntity) => { + const { paragraph, marker } = context.insertPoint; + const index = paragraph.segments.indexOf(marker); + + for (let i = index - 1; i >= 0; i--) { + const segment = paragraph.segments[i]; + + segment.isSelected = true; + + if (deleteSegment(paragraph, segment, onDeleteEntity)) { + context.deleteResult = DeleteResult.Range; + } + } +}; diff --git a/packages/roosterjs-content-model/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts b/packages/roosterjs-content-model/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts new file mode 100644 index 00000000000..e52a6775b5b --- /dev/null +++ b/packages/roosterjs-content-model/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts @@ -0,0 +1,184 @@ +import { ContentModelParagraph } from '../../../publicTypes/block/ContentModelParagraph'; +import { isPunctuation, isSpace, normalizeText } from '../../../domUtils/stringUtil'; +import { isWhiteSpacePreserved } from '../../common/isWhiteSpacePreserved'; +import { + DeleteResult, + DeleteSelectionContext, + DeleteSelectionStep, +} from '../utils/DeleteSelectionStep'; + +const enum DeleteWordState { + Start, + Punctuation, + Text, + NonText, + Space, + End, +} + +interface CharInfo { + text: boolean; + space: boolean; + punctuation: boolean; +} + +function getDeleteWordSelection(direction: 'forward' | 'backward'): DeleteSelectionStep { + return context => { + const { marker, paragraph } = context.insertPoint; + const startIndex = paragraph.segments.indexOf(marker); + const deleteNext = direction == 'forward'; + + let iterator = iterateSegments(paragraph, startIndex, deleteNext, context); + let curr = iterator.next(); + + for (let state = DeleteWordState.Start; state != DeleteWordState.End && !curr.done; ) { + const { punctuation, space, text } = curr.value; + + // This is a state machine of how to delete a whole word together with space and punctuations. + // For a full state machine chart, see + // Forward delete: https://github.com/microsoft/roosterjs/blob/master/assets/design-charts/ForwardDeleteWord.png + // Backward delete: https://github.com/microsoft/roosterjs/blob/master/assets/design-charts/BackwardDeleteWord.png + switch (state) { + case DeleteWordState.Start: + state = space + ? DeleteWordState.Space + : punctuation + ? DeleteWordState.Punctuation + : DeleteWordState.Text; + curr = iterator.next(true /*delete*/); + break; + + case DeleteWordState.Punctuation: + if (deleteNext && space) { + state = DeleteWordState.NonText; + curr = iterator.next(true /*delete*/); + } else if (punctuation) { + curr = iterator.next(true /*delete*/); + } else { + state = DeleteWordState.End; + } + break; + + case DeleteWordState.Text: + if (deleteNext && space) { + state = DeleteWordState.NonText; + curr = iterator.next(true /*delete*/); + } else if (text) { + curr = iterator.next(true /*delete*/); + } else { + state = DeleteWordState.End; + } + break; + + case DeleteWordState.NonText: + if (punctuation || !space) { + state = DeleteWordState.End; + } else { + curr = iterator.next(true /*delete*/); + } + break; + + case DeleteWordState.Space: + if (space) { + curr = iterator.next(true /*delete*/); + } else if (punctuation) { + state = deleteNext ? DeleteWordState.NonText : DeleteWordState.Punctuation; + curr = iterator.next(true /*delete*/); + } else { + state = deleteNext ? DeleteWordState.End : DeleteWordState.Text; + } + break; + } + } + }; +} + +function* iterateSegments( + paragraph: ContentModelParagraph, + markerIndex: number, + forward: boolean, + context: DeleteSelectionContext +): Generator { + const step = forward ? 1 : -1; + const segments = paragraph.segments; + const preserveWhiteSpace = isWhiteSpacePreserved(paragraph); + + for (let i = markerIndex + step; i >= 0 && i < segments.length; i += step) { + const segment = segments[i]; + + switch (segment.segmentType) { + case 'Text': + for ( + let j = forward ? 0 : segment.text.length - 1; + j >= 0 && j < segment.text.length; + j += step + ) { + const c = segment.text[j]; + const punctuation = isPunctuation(c); + const space = isSpace(c); + const text = !punctuation && !space; + + if (yield { punctuation, space, text }) { + let newText = segment.text; + + newText = newText.substring(0, j) + newText.substring(j + 1); + + if (!preserveWhiteSpace) { + newText = normalizeText(newText, forward); + } + + context.deleteResult = DeleteResult.Range; + + if (newText) { + segment.text = newText; + + if (step > 0) { + j -= step; + } + } else { + segments.splice(i, 1); + + if (step > 0) { + i -= step; + } + + break; + } + } + } + break; + + case 'Image': + if ( + yield { punctuation: true, space: false, text: false } // Treat image as punctuation since they have the same behavior. + ) { + segments.splice(i, 1); + + if (step > 0) { + i -= step; + } + + context.deleteResult = DeleteResult.Range; + } + break; + + case 'SelectionMarker': + break; + + default: + return null; + } + } + + return null; +} + +/** + * @internal + */ +export const forwardDeleteWordSelection = getDeleteWordSelection('forward'); + +/** + * @internal + */ +export const backwardDeleteWordSelection = getDeleteWordSelection('backward'); diff --git a/packages/roosterjs-content-model/lib/modelApi/edit/utils/deleteSegment.ts b/packages/roosterjs-content-model/lib/modelApi/edit/utils/deleteSegment.ts index 418b545324c..39829c91e14 100644 --- a/packages/roosterjs-content-model/lib/modelApi/edit/utils/deleteSegment.ts +++ b/packages/roosterjs-content-model/lib/modelApi/edit/utils/deleteSegment.ts @@ -4,6 +4,7 @@ import { createNormalizeSegmentContext, normalizeSegment } from '../../common/no import { deleteSingleChar } from './deleteSingleChar'; import { EntityOperation } from 'roosterjs-editor-types'; import { isWhiteSpacePreserved } from '../../common/isWhiteSpacePreserved'; +import { normalizeText } from '../../../domUtils/stringUtil'; import { OnDeleteEntity } from './DeleteSelectionStep'; /** @@ -55,7 +56,7 @@ export function deleteSegment( text = deleteSingleChar(text, isForward); // isForward ? text.substring(1) : text.substring(0, text.length - 1); if (!preserveWhiteSpace) { - text = text.replace(isForward ? /^\u0020+/ : /\u0020+$/, '\u00A0'); + text = normalizeText(text, isForward); } if (text == '') { diff --git a/packages/roosterjs-content-model/lib/modelApi/selection/adjustWordSelection.ts b/packages/roosterjs-content-model/lib/modelApi/selection/adjustWordSelection.ts index 90d8ef36766..529575ae96b 100644 --- a/packages/roosterjs-content-model/lib/modelApi/selection/adjustWordSelection.ts +++ b/packages/roosterjs-content-model/lib/modelApi/selection/adjustWordSelection.ts @@ -3,6 +3,7 @@ import { ContentModelParagraph } from '../../publicTypes/block/ContentModelParag import { ContentModelSegment } from '../../publicTypes/segment/ContentModelSegment'; import { ContentModelText } from '../../publicTypes/segment/ContentModelText'; import { createText } from '../creators/createText'; +import { isPunctuation, isSpace } from '../../domUtils/stringUtil'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; /** @@ -93,22 +94,23 @@ https://unicode.org/Public/UNIDATA/Scripts.txt \u205f​ = MEDIUM MATHEMATICAL SPACE \u3000 = IDEOGRAPHIC SPACE */ -const SPACES_REGEX = /[\u2000\u2009\u200a​\u200b​\u202f\u205f​\u3000\s\t\r\n]/gm; -const PUNCTUATION_REGEX = /[.,?!:"()\[\]\\/]/gu; - -export function findDelimiter(segment: ContentModelText, moveRightward: boolean): number { +function findDelimiter(segment: ContentModelText, moveRightward: boolean): number { const word = segment.text; let offset = -1; if (moveRightward) { for (let i = 0; i < word.length; i++) { - if (isWordDelimiter(word[i])) { + const char = word[i]; + + if (isPunctuation(char) || isSpace(char)) { offset = i; break; } } } else { for (let i = word.length - 1; i >= 0; i--) { - if (isWordDelimiter(word[i])) { + const char = word[i]; + + if (isPunctuation(char) || isSpace(char)) { offset = i + 1; break; } @@ -142,16 +144,3 @@ function splitTextSegment( textSegment.text = text.substring(found, text.length); segments.splice(index, 0, newSegment); } - -function isWordDelimiter(char: string) { - return PUNCTUATION_REGEX.test(char) || isSpace(char); -} - -function isSpace(char: string) { - return ( - char && - (char.toString() == String.fromCharCode(160) /*   | \u00A0*/ || - char.toString() == String.fromCharCode(32) /* RegularSpace | \u0020*/ || - SPACES_REGEX.test(char)) - ); -} diff --git a/packages/roosterjs-content-model/lib/publicApi/editing/handleKeyDownEvent.ts b/packages/roosterjs-content-model/lib/publicApi/editing/handleKeyDownEvent.ts index 1e71a1054d6..3cb512bd938 100644 --- a/packages/roosterjs-content-model/lib/publicApi/editing/handleKeyDownEvent.ts +++ b/packages/roosterjs-content-model/lib/publicApi/editing/handleKeyDownEvent.ts @@ -1,4 +1,6 @@ +import { Browser } from 'roosterjs-editor-dom'; import { ChangeSource, EntityOperationEvent, Keys } from 'roosterjs-editor-types'; +import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { formatWithContentModel } from '../utils/formatWithContentModel'; @@ -6,7 +8,13 @@ import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { getOnDeleteEntityCallback, handleKeyboardEventResult, + shouldDeleteAllSegmentsBefore, + shouldDeleteWord, } from '../../editor/utils/handleKeyboardEventCommon'; +import { + backwardDeleteWordSelection, + forwardDeleteWordSelection, +} from '../../modelApi/edit/deleteSteps/deleteWordSelection'; import { backwardDeleteCollapsedSelection, forwardDeleteCollapsedSelection, @@ -29,12 +37,23 @@ export default function handleKeyDownEvent( const deleteCollapsedSelection = isForward ? forwardDeleteCollapsedSelection : backwardDeleteCollapsedSelection; + const deleteWordSelection = shouldDeleteWord(rawEvent, !!Browser.isMac) + ? isForward + ? forwardDeleteWordSelection + : backwardDeleteWordSelection + : null; formatWithContentModel( editor, apiName, model => { - const steps: (DeleteSelectionStep | null)[] = [deleteCollapsedSelection]; + const steps: (DeleteSelectionStep | null)[] = [ + shouldDeleteAllSegmentsBefore(rawEvent) && !isForward + ? deleteAllSegmentBefore + : null, + deleteWordSelection, + deleteCollapsedSelection, + ]; const result = deleteSelection( model, diff --git a/packages/roosterjs-content-model/test/domUtils/hasSpacesOnlyTest.ts b/packages/roosterjs-content-model/test/domUtils/stringUtilTest.ts similarity index 93% rename from packages/roosterjs-content-model/test/domUtils/hasSpacesOnlyTest.ts rename to packages/roosterjs-content-model/test/domUtils/stringUtilTest.ts index 57c10a5ead2..2e2d1b8ab08 100644 --- a/packages/roosterjs-content-model/test/domUtils/hasSpacesOnlyTest.ts +++ b/packages/roosterjs-content-model/test/domUtils/stringUtilTest.ts @@ -1,4 +1,4 @@ -import { hasSpacesOnly } from '../../lib/domUtils/hasSpacesOnly'; +import { hasSpacesOnly } from '../../lib/domUtils/stringUtil'; describe('hasSpacesOnly', () => { it('Empty string', () => { diff --git a/packages/roosterjs-content-model/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages/roosterjs-content-model/test/editor/utils/handleKeyboardEventCommonTest.ts index 65a7b78db5d..b09b505a315 100644 --- a/packages/roosterjs-content-model/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages/roosterjs-content-model/test/editor/utils/handleKeyboardEventCommonTest.ts @@ -5,6 +5,8 @@ import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEdito import { getOnDeleteEntityCallback, handleKeyboardEventResult, + shouldDeleteAllSegmentsBefore, + shouldDeleteWord, } from '../../../lib/editor/utils/handleKeyboardEventCommon'; describe('getOnDeleteEntityCallback', () => { @@ -233,3 +235,70 @@ describe('handleKeyboardEventResult', () => { expect(addUndoSnapshot).not.toHaveBeenCalled(); }); }); + +describe('shouldDeleteWord', () => { + function runTest( + isMac: boolean, + altKey: boolean, + ctrlKey: boolean, + metaKey: boolean, + expectedResult: boolean + ) { + const rawEvent = { + altKey, + metaKey, + ctrlKey, + } as any; + + const result = shouldDeleteWord(rawEvent, isMac); + + expect(result).toEqual(expectedResult); + } + + it('PC', () => { + runTest(false, false, false, false, false); + runTest(false, false, false, true, false); + runTest(false, false, true, false, true); + runTest(false, false, true, true, true); + runTest(false, true, false, false, false); + runTest(false, true, false, true, false); + runTest(false, true, true, false, false); + runTest(false, true, true, true, false); + }); + + it('MAC', () => { + runTest(true, false, false, false, false); + runTest(true, false, false, true, false); + runTest(true, false, true, false, false); + runTest(true, false, true, true, false); + runTest(true, true, false, false, true); + runTest(true, true, false, true, false); + runTest(true, true, true, false, true); + runTest(true, true, true, true, false); + }); +}); + +describe('shouldDeleteAllSegmentsBefore', () => { + function runTest(altKey: boolean, ctrlKey: boolean, metaKey: boolean, expectedResult: boolean) { + const rawEvent = { + altKey, + metaKey, + ctrlKey, + } as any; + + const result = shouldDeleteAllSegmentsBefore(rawEvent); + + expect(result).toEqual(expectedResult); + } + + it('Test', () => { + runTest(false, false, false, false); + runTest(false, false, true, true); + runTest(false, true, false, false); + runTest(false, true, true, true); + runTest(true, false, false, false); + runTest(true, false, true, false); + runTest(true, true, false, false); + runTest(true, true, true, false); + }); +}); diff --git a/packages/roosterjs-content-model/test/modelApi/edit/deleteSelectionTest.ts b/packages/roosterjs-content-model/test/modelApi/edit/deleteSelectionTest.ts index b619e6960ea..87f93ef783f 100644 --- a/packages/roosterjs-content-model/test/modelApi/edit/deleteSelectionTest.ts +++ b/packages/roosterjs-content-model/test/modelApi/edit/deleteSelectionTest.ts @@ -16,6 +16,10 @@ import { createText } from '../../../lib/modelApi/creators/createText'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../../lib/modelApi/edit/deleteSelection'; import { EntityOperation } from 'roosterjs-editor-types'; +import { + backwardDeleteWordSelection, + forwardDeleteWordSelection, +} from '../../../lib/modelApi/edit/deleteSteps/deleteWordSelection'; import { backwardDeleteCollapsedSelection, forwardDeleteCollapsedSelection, @@ -2516,6 +2520,180 @@ describe('deleteSelection - forward', () => { ], }); }); + + it('Delete word: text+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText(' '); + const text3 = createText('test2'); + + para.segments.push(marker, text1, text2, text3); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Delete word: space+text+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText(' test1 test2'); + + para.segments.push(marker, text1); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test1 test2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Delete word: text+punc+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1. test2'); + + para.segments.push(marker, text1); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '. test2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Delete word: punc+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('. test2'); + + para.segments.push(marker, text1); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }, + ], + }); + }); }); describe('deleteSelection - backward', () => { @@ -4155,4 +4333,228 @@ describe('deleteSelection - backward', () => { ], }); }); + + it('Delete word: text+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText(' '); + const text3 = createText('test2'); + + para.segments.push(text1, text2, text3, marker); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete word: space+text+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('\u00A0 \u00A0test1 \u00A0 test2'); + + para.segments.push(text1, marker); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '\u00A0 \u00A0test1 \u00A0\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete word: text+punc+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1. test2'); + + para.segments.push(text1, marker); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1.\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete word: punc+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('. test2'); + + para.segments.push(text1, marker); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '.\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete all before', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + + para.segments.push(text1, text2, marker, text3); + model.blocks.push(para); + + const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe(DeleteResult.Range); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test3', + format: {}, + }, + ], + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model/test/publicApi/editing/handleKeyDownEventTest.ts b/packages/roosterjs-content-model/test/publicApi/editing/handleKeyDownEventTest.ts index a618da7ceb8..2142cf2b311 100644 --- a/packages/roosterjs-content-model/test/publicApi/editing/handleKeyDownEventTest.ts +++ b/packages/roosterjs-content-model/test/publicApi/editing/handleKeyDownEventTest.ts @@ -4,7 +4,12 @@ import * as handleKeyboardEventResult from '../../../lib/editor/utils/handleKeyb import handleKeyDownEvent from '../../../lib/publicApi/editing/handleKeyDownEvent'; import { ChangeSource, Keys } from 'roosterjs-editor-types'; import { ContentModelDocument } from '../../../lib/publicTypes/group/ContentModelDocument'; +import { deleteAllSegmentBefore } from '../../../lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { editingTestCommon } from './editingTestCommon'; +import { + backwardDeleteWordSelection, + forwardDeleteWordSelection, +} from '../../../lib/modelApi/edit/deleteSteps/deleteWordSelection'; import { DeleteResult, DeleteSelectionStep, @@ -87,7 +92,7 @@ describe('handleKeyDownEvent', () => { blockGroupType: 'Document', blocks: [], }, - [forwardDeleteCollapsedSelection], + [null!, null!, forwardDeleteCollapsedSelection], DeleteResult.NotDeleted, 0 ); @@ -104,7 +109,83 @@ describe('handleKeyDownEvent', () => { blockGroupType: 'Document', blocks: [], }, - [backwardDeleteCollapsedSelection], + [null!, null!, backwardDeleteCollapsedSelection], + DeleteResult.NotDeleted, + 0 + ); + }); + + it('Empty model, delete word selection, forward', () => { + spyOn(handleKeyboardEventResult, 'shouldDeleteWord').and.returnValue(true); + + runTest( + { + blockGroupType: 'Document', + blocks: [], + }, + Keys.DELETE, + { + blockGroupType: 'Document', + blocks: [], + }, + [null!, forwardDeleteWordSelection, forwardDeleteCollapsedSelection], + DeleteResult.NotDeleted, + 0 + ); + }); + + it('Empty model, delete word selection, backward', () => { + spyOn(handleKeyboardEventResult, 'shouldDeleteWord').and.returnValue(true); + + runTest( + { + blockGroupType: 'Document', + blocks: [], + }, + Keys.BACKSPACE, + { + blockGroupType: 'Document', + blocks: [], + }, + [null!, backwardDeleteWordSelection, backwardDeleteCollapsedSelection], + DeleteResult.NotDeleted, + 0 + ); + }); + + it('Empty model, delete all before segments, forward', () => { + spyOn(handleKeyboardEventResult, 'shouldDeleteAllSegmentsBefore').and.returnValue(true); + + runTest( + { + blockGroupType: 'Document', + blocks: [], + }, + Keys.DELETE, + { + blockGroupType: 'Document', + blocks: [], + }, + [null!, null!, forwardDeleteCollapsedSelection], + DeleteResult.NotDeleted, + 0 + ); + }); + + it('Empty model, delete all before segments, backward', () => { + spyOn(handleKeyboardEventResult, 'shouldDeleteAllSegmentsBefore').and.returnValue(true); + + runTest( + { + blockGroupType: 'Document', + blocks: [], + }, + Keys.BACKSPACE, + { + blockGroupType: 'Document', + blocks: [], + }, + [deleteAllSegmentBefore, null!, backwardDeleteCollapsedSelection], DeleteResult.NotDeleted, 0 ); @@ -145,7 +226,7 @@ describe('handleKeyDownEvent', () => { }, ], }, - [forwardDeleteCollapsedSelection], + [null!, null!, forwardDeleteCollapsedSelection], DeleteResult.NotDeleted, 0 ); @@ -186,7 +267,7 @@ describe('handleKeyDownEvent', () => { }, ], }, - [backwardDeleteCollapsedSelection], + [null!, null!, backwardDeleteCollapsedSelection], DeleteResult.NotDeleted, 0 ); @@ -237,7 +318,7 @@ describe('handleKeyDownEvent', () => { }, ], }, - [forwardDeleteCollapsedSelection], + [null!, null!, forwardDeleteCollapsedSelection], DeleteResult.SingleChar, 1 ); @@ -288,7 +369,7 @@ describe('handleKeyDownEvent', () => { }, ], }, - [backwardDeleteCollapsedSelection], + [null!, null!, backwardDeleteCollapsedSelection], DeleteResult.SingleChar, 1 ); diff --git a/packages/roosterjs-editor-core/lib/corePlugins/UndoPlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/UndoPlugin.ts index c044ed6c168..ff467d5719b 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/UndoPlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/UndoPlugin.ts @@ -166,6 +166,10 @@ export default class UndoPlugin implements PluginWithState { this.addUndoSnapshot(); } this.lastKeyPress = 0; + } else if (this.lastKeyPress == Keys.BACKSPACE || this.lastKeyPress == Keys.DELETE) { + if (this.state.hasNewContent) { + this.addUndoSnapshot(); + } } }