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..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
@@ -52,6 +53,7 @@ class FormatPlugin implements PluginWithState {
this.state = {
defaultFormat: { ...option.defaultSegmentFormat },
pendingFormat: null,
+ applyDefaultFormatChecker: option.applyDefaultFormatChecker ?? null,
};
this.defaultFormatKeys = new Set();
@@ -118,13 +120,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) &&
+ (this.defaultFormatKeys.size > 0 || this.state.applyDefaultFormatChecker) &&
+ (isAndroidIME ||
+ isCharacterValue(event.rawEvent) ||
+ event.rawEvent.key == ProcessKey) &&
this.shouldApplyDefaultFormat(this.editor)
) {
applyDefaultFormat(this.editor, this.state.defaultFormat);
@@ -189,33 +194,42 @@ class FormatPlugin implements PluginWithState {
let element: HTMLElement | null = isNodeOfType(posContainer, 'ELEMENT_NODE')
? posContainer
: posContainer.parentElement;
- 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;
+
+ 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 (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 300a3d50f8c..863a5203f24 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,59 @@ 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?.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;
+ });
+ }
}
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-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 201f387f455..429eb9d8e5e 100644
--- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts
+++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts
@@ -1,10 +1,27 @@
import {
ChangeSource,
+ createText,
deleteSelection,
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
@@ -17,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;
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: [],
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;
}