diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts index f458ad34fb6..053888b7160 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts @@ -17,6 +17,7 @@ import type { PluginWithState, EditorOptions, TextColorFormat, + DOMHelper, } from 'roosterjs-content-model-types'; // During IME input, KeyDown event will have "Process" as key @@ -125,7 +126,7 @@ class FormatPlugin implements PluginWithState { this.clearPendingFormat(); this.lastCheckedNode = null; } else if ( - this.defaultFormatKeys.size > 0 && + (this.defaultFormatKeys.size > 0 || this.state.applyDefaultFormatChecker) && (isAndroidIME || isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) && @@ -193,37 +194,42 @@ class FormatPlugin implements PluginWithState { let element: HTMLElement | null = isNodeOfType(posContainer, 'ELEMENT_NODE') ? posContainer : posContainer.parentElement; - const foundFormatKeys = new Set(); - if (element && this.state.applyDefaultFormatChecker?.(element, editor.getDOMHelper())) { - return true; - } + return ( + (element && this.state.applyDefaultFormatChecker?.(element, domHelper)) || + (this.defaultFormatKeys.size > 0 && + this.cssDefaultFormatChecker(element, domHelper)) + ); + } else { + return false; + } + } + + private cssDefaultFormatChecker(element: HTMLElement | null, domHelper: DOMHelper): boolean { + const foundFormatKeys = new Set(); - while (element?.parentElement && domHelper.isNodeInEditor(element.parentElement)) { - if (element.getAttribute?.('style')) { - const style = element.style; - this.defaultFormatKeys.forEach(key => { - if (style[key]) { - foundFormatKeys.add(key); - } - }); - - if (foundFormatKeys.size == this.defaultFormatKeys.size) { - return false; + while (element?.parentElement && domHelper.isNodeInEditor(element.parentElement)) { + if (element.getAttribute?.('style')) { + const style = element.style; + this.defaultFormatKeys.forEach(key => { + if (style[key]) { + foundFormatKeys.add(key); } - } + }); - if (isBlockElement(element)) { - break; + if (foundFormatKeys.size == this.defaultFormatKeys.size) { + return false; } + } - element = element.parentElement; + if (isBlockElement(element)) { + break; } - return true; - } else { - return false; + element = element.parentElement; } + + return true; } } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts index bdcac7dd12a..863a5203f24 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts @@ -10,9 +10,7 @@ import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model export function applyDefaultFormat(editor: IEditor, defaultFormat: ContentModelSegmentFormat) { const selection = editor.getDOMSelection(); - if (!selection) { - // NO OP, should never happen - } else if (selection?.type == 'range' && selection.range.collapsed) { + if (selection?.type == 'range' && selection.range.collapsed) { editor.formatContentModel((model, context) => { iterateSelections(model, (path, _, paragraph, segments) => { const marker = segments?.[0]; @@ -62,8 +60,6 @@ export function applyDefaultFormat(editor: IEditor, defaultFormat: ContentModelS // We didn't do any change but just apply default format to pending format, so no need to write back return false; }); - } else { - editor.takeSnapshot(); } } diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 5fdae3d372e..94b925313f9 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -622,6 +622,7 @@ describe('formatContentModel', () => { core.format = { defaultFormat: {}, pendingFormat: null, + applyDefaultFormatChecker: null, }; const mockedRange = { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts index b947a73cffa..dae53251cb4 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts @@ -225,14 +225,12 @@ describe('FormatPlugin for default format', () => { let getDOMSelection: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; let cacheContentModelSpy: jasmine.Spy; - let takeSnapshotSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; beforeEach(() => { getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); getDOMSelection = jasmine.createSpy('getDOMSelection'); cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); - takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); formatContentModelSpy = jasmine.createSpy('formatContentModelSpy'); contentDiv = document.createElement('div'); @@ -243,7 +241,6 @@ describe('FormatPlugin for default format', () => { getDOMSelection, getPendingFormat: getPendingFormatSpy, cacheContentModel: cacheContentModelSpy, - takeSnapshot: takeSnapshotSpy, formatContentModel: formatContentModelSpy, getEnvironment: () => ({}), } as any) as IEditor; @@ -355,7 +352,6 @@ describe('FormatPlugin for default format', () => { }); expect(context).toEqual({}); - expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); }); it('Collapsed range, IME input, under editor directly', () => { @@ -685,3 +681,81 @@ describe('FormatPlugin for default format', () => { expect(applyDefaultFormatSpy).not.toHaveBeenCalled(); }); }); + +describe('FormatPlugin with default style checker', () => { + it('style checker return false', () => { + const div = document.createElement('div'); + const getDOMSelection = jasmine.createSpy('getDOMSelection').and.returnValue({ + type: 'range', + range: { + startContainer: div, + startOffset: 0, + collapsed: true, + }, + }); + const domHelper = 'HELPER' as any; + const getDOMHelper = jasmine.createSpy('getDOMHelper').and.returnValue(domHelper); + + const editor = ({ + cacheContentModel: () => {}, + isDarkMode: () => false, + getEnvironment: () => ({}), + getDOMSelection, + getDOMHelper, + } as any) as IEditor; + const applyDefaultFormatSpy = spyOn(applyDefaultFormat, 'applyDefaultFormat'); + const styleChecker = jasmine.createSpy('styleCheker').and.returnValue(false); + const plugin = createFormatPlugin({ applyDefaultFormatChecker: styleChecker }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: ({ key: 'a' } as any) as KeyboardEvent, + }); + + plugin.dispose(); + + expect(styleChecker).toHaveBeenCalledWith(div, domHelper); + expect(plugin.getState().pendingFormat).toBeNull(); + expect(applyDefaultFormatSpy).not.toHaveBeenCalled(); + }); + + it('style checker return true', () => { + const div = document.createElement('div'); + const getDOMSelection = jasmine.createSpy('getDOMSelection').and.returnValue({ + type: 'range', + range: { + startContainer: div, + startOffset: 0, + collapsed: true, + }, + }); + const domHelper = 'HELPER' as any; + const getDOMHelper = jasmine.createSpy('getDOMHelper').and.returnValue(domHelper); + + const editor = ({ + cacheContentModel: () => {}, + isDarkMode: () => false, + getEnvironment: () => ({}), + getDOMSelection, + getDOMHelper, + } as any) as IEditor; + const applyDefaultFormatSpy = spyOn(applyDefaultFormat, 'applyDefaultFormat'); + const styleChecker = jasmine.createSpy('styleCheker').and.returnValue(true); + const plugin = createFormatPlugin({ applyDefaultFormatChecker: styleChecker }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: ({ key: 'a' } as any) as KeyboardEvent, + }); + + plugin.dispose(); + + expect(styleChecker).toHaveBeenCalledWith(div, domHelper); + expect(plugin.getState().pendingFormat).toBeNull(); + expect(applyDefaultFormatSpy).toHaveBeenCalledWith(editor, {}); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts index da1eb166360..3b5b4748f51 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts @@ -1,5 +1,4 @@ import * as deleteSelection from 'roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection'; -import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyDefaultFormat } from '../../../lib/corePlugin/format/applyDefaultFormat'; import { ContentModelDocument, @@ -24,8 +23,6 @@ describe('applyDefaultFormat', () => { let getDOMSelectionSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let deleteSelectionSpy: jasmine.Spy; - let normalizeContentModelSpy: jasmine.Spy; - let takeSnapshotSpy: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; let isNodeInEditorSpy: jasmine.Spy; @@ -46,8 +43,6 @@ describe('applyDefaultFormat', () => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelectionSpy'); deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); - normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); - takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); isNodeInEditorSpy = jasmine.createSpy('isNodeInEditor'); @@ -71,7 +66,6 @@ describe('applyDefaultFormat', () => { }), getDOMSelection: getDOMSelectionSpy, formatContentModel: formatContentModelSpy, - takeSnapshot: takeSnapshotSpy, getPendingFormat: getPendingFormatSpy, } as any; }); @@ -82,7 +76,7 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); - expect(formatContentModelSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); }); it('Selection already has style', () => { @@ -99,6 +93,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); deleteSelectionSpy.and.returnValue({ @@ -124,6 +119,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: text, startOffset: 0, + collapsed: true, }, }); deleteSelectionSpy.and.returnValue({ @@ -143,6 +139,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -154,9 +151,7 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).toHaveBeenCalledWith(model); - expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); - expect(formatResult).toBeTrue(); + expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], newEntities: [], @@ -174,6 +169,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -185,8 +181,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalledWith(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -204,6 +198,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -215,8 +210,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalledWith(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -246,6 +239,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -257,8 +251,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -288,6 +280,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -299,8 +292,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -331,6 +322,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -342,8 +334,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -373,6 +363,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -384,8 +375,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], @@ -419,6 +408,7 @@ describe('applyDefaultFormat', () => { range: { startContainer: node, startOffset: 0, + collapsed: true, }, }); @@ -435,8 +425,6 @@ describe('applyDefaultFormat', () => { applyDefaultFormat(editor, defaultFormat); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); expect(formatResult).toBeFalse(); expect(context).toEqual({ deletedEntities: [], diff --git a/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts index 9b2ed118a8c..f61b76a13ab 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts @@ -1,6 +1,7 @@ import * as iterateSelections from 'roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyPendingFormat } from '../../../lib/corePlugin/format/applyPendingFormat'; +import { Editor } from '../../../lib/editor/Editor'; import { ContentModelDocument, ContentModelParagraph, @@ -9,6 +10,7 @@ import { ContentModelFormatter, FormatContentModelOptions, IEditor, + EditorPlugin, } from 'roosterjs-content-model-types'; import { createContentModelDocument, @@ -50,8 +52,11 @@ describe('applyPendingFormat', () => { }); }); + const triggerEventSpy = jasmine.createSpy('triggerEvent'); + const editor = ({ formatContentModel: formatContentModelSpy, + triggerEvent: triggerEventSpy, } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -64,6 +69,13 @@ describe('applyPendingFormat', () => { }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledWith('applyPendingFormat', { + paragraph: paragraph, + text: { segmentType: 'Text', text: 'c', format: { fontSize: '10px' } }, + path: [model], + format: { fontSize: '10px' }, + }); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -122,8 +134,11 @@ describe('applyPendingFormat', () => { callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); }); + const triggerEventSpy = jasmine.createSpy('triggerEvent'); + const editor = ({ formatContentModel: formatContentModelSpy, + triggerEvent: triggerEventSpy, } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -136,6 +151,7 @@ describe('applyPendingFormat', () => { }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledTimes(0); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -181,8 +197,11 @@ describe('applyPendingFormat', () => { }; const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const triggerEventSpy = jasmine.createSpy('triggerEvent'); + const editor = ({ formatContentModel: formatContentModelSpy, + triggerEvent: triggerEventSpy, } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -192,6 +211,7 @@ describe('applyPendingFormat', () => { applyPendingFormat(editor, 'd', {}); + expect(triggerEventSpy).toHaveBeenCalledTimes(0); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -238,9 +258,11 @@ describe('applyPendingFormat', () => { expect(options.apiName).toEqual('applyPendingFormat'); callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); }); + const triggerEventSpy = jasmine.createSpy('triggerEvent'); const editor = ({ formatContentModel: formatContentModelSpy, + triggerEvent: triggerEventSpy, } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -253,6 +275,7 @@ describe('applyPendingFormat', () => { }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledTimes(0); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -287,9 +310,11 @@ describe('applyPendingFormat', () => { expect(options.apiName).toEqual('applyPendingFormat'); callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); }); + const triggerEventSpy = jasmine.createSpy('triggerEvent'); const editor = ({ formatContentModel: formatContentModelSpy, + triggerEvent: triggerEventSpy, } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -303,6 +328,13 @@ describe('applyPendingFormat', () => { }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledTimes(1); + expect(triggerEventSpy).toHaveBeenCalledWith('applyPendingFormat', { + paragraph: paragraph, + text: { segmentType: 'Text', text: 't', format: { fontSize: '10px' } }, + path: [model], + format: { fontSize: '10px' }, + }); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -338,3 +370,78 @@ describe('applyPendingFormat', () => { expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalled(); }); }); + +describe('applyPendingFormat with event - end to end', () => { + let div: HTMLDivElement; + + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + }); + + afterEach(() => { + document.body.removeChild(div); + }); + + it('Test plugin handling applyPendingFormat event', () => { + const onPluginEvent = jasmine.createSpy('onPluginEvent'); + const plugin: EditorPlugin = { + getName: () => 'test', + initialize: () => {}, + dispose: () => {}, + onPluginEvent, + }; + + const editor = new Editor(div, { + plugins: [plugin], + initialModel: { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'a', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + }); + + applyPendingFormat(editor, 'a', {}); + + editor.dispose(); + + const text = { segmentType: 'Text', text: 'a', format: {} }; + const paragraph = { + blockType: 'Paragraph', + format: {}, + segments: [text, { segmentType: 'SelectionMarker', format: {}, isSelected: true }], + cachedElement: jasmine.anything(), + }; + const path = [ + { + blockGroupType: 'Document', + blocks: [paragraph], + persistCache: true, + }, + ]; + + expect(onPluginEvent).toHaveBeenCalledWith({ + eventType: 'applyPendingFormat', + paragraph, + text, + path, + format: {}, + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index ed00c5e5d1c..429eb9d8e5e 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -5,7 +5,23 @@ import { isModifierKey, normalizeContentModel, } from 'roosterjs-content-model-dom'; -import type { DOMSelection, IEditor } from 'roosterjs-content-model-types'; +import type { DeleteSelectionStep, DOMSelection, IEditor } from 'roosterjs-content-model-types'; + +// Insert a ZeroWidthSpace(ZWS) segment with selection before selection marker +// so that later browser will replace this selection with inputted text and keep format +const insertZWS: DeleteSelectionStep = context => { + if (context.deleteResult == 'range') { + const { marker, paragraph } = context.insertPoint; + const index = paragraph.segments.indexOf(marker); + + if (index >= 0) { + const text = createText('\u200B', marker.format); + text.isSelected = true; + + paragraph.segments.splice(index, 0, text); + } + } +}; /** * @internal @@ -18,7 +34,7 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, [], context); + const result = deleteSelection(model, [insertZWS], context); // Skip undo snapshot here and add undo snapshot before the operation so that we don't add another undo snapshot in middle of this replace operation context.skipUndoSnapshot = true; @@ -27,19 +43,6 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { // We have deleted something, next input should inherit the segment format from deleted content, so set pending format here context.newPendingFormat = result.insertPoint?.marker.format; - if (result.insertPoint) { - // Replace selection marker to a ZWS text segment so that later browser will replace this selection with inputted text and keep format - const { marker, paragraph } = result.insertPoint; - const text = createText('\u200B', marker.format); - const index = paragraph.segments.indexOf(marker); - - text.isSelected = true; - - if (index >= 0) { - paragraph.segments.splice(index, 1, text); - } - } - normalizeContentModel(model); // Do not preventDefault since we still want browser to handle the final input for now diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index b2435499c9f..fe3e31af310 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -99,7 +99,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeFalse(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -130,7 +134,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -142,6 +150,90 @@ describe('keyboardInput', () => { expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); + it('Letter input, expanded selection, no modifier key, deleteSelection returns range, do real deleting', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.callThrough(); + + mockedModel = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'aa', + format: {}, + }, + { + segmentType: 'Text', + text: '', + format: { fontSize: '10pt' }, + isSelected: true, + }, + ], + }, + ], + }; + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + skipUndoSnapshot: true, + newPendingFormat: { fontSize: '10pt' }, + }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'aa', + format: {}, + }, + { + segmentType: 'Text', + text: '\u200B', + format: { fontSize: '10pt' }, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10pt' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + it('Letter input, table selection, no modifier key, deleteSelection returns range', () => { getDOMSelectionSpy.and.returnValue({ type: 'table', @@ -159,7 +251,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -188,7 +284,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -273,7 +373,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [], @@ -338,7 +442,11 @@ describe('keyboardInput', () => { expect(getDOMSelectionSpy).toHaveBeenCalled(); expect(takeSnapshotSpy).toHaveBeenCalled(); expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [jasmine.anything()], + mockedContext + ); expect(formatResult).toBeTrue(); expect(mockedContext).toEqual({ deletedEntities: [],