From 36883a44db459a84748ec29aacf4ff0f50ff9886 Mon Sep 17 00:00:00 2001 From: "Jiuqing Song (from Dev Box)" Date: Wed, 20 Nov 2024 23:30:33 -0800 Subject: [PATCH 1/3] #2861 --- .../formatContentModel/formatContentModel.ts | 15 ++++-- .../lib/corePlugin/format/FormatPlugin.ts | 14 +++-- .../corePlugin/format/applyPendingFormat.ts | 51 ++++++++++++------- .../parameter/FormatContentModelContext.ts | 10 ++++ .../lib/pluginState/FormatPluginState.ts | 8 ++- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 35be053782e..45a53be6dd2 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -136,10 +136,19 @@ function handlePendingFormat( context.newPendingFormat == 'preserve' ? core.format.pendingFormat?.format : context.newPendingFormat; - - if (pendingFormat && selection?.type == 'range' && selection.range.collapsed) { + const pendingParagraphFormat = + context.newPendingParagraphFormat == 'preserve' + ? core.format.pendingFormat?.paragraphFormat + : context.newPendingParagraphFormat; + + if ( + (pendingFormat || pendingParagraphFormat) && + selection?.type == 'range' && + selection.range.collapsed + ) { core.format.pendingFormat = { - format: { ...pendingFormat }, + format: pendingFormat ? { ...pendingFormat } : undefined, + paragraphFormat: pendingParagraphFormat ? { ...pendingParagraphFormat } : undefined, insertPoint: { node: selection.range.startContainer, offset: selection.range.startOffset, 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..eb068b1a113 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts @@ -118,13 +118,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); @@ -145,7 +148,12 @@ class FormatPlugin implements PluginWithState { private checkAndApplyPendingFormat(data: string | null) { if (this.editor && data && this.state.pendingFormat) { - applyPendingFormat(this.editor, data, this.state.pendingFormat.format); + applyPendingFormat( + this.editor, + data, + this.state.pendingFormat.format, + this.state.pendingFormat.paragraphFormat + ); this.clearPendingFormat(); } } 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..ca8ec9309fc 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts @@ -1,11 +1,16 @@ import { createText, iterateSelections, + mutateBlock, mutateSegment, normalizeContentModel, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; -import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; +import type { + ContentModelBlockFormat, + ContentModelSegmentFormat, + IEditor, +} from 'roosterjs-content-model-types'; const ANSI_SPACE = '\u0020'; const NON_BREAK_SPACE = '\u00A0'; @@ -19,7 +24,8 @@ const NON_BREAK_SPACE = '\u00A0'; export function applyPendingFormat( editor: IEditor, data: string, - format: ContentModelSegmentFormat + segmentFormat?: ContentModelSegmentFormat, + paragraphFormat?: ContentModelBlockFormat ) { let isChanged = false; @@ -41,24 +47,35 @@ export function applyPendingFormat( // For space, there can be (space) or   ( ), we treat them as the same if (subStr == data || (data == ANSI_SPACE && subStr == NON_BREAK_SPACE)) { - mutateSegment(block, previousSegment, previousSegment => { - previousSegment.text = text.substring(0, text.length - data.length); - }); + if (segmentFormat) { + mutateSegment(block, previousSegment, previousSegment => { + previousSegment.text = text.substring( + 0, + text.length - data.length + ); + }); - mutateSegment(block, marker, (marker, block) => { - marker.format = { ...format }; + mutateSegment(block, marker, (marker, block) => { + marker.format = { ...segmentFormat }; - const newText = createText( - data == ANSI_SPACE ? NON_BREAK_SPACE : data, - { - ...previousSegment.format, - ...format, - } - ); + const newText = createText( + data == ANSI_SPACE ? NON_BREAK_SPACE : data, + { + ...previousSegment.format, + ...segmentFormat, + } + ); - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); - }); + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + }); + } + + if (paragraphFormat) { + const mutableParagraph = mutateBlock(block); + + Object.assign(mutableParagraph.format, paragraphFormat); + } isChanged = true; } diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts index 0cc6e7f63c2..31381f5b28b 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts @@ -1,3 +1,4 @@ +import { ContentModelBlockFormat } from 'roosterjs/lib'; import type { AnnounceData } from './AnnounceData'; import type { ContentModelEntity } from '../contentModel/entity/ContentModelEntity'; import type { ContentModelImage } from '../contentModel/segment/ContentModelImage'; @@ -87,6 +88,15 @@ export interface FormatContentModelContext { */ newPendingFormat?: ContentModelSegmentFormat | 'preserve'; + /** + * @optional + * Specify new pending format for paragraph + * To keep current format event selection position is changed, set this value to "preserved", editor will update pending format position to the new position + * To set a new pending format, set this property to the format object + * Otherwise, leave it there and editor will automatically decide if the original pending format is still available + */ + newPendingParagraphFormat?: ContentModelBlockFormat | 'preserve'; + /** * @optional Entity states related to the format API that will be added together with undo snapshot. * When entity states are set, each entity state will cause an EntityOperation event with operation = EntityOperation.UpdateEntityState diff --git a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts index 5286fc09d71..3dda933222d 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts @@ -1,5 +1,6 @@ import type { DOMInsertPoint } from '../selection/DOMSelection'; import type { ContentModelSegmentFormat } from '../contentModel/format/ContentModelSegmentFormat'; +import type { ContentModelBlockFormat } from '../contentModel/format/ContentModelBlockFormat'; /** * Pending format holder interface @@ -8,7 +9,12 @@ export interface PendingFormat { /** * The pending format */ - format: ContentModelSegmentFormat; + format?: ContentModelSegmentFormat; + + /** + * Customized format for paragraph + */ + paragraphFormat?: ContentModelBlockFormat; /** * Insert point of pending format From 8268e4d243c9ec1a625acb222b5b73d7cc56f326 Mon Sep 17 00:00:00 2001 From: "Jiuqing Song (from Dev Box)" Date: Wed, 20 Nov 2024 23:48:14 -0800 Subject: [PATCH 2/3] Fix build and test --- .../coreApi/formatContentModel/formatContentModelTest.ts | 6 ++++++ .../test/corePlugin/format/FormatPluginTest.ts | 4 ++-- .../lib/parameter/FormatContentModelContext.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) 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..10729d754dd 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -671,6 +671,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -694,6 +695,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -709,6 +711,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -724,6 +727,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -747,6 +751,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -770,6 +775,7 @@ describe('formatContentModel', () => { expect(core.format.pendingFormat).toEqual({ format: mockedFormat2, + paragraphFormat: undefined, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, 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..c1111dbcc76 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts @@ -60,7 +60,7 @@ describe('FormatPlugin', () => { plugin.dispose(); expect(applyPendingFormatSpy).toHaveBeenCalledTimes(1); - expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'a', mockedFormat); + expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'a', mockedFormat, undefined); expect(state.pendingFormat).toBeNull(); }); @@ -92,7 +92,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'test', mockedFormat); + expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'test', mockedFormat, undefined); expect(state.pendingFormat).toBeNull(); }); diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts index 31381f5b28b..b7115dc66da 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts @@ -1,9 +1,9 @@ -import { ContentModelBlockFormat } from 'roosterjs/lib'; import type { AnnounceData } from './AnnounceData'; import type { ContentModelEntity } from '../contentModel/entity/ContentModelEntity'; import type { ContentModelImage } from '../contentModel/segment/ContentModelImage'; import type { ContentModelSegmentFormat } from '../contentModel/format/ContentModelSegmentFormat'; import type { EntityRemovalOperation } from '../enum/EntityOperation'; +import type { ContentModelBlockFormat } from '../contentModel/format/ContentModelBlockFormat'; /** * State for an entity. This is used for storing entity undo snapshot From 9943d1214debdd7e398eb92619e73ee010061f70 Mon Sep 17 00:00:00 2001 From: "Jiuqing Song (from Dev Box)" Date: Thu, 21 Nov 2024 10:07:45 -0800 Subject: [PATCH 3/3] add test --- .../formatContentModelTest.ts | 98 +++++++- .../corePlugin/format/FormatPluginTest.ts | 15 +- .../format/applyPendingFormatTest.ts | 219 ++++++++++++++++++ 3 files changed, 327 insertions(+), 5 deletions(-) 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 10729d754dd..8109dea5a2b 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -658,6 +658,7 @@ describe('formatContentModel', () => { it('Has pending format, callback returns true, preserve pending format', () => { core.format.pendingFormat = { format: mockedFormat1, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer1, offset: mockedStartOffset1, @@ -679,9 +680,61 @@ describe('formatContentModel', () => { } as any); }); - it('Has pending format, callback returns false, preserve pending format', () => { + it('Has pending format, callback returns true, preserve paragraph pending format', () => { core.format.pendingFormat = { format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + } as any); + }); + + it('Has pending format, callback returns true, preserve both pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + context.newPendingParagraphFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + } as any); + }); + + it('Has pending format, callback returns false, preserve both pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer1, offset: mockedStartOffset1, @@ -690,12 +743,13 @@ describe('formatContentModel', () => { formatContentModel(core, (model, context) => { context.newPendingFormat = 'preserve'; + context.newPendingParagraphFormat = 'preserve'; return false; }); expect(core.format.pendingFormat).toEqual({ format: mockedFormat1, - paragraphFormat: undefined, + paragraphFormat: mockedFormat2, insertPoint: { node: mockedStartContainer2, offset: mockedStartOffset2, @@ -719,6 +773,22 @@ describe('formatContentModel', () => { }); }); + it('No pending format, callback returns true, new paragraph format', () => { + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = mockedFormat2; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + }); + }); + it('No pending format, callback returns false, new format', () => { formatContentModel(core, (model, context) => { context.newPendingFormat = mockedFormat2; @@ -783,6 +853,30 @@ describe('formatContentModel', () => { }); }); + it('Has pending format, callback returns false, new paragraph format', () => { + core.format.pendingFormat = { + paragraphFormat: mockedFormat1, + insertPoint: { + node: mockedStartContainer1, + offset: mockedStartOffset1, + }, + }; + + formatContentModel(core, (model, context) => { + context.newPendingParagraphFormat = mockedFormat2; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: undefined, + paragraphFormat: mockedFormat2, + insertPoint: { + node: mockedStartContainer2, + offset: mockedStartOffset2, + }, + }); + }); + it('Has pending format, callback returns false, preserve format, selection is not collapsed', () => { core.format.pendingFormat = { format: mockedFormat1, 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 c1111dbcc76..e278325c4cf 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts @@ -8,6 +8,9 @@ describe('FormatPlugin', () => { const mockedFormat = { fontSize: '10px', }; + const mockedFormat2 = { + lineSpace: 2, + }; let applyPendingFormatSpy: jasmine.Spy; beforeEach(() => { @@ -49,6 +52,7 @@ describe('FormatPlugin', () => { (state.pendingFormat = { format: mockedFormat, + paragraphFormat: mockedFormat2, } as any), plugin.initialize(editor); @@ -60,7 +64,12 @@ describe('FormatPlugin', () => { plugin.dispose(); expect(applyPendingFormatSpy).toHaveBeenCalledTimes(1); - expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'a', mockedFormat, undefined); + expect(applyPendingFormatSpy).toHaveBeenCalledWith( + editor, + 'a', + mockedFormat, + mockedFormat2 + ); expect(state.pendingFormat).toBeNull(); }); @@ -111,7 +120,7 @@ describe('FormatPlugin', () => { const state = plugin.getState(); state.pendingFormat = { - format: mockedFormat, + paragraphFormat: mockedFormat2, } as any; plugin.onPluginEvent({ @@ -122,7 +131,7 @@ describe('FormatPlugin', () => { expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ - format: mockedFormat, + paragraphFormat: mockedFormat2, } as any); }); 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..be044ad6e32 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts @@ -94,6 +94,225 @@ describe('applyPendingFormat', () => { }); }); + it('Has pending paragraph format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: { textAlign: 'start', textIndent: '10pt' }, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat(editor, 'c', undefined, { + textIndent: '20pt', + lineHeight: '2', + }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { textAlign: 'start', textIndent: '20pt', lineHeight: '2' }, + segments: [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Has pending both format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: { textAlign: 'start', textIndent: '10pt' }, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat( + editor, + 'c', + { fontSize: '10px' }, + { + textIndent: '20pt', + lineHeight: '2', + } + ); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { textAlign: 'start', textIndent: '20pt', lineHeight: '2' }, + segments: [ + { + segmentType: 'Text', + text: 'ab', + format: {}, + }, + { + segmentType: 'Text', + text: 'c', + format: { + fontSize: '10px', + }, + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Has no pending format', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'abc', + format: {}, + }; + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [text, marker], + format: {}, + }; + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [paragraph], + }; + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IEditor; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { + callback([model], undefined, paragraph, [marker]); + return false; + }); + + applyPendingFormat(editor, 'c'); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'abc', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + it('Has pending format but wrong text', () => { const text: ContentModelText = { segmentType: 'Text',