From 53441eec3942eaf8f1a88145d9da6f6e6f623a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 22 Feb 2024 13:51:07 -0300 Subject: [PATCH 01/11] handle Tab key for paragraph --- .../lib/edit/keyboardTab.ts | 42 +- .../lib/edit/tabUtils/handleTabOnList.ts | 25 + .../lib/edit/tabUtils/handleTabOnParagraph.ts | 75 +++ .../test/edit/keyboardTabTest.ts | 564 +++++++++++++++++- .../test/edit/tabUtils/handleTabOnListTest.ts | 305 ++++++++++ .../edit/tabUtils/handleTabOnParagraphTest.ts | 429 +++++++++++++ 6 files changed, 1417 insertions(+), 23 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index da530533f73..049285b3541 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -1,9 +1,11 @@ import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; -import { setModelIndentation } from 'roosterjs-content-model-api'; +import { handleTabOnList } from './tabUtils/handleTabOnList'; +import { handleTabOnParagraph } from './tabUtils/handleTabOnParagraph'; import type { ContentModelDocument, ContentModelListItem, IEditor, + RangeSelection, } from 'roosterjs-content-model-types'; /** @@ -15,32 +17,30 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { if (selection?.type == 'range') { editor.takeSnapshot(); - editor.formatContentModel((model, _context) => { - return handleTabOnList(model, rawEvent); - }); + editor.formatContentModel( + (model, _context) => { + return handleTab(model, rawEvent, selection); + }, + { + apiName: 'handleTabKey', + } + ); return true; } } -function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { - return ( - listItem.blocks[0].blockType == 'Paragraph' && - listItem.blocks[0].segments[0].segmentType == 'SelectionMarker' - ); -} - -function handleTabOnList(model: ContentModelDocument, rawEvent: KeyboardEvent) { +function handleTab( + model: ContentModelDocument, + rawEvent: KeyboardEvent, + selection: RangeSelection +) { const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); - const listItem = blocks[0].block; - - if ( - isBlockGroupOfType(listItem, 'ListItem') && - isMarkerAtStartOfBlock(listItem) - ) { - setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); - rawEvent.preventDefault(); - return true; + const block = blocks[0].block; + if (block.blockType === 'Paragraph') { + return handleTabOnParagraph(model, block, rawEvent, selection); + } else if (isBlockGroupOfType(block, 'ListItem')) { + return handleTabOnList(model, block, rawEvent); } return false; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts new file mode 100644 index 00000000000..01c7abc1fd0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts @@ -0,0 +1,25 @@ +import { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; +import { setModelIndentation } from 'roosterjs-content-model-api'; + +/** + * @internal + */ +export function handleTabOnList( + model: ContentModelDocument, + listItem: ContentModelListItem, + rawEvent: KeyboardEvent +) { + if (isMarkerAtStartOfBlock(listItem)) { + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + rawEvent.preventDefault(); + return true; + } + return false; +} + +function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { + return ( + listItem.blocks[0].blockType == 'Paragraph' && + listItem.blocks[0].segments[0].segmentType == 'SelectionMarker' + ); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts new file mode 100644 index 00000000000..e7322b724e6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts @@ -0,0 +1,75 @@ +import { createSelectionMarker, createText } from 'roosterjs-content-model-dom/lib'; +import { setModelIndentation } from 'roosterjs-content-model-api'; +import { + ContentModelDocument, + ContentModelParagraph, + RangeSelection, +} from 'roosterjs-content-model-types/lib'; + +const tabSpaces = '    '; +const space = ' '; + +/** + * @internal + */ +export function handleTabOnParagraph( + model: ContentModelDocument, + paragraph: ContentModelParagraph, + rawEvent: KeyboardEvent, + selection: RangeSelection +) { + if (paragraph.segments[0].segmentType === 'SelectionMarker' && selection.range.collapsed) { + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + } else { + const markerIndex = paragraph.segments.findIndex( + segment => segment.segmentType === 'SelectionMarker' + ); + if (!selection.range.collapsed) { + let firstSelectedSegmentIndex: number | undefined = undefined; + let lastSelectedSegmentIndex: number | undefined = undefined; + + paragraph.segments.forEach((segment, index) => { + if (segment.isSelected) { + if (!firstSelectedSegmentIndex) { + firstSelectedSegmentIndex = index; + } + lastSelectedSegmentIndex = index; + } + }); + if (firstSelectedSegmentIndex && lastSelectedSegmentIndex) { + const firstSelectedSegment = paragraph.segments[firstSelectedSegmentIndex]; + const spaceText = createText(rawEvent.shiftKey ? tabSpaces : space); + const marker = createSelectionMarker(firstSelectedSegment.format); + paragraph.segments.splice( + firstSelectedSegmentIndex, + lastSelectedSegmentIndex - firstSelectedSegmentIndex + 1, + spaceText, + marker + ); + } else { + return false; + } + } else { + if (!rawEvent.shiftKey) { + const tabText = createText(tabSpaces); + paragraph.segments.splice(markerIndex, 0, tabText); + } else { + const tabText = paragraph.segments[markerIndex - 1]; + const tabSpacesLength = tabSpaces.length; + if (tabText.segmentType == 'Text') { + const tabSpaceTextLength = tabText.text.length - tabSpacesLength; + if (tabText.text === tabSpaces) { + paragraph.segments.splice(markerIndex - 1, 1); + } else if (tabText.text.substring(tabSpaceTextLength) === tabSpaces) { + tabText.text = tabText.text.substring(0, tabSpaceTextLength); + } else { + return false; + } + } + } + } + } + + rawEvent.preventDefault(); + return true; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts index 150d9b5c673..bc218d57e84 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -1,5 +1,6 @@ import * as setModelIndentation from '../../../roosterjs-content-model-api/lib/modelApi/block/setModelIndentation'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { editingTestCommon } from './editingTestCommon'; import { keyboardTab } from '../../lib/edit/keyboardTab'; describe('keyboardTab', () => { @@ -35,6 +36,9 @@ describe('keyboardTab', () => { getDOMSelection: () => { return { type: 'range', + range: { + collapsed: true, + }, }; }, }; @@ -56,7 +60,7 @@ describe('keyboardTab', () => { } } - it('tab on paragraph', () => { + it('tab on the end of paragraph', () => { const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -80,7 +84,34 @@ describe('keyboardTab', () => { format: {}, }; - runTest(model, undefined, false, false); + runTest(model, undefined, false, true); + }); + + it('tab on the start of paragraph', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + runTest(model, 'indent', false, true); }); it('tab on empty list', () => { @@ -868,3 +899,532 @@ describe('keyboardTab', () => { runTest(model, undefined, true, false); }); }); + +describe('keyboardTab - handleTabOnParagraph -', () => { + function runTest( + input: ContentModelDocument, + key: string, + collapsed: boolean, + shiftKey: boolean, + expectedResult: ContentModelDocument, + calledTimes: number = 1 + ) { + const preventDefault = jasmine.createSpy('preventDefault'); + const mockedEvent = ({ + key, + shiftKey: shiftKey, + preventDefault, + } as any) as KeyboardEvent; + + let editor: any; + + editingTestCommon( + 'handleTabKey', + newEditor => { + editor = newEditor; + + editor.getDOMSelection = () => ({ + type: 'range', + range: { + collapsed: collapsed, + }, + }); + + keyboardTab(editor, mockedEvent); + }, + input, + expectedResult, + calledTimes + ); + } + + it('collapsed range | tab on the end of paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, false, expectedResult); + }); + + it('collapsed range | tab on the start of paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, false, expectedResult); + }); + + it('collapsed range | tab on the middle of paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, false, expectedResult); + }); + + it('collapsed range | shift tab on the end of paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, true, input, 0); + }); + + it('collapsed range | shift tab on the start of paragraph indented', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, true, expectedResult); + }); + + it('collapsed range | shift tab on the end of paragraph indented', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, true, expectedResult); + }); + + it('collapsed range | shift tab on the middle of paragraph indented', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, true, expectedResult); + }); + + it('expanded range | tab on paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '456', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', false, false, expectedResult); + }); + + it('expanded range | shift tab on paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '456', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', false, true, expectedResult); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts new file mode 100644 index 00000000000..8a14ad04cb1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts @@ -0,0 +1,305 @@ +import { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; +import { handleTabOnList } from '../../../lib/edit/tabUtils/handleTabOnList'; + +describe('handleTabOnList', () => { + function runTest( + model: ContentModelDocument, + listItem: ContentModelListItem, + rawEvent: KeyboardEvent, + expectedReturnValue: boolean + ) { + // Act + const result = handleTabOnList(model, listItem, rawEvent); + + // Assert + expect(result).toBe(expectedReturnValue); + } + + it('should return true when the cursor is at the start of the list item', () => { + // Arrange + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }; + const rawEvent = { + shiftKey: false, + preventDefault: () => {}, + } as KeyboardEvent; + const expectedReturnValue = true; + + // Act + runTest(model, listItem, rawEvent, expectedReturnValue); + }); + + it('Outdent - should return true when the cursor is at the start of the list item', () => { + // Arrange + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }; + const rawEvent = { + shiftKey: false, + preventDefault: () => {}, + } as KeyboardEvent; + const expectedReturnValue = true; + + // Act + runTest(model, listItem, rawEvent, expectedReturnValue); + }); + + it('should return false when the cursor is not at the start of the list item', () => { + // Arrange + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }; + const rawEvent = { + shiftKey: false, + preventDefault: () => {}, + } as KeyboardEvent; + const expectedReturnValue = false; + + // Act + runTest(model, listItem, rawEvent, expectedReturnValue); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts new file mode 100644 index 00000000000..60ee90767e3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts @@ -0,0 +1,429 @@ +import { handleTabOnParagraph } from '../../../lib/edit/tabUtils/handleTabOnParagraph'; +import { + ContentModelDocument, + ContentModelParagraph, + RangeSelection, +} from 'roosterjs-content-model-types'; + +describe('handleTabOnParagraph', () => { + function runTest( + model: ContentModelDocument, + paragraph: ContentModelParagraph, + rawEvent: KeyboardEvent, + selection: RangeSelection, + expectedReturnValue: boolean + ) { + // Act + const result = handleTabOnParagraph(model, paragraph, rawEvent, selection); + + // Assert + expect(result).toBe(expectedReturnValue); + } + + it('Indent - collapsed range should return true when cursor is at the end', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Outdent - collapsed range should return false when cursor is at the end', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, false); + }); + + it('Indent - collapsed range should return true when cursor is at the start', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Outdent - collapsed range should return true when cursor is at the start', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Indent - collapsed range should return true when cursor is at the middle', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Outdent - collapsed range should return true when cursor is at the middle', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, false); + }); + + it('Outdent - Intended - collapsed range should return true when cursor is at the end', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Outdent - Intended - collapsed range should return true when cursor is at the middle', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Indent - expanded range should return true', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '456', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const selection = { + type: 'range', + range: { + collapsed: false, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('outdent - expanded range should return true', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '456', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: false, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); +}); From 55bc45135274760401f54061999a93380a8cccfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 22 Feb 2024 13:54:02 -0300 Subject: [PATCH 02/11] fix build --- .../lib/edit/tabUtils/handleTabOnParagraph.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts index e7322b724e6..079a32069e2 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts @@ -1,10 +1,10 @@ import { createSelectionMarker, createText } from 'roosterjs-content-model-dom/lib'; import { setModelIndentation } from 'roosterjs-content-model-api'; -import { +import type { ContentModelDocument, ContentModelParagraph, RangeSelection, -} from 'roosterjs-content-model-types/lib'; +} from 'roosterjs-content-model-types'; const tabSpaces = '    '; const space = ' '; From 6b1b9a8087ce1b8e9d2eb6b7ef324d0ffca28c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 22 Feb 2024 13:54:53 -0300 Subject: [PATCH 03/11] fix build --- .../lib/edit/tabUtils/handleTabOnList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts index 01c7abc1fd0..e46740c7f53 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts @@ -1,5 +1,5 @@ -import { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; import { setModelIndentation } from 'roosterjs-content-model-api'; +import type { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; /** * @internal From 34c735a0a05793b1c77d8d6ace7ef45cf27b4be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 22 Feb 2024 13:57:28 -0300 Subject: [PATCH 04/11] remove /lib --- .../lib/edit/tabUtils/handleTabOnParagraph.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts index 079a32069e2..05dce57db71 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts @@ -1,4 +1,4 @@ -import { createSelectionMarker, createText } from 'roosterjs-content-model-dom/lib'; +import { createSelectionMarker, createText } from 'roosterjs-content-model-dom'; import { setModelIndentation } from 'roosterjs-content-model-api'; import type { ContentModelDocument, From abfae448b139f3c783ffd51024357eeaa4e7dec5 Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Mon, 26 Feb 2024 17:22:37 -0300 Subject: [PATCH 05/11] save last focus --- .../roosterjs-content-model-core/lib/coreApi/paste.ts | 2 ++ .../roosterjs-content-model-core/test/coreApi/pasteTest.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts index 44258f6ff84..b1b57ffe5e5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts @@ -1,3 +1,4 @@ +import { addUndoSnapshot } from './addUndoSnapshot'; import { cloneModel } from '../publicApi/model/cloneModel'; import { convertInlineCss } from '../utils/convertInlineCss'; import { createPasteFragment } from '../utils/paste/createPasteFragment'; @@ -30,6 +31,7 @@ export const paste: Paste = ( pasteType: PasteType = 'normal' ) => { core.api.focus(core); + addUndoSnapshot(core, false); if (clipboardData.modelBeforePaste) { core.api.setContentModel(core, cloneModel(clipboardData.modelBeforePaste, CloneOption)); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index fcb8f1e13d4..31286822ad0 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -1,4 +1,5 @@ import * as addParserF from 'roosterjs-content-model-plugins/lib/paste/utils/addParser'; +import * as addUndoSnapshot from '../../lib/coreApi/addUndoSnapshot'; import * as cloneModel from '../../lib/publicApi/model/cloneModel'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as ExcelF from 'roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel'; @@ -31,6 +32,7 @@ describe('Paste ', () => { let mockedModel: ContentModelDocument; let mockedMergeModel: ContentModelDocument; let getVisibleViewport: jasmine.Spy; + let addUndoSnapshotSpy: jasmine.Spy; const mockedCloneModel = 'CloneModel' as any; @@ -39,6 +41,7 @@ describe('Paste ', () => { beforeEach(() => { spyOn(domToContentModel, 'domToContentModel').and.callThrough(); spyOn(cloneModel, 'cloneModel').and.returnValue(mockedCloneModel); + addUndoSnapshotSpy = spyOn(addUndoSnapshot, 'addUndoSnapshot'); clipboardData = { types: ['image/png', 'text/html'], text: '', @@ -95,12 +98,14 @@ describe('Paste ', () => { editor.pasteFromClipboard(clipboardData); expect(mockedModel).toEqual(mockedMergeModel); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); }); it('Execute | As plain text', () => { editor.pasteFromClipboard(clipboardData, 'asPlainText'); expect(mockedModel).toEqual(mockedMergeModel); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); }); }); From 44f6c07bde7d1f0c4b2ebd9d9dd4b2742ec5d597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 27 Feb 2024 14:42:01 -0300 Subject: [PATCH 06/11] refactor --- .../lib/edit/keyboardTab.ts | 28 +- .../lib/edit/tabUtils/handleTabOnList.ts | 20 +- .../lib/edit/tabUtils/handleTabOnParagraph.ts | 43 +- .../test/edit/keyboardTabTest.ts | 437 +++++++++++++++++- .../test/edit/tabUtils/handleTabOnListTest.ts | 5 +- 5 files changed, 499 insertions(+), 34 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index 049285b3541..04f4e25e0dc 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -1,11 +1,11 @@ import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; import { handleTabOnList } from './tabUtils/handleTabOnList'; import { handleTabOnParagraph } from './tabUtils/handleTabOnParagraph'; +import { setModelIndentation } from 'roosterjs-content-model-api'; import type { ContentModelDocument, ContentModelListItem, IEditor, - RangeSelection, } from 'roosterjs-content-model-types'; /** @@ -15,11 +15,9 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); if (selection?.type == 'range') { - editor.takeSnapshot(); - editor.formatContentModel( - (model, _context) => { - return handleTab(model, rawEvent, selection); + model => { + return handleTab(model, rawEvent); }, { apiName: 'handleTabKey', @@ -30,15 +28,19 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { } } -function handleTab( - model: ContentModelDocument, - rawEvent: KeyboardEvent, - selection: RangeSelection -) { +/** + * If multiple blocks are selected, indent or outdent the selected blocks with setModelIndentation. + * If only one block is selected, call handleTabOnParagraph or handleTabOnList to handle the tab key. + */ +function handleTab(model: ContentModelDocument, rawEvent: KeyboardEvent) { const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); - const block = blocks[0].block; - if (block.blockType === 'Paragraph') { - return handleTabOnParagraph(model, block, rawEvent, selection); + const block = blocks.length > 0 ? blocks[0].block : undefined; + if (blocks.length > 1) { + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + rawEvent.preventDefault(); + return true; + } else if (block?.blockType === 'Paragraph') { + return handleTabOnParagraph(model, block, rawEvent); } else if (isBlockGroupOfType(block, 'ListItem')) { return handleTabOnList(model, block, rawEvent); } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts index e46740c7f53..1f2cbf130ff 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts @@ -1,7 +1,10 @@ +import { handleTabOnParagraph } from './handleTabOnParagraph'; import { setModelIndentation } from 'roosterjs-content-model-api'; import type { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; /** + * 1. When the selection is collapsed and the cursor is at start of a list item, call setModelIndentation. + * 2. Otherwise call handleTabOnParagraph. * @internal */ export function handleTabOnList( @@ -9,12 +12,18 @@ export function handleTabOnList( listItem: ContentModelListItem, rawEvent: KeyboardEvent ) { - if (isMarkerAtStartOfBlock(listItem)) { + const selectedParagraph = findSelectedParagraph(listItem); + if ( + !isMarkerAtStartOfBlock(listItem) && + selectedParagraph.length == 1 && + selectedParagraph[0].blockType === 'Paragraph' + ) { + return handleTabOnParagraph(model, selectedParagraph[0], rawEvent); + } else { setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); rawEvent.preventDefault(); return true; } - return false; } function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { @@ -23,3 +32,10 @@ function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { listItem.blocks[0].segments[0].segmentType == 'SelectionMarker' ); } + +function findSelectedParagraph(listItem: ContentModelListItem) { + return listItem.blocks.filter( + block => + block.blockType == 'Paragraph' && block.segments.some(segment => segment.isSelected) + ); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts index 05dce57db71..684ef48e70e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts @@ -1,30 +1,38 @@ import { createSelectionMarker, createText } from 'roosterjs-content-model-dom'; import { setModelIndentation } from 'roosterjs-content-model-api'; -import type { - ContentModelDocument, - ContentModelParagraph, - RangeSelection, -} from 'roosterjs-content-model-types'; +import type { ContentModelDocument, ContentModelParagraph } from 'roosterjs-content-model-types'; const tabSpaces = '    '; const space = ' '; /** * @internal + The handleTabOnParagraph function will handle the tab key in following scenarios: + * 1. When the selection is collapsed and the cursor is at the end of a paragraph, add 4 spaces. + * 2. When the selection is collapsed and the cursor is at the start of a paragraph, call setModelIndention function to indent the whole paragraph + * 3. When the selection is collapsed and the cursor is at the middle of a paragraph, add 4 spaces. + * 4. When the selection is not collapsed, replace the selected range with a single space. + * 5. When the selection is not collapsed, but all segments are selected, call setModelIndention function to indent the whole paragraph + The handleTabOnParagraph function will handle the shift + tab key in a indented paragraph in following scenarios: + * 1. When the selection is collapsed and the cursor is at the end of a paragraph, remove 4 spaces. + * 2. When the selection is collapsed and the cursor is at the start of a paragraph, call setModelIndention function to outdent the whole paragraph + * 3. When the selection is collapsed and the cursor is at the middle of a paragraph, remove 4 spaces. + * 4. When the selection is not collapsed, replace the selected range with a 4 space. + * 5. When the selection is not collapsed, but all segments are selected, call setModelIndention function to outdent the whole paragraph */ export function handleTabOnParagraph( model: ContentModelDocument, paragraph: ContentModelParagraph, - rawEvent: KeyboardEvent, - selection: RangeSelection + rawEvent: KeyboardEvent ) { - if (paragraph.segments[0].segmentType === 'SelectionMarker' && selection.range.collapsed) { + const selectedSegments = paragraph.segments.filter(segment => segment.isSelected); + const isCollapsed = + selectedSegments.length === 1 && selectedSegments[0].segmentType === 'SelectionMarker'; + const isAllSelected = paragraph.segments.every(segment => segment.isSelected); + if ((paragraph.segments[0].segmentType === 'SelectionMarker' && isCollapsed) || isAllSelected) { setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); } else { - const markerIndex = paragraph.segments.findIndex( - segment => segment.segmentType === 'SelectionMarker' - ); - if (!selection.range.collapsed) { + if (!isCollapsed) { let firstSelectedSegmentIndex: number | undefined = undefined; let lastSelectedSegmentIndex: number | undefined = undefined; @@ -38,7 +46,10 @@ export function handleTabOnParagraph( }); if (firstSelectedSegmentIndex && lastSelectedSegmentIndex) { const firstSelectedSegment = paragraph.segments[firstSelectedSegmentIndex]; - const spaceText = createText(rawEvent.shiftKey ? tabSpaces : space); + const spaceText = createText( + rawEvent.shiftKey ? tabSpaces : space, + firstSelectedSegment.format + ); const marker = createSelectionMarker(firstSelectedSegment.format); paragraph.segments.splice( firstSelectedSegmentIndex, @@ -50,8 +61,12 @@ export function handleTabOnParagraph( return false; } } else { + const markerIndex = paragraph.segments.findIndex( + segment => segment.segmentType === 'SelectionMarker' + ); if (!rawEvent.shiftKey) { - const tabText = createText(tabSpaces); + const markerFormat = paragraph.segments[markerIndex].format; + const tabText = createText(tabSpaces, markerFormat); paragraph.segments.splice(markerIndex, 0, tabText); } else { const tabText = paragraph.segments[markerIndex - 1]; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts index bc218d57e84..bfc2cf68bd4 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -413,7 +413,7 @@ describe('keyboardTab', () => { ], format: {}, }; - runTest(model, undefined, false, false); + runTest(model, undefined, false, true); }); it('tab on the start second item on the list', () => { @@ -665,7 +665,7 @@ describe('keyboardTab', () => { ], format: {}, }; - runTest(model, undefined, false, false); + runTest(model, undefined, false, true); }); it('shift tab on empty list item', () => { @@ -1427,4 +1427,437 @@ describe('keyboardTab - handleTabOnParagraph -', () => { }; runTest(input, 'Tab', false, true, expectedResult); }); + + it('expanded range | multiple paragraphs', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + + runTest(input, 'Tab', false, false, expectedResult); + }); + + it('expanded range | outdent paragraphs', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + }; + runTest(input, 'Tab', false, true, expectedResult); + }); + + it('collapsed range | middle list', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + }; + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, false, expectedResult); + }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts index 8a14ad04cb1..6f457fb0ae6 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts @@ -207,7 +207,7 @@ describe('handleTabOnList', () => { runTest(model, listItem, rawEvent, expectedReturnValue); }); - it('should return false when the cursor is not at the start of the list item', () => { + it('should return true when the cursor is not at the start of the list item', () => { // Arrange const model: ContentModelDocument = { blockGroupType: 'Document', @@ -297,9 +297,8 @@ describe('handleTabOnList', () => { shiftKey: false, preventDefault: () => {}, } as KeyboardEvent; - const expectedReturnValue = false; // Act - runTest(model, listItem, rawEvent, expectedReturnValue); + runTest(model, listItem, rawEvent, true); }); }); From a692c399a1e3469ad7b21d6781b1e40bce528bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 27 Feb 2024 14:56:29 -0300 Subject: [PATCH 07/11] fix build --- .../test/edit/tabUtils/handleTabOnParagraphTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts index 60ee90767e3..9f79f57b793 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts @@ -14,7 +14,7 @@ describe('handleTabOnParagraph', () => { expectedReturnValue: boolean ) { // Act - const result = handleTabOnParagraph(model, paragraph, rawEvent, selection); + const result = handleTabOnParagraph(model, paragraph, rawEvent); // Assert expect(result).toBe(expectedReturnValue); From a04f9f0eefdaef48826716f55a26441b78070978 Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Tue, 27 Feb 2024 19:52:14 -0300 Subject: [PATCH 08/11] format with content model --- .../lib/coreApi/formatContentModel.ts | 4 +--- .../lib/coreApi/paste.ts | 2 -- .../lib/editor/SnapshotsManagerImpl.ts | 20 ++++++++++++------- .../test/coreApi/formatContentModelTest.ts | 10 +++++----- .../test/coreApi/pasteTest.ts | 6 +----- 5 files changed, 20 insertions(+), 22 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index d7d1d2c5f6e..3e01ee49079 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -42,9 +42,7 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) if (shouldAddSnapshot) { core.undo.isNested = true; - if (core.undo.snapshotsManager.hasNewContent || entityStates) { - core.api.addUndoSnapshot(core, !!canUndoByBackspace); - } + core.api.addUndoSnapshot(core, !!canUndoByBackspace, entityStates); } try { diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts index b1b57ffe5e5..44258f6ff84 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts @@ -1,4 +1,3 @@ -import { addUndoSnapshot } from './addUndoSnapshot'; import { cloneModel } from '../publicApi/model/cloneModel'; import { convertInlineCss } from '../utils/convertInlineCss'; import { createPasteFragment } from '../utils/paste/createPasteFragment'; @@ -31,7 +30,6 @@ export const paste: Paste = ( pasteType: PasteType = 'normal' ) => { core.api.focus(core); - addUndoSnapshot(core, false); if (clipboardData.modelBeforePaste) { core.api.setContentModel(core, cloneModel(clipboardData.modelBeforePaste, CloneOption)); diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts index cfe4d25f59e..cb00a28ddb1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts @@ -47,13 +47,9 @@ class SnapshotsManagerImpl implements SnapshotsManager { addSnapshot(snapshot: Snapshot, isAutoCompleteSnapshot: boolean): void { const currentSnapshot = this.snapshots.snapshots[this.snapshots.currentIndex]; - const isSameSnapshot = - currentSnapshot && - currentSnapshot.html == snapshot.html && - !currentSnapshot.entityStates && - !snapshot.entityStates; + const addSnapshot = !currentSnapshot || shouldAddSnapshot(currentSnapshot, snapshot); - if (this.snapshots.currentIndex < 0 || !currentSnapshot || !isSameSnapshot) { + if (this.snapshots.currentIndex < 0 || addSnapshot) { this.clearRedo(); this.snapshots.snapshots.push(snapshot); this.snapshots.currentIndex++; @@ -82,7 +78,7 @@ class SnapshotsManagerImpl implements SnapshotsManager { if (isAutoCompleteSnapshot) { this.snapshots.autoCompleteIndex = this.snapshots.currentIndex; } - } else if (isSameSnapshot) { + } else if (!addSnapshot) { // replace the currentSnapshot's metadata so the selection is updated this.snapshots.snapshots.splice(this.snapshots.currentIndex, 1, snapshot); } @@ -129,3 +125,13 @@ class SnapshotsManagerImpl implements SnapshotsManager { export function createSnapshotsManager(snapshots?: Snapshots): SnapshotsManager { return new SnapshotsManagerImpl(snapshots); } + +function shouldAddSnapshot(currentSnapshot: Snapshot, snapshot: Snapshot) { + return ( + currentSnapshot.html !== snapshot.html || + (currentSnapshot.entityStates && + snapshot.entityStates && + currentSnapshot.entityStates !== snapshot.entityStates) || + (!currentSnapshot.entityStates && snapshot.entityStates) + ); +} diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts index 40a382043e4..884709a8055 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts @@ -93,7 +93,7 @@ describe('formatContentModel', () => { newImages: [], }); expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -725,7 +725,7 @@ describe('formatContentModel', () => { expect(callback).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(2); - expect(addUndoSnapshot).toHaveBeenCalledWith(core, false); + expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -750,7 +750,7 @@ describe('formatContentModel', () => { expect(callback).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(2); - expect(addUndoSnapshot).toHaveBeenCalledWith(core, false); + expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, mockedEntityState); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, mockedEntityState); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -771,7 +771,7 @@ describe('formatContentModel', () => { formatContentModel(core, callback); expect(callback).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, true, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -800,7 +800,7 @@ describe('formatContentModel', () => { formatContentModel(core, callback); expect(callback).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, true, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index 31286822ad0..fcf2e11f8e4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -1,5 +1,4 @@ import * as addParserF from 'roosterjs-content-model-plugins/lib/paste/utils/addParser'; -import * as addUndoSnapshot from '../../lib/coreApi/addUndoSnapshot'; import * as cloneModel from '../../lib/publicApi/model/cloneModel'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as ExcelF from 'roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel'; @@ -32,7 +31,6 @@ describe('Paste ', () => { let mockedModel: ContentModelDocument; let mockedMergeModel: ContentModelDocument; let getVisibleViewport: jasmine.Spy; - let addUndoSnapshotSpy: jasmine.Spy; const mockedCloneModel = 'CloneModel' as any; @@ -41,7 +39,7 @@ describe('Paste ', () => { beforeEach(() => { spyOn(domToContentModel, 'domToContentModel').and.callThrough(); spyOn(cloneModel, 'cloneModel').and.returnValue(mockedCloneModel); - addUndoSnapshotSpy = spyOn(addUndoSnapshot, 'addUndoSnapshot'); + clipboardData = { types: ['image/png', 'text/html'], text: '', @@ -98,14 +96,12 @@ describe('Paste ', () => { editor.pasteFromClipboard(clipboardData); expect(mockedModel).toEqual(mockedMergeModel); - expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); }); it('Execute | As plain text', () => { editor.pasteFromClipboard(clipboardData, 'asPlainText'); expect(mockedModel).toEqual(mockedMergeModel); - expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); }); }); From ac25fcf4fb1b381e6c20edf2137c6f1d54369a0c Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Tue, 27 Feb 2024 19:54:34 -0300 Subject: [PATCH 09/11] remove empty line --- .../roosterjs-content-model-core/test/coreApi/pasteTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index fcf2e11f8e4..fcb8f1e13d4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -39,7 +39,6 @@ describe('Paste ', () => { beforeEach(() => { spyOn(domToContentModel, 'domToContentModel').and.callThrough(); spyOn(cloneModel, 'cloneModel').and.returnValue(mockedCloneModel); - clipboardData = { types: ['image/png', 'text/html'], text: '', From c0ff665bf84db61f4f3f704abf7ecb3c9eb73d89 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Wed, 28 Feb 2024 11:45:27 -0300 Subject: [PATCH 10/11] add unit test --- .../lib/editor/SnapshotsManagerImpl.ts | 7 +- .../test/editor/SnapshotsManagerImplTest.ts | 165 ++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts index cb00a28ddb1..338d076295a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts @@ -47,6 +47,11 @@ class SnapshotsManagerImpl implements SnapshotsManager { addSnapshot(snapshot: Snapshot, isAutoCompleteSnapshot: boolean): void { const currentSnapshot = this.snapshots.snapshots[this.snapshots.currentIndex]; + const isSameSnapshot = + currentSnapshot && + currentSnapshot.html == snapshot.html && + !currentSnapshot.entityStates && + !snapshot.entityStates; const addSnapshot = !currentSnapshot || shouldAddSnapshot(currentSnapshot, snapshot); if (this.snapshots.currentIndex < 0 || addSnapshot) { @@ -78,7 +83,7 @@ class SnapshotsManagerImpl implements SnapshotsManager { if (isAutoCompleteSnapshot) { this.snapshots.autoCompleteIndex = this.snapshots.currentIndex; } - } else if (!addSnapshot) { + } else if (isSameSnapshot) { // replace the currentSnapshot's metadata so the selection is updated this.snapshots.snapshots.splice(this.snapshots.currentIndex, 1, snapshot); } diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts index 70d0d6ec426..636f850d0b4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts @@ -300,6 +300,171 @@ describe('SnapshotsManagerImpl.addSnapshot', () => { ]); }); + it('Add snapshot with entity state with equal entity states', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + }); + + it('Add snapshot with entity state with different entity states', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + const mockedEntityStates2 = 'ENTITYSTATES2' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates2, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates2, + }, + ]); + }); + + it('Add snapshot without entity state after a snapshot with empty state', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + }); + it('Has onChanged', () => { const onChanged = jasmine.createSpy('onChanged'); snapshots.onChanged = onChanged; From eae992c40f0a4769ceedfdc21a44f1727f5746a0 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 28 Feb 2024 10:57:54 -0800 Subject: [PATCH 11/11] Fix dispose plugin issus in EditorAdapter (#2452) --- packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index f986577a5fd..84a85f712fc 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -152,6 +152,8 @@ export class EditorAdapter extends Editor implements ILegacyEditor { * Dispose this editor, dispose all plugins and custom data */ dispose(): void { + super.dispose(); + const core = this.contentModelEditorCore; if (core) { @@ -167,8 +169,6 @@ export class EditorAdapter extends Editor implements ILegacyEditor { this.contentModelEditorCore = undefined; } - - super.dispose(); } /**