From cbba2ce4e7cb809360fa0da9721993e2978706b8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 7 Nov 2023 08:53:26 -0800 Subject: [PATCH] Content Model: Move pending format into editor core (#2188) * Move formatWithContentModel to be a core API * Content Model: Allow clear cache from formatContentModel * Content Model: Move pending format into editor core --- .../lib/editor/ContentModelEditor.ts | 8 + .../lib/editor/coreApi/formatContentModel.ts | 31 +- .../corePlugins/ContentModelFormatPlugin.ts | 43 +- .../editor/createContentModelEditorCore.ts | 1 + .../lib/index.ts | 6 +- .../lib/modelApi/format/applyDefaultFormat.ts | 105 +++ .../lib/modelApi/format/applyPendingFormat.ts | 75 +++ .../lib/modelApi/format/pendingFormat.ts | 107 --- .../lib/publicApi/block/setIndentation.ts | 8 +- .../lib/publicApi/block/toggleBlockQuote.ts | 14 +- .../publicApi/format/applyDefaultFormat.ts | 117 ---- .../publicApi/format/applyPendingFormat.ts | 77 --- .../lib/publicApi/format/getFormatState.ts | 3 +- .../lib/publicApi/link/insertLink.ts | 5 +- .../lib/publicApi/list/toggleBullet.ts | 14 +- .../lib/publicApi/list/toggleNumbering.ts | 14 +- .../lib/publicApi/table/insertTable.ts | 3 +- .../utils/formatParagraphWithContentModel.ts | 7 +- .../utils/formatSegmentWithContentModel.ts | 16 +- .../lib/publicApi/utils/paste.ts | 16 +- .../lib/publicTypes/IContentModelEditor.ts | 6 + .../FormatWithContentModelContext.ts | 10 + .../ContentModelFormatPluginState.ts | 25 + .../test/editor/ContentModelEditorTest.ts | 16 + .../editor/coreApi/formatContentModelTest.ts | 191 +++++- .../ContentModelFormatPluginTest.ts | 627 +++++++----------- .../createContentModelEditorCoreTest.ts | 5 + .../modelApi/format/applyDefaultFormatTest.ts | 407 ++++++++++++ .../format/applyPendingFormatTest.ts | 39 +- .../test/modelApi/format/pendingFormatTest.ts | 252 ------- .../publicApi/block/setIndentationTest.ts | 35 +- .../publicApi/block/toggleBlockQuoteTest.ts | 35 +- .../publicApi/editing/editingTestCommon.ts | 5 +- .../publicApi/editing/keyboardDeleteTest.ts | 2 +- .../publicApi/format/getFormatStateTest.ts | 4 +- .../test/publicApi/image/changeImageTest.ts | 5 +- .../test/publicApi/link/insertLinkTest.ts | 2 +- .../test/publicApi/list/toggleBulletTest.ts | 16 +- .../publicApi/list/toggleNumberingTest.ts | 22 +- .../publicApi/segment/changeFontSizeTest.ts | 6 +- .../publicApi/segment/segmentTestCommon.ts | 5 +- .../utils/formatImageWithContentModelTest.ts | 5 +- .../formatParagraphWithContentModelTest.ts | 23 +- .../formatSegmentWithContentModelTest.ts | 54 +- .../test/publicApi/utils/pasteTest.ts | 23 +- 45 files changed, 1347 insertions(+), 1143 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/pendingFormat.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts rename packages-content-model/roosterjs-content-model-editor/test/{publicApi => modelApi}/format/applyPendingFormatTest.ts (93%) delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 1e79c0c05fb..6ad41f70a6c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -12,6 +12,7 @@ import type { } from '../publicTypes/IContentModelEditor'; import type { ContentModelDocument, + ContentModelSegmentFormat, DOMSelection, DomToModelOption, ModelToDomOption, @@ -113,4 +114,11 @@ export default class ContentModelEditor core.api.formatContentModel(core, formatter, options); } + + /** + * Get pending format of editor if any, or return null + */ + getPendingFormat(): ContentModelSegmentFormat | null { + return this.getCore().format.pendingFormat?.format ?? null; + } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts index cf277de10b4..0a8c54014ce 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts @@ -44,6 +44,8 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) selection = core.api.setContentModel(core, model, undefined /*options*/, onNodeCreated) || undefined; + + handlePendingFormat(core, context, selection); }; if (context.skipUndoSnapshot) { @@ -71,9 +73,13 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) }, }; core.api.triggerEvent(core, eventData, true /*broadcast*/); - } else if (context.clearModelCache) { - core.cache.cachedModel = undefined; - core.cache.cachedSelection = undefined; + } else { + if (context.clearModelCache) { + core.cache.cachedModel = undefined; + core.cache.cachedSelection = undefined; + } + + handlePendingFormat(core, context, core.api.getDOMSelection(core)); } }; @@ -152,3 +158,22 @@ function handleImages(core: ContentModelEditorCore, context: FormatWithContentMo } } } + +function handlePendingFormat( + core: ContentModelEditorCore, + context: FormatWithContentModelContext, + selection?: DOMSelection | null +) { + const pendingFormat = + context.newPendingFormat == 'preserve' + ? core.format.pendingFormat?.format + : context.newPendingFormat; + + if (pendingFormat && selection?.type == 'range' && selection.range.collapsed) { + core.format.pendingFormat = { + format: { ...pendingFormat }, + posContainer: selection.range.startContainer, + posOffset: selection.range.startOffset, + }; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts index 72d62a29148..45f8bb0a201 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts @@ -1,6 +1,5 @@ -import applyDefaultFormat from '../../publicApi/format/applyDefaultFormat'; -import applyPendingFormat from '../../publicApi/format/applyPendingFormat'; -import { canApplyPendingFormat, clearPendingFormat } from '../../modelApi/format/pendingFormat'; +import { applyDefaultFormat } from '../../modelApi/format/applyDefaultFormat'; +import { applyPendingFormat } from '../../modelApi/format/applyPendingFormat'; import { getObjectKeys } from 'roosterjs-content-model-dom'; import { isCharacterValue } from '../../domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; @@ -107,7 +106,7 @@ export default class ContentModelFormatPlugin case PluginEventType.KeyDown: if (CursorMovingKeys.has(event.rawEvent.key)) { - clearPendingFormat(this.editor); + this.clearPendingFormat(); } else if ( this.hasDefaultFormat && (isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) @@ -119,19 +118,45 @@ export default class ContentModelFormatPlugin case PluginEventType.MouseUp: case PluginEventType.ContentChanged: - if (!canApplyPendingFormat(this.editor)) { - clearPendingFormat(this.editor); + if (!this.canApplyPendingFormat()) { + this.clearPendingFormat(); } break; } } private checkAndApplyPendingFormat(data: string | null) { - if (this.editor && data) { - applyPendingFormat(this.editor, data); - clearPendingFormat(this.editor); + if (this.editor && data && this.state.pendingFormat) { + applyPendingFormat(this.editor, data, this.state.pendingFormat.format); + this.clearPendingFormat(); } } + + private clearPendingFormat() { + this.state.pendingFormat = null; + } + + /** + * @internal + * Check if this editor can apply pending format + * @param editor The editor to get format from + */ + private canApplyPendingFormat(): boolean { + let result = false; + + if (this.state.pendingFormat && this.editor) { + const selection = this.editor.getDOMSelection(); + const range = + selection?.type == 'range' && selection.range.collapsed ? selection.range : null; + const { posContainer, posOffset } = this.state.pendingFormat; + + if (range && range.startContainer == posContainer && range.startOffset == posOffset) { + result = true; + } + } + + return result; + } } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts index cb7853055ae..04682c5965b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts @@ -147,6 +147,7 @@ function getPluginState(options: ContentModelEditorOptions): ContentModelPluginS backgroundColor: format.backgroundColors?.lightModeColor || format.backgroundColor || undefined, }, + pendingFormat: null, }, }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 4d9e1f88a3a..3b74a3a7df5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -96,7 +96,6 @@ export { default as setImageBorder } from './publicApi/image/setImageBorder'; export { default as setImageBoxShadow } from './publicApi/image/setImageBoxShadow'; export { default as changeImage } from './publicApi/image/changeImage'; export { default as getFormatState } from './publicApi/format/getFormatState'; -export { default as applyPendingFormat } from './publicApi/format/applyPendingFormat'; export { default as clearFormat } from './publicApi/format/clearFormat'; export { default as insertLink } from './publicApi/link/insertLink'; export { default as removeLink } from './publicApi/link/removeLink'; @@ -130,4 +129,7 @@ export { updateListMetadata } from './domUtils/metadata/updateListMetadata'; export { ContentModelCachePluginState } from './publicTypes/pluginState/ContentModelCachePluginState'; export { ContentModelPluginState } from './publicTypes/pluginState/ContentModelPluginState'; -export { ContentModelFormatPluginState } from './publicTypes/pluginState/ContentModelFormatPluginState'; +export { + ContentModelFormatPluginState, + PendingFormat, +} from './publicTypes/pluginState/ContentModelFormatPluginState'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts new file mode 100644 index 00000000000..9b5020b2c09 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts @@ -0,0 +1,105 @@ +import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; +import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; +import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; + +/** + * @internal + * When necessary, set default format as current pending format so it will be applied when Input event is fired + * @param editor The Content Model Editor + * @param defaultFormat The default segment format to apply + */ +export function applyDefaultFormat( + editor: IContentModelEditor, + defaultFormat: ContentModelSegmentFormat +) { + const selection = editor.getDOMSelection(); + const range = selection?.type == 'range' ? selection.range : null; + const posContainer = range?.startContainer ?? null; + const posOffset = range?.startOffset ?? null; + + if (posContainer) { + let node: Node | null = posContainer; + + while (node && editor.contains(node)) { + if (isNodeOfType(node, 'ELEMENT_NODE')) { + if (node.getAttribute?.('style')) { + return; + } else if (isBlockElement(node)) { + break; + } + } + + node = node.parentNode; + } + } else { + return; + } + + editor.formatContentModel((model, context) => { + const result = deleteSelection(model, [], context); + + if (result.deleteResult == DeleteResult.Range) { + normalizeContentModel(model); + editor.addUndoSnapshot(); + + return true; + } else if ( + result.deleteResult == DeleteResult.NotDeleted && + result.insertPoint && + posContainer && + posOffset !== null + ) { + 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 + ); + } + } 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; + }); +} + +function getNewPendingFormat( + editor: IContentModelEditor, + defaultFormat: ContentModelSegmentFormat, + markerFormat: ContentModelSegmentFormat +): ContentModelSegmentFormat { + return { + ...defaultFormat, + ...editor.getPendingFormat(), + ...markerFormat, + }; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts new file mode 100644 index 00000000000..7fa99123c90 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts @@ -0,0 +1,75 @@ +import { iterateSelections } from '../../modelApi/selection/iterateSelections'; +import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { + createText, + normalizeContentModel, + setParagraphNotImplicit, +} from 'roosterjs-content-model-dom'; + +const ANSI_SPACE = '\u0020'; +const NON_BREAK_SPACE = '\u00A0'; + +/** + * @internal + * Apply pending format to the text user just input + * @param editor The editor to get format from + * @param data The text user just input + */ +export function applyPendingFormat( + editor: IContentModelEditor, + data: string, + format: ContentModelSegmentFormat +) { + let isChanged = false; + + editor.formatContentModel( + (model, context) => { + iterateSelections(model, (_, __, block, segments) => { + if ( + block?.blockType == 'Paragraph' && + segments?.length == 1 && + segments[0].segmentType == 'SelectionMarker' + ) { + const marker = segments[0]; + const index = block.segments.indexOf(marker); + const previousSegment = block.segments[index - 1]; + + if (previousSegment?.segmentType == 'Text') { + const text = previousSegment.text; + const subStr = text.substr(-data.length, data.length); + + // For space, there can be (space) or   ( ), we treat them as the same + if (subStr == data || (data == ANSI_SPACE && subStr == NON_BREAK_SPACE)) { + marker.format = { ...format }; + previousSegment.text = text.substring(0, text.length - data.length); + + const newText = createText( + data == ANSI_SPACE ? NON_BREAK_SPACE : data, + { + ...previousSegment.format, + ...format, + } + ); + + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + isChanged = true; + } + } + } + return true; + }); + + if (isChanged) { + normalizeContentModel(model); + context.skipUndoSnapshot = true; + } + + return isChanged; + }, + { + apiName: 'applyPendingFormat', + } + ); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/pendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/pendingFormat.ts deleted file mode 100644 index 8fd4ba9a39b..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/pendingFormat.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import type { - ContentModelFormatter, - FormatWithContentModelOptions, -} from '../../publicTypes/parameter/FormatWithContentModelContext'; - -/** - * @internal - * Get pending segment format from editor if any, otherwise null - * @param editor The editor to get format from - */ -export function getPendingFormat(editor: IContentModelEditor): ContentModelSegmentFormat | null { - return getPendingFormatHolder(editor).format; -} - -/** - * @internal - * Set pending segment format to editor - * @param editor The editor to set pending format to - * @param format The format to set. - * @param posContainer Container node of current focus position - * @param posOffset Offset number of current focus position - */ -export function setPendingFormat( - editor: IContentModelEditor, - format: ContentModelSegmentFormat, - posContainer: Node, - posOffset: number -) { - const holder = getPendingFormatHolder(editor); - - holder.format = format; - holder.posContainer = posContainer; - holder.posOffset = posOffset; -} - -/** - * @internal Clear pending format if any - * @param editor The editor to set pending format to - */ -export function clearPendingFormat(editor: IContentModelEditor) { - const holder = getPendingFormatHolder(editor); - - holder.format = null; - holder.posContainer = null; - holder.posOffset = null; -} - -/** - * @internal - * Check if this editor can apply pending format - * @param editor The editor to get format from - */ -export function canApplyPendingFormat(editor: IContentModelEditor): boolean { - const holder = getPendingFormatHolder(editor); - let result = false; - - if (holder.format && holder.posContainer && holder.posOffset !== null) { - const position = editor.getFocusedPosition(); - - if (position?.node == holder.posContainer && position?.offset == holder.posOffset) { - result = true; - } - } - - return result; -} - -/** - * @internal - * Execute a callback function and keep pending format state still available - * @param editor The editor to keep pending format - * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions - */ -export function formatAndKeepPendingFormat( - editor: IContentModelEditor, - formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions -) { - const pendingFormat = getPendingFormat(editor); - - editor.formatContentModel(formatter, options); - - const pos = editor.getFocusedPosition(); - - if (pendingFormat && pos) { - setPendingFormat(editor, pendingFormat, pos.node, pos.offset); - } -} - -interface PendingFormatHolder { - format: ContentModelSegmentFormat | null; - posContainer: Node | null; - posOffset: number | null; -} - -const PendingFormatHolderKey = '__ContentModelPendingFormat'; - -function getPendingFormatHolder(editor: IContentModelEditor): PendingFormatHolder { - return editor.getCustomData(PendingFormatHolderKey, () => ({ - format: null, - posContainer: null, - posOffset: null, - })); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts index dd92e35a7ab..609e3c8315e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setModelIndentation } from '../../modelApi/block/setModelIndentation'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -16,15 +15,16 @@ export default function setIndentation( ) { editor.focus(); - formatAndKeepPendingFormat( - editor, - model => { + editor.formatContentModel( + (model, context) => { const result = setModelIndentation(model, indentation, length); if (result) { normalizeContentModel(model); } + context.newPendingFormat = 'preserve'; + return result; }, { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts index 5227618b0d9..471da98421c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { toggleModelBlockQuote } from '../../modelApi/block/toggleModelBlockQuote'; import type { ContentModelFormatContainerFormat } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -33,7 +32,14 @@ export default function toggleBlockQuote( editor.focus(); - formatAndKeepPendingFormat(editor, model => toggleModelBlockQuote(model, fullQuoteFormat), { - apiName: 'toggleBlockQuote', - }); + editor.formatContentModel( + (model, context) => { + context.newPendingFormat = 'preserve'; + + return toggleModelBlockQuote(model, fullQuoteFormat); + }, + { + apiName: 'toggleBlockQuote', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts deleted file mode 100644 index e27497a8cf8..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; -import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; - -/** - * @internal - * When necessary, set default format as current pending format so it will be applied when Input event is fired - * @param editor The Content Model Editor - * @param defaultFormat The default segment format to apply - */ -export default function applyDefaultFormat( - editor: IContentModelEditor, - defaultFormat: ContentModelSegmentFormat -) { - const selection = editor.getDOMSelection(); - const range = selection?.type == 'range' ? selection.range : null; - const posContainer = range?.startContainer ?? null; - const posOffset = range?.startOffset ?? null; - let node = posContainer; - - while (node && editor.contains(node)) { - if (isNodeOfType(node, 'ELEMENT_NODE')) { - if (node.getAttribute?.('style')) { - return; - } else if (isBlockElement(node)) { - break; - } - } - - node = node.parentNode; - } - - editor.formatContentModel( - (model, context) => { - const result = deleteSelection(model, [], context); - - if (result.deleteResult == DeleteResult.Range) { - normalizeContentModel(model); - editor.addUndoSnapshot(); - - return true; - } else if ( - result.deleteResult == DeleteResult.NotDeleted && - result.insertPoint && - posContainer && - posOffset !== null - ) { - 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') { - internalApplyDefaultFormat( - editor, - defaultFormat, - marker.format, - posContainer, - posOffset - ); - } - } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { - internalApplyDefaultFormat( - editor, - defaultFormat, - marker.format, - posContainer, - posOffset - ); - } - - // We didn't do any change but just apply default format to pending format, so no need to write back - return false; - } else { - return false; - } - }, - { - apiName: 'input', - } - ); -} - -function internalApplyDefaultFormat( - editor: IContentModelEditor, - defaultFormat: ContentModelSegmentFormat, - currentFormat: ContentModelSegmentFormat, - posContainer: Node, - posOffset: number -) { - const pendingFormat = getPendingFormat(editor) || {}; - const newFormat: ContentModelSegmentFormat = { - ...defaultFormat, - ...pendingFormat, - ...currentFormat, - }; - - setPendingFormat(editor, newFormat, posContainer, posOffset); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts deleted file mode 100644 index cfec82a5873..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; -import { iterateSelections } from '../../modelApi/selection/iterateSelections'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { - createText, - normalizeContentModel, - setParagraphNotImplicit, -} from 'roosterjs-content-model-dom'; - -const ANSI_SPACE = '\u0020'; -const NON_BREAK_SPACE = '\u00A0'; - -/** - * Apply pending format to the text user just input - * @param editor The editor to get format from - * @param data The text user just input - */ -export default function applyPendingFormat(editor: IContentModelEditor, data: string) { - const format = getPendingFormat(editor); - - if (format) { - let isChanged = false; - - editor.formatContentModel( - (model, context) => { - iterateSelections(model, (_, __, block, segments) => { - if ( - block?.blockType == 'Paragraph' && - segments?.length == 1 && - segments[0].segmentType == 'SelectionMarker' - ) { - const marker = segments[0]; - const index = block.segments.indexOf(marker); - const previousSegment = block.segments[index - 1]; - - if (previousSegment?.segmentType == 'Text') { - const text = previousSegment.text; - const subStr = text.substr(-data.length, data.length); - - // For space, there can be (space) or   ( ), we treat them as the same - if ( - subStr == data || - (data == ANSI_SPACE && subStr == NON_BREAK_SPACE) - ) { - marker.format = { ...format }; - previousSegment.text = text.substring(0, text.length - data.length); - - const newText = createText( - data == ANSI_SPACE ? NON_BREAK_SPACE : data, - { - ...previousSegment.format, - ...format, - } - ); - - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); - isChanged = true; - } - } - } - return true; - }); - - if (isChanged) { - normalizeContentModel(model); - context.skipUndoSnapshot = true; - } - - return isChanged; - }, - { - apiName: 'applyPendingFormat', - } - ); - } -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts index 83a4cf72988..a2a1c601a33 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts @@ -1,4 +1,3 @@ -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { getSelectionRootNode } from '../../modelApi/selection/getSelectionRootNode'; import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; import type { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; @@ -16,7 +15,7 @@ import { * @param editor The editor to get format from */ export default function getFormatState(editor: IContentModelEditor): ContentModelFormatState { - const pendingFormat = getPendingFormat(editor); + const pendingFormat = editor.getPendingFormat(); const model = editor.createContentModel({ processorOverride: { child: reducedModelChildProcessor, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts index a84e9653a5e..3616c30d133 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts @@ -1,6 +1,5 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; import { mergeModel } from '../../modelApi/common/mergeModel'; import type { ContentModelLink } from 'roosterjs-content-model-types'; @@ -77,8 +76,8 @@ export default function insertLink( (!!text && text != originalText) ) { const segment = createText(text || (linkData ? linkData.originalUrl : url), { - ...(segments[0]?.format || {}), - ...(getPendingFormat(editor) || {}), + ...segments[0]?.format, + ...editor.getPendingFormat(), }); const doc = createContentModelDocument(); const link = createLink(linkUrl, anchorTitle, target); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts index fdcd033524f..abe38abd469 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { setListType } from '../../modelApi/list/setListType'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,7 +10,14 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function toggleBullet(editor: IContentModelEditor) { editor.focus(); - formatAndKeepPendingFormat(editor, model => setListType(model, 'UL'), { - apiName: 'toggleBullet', - }); + editor.formatContentModel( + (model, context) => { + context.newPendingFormat = 'preserve'; + + return setListType(model, 'UL'); + }, + { + apiName: 'toggleBullet', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts index 4508b1343c5..a7e360b530a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { setListType } from '../../modelApi/list/setListType'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,7 +10,14 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function toggleNumbering(editor: IContentModelEditor) { editor.focus(); - formatAndKeepPendingFormat(editor, model => setListType(model, 'OL'), { - apiName: 'toggleNumbering', - }); + editor.formatContentModel( + (model, context) => { + context.newPendingFormat = 'preserve'; + + return setListType(model, 'OL'); + }, + { + apiName: 'toggleNumbering', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index e19dbfdd4ea..810dea9e607 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -2,7 +2,6 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { createContentModelDocument, createSelectionMarker } from 'roosterjs-content-model-dom'; import { createTableStructure } from '../../modelApi/table/createTableStructure'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { normalizeTable } from '../../modelApi/table/normalizeTable'; import { setSelection } from '../../modelApi/selection/setSelection'; @@ -34,7 +33,7 @@ export default function insertTable( const doc = createContentModelDocument(); const table = createTableStructure(doc, columns, rows); - normalizeTable(table, getPendingFormat(editor) || insertPosition.marker.format); + normalizeTable(table, editor.getPendingFormat() || insertPosition.marker.format); // Assign default vertical align format = format || { verticalAlign: 'top' }; applyTableFormat(table, format); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts index 7767f011e92..be73c4cdd32 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { getSelectedParagraphs } from '../../modelApi/selection/collectSelections'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,12 +10,12 @@ export function formatParagraphWithContentModel( apiName: string, setStyleCallback: (paragraph: ContentModelParagraph) => void ) { - formatAndKeepPendingFormat( - editor, - model => { + editor.formatContentModel( + (model, context) => { const paragraphs = getSelectedParagraphs(model); paragraphs.forEach(setStyleCallback); + context.newPendingFormat = 'preserve'; return paragraphs.length > 0; }, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts index 233cb8c3c37..6c3ba558be6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -1,5 +1,4 @@ import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; -import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { getSelectedSegmentsAndParagraphs } from '../../modelApi/selection/collectSelections'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { @@ -29,12 +28,12 @@ export function formatSegmentWithContentModel( afterFormatCallback?: (model: ContentModelDocument) => void ) { editor.formatContentModel( - model => { + (model, context) => { let segmentAndParagraphs = getSelectedSegmentsAndParagraphs( model, !!includingFormatHolder ); - const pendingFormat = getPendingFormat(editor); + const pendingFormat = editor.getPendingFormat(); let isCollapsedSelection = segmentAndParagraphs.length == 1 && segmentAndParagraphs[0][0].segmentType == 'SelectionMarker'; @@ -73,16 +72,7 @@ export function formatSegmentWithContentModel( afterFormatCallback?.(model); if (!pendingFormat && isCollapsedSelection) { - const pos = editor.getFocusedPosition(); - - if (pos) { - setPendingFormat( - editor, - segmentAndParagraphs[0][0].format, - pos.node, - pos.offset - ); - } + context.newPendingFormat = segmentAndParagraphs[0][0].format; } if (isCollapsedSelection) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index 2feea8e386a..84886c59708 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -2,7 +2,6 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; import { GetContentMode, PasteType as OldPasteType, PluginEventType } from 'roosterjs-editor-types'; import { mergeModel } from '../../modelApi/common/mergeModel'; -import { setPendingFormat } from '../../modelApi/format/pendingFormat'; import type { InsertPoint } from '../../publicTypes/selection/InsertPoint'; import type { ContentModelDocument, @@ -106,6 +105,10 @@ export default function paste( originalFormat = insertPoint.marker.format; } + if (originalFormat) { + context.newPendingFormat = { ...EmptySegmentFormat, ...originalFormat }; // Use empty format as initial value to clear any other format inherits from pasted content + } + return true; }, @@ -115,17 +118,6 @@ export default function paste( apiName: 'paste', } ); - - const pos = editor.getFocusedPosition(); - - if (originalFormat && pos) { - setPendingFormat( - editor, - { ...EmptySegmentFormat, ...originalFormat }, // Use empty format as initial value to clear any other format inherits from pasted content - pos.node, - pos.offset - ); - } } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 3d69671ea0a..8775e2b026c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -5,6 +5,7 @@ import type { } from './parameter/FormatWithContentModelContext'; import type { ContentModelDocument, + ContentModelSegmentFormat, DOMSelection, DomToModelOption, ModelToDomOption, @@ -85,6 +86,11 @@ export interface IContentModelEditor extends IEditor { formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ): void; + + /** + * Get pending format of editor if any, or return null + */ + getPendingFormat(): ContentModelSegmentFormat | null; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts index 6790a4185ca..e907a3d7fae 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts @@ -2,6 +2,7 @@ import type { ContentModelDocument, ContentModelEntity, ContentModelImage, + ContentModelSegmentFormat, DOMSelection, OnNodeCreated, } from 'roosterjs-content-model-types'; @@ -103,6 +104,15 @@ export interface FormatWithContentModelContext { * When set to true, formatWithContentModel API will not keep cached Content Model. Next time when we need a Content Model, a new one will be created */ clearModelCache?: boolean; + + /** + * @optional + * Specify new pending format. + * 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 + */ + newPendingFormat?: ContentModelSegmentFormat | 'preserve'; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts index 81444445060..743dfefc555 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts @@ -1,5 +1,25 @@ import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +/** + * Pending format holder interface + */ +export interface PendingFormat { + /** + * The pending format + */ + format: ContentModelSegmentFormat; + + /** + * Container node of pending format + */ + posContainer: Node; + + /** + * Offset under container node of pending format + */ + posOffset: number; +} + /** * Plugin state for ContentModelFormatPlugin */ @@ -8,4 +28,9 @@ export interface ContentModelFormatPluginState { * Default format of this editor */ defaultFormat: ContentModelSegmentFormat; + + /** + * Pending format + */ + pendingFormat: PendingFormat | null; } diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 685adcb2726..47a14d34e74 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -4,6 +4,7 @@ import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelT import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import ContentModelEditor from '../../lib/editor/ContentModelEditor'; import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; +import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; const editorContext: EditorContext = { @@ -248,6 +249,21 @@ describe('ContentModelEditor', () => { }); }); + it('getPendingFormat', () => { + const div = document.createElement('div'); + const editor = new ContentModelEditor(div); + const core: ContentModelEditorCore = (editor as any).core; + const mockedFormat = 'FORMAT' as any; + + expect(editor.getPendingFormat()).toBeNull(); + + core.format.pendingFormat = { + format: mockedFormat, + } as any; + + expect(editor.getPendingFormat()).toEqual(mockedFormat); + }); + it('dispose', () => { const div = document.createElement('div'); div.style.fontFamily = 'Arial'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts index f8bee279c9a..1e4c49a28a9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts @@ -1,7 +1,6 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; import { createImage } from 'roosterjs-content-model-dom'; import { formatContentModel } from '../../../lib/editor/coreApi/formatContentModel'; @@ -15,7 +14,7 @@ describe('formatContentModel', () => { let cacheContentModel: jasmine.Spy; let getFocusedPosition: jasmine.Spy; let triggerEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; + let getDOMSelection: jasmine.Spy; const apiName = 'mockedApi'; const mockedContainer = 'C' as any; @@ -35,7 +34,7 @@ describe('formatContentModel', () => { .createSpy('getFocusedPosition') .and.returnValue({ node: mockedContainer, offset: mockedOffset }); triggerEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); + getDOMSelection = jasmine.createSpy('getDOMSelection').and.returnValue(null); core = ({ api: { @@ -45,10 +44,10 @@ describe('formatContentModel', () => { cacheContentModel, getFocusedPosition, triggerEvent, + getDOMSelection, }, lifecycle: {}, cache: {}, - getVisibleViewport, } as any) as ContentModelEditorCore; }); @@ -111,10 +110,6 @@ describe('formatContentModel', () => { context.skipUndoSnapshot = true; return true; }); - const mockedFormat = 'FORMAT' as any; - - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(mockedFormat); - spyOn(pendingFormat, 'setPendingFormat'); formatContentModel(core, callback, { apiName }); @@ -516,4 +511,182 @@ describe('formatContentModel', () => { cachedSelection: undefined, }); }); + + describe('Pending foramt', () => { + const mockedStartContainer1 = 'CONTAINER1' as any; + const mockedStartOffset1 = 'OFFSET1' as any; + const mockedFormat1: ContentModelSegmentFormat = { fontSize: '10pt' }; + + const mockedStartContainer2 = 'CONTAINER2' as any; + const mockedStartOffset2 = 'OFFSET2' as any; + const mockedFormat2: ContentModelSegmentFormat = { fontFamily: 'Arial' }; + + beforeEach(() => { + core.format = { + defaultFormat: {}, + pendingFormat: null, + }; + + const mockedRange = { + type: 'range', + range: { + collapsed: true, + startContainer: mockedStartContainer2, + startOffset: mockedStartOffset2, + }, + } as any; + + core.api.setContentModel = () => mockedRange; + core.api.getDOMSelection = () => mockedRange; + }); + + it('No pending format, callback returns true, preserve pending format', () => { + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toBeNull(); + }); + + it('No pending format, callback returns false, preserve pending format', () => { + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return false; + }); + + expect(core.format.pendingFormat).toBeNull(); + }); + + it('Has pending format, callback returns true, preserve pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + } as any); + }); + + it('Has pending format, callback returns false, preserve pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + } as any); + }); + + it('No pending format, callback returns true, new format', () => { + formatContentModel(core, (model, context) => { + context.newPendingFormat = mockedFormat2; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat2, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + }); + }); + + it('No pending format, callback returns false, new format', () => { + formatContentModel(core, (model, context) => { + context.newPendingFormat = mockedFormat2; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat2, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + }); + }); + + it('Has pending format, callback returns true, new format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = mockedFormat2; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat2, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + }); + }); + + it('Has pending format, callback returns false, new format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = mockedFormat2; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat2, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + }); + }); + + it('Has pending format, callback returns false, preserve format, selection is not collapsed', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + core.api.getDOMSelection = () => + ({ + type: 'range', + range: { + collapsed: false, + startContainer: mockedStartContainer2, + startOffset: mockedStartOffset2, + }, + } as any); + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }); + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts index e4d271edbaa..d25378e2fd5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts @@ -1,30 +1,34 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; +import * as applyPendingFormat from '../../../lib/modelApi/format/applyPendingFormat'; import ContentModelFormatPlugin from '../../../lib/editor/corePlugins/ContentModelFormatPlugin'; -import { ContentModelFormatPluginState } from '../../../lib/publicTypes/pluginState/ContentModelFormatPluginState'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; import { - ContentModelFormatter, - FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; + ContentModelFormatPluginState, + PendingFormat, +} from '../../../lib/publicTypes/pluginState/ContentModelFormatPluginState'; import { addSegment, createContentModelDocument, createSelectionMarker, - createText, } from 'roosterjs-content-model-dom'; describe('ContentModelFormatPlugin', () => { - it('no pending format, trigger key down event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); + const mockedFormat = { + fontSize: '10px', + }; + + beforeEach(() => { + spyOn(applyPendingFormat, 'applyPendingFormat'); + }); + it('no pending format, trigger key down event', () => { const editor = ({ cacheContentModel: () => {}, isDarkMode: () => false, } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: ({} as any) as PendingFormat, }; const plugin = new ContentModelFormatPlugin(state); plugin.initialize(editor); @@ -36,40 +40,23 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toBeNull(); }); it('no selection, trigger input event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - let formatResult: boolean | undefined; - - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); - const editor = ({ focus: jasmine.createSpy('focus'), createContentModel: () => model, isInIME: () => false, cacheContentModel: () => {}, getEnvironment: () => ({}), - formatContentModel, } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); const model = createContentModelDocument(); @@ -83,17 +70,16 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); - expect(formatResult).toBeFalse(); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); + expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledTimes(1); + expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledWith( + editor, + 'a', + mockedFormat + ); + expect(state.pendingFormat).toBeNull(); }); - it('with pending format and selection, has correct text before, trigger input event with isComposing = true', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - const setContentModel = jasmine.createSpy('setContentModel'); + it('with pending format and selection, trigger input event with isComposing = true', () => { const model = createContentModelDocument(); const marker = createSelectionMarker(); @@ -101,12 +87,14 @@ describe('ContentModelFormatPlugin', () => { const editor = ({ createContentModel: () => model, - setContentModel, cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); plugin.initialize(editor); @@ -116,169 +104,17 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(0); - }); - - it('with pending format and selection, no correct text before, trigger input event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - let formatResult: boolean | undefined; - const model = createContentModelDocument(); - const marker = createSelectionMarker(); - - addSegment(model, marker); - - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); - - const editor = ({ - focus: jasmine.createSpy('focus'), - createContentModel: () => model, - isInIME: () => false, - cacheContentModel: () => {}, - getEnvironment: () => ({}), - formatContentModel, - } as any) as IContentModelEditor; - const state = { - defaultFormat: {}, - }; - const plugin = new ContentModelFormatPlugin(state); - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.Input, - rawEvent: ({ data: 'a' } as any) as InputEvent, - }); - plugin.dispose(); - - expect(formatResult).toBeFalse(); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); - }); - - it('with pending format and selection, has correct text before, trigger input event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const model = createContentModelDocument(); - const text = createText('a'); - const marker = createSelectionMarker(); - let formatResult: boolean | undefined; - - addSegment(model, text); - addSegment(model, marker); - - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); - - const editor = ({ - createContentModel: () => model, - formatContentModel, - isInIME: () => false, - focus: () => {}, - addUndoSnapshot: (callback: () => void) => { - callback(); - }, - cacheContentModel: () => {}, - isDarkMode: () => false, - triggerPluginEvent: jasmine.createSpy('triggerPluginEvent'), - getVisibleViewport: jasmine.createSpy('getVisibleViewport'), - getEnvironment: () => ({}), - } as any) as IContentModelEditor; - const state = { - defaultFormat: {}, - }; - const plugin = new ContentModelFormatPlugin(state); - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.Input, - rawEvent: ({ data: 'a' } as any) as InputEvent, - }); - plugin.dispose(); - - expect(formatResult).toBeTrue(); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'Text', - format: { fontSize: '10px' }, - text: 'a', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toEqual({ + format: mockedFormat, }); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); }); - it('with pending format and selection, has correct text before, trigger CompositionEnd event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - let formatResult: boolean | undefined; + it('with pending format and selection, trigger CompositionEnd event', () => { const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - const model = createContentModelDocument(); - const text = createText('test a test', { fontFamily: 'Arial' }); - const marker = createSelectionMarker(); - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); - - addSegment(model, text); - addSegment(model, marker); const editor = ({ - createContentModel: () => model, - formatContentModel, focus: () => {}, addUndoSnapshot: (callback: () => void) => { callback(); @@ -290,6 +126,9 @@ describe('ContentModelFormatPlugin', () => { } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); @@ -300,54 +139,26 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(formatResult).toBeTrue(); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'Text', - format: { fontFamily: 'Arial' }, - text: 'test a ', - }, - { - segmentType: 'Text', - format: { fontSize: '10px', fontFamily: 'Arial' }, - text: 'test', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); + expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledWith( + editor, + 'test', + mockedFormat + ); + expect(state.pendingFormat).toBeNull(); }); it('Non-input and cursor moving key down should not trigger pending format change', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const editor = ({ createContentModel: () => model, - setContentModel, cacheContentModel: () => {}, } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); plugin.initialize(editor); @@ -357,24 +168,17 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(0); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toEqual({ + format: mockedFormat, + }); }); it('Content changed event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'canApplyPendingFormat').and.returnValue(false); - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const editor = ({ createContentModel: () => model, - setContentModel, addUndoSnapshot: (callback: () => void) => { callback(); }, @@ -382,8 +186,14 @@ describe('ContentModelFormatPlugin', () => { } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); + + spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(false); + plugin.initialize(editor); plugin.onPluginEvent({ eventType: PluginEventType.ContentChanged, @@ -391,32 +201,28 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); - expect(pendingFormat.canApplyPendingFormat).toHaveBeenCalledTimes(1); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toBeNull(); + expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); it('Mouse up event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'canApplyPendingFormat').and.returnValue(false); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const editor = ({ createContentModel: () => model, - setContentModel, cacheContentModel: () => {}, } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); + spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(false); + plugin.initialize(editor); plugin.onPluginEvent({ eventType: PluginEventType.MouseUp, @@ -424,33 +230,29 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); - expect(pendingFormat.canApplyPendingFormat).toHaveBeenCalledTimes(1); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toBeNull(); + expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); it('Mouse up event and pending format can still be applied', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'canApplyPendingFormat').and.returnValue(true); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const editor = ({ createContentModel: () => model, - setContentModel, cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); + spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(true); + plugin.initialize(editor); plugin.onPluginEvent({ eventType: PluginEventType.MouseUp, @@ -458,9 +260,11 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).not.toHaveBeenCalled(); - expect(pendingFormat.canApplyPendingFormat).toHaveBeenCalledTimes(1); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toEqual({ + format: mockedFormat, + }); + expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); }); @@ -469,14 +273,12 @@ describe('ContentModelFormatPlugin for default format', () => { let contentDiv: HTMLDivElement; let getDOMSelection: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; - let setPendingFormatSpy: jasmine.Spy; let cacheContentModelSpy: jasmine.Spy; let addUndoSnapshotSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; beforeEach(() => { - setPendingFormatSpy = spyOn(pendingFormat, 'setPendingFormat'); - getPendingFormatSpy = spyOn(pendingFormat, 'getPendingFormat'); + getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); getDOMSelection = jasmine.createSpy('getDOMSelection'); cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); @@ -487,6 +289,7 @@ describe('ContentModelFormatPlugin for default format', () => { editor = ({ contains: (e: Node) => contentDiv != e && contentDiv.contains(e), getDOMSelection, + getPendingFormat: getPendingFormatSpy, cacheContentModel: cacheContentModelSpy, addUndoSnapshot: addUndoSnapshotSpy, formatContentModel: formatContentModelSpy, @@ -496,6 +299,7 @@ describe('ContentModelFormatPlugin for default format', () => { it('Collapsed range, text input, under editor directly', () => { const state: ContentModelFormatPluginState = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'a' } as any; @@ -509,24 +313,29 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); + let context = {} as any; + formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -536,20 +345,19 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - contentDiv, - 0 - ); + expect(context).toEqual({ + newPendingFormat: { fontFamily: 'Arial' }, + }); }); it('Expanded range, text input, under editor directly', () => { const state = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'a' } as any; + const context = {} as any; getDOMSelection.and.returnValue({ type: 'range', @@ -561,24 +369,27 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -588,16 +399,18 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).not.toHaveBeenCalled(); + expect(context).toEqual({}); expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); }); it('Collapsed range, IME input, under editor directly', () => { const state = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'Process' } as any; + const context = {} as any; getDOMSelection.and.returnValue({ type: 'range', @@ -609,23 +422,26 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -635,20 +451,19 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - contentDiv, - 0 - ); + expect(context).toEqual({ + newPendingFormat: { fontFamily: 'Arial' }, + }); }); it('Collapsed range, other input, under editor directly', () => { - const state = { + const state: ContentModelFormatPluginState = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'Up' } as any; + const context = {} as any; getDOMSelection.and.returnValue({ type: 'range', @@ -660,23 +475,26 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -686,16 +504,18 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).not.toHaveBeenCalled(); + expect(context).toEqual({}); }); it('Collapsed range, normal input, not under editor directly, no style', () => { const state = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'a' } as any; const div = document.createElement('div'); + const context = {} as any; contentDiv.appendChild(div); @@ -709,22 +529,25 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -734,15 +557,21 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith(editor, { fontFamily: 'Arial' }, div, 0); + expect(context).toEqual({ + newPendingFormat: { fontFamily: 'Arial' }, + }); }); it('Collapsed range, text input, under editor directly, has pending format', () => { const state = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'a' } as any; + const context = {} as any; + + getPendingFormatSpy.and.returnValue(null); getDOMSelection.and.returnValue({ type: 'range', @@ -754,23 +583,26 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); getPendingFormatSpy.and.returnValue({ @@ -784,11 +616,8 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial', fontSize: '10pt' }, - contentDiv, - 0 - ); + expect(context).toEqual({ + newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt' }, + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index 2e2ae8ca432..02cc93c65f3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -140,6 +140,7 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + pendingFormat: null, }, contentDiv: { style: {}, @@ -220,6 +221,7 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + pendingFormat: null, }, contentDiv: { style: {}, @@ -313,6 +315,7 @@ describe('createContentModelEditorCore', () => { textColor: 'red', backgroundColor: 'blue', }, + pendingFormat: null, }, contentDiv: { style: {}, @@ -384,6 +387,7 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + pendingFormat: null, }, defaultDomToModelConfig: mockedDomToModelConfig, defaultModelToDomConfig: mockedModelToDomConfig, @@ -462,6 +466,7 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + pendingFormat: null, }, contentDiv: { style: {}, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts new file mode 100644 index 00000000000..c3823bee4e3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts @@ -0,0 +1,407 @@ +import * as deleteSelection from '../../../lib/modelApi/edit/deleteSelection'; +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; +import { applyDefaultFormat } from '../../../lib/modelApi/format/applyDefaultFormat'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; +import { InsertPoint } from '../../../lib/publicTypes/selection/InsertPoint'; +import { + createContentModelDocument, + createDivider, + createImage, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; +import { + ContentModelFormatter, + FormatWithContentModelContext, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +import type { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; + +describe('applyDefaultFormat', () => { + let editor: IContentModelEditor; + let getDOMSelectionSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let deleteSelectionSpy: jasmine.Spy; + let normalizeContentModelSpy: jasmine.Spy; + let addUndoSnapshotSpy: jasmine.Spy; + let getPendingFormatSpy: jasmine.Spy; + + let context: FormatWithContentModelContext | undefined; + let model: ContentModelDocument; + + let formatResult: boolean | undefined; + + const defaultFormat: ContentModelSegmentFormat = { + fontFamily: 'Arial', + fontSize: '10pt', + }; + + beforeEach(() => { + context = undefined; + formatResult = undefined; + model = createContentModelDocument(); + + getDOMSelectionSpy = jasmine.createSpy('getDOMSelectionSpy'); + deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); + normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); + addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); + + formatContentModelSpy = jasmine + .createSpy('formatContentModelSpy') + .and.callFake( + (formatter: ContentModelFormatter, options: FormatWithContentModelOptions) => { + context = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + + formatResult = formatter(model, context); + } + ); + + editor = { + contains: () => true, + getDOMSelection: getDOMSelectionSpy, + formatContentModel: formatContentModelSpy, + addUndoSnapshot: addUndoSnapshotSpy, + getPendingFormat: getPendingFormatSpy, + } as any; + }); + + it('No selection', () => { + getDOMSelectionSpy.and.returnValue(null); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Selection already has style', () => { + const node = document.createElement('div'); + node.style.fontFamily = 'Tahoma'; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Good selection, delete range ', () => { + const node = document.createElement('div'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.Range, + insertPoint: null!, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(model); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, NothingToDelete ', () => { + const node = document.createElement('div'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NothingToDelete, + insertPoint: null!, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalledWith(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, SingleChar ', () => { + const node = document.createElement('div'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.SingleChar, + insertPoint: null!, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalledWith(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, NotDeleted, has text segment ', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker(); + const text = createText('test'); + const para = createParagraph(); + + para.segments.push(text, marker); + model.blocks.push(para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, NotDeleted, no text segment ', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker(); + const img = createImage('test'); + const para = createParagraph(); + + para.segments.push(img, marker); + model.blocks.push(para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt' }, + }); + }); + + it('Good selection, NotDeleted, implicit and marker is the first segment, previous block is paragraph', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker(); + const paraPrev = createParagraph(); + const para = createParagraph(true /*isImplicit*/); + + para.segments.push(marker); + model.blocks.push(paraPrev, para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, NotDeleted, implicit and marker is the first segment, previous block is not paragraph', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker(); + const divider = createDivider('hr'); + const para = createParagraph(true /*isImplicit*/); + + para.segments.push(marker); + model.blocks.push(divider, para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt' }, + }); + }); + + it('Good selection, NotDeleted, no text segment, has pending format and marker format', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker({ + textColor: 'green', + backgroundColor: 'yellow', + }); + const img = createImage('test'); + const para = createParagraph(); + + para.segments.push(img, marker); + model.blocks.push(para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + getPendingFormatSpy.and.returnValue({ + fontSize: '20pt', + textColor: 'red', + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + newPendingFormat: { + fontFamily: 'Arial', + fontSize: '20pt', + textColor: 'green', + backgroundColor: 'yellow', + }, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts rename to packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts index 07772f4848b..5faa0d91c22 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts @@ -1,7 +1,6 @@ import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; -import applyPendingFormat from '../../../lib/publicApi/format/applyPendingFormat'; +import { applyPendingFormat } from '../../../lib/modelApi/format/applyPendingFormat'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, @@ -42,10 +41,6 @@ describe('applyPendingFormat', () => { blocks: [paragraph], }; - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - const formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( @@ -68,7 +63,9 @@ describe('applyPendingFormat', () => { return false; }); - applyPendingFormat(editor, 'c'); + applyPendingFormat(editor, 'c', { + fontSize: '10px', + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ @@ -122,10 +119,6 @@ describe('applyPendingFormat', () => { blocks: [paragraph], }; - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - const formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( @@ -144,7 +137,9 @@ describe('applyPendingFormat', () => { return false; }); - applyPendingFormat(editor, 'd'); + applyPendingFormat(editor, 'd', { + fontSize: '10px', + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ @@ -191,8 +186,6 @@ describe('applyPendingFormat', () => { blocks: [paragraph], }; - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const formatContentModelSpy = jasmine.createSpy('formatContentModel'); const editor = ({ formatContentModel: formatContentModelSpy, @@ -203,9 +196,8 @@ describe('applyPendingFormat', () => { return false; }); - applyPendingFormat(editor, 'd'); + applyPendingFormat(editor, 'd', {}); - expect(formatContentModelSpy).not.toHaveBeenCalled(); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -246,10 +238,6 @@ describe('applyPendingFormat', () => { blocks: [paragraph], }; - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - const formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( @@ -268,7 +256,9 @@ describe('applyPendingFormat', () => { return false; }); - applyPendingFormat(editor, 'd'); + applyPendingFormat(editor, 'd', { + fontSize: '10px', + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ @@ -299,9 +289,6 @@ describe('applyPendingFormat', () => { paragraph.segments.push(text, marker); model.blocks.push(paragraph); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); const formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( @@ -321,7 +308,9 @@ describe('applyPendingFormat', () => { }); spyOn(normalizeContentModel, 'normalizeContentModel').and.callThrough(); - applyPendingFormat(editor, 't'); + applyPendingFormat(editor, 't', { + fontSize: '10px', + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts deleted file mode 100644 index 377b3c4a660..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts +++ /dev/null @@ -1,252 +0,0 @@ -import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { - canApplyPendingFormat, - clearPendingFormat, - formatAndKeepPendingFormat, - getPendingFormat, - setPendingFormat, -} from '../../../lib/modelApi/format/pendingFormat'; - -describe('pendingFormat.getPendingFormat', () => { - it('no format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const format = getPendingFormat(editor); - - expect(format).toBeNull(); - }); - - it('has format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - }, - }; - - const format = getPendingFormat(editor); - - expect(format).toBe(mockedFormat); - }); -}); - -describe('pendingFormat.setPendingFormat', () => { - it('set format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - const mockedContainer = 'C' as any; - const mockedOffset = 'O' as any; - - setPendingFormat(editor, mockedFormat, mockedContainer, mockedOffset); - - expect((editor as any).core.lifecycle.customData.__ContentModelPendingFormat.value).toEqual( - { - format: mockedFormat, - posContainer: mockedContainer, - posOffset: mockedOffset, - } - ); - }); -}); - -describe('pendingFormat.clearPendingFormat', () => { - it('clear format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - const mockedContainer = 'C' as any; - const mockedOffset = 'O' as any; - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - posContainer: mockedContainer, - posOffset: mockedOffset, - }, - }; - - clearPendingFormat(editor); - - expect((editor as any).core.lifecycle.customData.__ContentModelPendingFormat.value).toEqual( - { - format: null, - posContainer: null, - posOffset: null, - } - ); - }); -}); - -describe('pendingFormat.canApplyPendingFormat', () => { - it('can apply format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - - const mockedContainer = 'C' as any; - const mockedOffset = 'O' as any; - - editor.getFocusedPosition = () => ({ node: mockedContainer, offset: mockedOffset } as any); - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - posContainer: mockedContainer, - posOffset: mockedOffset, - }, - }; - - const result = canApplyPendingFormat(editor); - - expect(result).toBeTrue(); - }); - - it('no pending format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - const equalTo = jasmine.createSpy('equalto').and.returnValue(true); - const mockedPosition2 = { - equalTo, - }; - - editor.getFocusedPosition = () => mockedPosition2 as any; - - const result = canApplyPendingFormat(editor); - - expect(result).toBeFalse(); - expect(equalTo).not.toHaveBeenCalled(); - }); - - it('no current position', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - const mockedPosition = 'POSITION' as any; - - const equalTo = jasmine.createSpy('equalto').and.returnValue(true); - - editor.getFocusedPosition = () => null as any; - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - position: mockedPosition, - }, - }; - - const result = canApplyPendingFormat(editor); - - expect(result).toBeFalse(); - expect(equalTo).not.toHaveBeenCalledWith(); - }); - - it('position is not the same', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - const mockedContainer1 = 'C1'; - const mockedContainer2 = 'C2'; - - const mockedPosition2 = { - node: mockedContainer2, - offset: 1, - }; - - editor.getFocusedPosition = () => mockedPosition2 as any; - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - posContainer: mockedContainer1, - posOffset: 0, - }, - }; - - const result = canApplyPendingFormat(editor); - - expect(result).toBeFalse(); - }); - - it('Preserve pending format, no pending format', () => { - const formatContentModel = jasmine.createSpy('formatContentModel').and.callFake(() => { - clearPendingFormat(editor); - }); - const getFocusedPosition = jasmine.createSpy('getFocusedPosition'); - const customData: any = {}; - - const editor = ({ - getCustomData: (key: string, getter?: () => any) => { - return (customData[key] = customData[key] || { - value: getter ? getter() : undefined, - }).value; - }, - formatContentModel, - getFocusedPosition, - } as any) as IContentModelEditor; - const formatter = jasmine.createSpy('formatter'); - const options = 'OPTIONS' as any; - - formatAndKeepPendingFormat(editor, formatter, options); - - expect(customData).toEqual({ - __ContentModelPendingFormat: Object({ - value: { format: null, posContainer: null, posOffset: null }, - }), - }); - expect(formatContentModel).toHaveBeenCalledWith(formatter, options); - }); - - it('Preserve pending format, have pending format', () => { - const mockedFormat = 'Format' as any; - const mockedContainer = 'Container' as any; - const mockedOffset = 'Offset' as any; - - const customData: any = { - __ContentModelPendingFormat: { - value: { - format: mockedFormat, - posContainer: mockedContainer, - posOffset: mockedOffset, - }, - }, - }; - - const formatContentModel = jasmine.createSpy('formatContentModel').and.callFake(() => { - clearPendingFormat(editor); - - expect(customData).toEqual({ - __ContentModelPendingFormat: { - value: { format: null, posContainer: null, posOffset: null }, - }, - }); - }); - const getFocusedPosition = jasmine.createSpy('getFocusedPosition'); - - const editor = ({ - getCustomData: (key: string, getter?: () => any) => { - return (customData[key] = customData[key] || { - value: getter ? getter() : undefined, - }).value; - }, - formatContentModel, - getFocusedPosition, - } as any) as IContentModelEditor; - const formatter = jasmine.createSpy('formatter'); - const options = 'OPTIONS' as any; - - formatAndKeepPendingFormat(editor, formatter, options); - - expect(customData).toEqual({ - __ContentModelPendingFormat: { - value: { format: null, posContainer: null, posOffset: null }, - }, - }); - expect(formatContentModel).toHaveBeenCalledWith(formatter, options); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts index 19f3b51e5c3..83aba0f0690 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts @@ -1,30 +1,35 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as setModelIndentation from '../../../lib/modelApi/block/setModelIndentation'; import setIndentation from '../../../lib/publicApi/block/setIndentation'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelContext, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('setIndentation', () => { const fakeModel: any = { a: 'b' }; let editor: IContentModelEditor; let formatContentModelSpy: jasmine.Spy; + let context: FormatWithContentModelContext; beforeEach(() => { + context = undefined!; formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake((callback: Function) => { - callback(fakeModel); + .and.callFake((callback: ContentModelFormatter) => { + context = { + newEntities: [], + newImages: [], + deletedEntities: [], + }; + callback(fakeModel, context); }); editor = ({ formatContentModel: formatContentModelSpy, focus: jasmine.createSpy('focus'), + getPendingFormat: () => null as any, } as any) as IContentModelEditor; - - spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( - (editor, formatter, options) => { - editor.formatContentModel(formatter, options); - } - ); }); it('indent', () => { @@ -39,6 +44,12 @@ describe('setIndentation', () => { 'indent', undefined ); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: 'preserve', + }); }); it('outdent', () => { @@ -53,5 +64,11 @@ describe('setIndentation', () => { 'outdent', undefined ); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts index 303987f0690..9d39f25aca2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts @@ -1,26 +1,31 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as toggleModelBlockQuote from '../../../lib/modelApi/block/toggleModelBlockQuote'; import toggleBlockQuote from '../../../lib/publicApi/block/toggleBlockQuote'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelContext, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('toggleBlockQuote', () => { const fakeModel: any = { a: 'b' }; let editor: IContentModelEditor; let formatContentModelSpy: jasmine.Spy; + let context: FormatWithContentModelContext; beforeEach(() => { + context = undefined!; + formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake((callback: Function) => { - callback(fakeModel); + .and.callFake((callback: ContentModelFormatter) => { + context = { + newEntities: [], + newImages: [], + deletedEntities: [], + }; + callback(fakeModel, context); }); - spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( - (editor, formatter, options) => { - editor.formatContentModel(formatter, options); - } - ); - editor = ({ focus: jasmine.createSpy('focus'), formatContentModel: formatContentModelSpy, @@ -43,6 +48,12 @@ describe('toggleBlockQuote', () => { a: 'b', c: 'd', } as any); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: 'preserve', + }); }); it('toggleBlockQuote with real format', () => { @@ -61,5 +72,11 @@ describe('toggleBlockQuote', () => { lineHeight: '2', textColor: 'red', } as any); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts index 2b0d984bc5d..38b257941c2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts @@ -1,4 +1,3 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { @@ -13,9 +12,6 @@ export function editingTestCommon( result: ContentModelDocument, calledTimes: number ) { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); let formatResult: boolean | undefined; @@ -23,6 +19,7 @@ export function editingTestCommon( const formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toBe(apiName); formatResult = callback(model, { newEntities: [], deletedEntities: [], diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts index 9d3f9071ea6..322aa7d6570 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts @@ -49,7 +49,7 @@ describe('keyboardDelete', () => { let editor: any; editingTestCommon( - 'handleBackspaceKey', + key == 'Delete' ? 'handleDeleteKey' : 'handleBackspaceKey', newEditor => { editor = newEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts index e1d4d6d67a4..f943577ef10 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts @@ -1,4 +1,3 @@ -import * as getPendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as getSelectionRootNode from '../../../lib/modelApi/selection/getSelectionRootNode'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; import { ContentModelFormatState } from '../../../lib/publicTypes/format/formatState/ContentModelFormatState'; @@ -38,6 +37,7 @@ describe('getFormatState', () => { }), isDarkMode: () => false, getZoomScale: () => 1, + getPendingFormat: () => pendingFormat, createContentModel: (options: DomToModelOption) => { const model = createContentModelDocument(); const editorDiv = document.createElement('div'); @@ -66,8 +66,6 @@ describe('getFormatState', () => { }, } as any) as IContentModelEditor; - spyOn(getPendingFormat, 'getPendingFormat').and.returnValue(pendingFormat); - const result = getFormatState(editor); expect(retrieveModelFormatState.retrieveModelFormatState).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts index 5ba65b4af8d..ebac050117a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts @@ -1,4 +1,3 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as readFile from '../../../lib/domUtils/readFile'; import changeImage from '../../../lib/publicApi/image/changeImage'; import { ContentModelDocument } from 'roosterjs-content-model-types'; @@ -27,9 +26,6 @@ describe('changeImage', () => { result: ContentModelDocument, calledTimes: number ) { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const getDOMSelection = jasmine .createSpy() .and.returnValues({ type: 'image', image: imageNode }); @@ -52,6 +48,7 @@ describe('changeImage', () => { const editor = ({ focus: jasmine.createSpy(), isDisposed: () => false, + getPendingFormat: () => null as any, getDOMSelection, triggerPluginEvent, formatContentModel, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts index 34d2397f563..146642963b6 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts @@ -22,7 +22,7 @@ describe('insertLink', () => { beforeEach(() => { editor = ({ focus: () => {}, - getCustomData: () => ({}), + getPendingFormat: () => null as any, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts index 9f8b333842e..f71a75b3d0b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts @@ -4,6 +4,7 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; @@ -12,19 +13,23 @@ describe('toggleBullet', () => { let formatContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; + let context: FormatWithContentModelContext; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; + context = undefined!; formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - callback(mockedModel, { + context = { newEntities: [], deletedEntities: [], newImages: [], - }); + rawEvent: options.rawEvent, + }; + callback(mockedModel, context); } ); focus = jasmine.createSpy('focus'); @@ -44,5 +49,12 @@ describe('toggleBullet', () => { expect(setListType.setListType).toHaveBeenCalledTimes(1); expect(setListType.setListType).toHaveBeenCalledWith(mockedModel, 'UL'); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: undefined, + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts index 28c0142eeb1..134276ddbef 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts @@ -1,10 +1,10 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleNumbering from '../../../lib/publicApi/list/toggleNumbering'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; @@ -12,28 +12,25 @@ describe('toggleNumbering', () => { let editor = ({} as any) as IContentModelEditor; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; + let context: FormatWithContentModelContext; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; + context = undefined!; focus = jasmine.createSpy('focus'); - spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( - (editor, formatter, options) => { - editor.formatContentModel(formatter, options); - } - ); - const formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - callback(mockedModel, { + context = { newEntities: [], deletedEntities: [], newImages: [], rawEvent: options.rawEvent, - }); + }; + callback(mockedModel, context); } ); @@ -50,5 +47,12 @@ describe('toggleNumbering', () => { expect(setListType.setListType).toHaveBeenCalledTimes(1); expect(setListType.setListType).toHaveBeenCalledWith(mockedModel, 'OL'); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: undefined, + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts index 257c1676a9f..d8c68162f43 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts @@ -1,6 +1,5 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import changeFontSize from '../../../lib/publicApi/segment/changeFontSize'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; @@ -332,8 +331,6 @@ describe('changeFontSize', () => { }); it('Test format parser', () => { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); const div = document.createElement('div'); const sub = document.createElement('sub'); @@ -363,6 +360,7 @@ describe('changeFontSize', () => { const editor = ({ formatContentModel, focus: jasmine.createSpy(), + getPendingFormat: () => null as ContentModelSegmentFormat, } as any) as IContentModelEditor; changeFontSize(editor, 'increase'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts index 6c45908ef73..c2983dca8f9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts @@ -1,4 +1,3 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { NodePosition } from 'roosterjs-editor-types'; @@ -14,9 +13,6 @@ export function segmentTestCommon( result: ContentModelDocument, calledTimes: number ) { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') @@ -31,6 +27,7 @@ export function segmentTestCommon( const editor = ({ focus: jasmine.createSpy(), getFocusedPosition: () => null as NodePosition, + getPendingFormat: () => null as any, formatContentModel, } as any) as IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts index dc168cfabe3..f2c92f3e5ca 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -1,4 +1,3 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import formatImageWithContentModel from '../../../lib/publicApi/utils/formatImageWithContentModel'; import { ContentModelDocument, ContentModelImage } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; @@ -201,9 +200,6 @@ function segmentTestForPluginEvent( result: ContentModelDocument, calledTimes: number ) { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') @@ -217,6 +213,7 @@ function segmentTestForPluginEvent( }); const editor = ({ formatContentModel, + getPendingFormat: () => null as any, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts index 8d730e6bd74..dbc2e3e8a35 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -1,9 +1,9 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument, ContentModelParagraph } from 'roosterjs-content-model-types'; import { formatParagraphWithContentModel } from '../../../lib/publicApi/utils/formatParagraphWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { @@ -15,6 +15,7 @@ import { describe('formatParagraphWithContentModel', () => { let editor: IContentModelEditor; let model: ContentModelDocument; + let context: FormatWithContentModelContext; const mockedContainer = 'C' as any; const mockedOffset = 'O' as any; @@ -22,16 +23,20 @@ describe('formatParagraphWithContentModel', () => { const apiName = 'mockedApi'; beforeEach(() => { + context = undefined!; + const formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - callback(model, { + context = { newEntities: [], - deletedEntities: [], newImages: [], + deletedEntities: [], rawEvent: options.rawEvent, - }); + }; + + callback(model, context); } ); @@ -101,14 +106,18 @@ describe('formatParagraphWithContentModel', () => { para.segments.push(text); model.blocks.push(para); - spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callThrough(); - const callback = (paragraph: ContentModelParagraph) => { paragraph.format.backgroundColor = 'red'; }; formatParagraphWithContentModel(editor, apiName, callback); - expect(pendingFormat.formatAndKeepPendingFormat).toHaveBeenCalled(); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: undefined, + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts index 5689a1c823c..18859242ca3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -1,9 +1,9 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { formatSegmentWithContentModel } from '../../../lib/publicApi/utils/formatSegmentWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { @@ -18,34 +18,36 @@ describe('formatSegmentWithContentModel', () => { let focus: jasmine.Spy; let model: ContentModelDocument; let getPendingFormat: jasmine.Spy; - let setPendingFormat: jasmine.Spy; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; + let context: FormatWithContentModelContext | undefined; const apiName = 'mockedApi'; beforeEach(() => { + context = undefined; formatResult = undefined; focus = jasmine.createSpy('focus'); - setPendingFormat = spyOn(pendingFormat, 'setPendingFormat'); - getPendingFormat = spyOn(pendingFormat, 'getPendingFormat'); - formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { + context = { newEntities: [], deletedEntities: [], newImages: [], - }); + }; + formatResult = callback(model, context); } ); + getPendingFormat = jasmine.createSpy('getPendingFormat'); + editor = ({ focus, formatContentModel, + getPendingFormat, } as any) as IContentModelEditor; }); @@ -61,7 +63,6 @@ describe('formatSegmentWithContentModel', () => { expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeFalse(); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); }); it('doc with selection', () => { @@ -97,7 +98,11 @@ describe('formatSegmentWithContentModel', () => { expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); it('doc with selection, all segments are already in expected state', () => { @@ -148,7 +153,11 @@ describe('formatSegmentWithContentModel', () => { expect(toggleStyleCallback).toHaveBeenCalledTimes(1); expect(toggleStyleCallback).toHaveBeenCalledWith(text.format, false, text, para); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); it('doc with selection, some segments are in expected state', () => { @@ -221,7 +230,11 @@ describe('formatSegmentWithContentModel', () => { expect(toggleStyleCallback).toHaveBeenCalledWith(text1.format, true, text1, para); expect(toggleStyleCallback).toHaveBeenCalledWith(text3.format, true, text3, para); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); it('Collapsed selection', () => { @@ -263,16 +276,15 @@ describe('formatSegmentWithContentModel', () => { expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeFalse(); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledWith( - editor, - { + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { fontSize: '10px', fontFamily: 'test', }, - mockedContainer, - mockedOffset - ); + }); }); it('With pending format', () => { @@ -313,6 +325,10 @@ describe('formatSegmentWithContentModel', () => { fontFamily: 'test', }); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 1b7d0205993..1b5126950ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -4,7 +4,6 @@ import * as ExcelF from '../../../lib/editor/plugins/PastePlugin/Excel/processPa import * as getPasteSourceF from '../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; import * as getSelectedSegmentsF from '../../../lib/publicApi/selection/getSelectedSegments'; import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; -import * as pendingFormatF from '../../../lib/modelApi/format/pendingFormat'; import * as PPT from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; import * as setProcessorF from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; @@ -17,6 +16,7 @@ import { expectEqual, initEditor } from '../../editor/plugins/paste/e2e/testUtil import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import paste, * as pasteF from '../../../lib/publicApi/utils/paste'; @@ -46,8 +46,8 @@ describe('Paste ', () => { let triggerPluginEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; let mergeModelSpy: jasmine.Spy; - let setPendingFormatSpy: jasmine.Spy; let formatResult: boolean | undefined; + let context: FormatWithContentModelContext | undefined; const mockedPos = 'POS' as any; @@ -73,7 +73,6 @@ describe('Paste ', () => { getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); getContent = jasmine.createSpy('getContent'); getDocument = jasmine.createSpy('getDocument').and.returnValue(document); - setPendingFormatSpy = spyOn(pendingFormatF, 'setPendingFormat'); triggerPluginEvent = jasmine.createSpy('triggerPluginEvent').and.returnValue({ clipboardData, fragment: document.createDocumentFragment(), @@ -117,15 +116,17 @@ describe('Paste ', () => { .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(mockedModel, { + context = { newEntities: [], deletedEntities: [], newImages: [], - }); + }; + formatResult = callback(mockedModel, context); } ); formatResult = undefined; + context = undefined; editor = ({ focus, @@ -196,9 +197,11 @@ describe('Paste ', () => { }, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: { backgroundColor: '', fontFamily: 'Arial', fontSize: '', @@ -211,9 +214,7 @@ describe('Paste ', () => { textColor: '', underline: false, }, - mockedNode, - mockedOffset - ); + }); }); });