From 010b288a3e1b9491fe149c22a5a2eefa0ceb339f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 15 Nov 2024 20:00:12 -0800 Subject: [PATCH 1/2] #2861 #2862 --- .../sidePane/eventViewer/EventViewPane.tsx | 16 +++ .../lib/corePlugin/format/FormatPlugin.ts | 12 ++- .../corePlugin/format/applyDefaultFormat.ts | 98 ++++++++++--------- .../corePlugin/format/applyPendingFormat.ts | 35 ++++--- .../lib/edit/keyboardInput.ts | 14 +++ .../lib/editor/EditorOptions.ts | 9 ++ .../lib/event/ApplyPendingFormatEvent.ts | 30 ++++++ .../lib/event/PluginEvent.ts | 2 + .../lib/event/PluginEventType.ts | 7 +- .../lib/index.ts | 1 + .../lib/pluginState/FormatPluginState.ts | 9 ++ 11 files changed, 173 insertions(+), 60 deletions(-) create mode 100644 packages/roosterjs-content-model-types/lib/event/ApplyPendingFormatEvent.ts diff --git a/demo/scripts/controlsV2/sidePane/eventViewer/EventViewPane.tsx b/demo/scripts/controlsV2/sidePane/eventViewer/EventViewPane.tsx index b4c551699ce..6fd098f9b40 100644 --- a/demo/scripts/controlsV2/sidePane/eventViewer/EventViewPane.tsx +++ b/demo/scripts/controlsV2/sidePane/eventViewer/EventViewPane.tsx @@ -184,6 +184,22 @@ export default class ContentModelEventViewPane extends React.Component< case 'input': return Input type={event.rawEvent.inputType}; + case 'applyPendingFormat': + return ( + <> + + Text={event.text.text} +
+
+ {getObjectKeys(event.format).map(key => ( + + {key}:{event.format[key]?.toString() ?? ''} +
+
+ ))} + + ); + default: return null; } 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 872a4dd1d12..f458ad34fb6 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts @@ -52,6 +52,7 @@ class FormatPlugin implements PluginWithState { this.state = { defaultFormat: { ...option.defaultSegmentFormat }, pendingFormat: null, + applyDefaultFormatChecker: option.applyDefaultFormatChecker ?? null, }; this.defaultFormatKeys = new Set(); @@ -118,13 +119,16 @@ class FormatPlugin implements PluginWithState { break; case 'keyDown': - const isAndroidIME = this.editor.getEnvironment().isAndroid && event.rawEvent.key == UnidentifiedKey; + const isAndroidIME = + this.editor.getEnvironment().isAndroid && event.rawEvent.key == UnidentifiedKey; if (isCursorMovingKey(event.rawEvent)) { this.clearPendingFormat(); this.lastCheckedNode = null; } else if ( this.defaultFormatKeys.size > 0 && - (isAndroidIME || isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) && + (isAndroidIME || + isCharacterValue(event.rawEvent) || + event.rawEvent.key == ProcessKey) && this.shouldApplyDefaultFormat(this.editor) ) { applyDefaultFormat(this.editor, this.state.defaultFormat); @@ -191,6 +195,10 @@ class FormatPlugin implements PluginWithState { : posContainer.parentElement; const foundFormatKeys = new Set(); + if (element && this.state.applyDefaultFormatChecker?.(element, editor.getDOMHelper())) { + return true; + } + while (element?.parentElement && domHelper.isNodeInEditor(element.parentElement)) { if (element.getAttribute?.('style')) { const style = element.style; 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 300a3d50f8c..bdcac7dd12a 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts @@ -1,4 +1,4 @@ -import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { iterateSelections } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; /** @@ -8,55 +8,63 @@ import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model * @param defaultFormat The default segment format to apply */ export function applyDefaultFormat(editor: IEditor, defaultFormat: ContentModelSegmentFormat) { - editor.formatContentModel((model, context) => { - const result = deleteSelection(model, [], context); + const selection = editor.getDOMSelection(); - if (result.deleteResult == 'range') { - normalizeContentModel(model); + if (!selection) { + // NO OP, should never happen + } else if (selection?.type == 'range' && selection.range.collapsed) { + editor.formatContentModel((model, context) => { + iterateSelections(model, (path, _, paragraph, segments) => { + const marker = segments?.[0]; + if ( + paragraph?.blockType == 'Paragraph' && + marker?.segmentType == 'SelectionMarker' + ) { + const blocks = path[0].blocks; + const blockCount = blocks.length; + const blockIndex = blocks.indexOf(paragraph); - editor.takeSnapshot(); + if ( + paragraph.isImplicit && + paragraph.segments.length == 1 && + paragraph.segments[0] == marker && + blockCount > 0 && + blockIndex == blockCount - 1 + ) { + // Focus is in the last paragraph which is implicit and there is not other segments. + // This can happen when focus is moved after all other content under current block group. + // We need to check if browser will merge focus into previous paragraph by checking if + // previous block is block. If previous block is paragraph, browser will most likely merge + // the input into previous paragraph, then nothing need to do here. Otherwise we need to + // apply pending format since this input event will start a new real paragraph. + const previousBlock = blocks[blockIndex - 1]; - return true; - } else if (result.deleteResult == 'notDeleted' && result.insertPoint) { - const { paragraph, path, marker } = result.insertPoint; - const blocks = path[0].blocks; - const blockCount = blocks.length; - const blockIndex = blocks.indexOf(paragraph); - - if ( - paragraph.isImplicit && - paragraph.segments.length == 1 && - paragraph.segments[0] == marker && - blockCount > 0 && - blockIndex == blockCount - 1 - ) { - // Focus is in the last paragraph which is implicit and there is not other segments. - // This can happen when focus is moved after all other content under current block group. - // We need to check if browser will merge focus into previous paragraph by checking if - // previous block is block. If previous block is paragraph, browser will most likely merge - // the input into previous paragraph, then nothing need to do here. Otherwise we need to - // apply pending format since this input event will start a new real paragraph. - const previousBlock = blocks[blockIndex - 1]; - - if (previousBlock?.blockType != 'Paragraph') { - context.newPendingFormat = getNewPendingFormat( - editor, - defaultFormat, - marker.format - ); + if (previousBlock?.blockType != 'Paragraph') { + context.newPendingFormat = getNewPendingFormat( + editor, + defaultFormat, + marker.format + ); + } + } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { + context.newPendingFormat = getNewPendingFormat( + editor, + defaultFormat, + marker.format + ); + } } - } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { - context.newPendingFormat = getNewPendingFormat( - editor, - defaultFormat, - marker.format - ); - } - } - // We didn't do any change but just apply default format to pending format, so no need to write back - return false; - }); + // Stop searching more selection + return true; + }); + + // 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(); + } } function getNewPendingFormat( diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts index d5086b2b99c..8a1c5bb4578 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts @@ -25,7 +25,7 @@ export function applyPendingFormat( editor.formatContentModel( (model, context) => { - iterateSelections(model, (_, __, block, segments) => { + iterateSelections(model, (path, _, block, segments) => { if ( block?.blockType == 'Paragraph' && segments?.length == 1 && @@ -45,25 +45,36 @@ export function applyPendingFormat( previousSegment.text = text.substring(0, text.length - data.length); }); - mutateSegment(block, marker, (marker, block) => { - marker.format = { ...format }; + const newText = createText( + data == ANSI_SPACE ? NON_BREAK_SPACE : data, + { + ...previousSegment.format, + ...format, + } + ); + const [mutableParagraph] = mutateSegment( + block, + marker, + (marker, block) => { + marker.format = { ...format }; - const newText = createText( - data == ANSI_SPACE ? NON_BREAK_SPACE : data, - { - ...previousSegment.format, - ...format, - } - ); + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + } + ); - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); + editor.triggerEvent('applyPendingFormat', { + paragraph: mutableParagraph, + text: newText, + path, + format, }); isChanged = true; } } } + return true; }); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 201f387f455..ed00c5e5d1c 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,5 +1,6 @@ import { ChangeSource, + createText, deleteSelection, isModifierKey, normalizeContentModel, @@ -26,6 +27,19 @@ 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-types/lib/editor/EditorOptions.ts b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts index 4bc635e03f1..4c9339fdceb 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts @@ -10,6 +10,7 @@ import type { ModelToDomOption } from '../context/ModelToDomOption'; import type { ContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; import type { Snapshots } from '../parameter/Snapshot'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; +import type { DOMHelper } from '../parameter/DOMHelper'; /** * Options for colors and dark mode @@ -164,6 +165,14 @@ export interface EditorBaseOptions { * @returns A template string to announce, use placeholder such as "{0}" for variables if necessary */ announcerStringGetter?: (key: KnownAnnounceStrings) => string; + + /** + * An optional checker function to determine if we should run the default format apply function to current editing position + * @param element Current HTML element + * @param domHelper DOM Helper to help doing checking + * @returns True if we need to apply default format, otherwise false + */ + applyDefaultFormatChecker?: (element: HTMLElement, domHelper: DOMHelper) => boolean; } /** diff --git a/packages/roosterjs-content-model-types/lib/event/ApplyPendingFormatEvent.ts b/packages/roosterjs-content-model-types/lib/event/ApplyPendingFormatEvent.ts new file mode 100644 index 00000000000..d01a52a04b0 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/event/ApplyPendingFormatEvent.ts @@ -0,0 +1,30 @@ +import { ContentModelSegmentFormat } from 'roosterjs/lib'; +import type { ShallowMutableContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; +import type { ReadonlyContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; +import type { ContentModelText } from '../contentModel/segment/ContentModelText'; +import type { BasePluginEvent } from './BasePluginEvent'; + +/** + * Provides a chance for plugin to apply additional format when we apply pending format + */ +export interface ApplyPendingFormatEvent extends BasePluginEvent<'applyPendingFormat'> { + /** + * The text segment that we are applying default format to + */ + text: ContentModelText; + + /** + * The parent paragraph of the given text segment + */ + paragraph: ShallowMutableContentModelParagraph; + + /** + * Block group path of the given paragraph + */ + path: ReadonlyContentModelBlockGroup[]; + + /** + * The segment format that we just applied + */ + format: Readonly; +} diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts index f76b5091740..5b02ee3e335 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEvent.ts @@ -1,3 +1,4 @@ +import type { ApplyPendingFormatEvent } from './ApplyPendingFormatEvent'; import type { BeforeCutCopyEvent } from './BeforeCutCopyEvent'; import type { BeforeDisposeEvent } from './BeforeDisposeEvent'; import type { BeforeKeyboardEditingEvent } from './BeforeKeyboardEditingEvent'; @@ -22,6 +23,7 @@ import type { ZoomChangedEvent } from './ZoomChangedEvent'; * Editor plugin event interface */ export type PluginEvent = + | ApplyPendingFormatEvent | BeforeCutCopyEvent | BeforeDisposeEvent | BeforeKeyboardEditingEvent diff --git a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts index bd3681f5adb..081c40edc9f 100644 --- a/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts +++ b/packages/roosterjs-content-model-types/lib/event/PluginEventType.ts @@ -128,4 +128,9 @@ export type PluginEventType = * Editor content is about to be changed by keyboard event. * This is only used by Content Model editing */ - | 'beforeKeyboardEditing'; + | 'beforeKeyboardEditing' + + /** + * We just applied default format to the given text segment + */ + | 'applyPendingFormat'; diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 7ecc0e7d9be..168502f4d05 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -447,6 +447,7 @@ export { } from './parameter/ModelToTextCallbacks'; export { ConflictFormatSolution } from './parameter/ConflictFormatSolution'; +export { ApplyPendingFormatEvent } from './event/ApplyPendingFormatEvent'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; export { BeforeDisposeEvent } from './event/BeforeDisposeEvent'; diff --git a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts index 5286fc09d71..7403b9b185c 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts @@ -1,3 +1,4 @@ +import { DOMHelper } from 'roosterjs/lib'; import type { DOMInsertPoint } from '../selection/DOMSelection'; import type { ContentModelSegmentFormat } from '../contentModel/format/ContentModelSegmentFormat'; @@ -29,4 +30,12 @@ export interface FormatPluginState { * Pending format */ pendingFormat: PendingFormat | null; + + /** + * An optional checker function to determine if we should run the default format apply function to current editing position + * @param element Current HTML element + * @param domHelper DOM Helper to help doing checking + * @returns True if we need to apply default format, otherwise false + */ + applyDefaultFormatChecker: ((element: HTMLElement, domHelper: DOMHelper) => boolean) | null; } From 0daeed916d5f3300f194ebf0f7ae6ebd0d528e8c Mon Sep 17 00:00:00 2001 From: "Jiuqing Song (from Dev Box)" Date: Wed, 20 Nov 2024 22:42:58 -0800 Subject: [PATCH 2/2] Fix test --- .../lib/corePlugin/format/FormatPlugin.ts | 52 ++++---- .../corePlugin/format/applyDefaultFormat.ts | 6 +- .../formatContentModelTest.ts | 1 + .../corePlugin/format/FormatPluginTest.ts | 82 +++++++++++- .../format/applyDefaultFormatTest.ts | 36 ++---- .../format/applyPendingFormatTest.ts | 107 ++++++++++++++++ .../lib/edit/keyboardInput.ts | 33 ++--- .../test/edit/keyboardInputTest.ts | 120 +++++++++++++++++- 8 files changed, 360 insertions(+), 77 deletions(-) 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: [],