diff --git a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index f29c25093d8..e7c35e5af0b 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,15 +1,15 @@ import { reducedModelChildProcessor } from '../../modelApi/common/reducedModelChildProcessor'; import { retrieveModelFormatState } from 'roosterjs-content-model-dom'; -import type { IEditor, ContentModelFormatState, MergeFormatValueCallbacks } from 'roosterjs-content-model-types'; +import type { IEditor, ContentModelFormatState, ConflictFormatSolution } from 'roosterjs-content-model-types'; /** * Get current format state * @param editor The editor to get format from - * @param callbacks Callbacks to customize the behavior of merging format values + * @param conflictSolution The strategy for handling format conflicts */ export function getFormatState( editor: IEditor, - callbacks?: MergeFormatValueCallbacks + conflictSolution: ConflictFormatSolution = 'remove' ): ContentModelFormatState { const pendingFormat = editor.getPendingFormat(); const manager = editor.getSnapshotsManager(); @@ -21,7 +21,7 @@ export function getFormatState( editor.formatContentModel( model => { - retrieveModelFormatState(model, pendingFormat, result, callbacks); + retrieveModelFormatState(model, pendingFormat, result, conflictSolution); return false; }, diff --git a/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index 4b5e000b46f..1ac08277482 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -73,7 +73,7 @@ describe('getFormatState', () => { canRedo: false, isDarkMode: false, }, - undefined + 'remove' ); expect(result).toEqual(expectedFormat); } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts index c0c4f028ad4..6c6e6e0800a 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts @@ -5,10 +5,9 @@ import { isBold } from '../../domUtils/style/isBold'; import { iterateSelections } from '../selection/iterateSelections'; import { parseValueWithUnit } from '../../formatHandlers/utils/parseValueWithUnit'; import type { + ConflictFormatSolution, ContentModelFormatState, ContentModelSegmentFormat, - MergeFormatValueCallback, - MergeFormatValueCallbacks, ReadonlyContentModelBlockGroup, ReadonlyContentModelBlock, ReadonlyContentModelImage, @@ -24,13 +23,13 @@ import type { * @param model The Content Model to retrieve format state from * @param pendingFormat Existing pending format, if any * @param formatState Existing format state object, used for receiving the result - * @param callbacks Callbacks to customize the behavior of merging format values + * @param conflictSolution The strategy for handling format conflicts */ export function retrieveModelFormatState( model: ReadonlyContentModelDocument, pendingFormat: ContentModelSegmentFormat | null, formatState: ContentModelFormatState, - callbacks?: MergeFormatValueCallbacks + conflictSolution: ConflictFormatSolution = 'remove' ) { let firstTableContext: ReadonlyTableSelectionContext | undefined; let firstBlock: ReadonlyContentModelBlock | undefined; @@ -42,7 +41,7 @@ export function retrieveModelFormatState( model, (path, tableContext, block, segments) => { // Structure formats - retrieveStructureFormat(formatState, path, isFirst, callbacks); + retrieveStructureFormat(formatState, path, isFirst, conflictSolution); // Multiple line format if (block) { @@ -55,7 +54,7 @@ export function retrieveModelFormatState( if (block?.blockType == 'Paragraph') { // Paragraph formats - retrieveParagraphFormat(formatState, block, isFirst, callbacks); + retrieveParagraphFormat(formatState, block, isFirst, conflictSolution); // Segment formats segments?.forEach(segment => { @@ -79,10 +78,10 @@ export function retrieveModelFormatState( segment.link?.format, pendingFormat ), - callbacks + conflictSolution ); - mergeValue(formatState, 'isCodeInline', !!segment?.code, isFirst, undefined, callbacks); + mergeValue(formatState, 'isCodeInline', !!segment?.code, isFirst, conflictSolution); } // We only care the format of selection marker when it is the first selected segment. This is because when selection marker @@ -144,54 +143,54 @@ function retrieveSegmentFormat( result: ContentModelFormatState, isFirst: boolean, mergedFormat: ContentModelSegmentFormat, - callbacks?: MergeFormatValueCallbacks + conflictSolution: ConflictFormatSolution = 'remove' ) { const superOrSubscript = mergedFormat.superOrSubScriptSequence?.split(' ')?.pop(); - mergeValue(result, 'isBold', isBold(mergedFormat.fontWeight), isFirst, undefined, callbacks); - mergeValue(result, 'isItalic', mergedFormat.italic, isFirst, undefined, callbacks); - mergeValue(result, 'isUnderline', mergedFormat.underline, isFirst, undefined, callbacks); - mergeValue(result, 'isStrikeThrough', mergedFormat.strikethrough, isFirst, undefined, callbacks); - mergeValue(result, 'isSuperscript', superOrSubscript == 'super', isFirst, undefined, callbacks); - mergeValue(result, 'isSubscript', superOrSubscript == 'sub', isFirst, undefined, callbacks); - mergeValue(result, 'letterSpacing', mergedFormat.letterSpacing, isFirst, undefined, callbacks); + mergeValue(result, 'isBold', isBold(mergedFormat.fontWeight), isFirst, conflictSolution); + mergeValue(result, 'isItalic', mergedFormat.italic, isFirst, conflictSolution); + mergeValue(result, 'isUnderline', mergedFormat.underline, isFirst, conflictSolution); + mergeValue(result, 'isStrikeThrough', mergedFormat.strikethrough, isFirst, conflictSolution); + mergeValue(result, 'isSuperscript', superOrSubscript == 'super', isFirst, conflictSolution); + mergeValue(result, 'isSubscript', superOrSubscript == 'sub', isFirst, conflictSolution); + mergeValue(result, 'letterSpacing', mergedFormat.letterSpacing, isFirst, conflictSolution); - mergeValue(result, 'fontName', mergedFormat.fontFamily, isFirst, undefined, callbacks); + mergeValue(result, 'fontName', mergedFormat.fontFamily, isFirst, conflictSolution); mergeValue( result, 'fontSize', mergedFormat.fontSize, isFirst, - val => parseValueWithUnit(val, undefined, 'pt') + 'pt', - callbacks + conflictSolution, + val => parseValueWithUnit(val, undefined, 'pt') + 'pt' ); - mergeValue(result, 'backgroundColor', mergedFormat.backgroundColor, isFirst, undefined, callbacks); - mergeValue(result, 'textColor', mergedFormat.textColor, isFirst, undefined, callbacks); - mergeValue(result, 'fontWeight', mergedFormat.fontWeight, isFirst, undefined, callbacks); - mergeValue(result, 'lineHeight', mergedFormat.lineHeight, isFirst, undefined, callbacks); + mergeValue(result, 'backgroundColor', mergedFormat.backgroundColor, isFirst, conflictSolution); + mergeValue(result, 'textColor', mergedFormat.textColor, isFirst, conflictSolution); + mergeValue(result, 'fontWeight', mergedFormat.fontWeight, isFirst, conflictSolution); + mergeValue(result, 'lineHeight', mergedFormat.lineHeight, isFirst, conflictSolution); } function retrieveParagraphFormat( result: ContentModelFormatState, paragraph: ReadonlyContentModelParagraph, isFirst: boolean, - callbacks?: MergeFormatValueCallbacks + conflictSolution: ConflictFormatSolution = 'remove' ) { const headingLevel = parseInt((paragraph.decorator?.tagName || '').substring(1)); const validHeadingLevel = headingLevel >= 1 && headingLevel <= 6 ? headingLevel : undefined; - mergeValue(result, 'marginBottom', paragraph.format.marginBottom, isFirst, undefined, callbacks); - mergeValue(result, 'marginTop', paragraph.format.marginTop, isFirst, undefined, callbacks); - mergeValue(result, 'headingLevel', validHeadingLevel, isFirst, undefined, callbacks); - mergeValue(result, 'textAlign', paragraph.format.textAlign, isFirst, undefined, callbacks); - mergeValue(result, 'direction', paragraph.format.direction, isFirst, undefined, callbacks); + mergeValue(result, 'marginBottom', paragraph.format.marginBottom, isFirst, conflictSolution); + mergeValue(result, 'marginTop', paragraph.format.marginTop, isFirst, conflictSolution); + mergeValue(result, 'headingLevel', validHeadingLevel, isFirst, conflictSolution); + mergeValue(result, 'textAlign', paragraph.format.textAlign, isFirst, conflictSolution); + mergeValue(result, 'direction', paragraph.format.direction, isFirst, conflictSolution); } function retrieveStructureFormat( result: ContentModelFormatState, path: ReadonlyContentModelBlockGroup[], isFirst: boolean, - callbacks?: MergeFormatValueCallbacks + conflictSolution: ConflictFormatSolution = 'remove' ) { const listItemIndex = getClosestAncestorBlockGroupIndex(path, ['ListItem'], []); const containerIndex = getClosestAncestorBlockGroupIndex(path, ['FormatContainer'], []); @@ -200,8 +199,8 @@ function retrieveStructureFormat( const listItem = path[listItemIndex] as ReadonlyContentModelListItem; const listType = listItem?.levels[listItem.levels.length - 1]?.listType; - mergeValue(result, 'isBullet', listType == 'UL', isFirst, undefined, callbacks); - mergeValue(result, 'isNumbering', listType == 'OL', isFirst, undefined, callbacks); + mergeValue(result, 'isBullet', listType == 'UL', isFirst, conflictSolution); + mergeValue(result, 'isNumbering', listType == 'OL', isFirst, conflictSolution); } mergeValue( @@ -210,8 +209,7 @@ function retrieveStructureFormat( containerIndex >= 0 && (path[containerIndex] as ReadonlyContentModelFormatContainer)?.tagName == 'blockquote', isFirst, - undefined, - callbacks + conflictSolution ); } @@ -252,19 +250,27 @@ function mergeValue( key: K, newValue: ContentModelFormatState[K] | undefined, isFirst: boolean, + conflictSolution: ConflictFormatSolution = 'remove', parseFn: (val: ContentModelFormatState[K]) => ContentModelFormatState[K] = val => val, - callbacks?: MergeFormatValueCallbacks ) { if (isFirst) { if (newValue !== undefined) { format[key] = newValue; } } else if (parseFn(newValue) !== parseFn(format[key])) { - const callback = callbacks?.[key] as MergeFormatValueCallback | undefined; - if (callback) { - callback(format, newValue); - } else { - delete format[key]; + switch (conflictSolution) { + case 'remove': + delete format[key]; + break; + case 'keepFirst': + break; + case 'returnMultiple': + if (typeof format[key] === 'string') { + (format[key] as string) = 'Multiple'; + } else { + delete format[key]; + } + break; } } } diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts index 68455a6aacd..9445efee5b6 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts @@ -2,7 +2,7 @@ import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelec import { addCode } from '../../../lib/modelApi/common/addDecorators'; import { addSegment } from '../../../lib/modelApi/common/addSegment'; import { applyTableFormat } from '../../../lib/modelApi/editing/applyTableFormat'; -import { ContentModelFormatState, ContentModelSegmentFormat, MergeFormatValueCallbacks } from 'roosterjs-content-model-types'; +import { ContentModelFormatState, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDivider } from '../../../lib/modelApi/creators/createDivider'; import { createFormatContainer } from '../../../lib/modelApi/creators/createFormatContainer'; @@ -810,16 +810,13 @@ describe('retrieveModelFormatState', () => { }); }); - it('Different format with callbacks', () => { + it('Returns multiple for conflict format', () => { const model = createContentModelDocument({}); const result: ContentModelFormatState = {}; const para = createParagraph(); - const text1 = createText('test1', { fontFamily: 'Aptos', fontSize: '16pt' }); - const text2 = createText('test2', { fontFamily: 'Arial', fontSize: '12pt' }); + const text1 = createText('test1', { isBold: true, fontFamily: 'Aptos', fontSize: '16pt' }); + const text2 = createText('test2', { isBold: false, fontFamily: 'Arial', fontSize: '12pt' }); para.segments.push(text1, text2); - const callbacks: MergeFormatValueCallbacks = { - fontName: (format, _newValue) => format.fontName = 'Multiple', - }; text1.isSelected = true; text2.isSelected = true; @@ -829,14 +826,14 @@ describe('retrieveModelFormatState', () => { return false; }); - retrieveModelFormatState(model, null, result, callbacks); + retrieveModelFormatState(model, null, result, 'returnMultiple'); expect(result).toEqual({ isBlockQuote: false, - isBold: false, isSuperscript: false, isSubscript: false, fontName: 'Multiple', + fontSize: 'Multiple', isCodeInline: false, canUnlink: false, canAddImageAltText: false, diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 424699a4fb5..7ecc0e7d9be 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -445,7 +445,7 @@ export { ModelToTextCallbacks, ModelToTextChecker, } from './parameter/ModelToTextCallbacks'; -export { MergeFormatValueCallback, MergeFormatValueCallbacks } from './parameter/MergeFormatValueCallbacks'; +export { ConflictFormatSolution } from './parameter/ConflictFormatSolution'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/ConflictFormatSolution.ts b/packages/roosterjs-content-model-types/lib/parameter/ConflictFormatSolution.ts new file mode 100644 index 00000000000..ee166f4c232 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/ConflictFormatSolution.ts @@ -0,0 +1,7 @@ +/** + * Specify how to handle conflicts when retrieving format state + * remove: removes the conflicting key from the result + * keepFirst: retains the first value of the conflicting key + * returnMultiple: sets 'Multiple' as the value if the conflicting value's type is string + */ +export type ConflictFormatSolution = 'remove' | 'keepFirst' | 'returnMultiple'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallbacks.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallbacks.ts deleted file mode 100644 index a525ff4ad4a..00000000000 --- a/packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallbacks.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ContentModelFormatState } from './ContentModelFormatState'; - -/** - * Callback function type to merge the values of a specific format key - * @param format The current retrieved format state - * @param newValue The new format value to merge - */ -export type MergeFormatValueCallback = ( - format: ContentModelFormatState, - newValue: ContentModelFormatState[K] | undefined -) => void; - -/** - * Callbacks to customize the behavior of merging different format values from selected content - * @param format The current retrieved format state - * @param newValue The new format value to merge - */ -export type MergeFormatValueCallbacks = { - [K in keyof ContentModelFormatState]?: MergeFormatValueCallback; -};