diff --git a/demo/scripts/controls/sidePane/editorOptions/ContentEditFeatures.tsx b/demo/scripts/controls/sidePane/editorOptions/ContentEditFeatures.tsx index 761c6440fdc..5b5fd35382f 100644 --- a/demo/scripts/controls/sidePane/editorOptions/ContentEditFeatures.tsx +++ b/demo/scripts/controls/sidePane/editorOptions/ContentEditFeatures.tsx @@ -47,6 +47,7 @@ const EditFeatureDescriptionMap: Record, -->, >, => in an empty line, toggle bullet', autoNumberingList: 'When press space after an number, a letter or roman number followed by ), ., -, or between parenthesis in an empty line, toggle numbering', + mergeListOnBackspaceAfterList: 'When backspacing between lists, merge the lists', }; export interface ContentEditFeaturessProps { diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts index 8eb9d7de97a..64863163a3d 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts @@ -18,6 +18,8 @@ import { createVListFromRegion, isBlockElement, cacheGetEventData, + safeInstanceOf, + VList, createObjectDefinition, createNumberDefinition, getMetadata, @@ -33,10 +35,14 @@ import { RegionBase, ListType, ExperimentalFeatures, + PositionType, NumberingListType, BulletListType, } from 'roosterjs-editor-types'; +const PREVIOUS_BLOCK_CACHE_KEY = 'previousBlock'; +const NEXT_BLOCK_CACHE_KEY = 'nextBlock'; + interface ListStyleMetadata { orderedStyleType?: NumberingListType; unorderedStyleType?: BulletListType; @@ -456,6 +462,86 @@ function shouldTriggerList( ); } +/** + * MergeListOnBackspaceAfterList edit feature, provides the ability to merge list on backspace on block after a list. + */ +const MergeListOnBackspaceAfterList: BuildInEditFeature = { + keys: [Keys.BACKSPACE], + shouldHandleEvent: (event, editor) => { + const target = editor.getElementAtCursor(); + if (target) { + const cursorBlock = editor.getBlockElementAtNode(target)?.getStartNode() as HTMLElement; + const previousBlock = cursorBlock?.previousElementSibling ?? null; + + if (isList(previousBlock)) { + const range = editor.getSelectionRange(); + const searcher = editor.getContentSearcherOfCursor(event); + const textBeforeCursor = searcher?.getSubStringBefore(4); + const nearestInline = searcher?.getNearestNonTextInlineElement(); + + if (range && range.collapsed && textBeforeCursor === '' && !nearestInline) { + const tempBlock = cursorBlock?.nextElementSibling; + const nextBlock = isList(tempBlock) ? tempBlock : tempBlock?.firstChild; + + if ( + isList(nextBlock) && + getTagOfNode(previousBlock) == getTagOfNode(nextBlock) + ) { + const element = cacheGetEventData( + event, + PREVIOUS_BLOCK_CACHE_KEY, + () => previousBlock + ); + const nextElement = cacheGetEventData( + event, + NEXT_BLOCK_CACHE_KEY, + () => nextBlock + ); + + return !!element && !!nextElement; + } + } + } + } + + return false; + }, + handleEvent: (event, editor) => { + editor.runAsync(editor => { + const previousList = cacheGetEventData( + event, + PREVIOUS_BLOCK_CACHE_KEY, + () => null + ); + const targetBlock = cacheGetEventData( + event, + NEXT_BLOCK_CACHE_KEY, + () => null + ); + + const rangeBeforeWriteBack = editor.getSelectionRange(); + + if (previousList && targetBlock && rangeBeforeWriteBack) { + const fvList = new VList(previousList); + fvList.mergeVList(new VList(targetBlock)); + + let span = editor.getDocument().createElement('span'); + span.id = 'restoreRange'; + rangeBeforeWriteBack.insertNode(span); + + fvList.writeBack(); + + span = editor.queryElements('#restoreRange')[0]; + + if (span.parentElement) { + editor.select(new Position(span, PositionType.After)); + span.parentElement.removeChild(span); + } + } + }); + }, +}; + /** * @internal */ @@ -473,4 +559,12 @@ export const ListFeatures: Record< maintainListChainWhenDelete: MaintainListChainWhenDelete, autoNumberingList: AutoNumberingList, autoBulletList: AutoBulletList, + mergeListOnBackspaceAfterList: MergeListOnBackspaceAfterList, }; + +function isList(element: Node | null | undefined): element is HTMLOListElement | HTMLOListElement { + return ( + !!element && + (safeInstanceOf(element, 'HTMLOListElement') || safeInstanceOf(element, 'HTMLUListElement')) + ); +} diff --git a/packages/roosterjs-editor-plugins/test/ContentEdit/features/listFeaturesTest.ts b/packages/roosterjs-editor-plugins/test/ContentEdit/features/listFeaturesTest.ts index 7fd749a1e97..ef26d050cce 100644 --- a/packages/roosterjs-editor-plugins/test/ContentEdit/features/listFeaturesTest.ts +++ b/packages/roosterjs-editor-plugins/test/ContentEdit/features/listFeaturesTest.ts @@ -648,3 +648,103 @@ describe('listFeatures | MaintainListChain', () => { ); }); }); + +describe('listFeatures | mergeListOnBackspaceAfterList', () => { + let editor: IEditor; + const TEST_ID = 'listFeatureTests'; + const ITEM_1 = 'ITEM_1'; + const getKeyboardEvent = (shiftKey: boolean) => + new KeyboardEvent('keydown', { + shiftKey, + altKey: false, + ctrlKey: false, + }); + + beforeEach(() => { + editor = TestHelper.initEditor(TEST_ID); + + editor.runAsync = callback => { + callback(editor); + return () => {}; + }; + }); + + afterEach(() => { + let element = document.getElementById(TEST_ID); + if (element) { + element.parentElement?.removeChild(element); + } + editor.dispose(); + }); + + function runTestShouldHandleEvent(content: string, shouldHandle: boolean) { + editor.setContent(content); + editor.focus(); + const item = editor.getDocument().getElementById(ITEM_1); + if (item) { + const range = document.createRange(); + range.setStart(item, 0); + editor.select(range); + } + + const keyboardEvent: PluginKeyboardEvent = { + eventType: PluginEventType.KeyDown, + rawEvent: getKeyboardEvent(false), + }; + + const triggered = ListFeatures.mergeListOnBackspaceAfterList.shouldHandleEvent( + keyboardEvent, + editor, + false + ) + ? true + : false; + expect(triggered).toBe(shouldHandle); + } + + function runTestHandleEvent(content: string) { + editor.setContent(content); + editor.focus(); + const item = editor.getDocument().getElementById(ITEM_1); + if (item) { + const range = document.createRange(); + range.setStart(item, 0); + range.collapse(); + editor.select(range); + } + const keyboardEvent: PluginKeyboardEvent = { + eventType: PluginEventType.KeyDown, + rawEvent: getKeyboardEvent(false), + }; + + ListFeatures.mergeListOnBackspaceAfterList.shouldHandleEvent(keyboardEvent, editor, false); + item?.parentElement?.removeChild(item); + ListFeatures.mergeListOnBackspaceAfterList.handleEvent(keyboardEvent, editor); + + expect(editor.queryElements('ol,ul').length).toEqual(1); + } + + it('should handle event', () => { + runTestShouldHandleEvent( + `
  1. 123

  1. 213

`, + true + ); + }); + + it('Should not handle event, lists have different TagName', () => { + runTestShouldHandleEvent( + `
  • 123

  1. 213

`, + false + ); + }); + + it('should not handle event', () => { + runTestShouldHandleEvent(`1`, false); + }); + + it('should handle editor async', () => { + runTestHandleEvent( + `
  1. 123

  1. 213

` + ); + }); +}); diff --git a/packages/roosterjs-editor-types/lib/interface/ContentEditFeatureSettings.ts b/packages/roosterjs-editor-types/lib/interface/ContentEditFeatureSettings.ts index 8782e8b9f67..4aa43f01db5 100644 --- a/packages/roosterjs-editor-types/lib/interface/ContentEditFeatureSettings.ts +++ b/packages/roosterjs-editor-types/lib/interface/ContentEditFeatureSettings.ts @@ -125,6 +125,12 @@ export interface ListFeatureSettings { * @default true */ autoNumberingList: boolean; + + /** + * MergeListOnBackspaceAfterList edit feature, provides the ability to merge list on backspace on block after a list. + * @default true + */ + mergeListOnBackspaceAfterList: boolean; } /**