From 1bec03b0384410e123d2a813774ed6294826cd72 Mon Sep 17 00:00:00 2001 From: "Rain Zheng (from Dev Box)" Date: Fri, 8 Nov 2024 17:52:05 +0800 Subject: [PATCH 1/4] define types --- .../lib/index.ts | 1 + .../lib/parameter/MergeFormatValueCallback.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallback.ts diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 888ccf1263b..424699a4fb5 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -445,6 +445,7 @@ export { ModelToTextCallbacks, ModelToTextChecker, } from './parameter/ModelToTextCallbacks'; +export { MergeFormatValueCallback, MergeFormatValueCallbacks } from './parameter/MergeFormatValueCallbacks'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallback.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallback.ts new file mode 100644 index 00000000000..a525ff4ad4a --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallback.ts @@ -0,0 +1,20 @@ +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; +}; From 668dcd986e45b7da7c0ee592c973dcd69cd90e74 Mon Sep 17 00:00:00 2001 From: "Rain Zheng (from Dev Box)" Date: Fri, 8 Nov 2024 18:00:27 +0800 Subject: [PATCH 2/4] add callbacks --- .../lib/publicApi/format/getFormatState.ts | 10 ++- .../publicApi/format/getFormatStateTest.ts | 3 +- .../editing/retrieveModelFormatState.ts | 79 +++++++++++-------- .../editing/retrieveModelFormatStateTest.ts | 35 +++++++- ...llback.ts => MergeFormatValueCallbacks.ts} | 0 5 files changed, 91 insertions(+), 36 deletions(-) rename packages/roosterjs-content-model-types/lib/parameter/{MergeFormatValueCallback.ts => MergeFormatValueCallbacks.ts} (100%) 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 6c476eff4e5..f29c25093d8 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,12 +1,16 @@ import { reducedModelChildProcessor } from '../../modelApi/common/reducedModelChildProcessor'; import { retrieveModelFormatState } from 'roosterjs-content-model-dom'; -import type { IEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; +import type { IEditor, ContentModelFormatState, MergeFormatValueCallbacks } 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 */ -export function getFormatState(editor: IEditor): ContentModelFormatState { +export function getFormatState( + editor: IEditor, + callbacks?: MergeFormatValueCallbacks +): ContentModelFormatState { const pendingFormat = editor.getPendingFormat(); const manager = editor.getSnapshotsManager(); const result: ContentModelFormatState = { @@ -17,7 +21,7 @@ export function getFormatState(editor: IEditor): ContentModelFormatState { editor.formatContentModel( model => { - retrieveModelFormatState(model, pendingFormat, result); + retrieveModelFormatState(model, pendingFormat, result, callbacks); 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 a45acf4ef0b..4b5e000b46f 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -72,7 +72,8 @@ describe('getFormatState', () => { canUndo: false, canRedo: false, isDarkMode: false, - } + }, + undefined ); 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 8b7cbb0a19b..c0c4f028ad4 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts @@ -7,6 +7,8 @@ import { parseValueWithUnit } from '../../formatHandlers/utils/parseValueWithUni import type { ContentModelFormatState, ContentModelSegmentFormat, + MergeFormatValueCallback, + MergeFormatValueCallbacks, ReadonlyContentModelBlockGroup, ReadonlyContentModelBlock, ReadonlyContentModelImage, @@ -22,11 +24,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 */ export function retrieveModelFormatState( model: ReadonlyContentModelDocument, pendingFormat: ContentModelSegmentFormat | null, - formatState: ContentModelFormatState + formatState: ContentModelFormatState, + callbacks?: MergeFormatValueCallbacks ) { let firstTableContext: ReadonlyTableSelectionContext | undefined; let firstBlock: ReadonlyContentModelBlock | undefined; @@ -38,7 +42,7 @@ export function retrieveModelFormatState( model, (path, tableContext, block, segments) => { // Structure formats - retrieveStructureFormat(formatState, path, isFirst); + retrieveStructureFormat(formatState, path, isFirst, callbacks); // Multiple line format if (block) { @@ -51,7 +55,7 @@ export function retrieveModelFormatState( if (block?.blockType == 'Paragraph') { // Paragraph formats - retrieveParagraphFormat(formatState, block, isFirst); + retrieveParagraphFormat(formatState, block, isFirst, callbacks); // Segment formats segments?.forEach(segment => { @@ -74,10 +78,11 @@ export function retrieveModelFormatState( segment.code?.format, segment.link?.format, pendingFormat - ) + ), + callbacks ); - mergeValue(formatState, 'isCodeInline', !!segment?.code, isFirst); + mergeValue(formatState, 'isCodeInline', !!segment?.code, isFirst, undefined, callbacks); } // We only care the format of selection marker when it is the first selected segment. This is because when selection marker @@ -138,51 +143,55 @@ export function retrieveModelFormatState( function retrieveSegmentFormat( result: ContentModelFormatState, isFirst: boolean, - mergedFormat: ContentModelSegmentFormat + mergedFormat: ContentModelSegmentFormat, + callbacks?: MergeFormatValueCallbacks ) { const superOrSubscript = mergedFormat.superOrSubScriptSequence?.split(' ')?.pop(); - mergeValue(result, 'isBold', isBold(mergedFormat.fontWeight), isFirst); - mergeValue(result, 'isItalic', mergedFormat.italic, isFirst); - mergeValue(result, 'isUnderline', mergedFormat.underline, isFirst); - mergeValue(result, 'isStrikeThrough', mergedFormat.strikethrough, isFirst); - mergeValue(result, 'isSuperscript', superOrSubscript == 'super', isFirst); - mergeValue(result, 'isSubscript', superOrSubscript == 'sub', isFirst); - mergeValue(result, 'letterSpacing', mergedFormat.letterSpacing, isFirst); + 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, 'fontName', mergedFormat.fontFamily, isFirst); + mergeValue(result, 'fontName', mergedFormat.fontFamily, isFirst, undefined, callbacks); mergeValue( result, 'fontSize', mergedFormat.fontSize, isFirst, - val => parseValueWithUnit(val, undefined, 'pt') + 'pt' + val => parseValueWithUnit(val, undefined, 'pt') + 'pt', + callbacks ); - mergeValue(result, 'backgroundColor', mergedFormat.backgroundColor, isFirst); - mergeValue(result, 'textColor', mergedFormat.textColor, isFirst); - mergeValue(result, 'fontWeight', mergedFormat.fontWeight, isFirst); - mergeValue(result, 'lineHeight', mergedFormat.lineHeight, isFirst); + 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); } function retrieveParagraphFormat( result: ContentModelFormatState, paragraph: ReadonlyContentModelParagraph, - isFirst: boolean + isFirst: boolean, + callbacks?: MergeFormatValueCallbacks ) { const headingLevel = parseInt((paragraph.decorator?.tagName || '').substring(1)); const validHeadingLevel = headingLevel >= 1 && headingLevel <= 6 ? headingLevel : undefined; - mergeValue(result, 'marginBottom', paragraph.format.marginBottom, isFirst); - mergeValue(result, 'marginTop', paragraph.format.marginTop, isFirst); - mergeValue(result, 'headingLevel', validHeadingLevel, isFirst); - mergeValue(result, 'textAlign', paragraph.format.textAlign, isFirst); - mergeValue(result, 'direction', paragraph.format.direction, isFirst); + 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); } function retrieveStructureFormat( result: ContentModelFormatState, path: ReadonlyContentModelBlockGroup[], - isFirst: boolean + isFirst: boolean, + callbacks?: MergeFormatValueCallbacks ) { const listItemIndex = getClosestAncestorBlockGroupIndex(path, ['ListItem'], []); const containerIndex = getClosestAncestorBlockGroupIndex(path, ['FormatContainer'], []); @@ -191,8 +200,8 @@ function retrieveStructureFormat( const listItem = path[listItemIndex] as ReadonlyContentModelListItem; const listType = listItem?.levels[listItem.levels.length - 1]?.listType; - mergeValue(result, 'isBullet', listType == 'UL', isFirst); - mergeValue(result, 'isNumbering', listType == 'OL', isFirst); + mergeValue(result, 'isBullet', listType == 'UL', isFirst, undefined, callbacks); + mergeValue(result, 'isNumbering', listType == 'OL', isFirst, undefined, callbacks); } mergeValue( @@ -200,7 +209,9 @@ function retrieveStructureFormat( 'isBlockQuote', containerIndex >= 0 && (path[containerIndex] as ReadonlyContentModelFormatContainer)?.tagName == 'blockquote', - isFirst + isFirst, + undefined, + callbacks ); } @@ -241,14 +252,20 @@ function mergeValue( key: K, newValue: ContentModelFormatState[K] | undefined, isFirst: boolean, - parseFn: (val: ContentModelFormatState[K]) => ContentModelFormatState[K] = val => val + 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])) { - delete format[key]; + const callback = callbacks?.[key] as MergeFormatValueCallback | undefined; + if (callback) { + callback(format, newValue); + } else { + delete format[key]; + } } } 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 6e4e485bdf5..68455a6aacd 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 } from 'roosterjs-content-model-types'; +import { ContentModelFormatState, ContentModelSegmentFormat, MergeFormatValueCallbacks } 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'; @@ -809,4 +809,37 @@ describe('retrieveModelFormatState', () => { canAddImageAltText: false, }); }); + + it('Different format with callbacks', () => { + 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' }); + para.segments.push(text1, text2); + const callbacks: MergeFormatValueCallbacks = { + fontName: (format, _newValue) => format.fontName = 'Multiple', + }; + + text1.isSelected = true; + text2.isSelected = true; + + spyOn(iterateSelections, 'iterateSelections').and.callFake((path: any, callback) => { + callback([path], undefined, para, [text1, text2]); + return false; + }); + + retrieveModelFormatState(model, null, result, callbacks); + + expect(result).toEqual({ + isBlockQuote: false, + isBold: false, + isSuperscript: false, + isSubscript: false, + fontName: 'Multiple', + isCodeInline: false, + canUnlink: false, + canAddImageAltText: false, + }); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallback.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallbacks.ts similarity index 100% rename from packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallback.ts rename to packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallbacks.ts From 5290282a39bd0388514c0439fe2d3b50954408c7 Mon Sep 17 00:00:00 2001 From: "Rain Zheng (from Dev Box)" Date: Thu, 14 Nov 2024 12:39:10 +0800 Subject: [PATCH 3/4] use param instead --- .../lib/publicApi/format/getFormatState.ts | 8 +- .../publicApi/format/getFormatStateTest.ts | 2 +- .../editing/retrieveModelFormatState.ts | 86 ++++++++++--------- .../editing/retrieveModelFormatStateTest.ts | 15 ++-- .../lib/index.ts | 2 +- .../lib/parameter/ConflictFormatSolution.ts | 7 ++ .../parameter/MergeFormatValueCallbacks.ts | 20 ----- 7 files changed, 65 insertions(+), 75 deletions(-) create mode 100644 packages/roosterjs-content-model-types/lib/parameter/ConflictFormatSolution.ts delete mode 100644 packages/roosterjs-content-model-types/lib/parameter/MergeFormatValueCallbacks.ts 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; -}; From a83310895125bc0604c97da85a6890d920f7596f Mon Sep 17 00:00:00 2001 From: "Rain Zheng (from Dev Box)" Date: Thu, 14 Nov 2024 12:51:47 +0800 Subject: [PATCH 4/4] fix test --- .../test/modelApi/editing/retrieveModelFormatStateTest.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 9445efee5b6..c80bc063d8a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts @@ -814,8 +814,8 @@ describe('retrieveModelFormatState', () => { const model = createContentModelDocument({}); const result: ContentModelFormatState = {}; const para = createParagraph(); - const text1 = createText('test1', { isBold: true, fontFamily: 'Aptos', fontSize: '16pt' }); - const text2 = createText('test2', { isBold: false, fontFamily: 'Arial', fontSize: '12pt' }); + const text1 = createText('test1', { italic: true, fontFamily: 'Aptos', fontSize: '16pt' }); + const text2 = createText('test2', { fontFamily: 'Arial', fontSize: '12pt' }); para.segments.push(text1, text2); text1.isSelected = true; @@ -830,6 +830,7 @@ describe('retrieveModelFormatState', () => { expect(result).toEqual({ isBlockQuote: false, + isBold: false, isSuperscript: false, isSubscript: false, fontName: 'Multiple',