diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 1d36d4dc51c6..08ddf4ee215b 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -59,7 +59,13 @@ const initialState: OptionState = { codeFormat: {}, }, editPluginOptions: { - handleTabKey: true, + handleTabKey: { + indentMultipleBlocks: true, + indentTable: true, + appendTableRow: true, + indentList: true, + indentParagraph: true, + }, }, customReplacements: emojiReplacements, disableSideResize: false, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index b08130cd5b5a..291915581f5c 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -2,6 +2,7 @@ import { AutoFormatOptions, CustomReplace, EditOptions, + HandleTabOptions, MarkdownOptions, } from 'roosterjs-content-model-plugins'; import type { SidePaneElementProps } from '../SidePaneElement'; @@ -39,7 +40,7 @@ export interface OptionState { autoFormatOptions: AutoFormatOptions; markdownOptions: MarkdownOptions; customReplacements: CustomReplace[]; - editPluginOptions: EditOptions; + editPluginOptions: EditOptions & { handleTabKey: HandleTabOptions }; disableSideResize: boolean; // Legacy plugin options diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index 7846778fb84c..cad56c7d3920 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -205,10 +205,40 @@ export class Plugins extends PluginsBase { 'Edit', <> {this.renderCheckBox( - 'Handle Tab Key', + 'Tab to indent multiple blocks', this.handleTabKey, - this.props.state.editPluginOptions.handleTabKey, - (state, value) => (state.editPluginOptions.handleTabKey = value) + this.props.state.editPluginOptions.handleTabKey + .indentMultipleBlocks, + (state, value) => + (state.editPluginOptions.handleTabKey.indentMultipleBlocks = value) + )} + {this.renderCheckBox( + 'Tab to indent table', + this.handleTabKey, + this.props.state.editPluginOptions.handleTabKey.indentTable, + (state, value) => + (state.editPluginOptions.handleTabKey.indentTable = value) + )} + {this.renderCheckBox( + 'Tab to append table row', + this.handleTabKey, + this.props.state.editPluginOptions.handleTabKey.appendTableRow, + (state, value) => + (state.editPluginOptions.handleTabKey.appendTableRow = value) + )} + {this.renderCheckBox( + 'Tab to indent list items', + this.handleTabKey, + this.props.state.editPluginOptions.handleTabKey.indentList, + (state, value) => + (state.editPluginOptions.handleTabKey.indentList = value) + )} + {this.renderCheckBox( + 'Tab to indent paragraph', + this.handleTabKey, + this.props.state.editPluginOptions.handleTabKey.indentParagraph, + (state, value) => + (state.editPluginOptions.handleTabKey.indentParagraph = value) )} {this.renderCheckBox( 'Handle Enter Key', diff --git a/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 21c0d4457819..b67c40def345 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -7,6 +7,7 @@ import { getOperationalBlocks, isBlockGroupOfType, mutateBlock, + normalizeContentModel, parseValueWithUnit, updateListMetadata, } from 'roosterjs-content-model-dom'; @@ -134,7 +135,13 @@ export function setModelIndentation( } }); - return paragraphOrListItem.length > 0; + if (paragraphOrListItem.length > 0) { + normalizeContentModel(model); + + return true; + } else { + return false; + } } function isSelected(listItem: ReadonlyContentModelListItem) { diff --git a/packages/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts b/packages/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts index 32ab89bd65be..873584b1f11c 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts @@ -1,4 +1,3 @@ -import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setModelIndentation } from '../../modelApi/block/setModelIndentation'; import type { IEditor } from 'roosterjs-content-model-types'; @@ -19,10 +18,6 @@ export function setIndentation( (model, context) => { const result = setModelIndentation(model, indentation, length); - if (result) { - normalizeContentModel(model); - } - context.newPendingFormat = 'preserve'; return result; diff --git a/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index b647eb014505..dc4aa9b02a13 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -1,4 +1,5 @@ import * as getListAnnounceData from '../../../lib/modelApi/list/getListAnnounceData'; +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as splitSelectedParagraphByBrModule from '../../../lib/modelApi/block/splitSelectedParagraphByBr'; import { ContentModelDocument, FormatContentModelContext } from 'roosterjs-content-model-types'; import { setModelIndentation } from '../../../lib/modelApi/block/setModelIndentation'; @@ -14,6 +15,7 @@ import { describe('indent', () => { let getListAnnounceDataSpy: jasmine.Spy; let splitSelectedParagraphByBrSpy: jasmine.Spy; + let normalizeContentModelSpy: jasmine.Spy; const mockedAnnounceData = 'ANNOUNCE' as any; beforeEach(() => { @@ -25,6 +27,8 @@ describe('indent', () => { splitSelectedParagraphByBrModule, 'splitSelectedParagraphByBr' ).and.callThrough(); + + normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); }); it('Empty group', () => { @@ -50,6 +54,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Group without selection', () => { @@ -77,6 +82,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Group with selected paragraph', () => { @@ -134,6 +140,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected indented paragraph', () => { @@ -197,6 +204,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected indented paragraph in RTL', () => { @@ -242,6 +250,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with multiple selected paragraph - 1', () => { @@ -302,6 +311,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with multiple selected paragraph - 2', () => { @@ -421,6 +431,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with paragraph under OL with formats', () => { @@ -496,6 +507,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with paragraph and multiple OL', () => { @@ -554,6 +566,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem2, group]); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with multiple selected paragraph and multiple OL', () => { @@ -637,6 +650,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem3, group]); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with multiple selected paragraph and UL and OL', () => { @@ -720,6 +734,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem2, group]); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Mixed with paragraph, list item and quote', () => { @@ -809,6 +824,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected indented paragraph, outdent with different length', () => { @@ -850,6 +866,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with list with first item selected', () => { @@ -935,6 +952,7 @@ describe('indent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Indent and follow previous item style', () => { @@ -1030,6 +1048,7 @@ describe('indent', () => { }); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(model); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Indent and follow next item style', () => { @@ -1149,6 +1168,7 @@ describe('indent', () => { }); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(model); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Indent, no style to follow', () => { @@ -1238,6 +1258,7 @@ describe('indent', () => { }); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(model); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); }); @@ -1245,6 +1266,7 @@ describe('outdent', () => { let getListAnnounceDataSpy: jasmine.Spy; let splitSelectedParagraphByBrSpy: jasmine.Spy; const mockedAnnounceData = 'ANNOUNCE' as any; + let normalizeContentModelSpy: jasmine.Spy; beforeEach(() => { getListAnnounceDataSpy = spyOn(getListAnnounceData, 'getListAnnounceData').and.returnValue( @@ -1254,6 +1276,8 @@ describe('outdent', () => { splitSelectedParagraphByBrModule, 'splitSelectedParagraphByBr' ).and.callThrough(); + + normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); }); it('Empty group', () => { @@ -1279,6 +1303,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Group without selection', () => { @@ -1304,6 +1329,7 @@ describe('outdent', () => { newImages: [], }); expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Group with selected paragraph that cannot outdent', () => { @@ -1343,6 +1369,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected single indented paragraph', () => { @@ -1410,6 +1437,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected 2 indented paragraph', () => { @@ -1483,6 +1511,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected multiple indented paragraph', () => { @@ -1545,6 +1574,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected list item', () => { @@ -1583,6 +1613,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected multiple level list item', () => { @@ -1645,6 +1676,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem, group]); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with mixed list item, quote and paragraph', () => { @@ -1709,6 +1741,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected indented paragraph in RTL', () => { @@ -1752,6 +1785,7 @@ describe('outdent', () => { newImages: [], }); expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with selected indented paragraph, outdent with different length', () => { @@ -1793,6 +1827,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Group with list with no indention selected', () => { @@ -1854,6 +1889,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Outdent parent format container, ltr', () => { @@ -1929,6 +1965,7 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); it('Outdent parent format container, rtl', () => { @@ -2013,5 +2050,6 @@ describe('outdent', () => { expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1); expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(group); + expect(normalizeContentModelSpy).toHaveBeenCalled(); }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditOptions.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditOptions.ts index b9e1dc723c17..86f814472a79 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditOptions.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditOptions.ts @@ -1,14 +1,52 @@ import type { IEditor } from 'roosterjs-content-model-types'; +/** + * Options for handling Tab key in Edit plugin + */ +export interface HandleTabOptions { + /** + * Whether to indent/outdent multiple selected blocks when Tab/Shift+Tab is pressed and multiple blocks are selected. + * @default true + */ + indentMultipleBlocks?: boolean; + + /** + * Whether to indent/outdent table cells when Tab key is pressed and a table is selected + * @default true + */ + indentTable?: boolean; + + /** + * Whether to append a new row when Tab key is pressed in the last cell of a table + * @default true + */ + appendTableRow?: boolean; + + /** + * Whether to indent/outdent list items when Tab key is pressed + * @default true + */ + indentList?: boolean; + + /** + * Whether to indent/outdent paragraph when Tab key is pressed + * @default true + */ + indentParagraph?: boolean; +} + /** * Options to customize the keyboard handling behavior of Edit plugin */ export type EditOptions = { /** - * Whether to handle Tab key in keyboard. @default true + * Whether to handle Tab key in keyboard, or an object to control specific Tab key behaviors. + * When true, all Tab features are enabled. When false, all are disabled. + * When an object, individual features can be controlled via HandleTabOptions. + * @default true */ - handleTabKey?: boolean; + handleTabKey?: HandleTabOptions | boolean; /** * Whether expanded selection within a text node should be handled by CM when pressing Backspace/Delete key. diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index cd453a00ee0b..6e0c3b186865 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -3,7 +3,7 @@ import { keyboardEnter } from './keyboardEnter'; import { keyboardInput } from './keyboardInput'; import { keyboardTab } from './keyboardTab'; import { parseTableCells } from 'roosterjs-content-model-dom'; -import type { EditOptions } from './EditOptions'; +import type { EditOptions, HandleTabOptions } from './EditOptions'; import type { DOMSelection, EditorPlugin, @@ -22,8 +22,24 @@ const DELETE_KEY = 46; */ const DEAD_KEY = 229; -const DefaultOptions: Partial = { - handleTabKey: true, +const DefaultHandleTabOptions: Required = { + indentMultipleBlocks: true, + indentTable: true, + appendTableRow: true, + indentList: true, + indentParagraph: true, +}; + +const DisabledHandleTabOptions: Required = { + indentMultipleBlocks: false, + indentTable: false, + appendTableRow: false, + indentList: false, + indentParagraph: false, +}; + +const DefaultOptions: Partial & { handleTabKey: Required } = { + handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true, }; @@ -40,13 +56,20 @@ export class EditPlugin implements EditorPlugin { private shouldHandleNextInputEvent = false; private selectionAfterDelete: DOMSelection | null = null; private handleNormalEnter: (editor: IEditor) => boolean = () => false; + private options: EditOptions & { handleTabKey: Required }; /** * @param options An optional parameter that takes in an object of type EditOptions, which includes the following properties: - * handleTabKey: A boolean that enables or disables Tab key handling. Defaults to true. + * handleTabKey: A boolean or HandleTabOptions object that controls Tab key handling. When a boolean, true enables all features and false disables all. When an object, individual features can be controlled. Defaults to all enabled. */ - constructor(private options: EditOptions = DefaultOptions) { - this.options = { ...DefaultOptions, ...options }; + constructor(options: EditOptions = DefaultOptions) { + const tabOptions = + options.handleTabKey === false + ? DisabledHandleTabOptions + : options.handleTabKey === true || !options.handleTabKey + ? DefaultHandleTabOptions + : { ...DefaultHandleTabOptions, ...options.handleTabKey }; + this.options = { ...DefaultOptions, ...options, handleTabKey: tabOptions }; } private createNormalEnterChecker(result: boolean) { @@ -136,7 +159,7 @@ export class EditPlugin implements EditorPlugin { willHandleEventExclusively(event: PluginEvent) { if ( this.editor && - this.options.handleTabKey && + this.options.handleTabKey.appendTableRow && event.eventType == 'keyDown' && event.rawEvent.key == 'Tab' && !event.rawEvent.shiftKey @@ -188,8 +211,8 @@ export class EditPlugin implements EditorPlugin { break; case 'Tab': - if (this.options.handleTabKey && !hasCtrlOrMetaKey) { - keyboardTab(editor, rawEvent); + if (!hasCtrlOrMetaKey) { + keyboardTab(editor, rawEvent, this.options.handleTabKey); } break; case 'Unidentified': diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index 0c275284a930..877750255441 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -3,6 +3,7 @@ import { handleTabOnParagraph } from './tabUtils/handleTabOnParagraph'; import { handleTabOnTable } from './tabUtils/handleTabOnTable'; import { handleTabOnTableCell } from './tabUtils/handleTabOnTableCell'; import { setModelIndentation } from 'roosterjs-content-model-api'; +import type { HandleTabOptions } from './EditOptions'; import { ChangeSource, getOperationalBlocks, @@ -19,14 +20,18 @@ import type { /** * @internal */ -export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { +export function keyboardTab( + editor: IEditor, + rawEvent: KeyboardEvent, + options: Required +) { const selection = editor.getDOMSelection(); switch (selection?.type) { case 'range': editor.formatContentModel( (model, context) => { - return handleTab(model, rawEvent, context); + return handleTab(model, rawEvent, context, options); }, { apiName: 'handleTabKey', @@ -35,21 +40,23 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { getChangeData: () => rawEvent.which, } ); + break; - return true; case 'table': - editor.formatContentModel( - model => { - return handleTabOnTable(model, rawEvent); - }, - { - apiName: 'handleTabKey', - rawEvent, - changeSource: ChangeSource.Keyboard, - getChangeData: () => rawEvent.which, - } - ); - return true; + if (options.indentTable) { + editor.formatContentModel( + model => { + return handleTabOnTable(model, rawEvent); + }, + { + apiName: 'handleTabKey', + rawEvent, + changeSource: ChangeSource.Keyboard, + getChangeData: () => rawEvent.which, + } + ); + } + break; } } @@ -63,7 +70,8 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { function handleTab( model: ReadonlyContentModelDocument, rawEvent: KeyboardEvent, - context: FormatContentModelContext + context: FormatContentModelContext, + options: Required ) { const blocks = getOperationalBlocks( model, @@ -73,15 +81,23 @@ function handleTab( const block = blocks.length > 0 ? blocks[0].block : undefined; if (blocks.length > 1) { - setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); - rawEvent.preventDefault(); - return true; + if (options.indentMultipleBlocks) { + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + rawEvent.preventDefault(); + return true; + } } else if (isBlockGroupOfType(block, 'TableCell')) { - return handleTabOnTableCell(model, block, rawEvent); + if (options.appendTableRow) { + return handleTabOnTableCell(model, block, rawEvent); + } } else if (block?.blockType === 'Paragraph') { - return handleTabOnParagraph(model, block, rawEvent, context); + if (options.indentParagraph) { + return handleTabOnParagraph(model, block, rawEvent, context); + } } else if (isBlockGroupOfType(block, 'ListItem')) { - return handleTabOnList(model, block, rawEvent, context); + if (options.indentList) { + return handleTabOnList(model, block, rawEvent, context); + } } return false; } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts index 010f73b9d0af..d42f7eafb227 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts @@ -32,6 +32,7 @@ export function handleTabOnList( context ); rawEvent.preventDefault(); + return true; } } diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index ffc43f3cfc8b..e5f452e5c837 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -5,7 +5,7 @@ export { TableWithRoot } from './tableEdit/TableWithRoot'; export { PastePlugin } from './paste/PastePlugin'; export { DefaultSanitizers } from './paste/DefaultSanitizers'; export { EditPlugin } from './edit/EditPlugin'; -export { EditOptions } from './edit/EditOptions'; +export { EditOptions, HandleTabOptions } from './edit/EditOptions'; export { AutoFormatPlugin } from './autoFormat/AutoFormatPlugin'; export { AutoFormatOptions } from './autoFormat/interface/AutoFormatOptions'; diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 299aafabe08b..f24bb1bc3d71 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -4,6 +4,15 @@ import * as keyboardInput from '../../lib/edit/keyboardInput'; import * as keyboardTab from '../../lib/edit/keyboardTab'; import { DOMEventRecord, IEditor } from 'roosterjs-content-model-types'; import { EditPlugin } from '../../lib/edit/EditPlugin'; +import { HandleTabOptions } from '../../lib/edit/EditOptions'; + +const DefaultHandleTabOptions: Required = { + indentMultipleBlocks: true, + indentTable: true, + appendTableRow: true, + indentList: true, + indentParagraph: true, +}; describe('EditPlugin', () => { let plugin: EditPlugin; @@ -67,7 +76,7 @@ describe('EditPlugin', () => { }); expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, { - handleTabKey: true, + handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true, }); expect(keyboardInputSpy).not.toHaveBeenCalled(); @@ -87,7 +96,7 @@ describe('EditPlugin', () => { }); expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, { - handleTabKey: true, + handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true, }); expect(keyboardInputSpy).not.toHaveBeenCalled(); @@ -124,7 +133,7 @@ describe('EditPlugin', () => { }); expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, { - handleTabKey: true, + handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true, shouldHandleEnterKey: true, }); @@ -159,7 +168,7 @@ describe('EditPlugin', () => { }); expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, { - handleTabKey: true, + handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: false, }); }); @@ -175,7 +184,7 @@ describe('EditPlugin', () => { rawEvent, }); - expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent, DefaultHandleTabOptions); expect(keyboardInputSpy).not.toHaveBeenCalled(); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardEnterSpy).not.toHaveBeenCalled(); @@ -194,7 +203,7 @@ describe('EditPlugin', () => { rawEvent, }); - expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent, DefaultHandleTabOptions); expect(keyboardInputSpy).not.toHaveBeenCalled(); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardEnterSpy).not.toHaveBeenCalled(); @@ -211,7 +220,13 @@ describe('EditPlugin', () => { rawEvent, }); - expect(keyboardTabSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent, { + indentMultipleBlocks: false, + indentTable: false, + appendTableRow: false, + indentList: false, + indentParagraph: false, + }); expect(keyboardInputSpy).not.toHaveBeenCalled(); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardEnterSpy).not.toHaveBeenCalled(); @@ -387,7 +402,7 @@ describe('EditPlugin', () => { { key: 'Delete', } as any, - { handleTabKey: true, handleExpandedSelectionOnDelete: true } + { handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true } ); plugin.onPluginEvent({ @@ -401,7 +416,7 @@ describe('EditPlugin', () => { { key: 'Delete', } as any, - { handleTabKey: true, handleExpandedSelectionOnDelete: true } + { handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true } ); expect(keyboardInputSpy).not.toHaveBeenCalled(); expect(keyboardEnterSpy).not.toHaveBeenCalled(); @@ -441,7 +456,7 @@ describe('EditPlugin', () => { keyCode: 8, which: 8, }), - { handleTabKey: true, handleExpandedSelectionOnDelete: true } + { handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true } ); }); @@ -470,7 +485,7 @@ describe('EditPlugin', () => { keyCode: 46, which: 46, }), - { handleTabKey: true, handleExpandedSelectionOnDelete: true } + { handleTabKey: DefaultHandleTabOptions, handleExpandedSelectionOnDelete: true } ); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts index 9a30174e1336..570fd7c3b969 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts @@ -1,3 +1,4 @@ +import * as normalizeContentModelModule from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { getDeleteCollapsedSelection } from '../../../lib/edit/deleteSteps/deleteCollapsedSelection'; import { createBr, @@ -25,6 +26,12 @@ const forwardDeleteCollapsedSelection = getDeleteCollapsedSelection('forward', { const backwardDeleteCollapsedSelection = getDeleteCollapsedSelection('backward', {}); describe('deleteSelection - forward', () => { + let normalizeContentModelSpy: jasmine.Spy; + + beforeEach(() => { + normalizeContentModelSpy = spyOn(normalizeContentModelModule, 'normalizeContentModel'); + }); + it('empty selection', () => { const model = createContentModelDocument(); const para = createParagraph(); @@ -46,6 +53,7 @@ describe('deleteSelection - forward', () => { expect(result.deleteResult).toBe('notDeleted'); expect(result.insertPoint).toBeNull(); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Single selection marker', () => { @@ -3238,6 +3246,8 @@ describe('deleteSelection - backward', () => { const para = createParagraph(); const marker = createSelectionMarker(); + spyOn(normalizeContentModelModule, 'normalizeContentModel'); + para.format.marginLeft = '40px'; para.segments.push(marker); @@ -3274,7 +3284,7 @@ describe('deleteSelection - backward', () => { }); }); - it('Dont outdent from empty paragraph nested in list', () => { + it('Do not outdent from empty paragraph nested in list', () => { const model = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -3330,7 +3340,7 @@ describe('deleteSelection - backward', () => { }); }); - it('Dont outdent empty para with no margins', () => { + it('Do not outdent empty para with no margins', () => { const model = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -3371,7 +3381,7 @@ describe('deleteSelection - backward', () => { }); }); - it('Dont outdent empty para with no margins and delete', () => { + it('Do not outdent empty para with no margins and delete', () => { const model = createContentModelDocument(); const para = createParagraph(); const para0 = createParagraph(); @@ -3431,6 +3441,8 @@ describe('deleteSelection - backward', () => { para.segments.push(marker); model.blocks.push(list); + spyOn(normalizeContentModelModule, 'normalizeContentModel'); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe('range'); diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts index cc785dbdb6f3..83972936400b 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -1,8 +1,17 @@ import * as setModelIndentation from '../../../roosterjs-content-model-api/lib/modelApi/block/setModelIndentation'; import { ContentModelDocument, FormatContentModelContext } from 'roosterjs-content-model-types'; import { editingTestCommon } from './editingTestCommon'; +import { HandleTabOptions } from '../../lib/edit/EditOptions'; import { keyboardTab } from '../../lib/edit/keyboardTab'; +const DefaultHandleTabOptions: Required = { + indentMultipleBlocks: true, + indentTable: true, + appendTableRow: true, + indentList: true, + indentParagraph: true, +}; + describe('keyboardTab', () => { let takeSnapshotSpy: jasmine.Spy; let setModelIndentationSpy: jasmine.Spy; @@ -50,7 +59,8 @@ describe('keyboardTab', () => { key: 'Tab', shiftKey: shiftKey, preventDefault: () => {}, - } as KeyboardEvent + } as KeyboardEvent, + DefaultHandleTabOptions ); expect(formatWithContentModelSpy).toHaveBeenCalled(); @@ -1086,7 +1096,7 @@ describe('keyboardTab - handleTabOnParagraph -', () => { }, }); - keyboardTab(editor, mockedEvent); + keyboardTab(editor, mockedEvent, DefaultHandleTabOptions); }, input, expectedResult,