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 48d29bd5e15..d8e57a63c4e 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 @@ -57,15 +57,14 @@ export default class ContentModelEditor } /** - * Cache a content model object. Next time when format with content model, we can reuse it. - * @param model + * Notify editor the current cache may be invalid */ - cacheContentModel(model: ContentModelDocument | null) { + invalidateCache() { const core = this.getCore(); if (!core.lifecycle.shadowEditFragment) { - core.cachedModel = model || undefined; - core.cachedRangeEx = undefined; + core.cache.cachedModel = undefined; + core.cache.cachedRangeEx = undefined; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index 76ef01b64f0..4cf8925e2b3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -19,14 +19,24 @@ import { * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export const createContentModel: CreateContentModel = (core, option, selectionOverride) => { - let cachedModel = selectionOverride ? null : core.cachedModel; + let cachedModel = selectionOverride ? null : core.cache.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { // When in shadow edit, use a cloned model so we won't pollute the cached one cachedModel = cloneModel(cachedModel, { includeCachedElement: true }); } - return cachedModel || internalCreateContentModel(core, option, selectionOverride); + if (cachedModel) { + return cachedModel; + } else { + const model = internalCreateContentModel(core, option, selectionOverride); + + if (!option && !selectionOverride) { + core.cache.cachedModel = model; + } + + return model; + } }; function internalCreateContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getSelectionRangeEx.ts index 9f6c871b169..45df9c490c0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getSelectionRangeEx.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getSelectionRangeEx.ts @@ -7,5 +7,5 @@ import { GetSelectionRangeEx } from 'roosterjs-editor-types'; export const getSelectionRangeEx: GetSelectionRangeEx = core => { const contentModelCore = core as ContentModelEditorCore; - return contentModelCore.cachedRangeEx ?? core.originalApi.getSelectionRangeEx(core); + return contentModelCore.cache.cachedRangeEx ?? core.originalApi.getSelectionRangeEx(core); }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts index d9e5a1dbfff..4aad709db70 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts @@ -28,8 +28,13 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea if (!core.lifecycle.shadowEditFragment) { core.api.select(core, range); - core.cachedRangeEx = range || undefined; + + if (range) { + core.cache.cachedRangeEx = range; + } } + // TODO: Reconcile selection text node cache + return range; }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts index 860232f5dcd..feb4d828d94 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts @@ -14,7 +14,7 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { - const model = !core.cachedModel ? core.api.createContentModel(core) : null; + const model = !core.cache.cachedModel ? core.api.createContentModel(core) : null; const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); // Fake object, not used in Content Model Editor, just to satisfy original editor code @@ -34,8 +34,8 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { // This need to be done after EnteredShadowEdit event is triggered since EnteredShadowEdit event will cause a SelectionChanged event // if current selection is table selection or image selection - if (!core.cachedModel && model) { - core.cachedModel = model; + if (!core.cache.cachedModel && model) { + core.cache.cachedModel = model; } core.lifecycle.shadowEditSelectionPath = selectionPath; @@ -52,8 +52,8 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { false /*broadcast*/ ); - if (core.cachedModel) { - core.api.setContentModel(core, core.cachedModel); + if (core.cache.cachedModel) { + core.api.setContentModel(core, core.cache.cachedModel); } } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts new file mode 100644 index 00000000000..8102176eef5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts @@ -0,0 +1,121 @@ +import { ContentModelCachePluginState } from '../../publicTypes/pluginState/ContentModelCachePluginState'; +import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { + IEditor, + Keys, + PluginEvent, + PluginEventType, + PluginWithState, + SelectionRangeEx, +} from 'roosterjs-editor-types'; + +/** + * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary + */ +export default class ContentModelCachePlugin + implements PluginWithState { + private editor: IContentModelEditor | null = null; + + /** + * Construct a new instance of ContentModelEditPlugin class + * @param state State of this plugin + */ + constructor(private state: ContentModelCachePluginState) { + // TODO: Remove tempState parameter once we have standalone Content Model editor + } + + /** + * Get name of this plugin + */ + getName() { + return 'ContentModelCache'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + // TODO: Later we may need a different interface for Content Model editor plugin + this.editor = editor as IContentModelEditor; + this.editor.getDocument().addEventListener('selectionchange', this.onNativeSelectionChange); + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + if (this.editor) { + this.editor + .getDocument() + .removeEventListener('selectionchange', this.onNativeSelectionChange); + this.editor = null; + } + } + + /** + * Get plugin state object + */ + getState(): ContentModelCachePluginState { + return this.state; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + + switch (event.eventType) { + case PluginEventType.KeyDown: + switch (event.rawEvent.which) { + case Keys.ENTER: + // ENTER key will create new paragraph, so need to update cache to reflect this change + // TODO: Handle ENTER key to better reuse content model + this.editor.invalidateCache(); + + break; + } + break; + + case PluginEventType.Input: + case PluginEventType.SelectionChanged: + this.reconcileSelection(this.editor); + break; + + case PluginEventType.ContentChanged: + this.editor.invalidateCache(); + break; + } + } + + private onNativeSelectionChange = () => { + if (this.editor?.hasFocus()) { + this.reconcileSelection(this.editor); + } + }; + + private reconcileSelection(editor: IContentModelEditor, newRangeEx?: SelectionRangeEx) { + // TODO: Really do reconcile selection + editor.invalidateCache(); + } +} + +/** + * @internal + * Create a new instance of ContentModelCachePlugin class. + * This is mostly for unit test + * @param state State of this plugin + */ +export function createContentModelCachePlugin(state: ContentModelCachePluginState) { + return new ContentModelCachePlugin(state); +} 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 772ce7cb9df..1cee2be7ee2 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 @@ -1,12 +1,14 @@ import ContentModelCopyPastePlugin from './corePlugins/ContentModelCopyPastePlugin'; -import ContentModelEditPlugin from './plugins/ContentModelEditPlugin'; -import ContentModelFormatPlugin from './plugins/ContentModelFormatPlugin'; import ContentModelTypeInContainerPlugin from './corePlugins/ContentModelTypeInContainerPlugin'; import { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; +import { ContentModelPluginState } from '../publicTypes/pluginState/ContentModelPluginState'; import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { CoreCreator, EditorCore, ExperimentalFeatures } from 'roosterjs-editor-types'; import { createContentModel } from './coreApi/createContentModel'; +import { createContentModelCachePlugin } from './corePlugins/ContentModelCachePlugin'; +import { createContentModelEditPlugin } from './plugins/ContentModelEditPlugin'; +import { createContentModelFormatPlugin } from './plugins/ContentModelFormatPlugin'; import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; import { createEditorContext } from './coreApi/createEditorContext'; import { createEditorCore, isFeatureEnabled } from 'roosterjs-editor-core'; @@ -22,12 +24,19 @@ export const createContentModelEditorCore: CoreCreator< ContentModelEditorCore, ContentModelEditorOptions > = (contentDiv, options) => { + const pluginState: ContentModelPluginState = { + cache: {}, + copyPaste: { + allowedCustomPasteType: options.allowedCustomPasteType || [], + }, + }; const modifiedOptions: ContentModelEditorOptions = { ...options, plugins: [ + createContentModelCachePlugin(pluginState.cache), ...(options.plugins || []), - new ContentModelFormatPlugin(), - new ContentModelEditPlugin(), + createContentModelFormatPlugin(), + createContentModelEditPlugin(), ], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), @@ -35,9 +44,7 @@ export const createContentModelEditorCore: CoreCreator< options.experimentalFeatures, ExperimentalFeatures.ContentModelPaste ) - ? new ContentModelCopyPastePlugin({ - allowedCustomPasteType: options.allowedCustomPasteType || [], - }) + ? new ContentModelCopyPastePlugin(pluginState.copyPaste) : undefined, ...(options.corePluginOverride || {}), }, @@ -45,7 +52,7 @@ export const createContentModelEditorCore: CoreCreator< const core = createEditorCore(contentDiv, modifiedOptions) as ContentModelEditorCore; - promoteToContentModelEditorCore(core, modifiedOptions); + promoteToContentModelEditorCore(core, modifiedOptions, pluginState); return core; }; @@ -57,15 +64,24 @@ export const createContentModelEditorCore: CoreCreator< */ export function promoteToContentModelEditorCore( core: EditorCore, - options: ContentModelEditorOptions + options: ContentModelEditorOptions, + pluginState: ContentModelPluginState ) { const cmCore = core as ContentModelEditorCore; + promoteCorePluginState(cmCore, pluginState); promoteDefaultFormat(cmCore); promoteContentModelInfo(cmCore, options); promoteCoreApi(cmCore); } +function promoteCorePluginState( + cmCore: ContentModelEditorCore, + pluginState: ContentModelPluginState +) { + Object.assign(cmCore, pluginState); +} + function promoteDefaultFormat(cmCore: ContentModelEditorCore) { cmCore.lifecycle.defaultFormat = cmCore.lifecycle.defaultFormat || {}; cmCore.defaultFormat = getDefaultSegmentFormat(cmCore); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts index 5f7e3e54ede..0258cc14ae9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts @@ -1,32 +1,13 @@ -import handleKeyDownEvent from '../../publicApi/editing/handleKeyDownEvent'; -import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; -import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; +import keyboardDelete from '../../publicApi/editing/keyboardDelete'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; import { EditorPlugin, IEditor, Keys, - NodePosition, - NodeType, PluginEvent, PluginEventType, PluginKeyDownEvent, - SelectionRangeTypes, } from 'roosterjs-editor-types'; -import { - getObjectKeys, - isBlockElement, - isCharacterValue, - isModifierKey, - Position, -} from 'roosterjs-editor-dom'; - -// During IME input, KeyDown event will have "Process" as key -const ProcessKey = 'Process'; /** * ContentModel plugins helps editor to do editing operation on top of content model. @@ -36,7 +17,6 @@ const ProcessKey = 'Process'; */ export default class ContentModelEditPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; - private hasDefaultFormat = false; /** * Get name of this plugin @@ -54,11 +34,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { initialize(editor: IEditor) { // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor as IContentModelEditor; - - const defaultFormat = this.editor.getContentModelDefaultFormat(); - this.hasDefaultFormat = - getObjectKeys(defaultFormat).filter(x => typeof defaultFormat[x] !== 'undefined') - .length > 0; } /** @@ -82,12 +57,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { case PluginEventType.KeyDown: this.handleKeyDownEvent(this.editor, event); break; - - case PluginEventType.ContentChanged: - case PluginEventType.MouseUp: - case PluginEventType.SelectionChanged: - this.editor.cacheContentModel(null); - break; } } } @@ -98,139 +67,26 @@ export default class ContentModelEditPlugin implements EditorPlugin { if (rawEvent.defaultPrevented || event.handledByEditFeature) { // Other plugins already handled this event, so it is most likely content is already changed, we need to clear cached content model - editor.cacheContentModel(null /*model*/); + editor.invalidateCache(); } else { // TODO: Consider use ContentEditFeature and need to hide other conflict features that are not based on Content Model switch (which) { case Keys.BACKSPACE: case Keys.DELETE: - const rangeEx = editor.getSelectionRangeEx(); - const range = - rangeEx.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; - - if (this.shouldDeleteWithContentModel(range, rawEvent)) { - handleKeyDownEvent(editor, rawEvent); - } else { - editor.cacheContentModel(null); - } - - break; - - default: - if ( - (isCharacterValue(rawEvent) || rawEvent.key == ProcessKey) && - this.hasDefaultFormat - ) { - this.tryApplyDefaultFormat(editor); - } - - editor.cacheContentModel(null); + // Use our API to handle BACKSPACE/DELETE key. + // No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache + keyboardDelete(editor, rawEvent); break; } } } +} - private tryApplyDefaultFormat(editor: IContentModelEditor) { - const rangeEx = editor.getSelectionRangeEx(); - const range = rangeEx?.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; - const startPos = range ? Position.getStart(range) : null; - let node: Node | null = startPos?.node ?? null; - - while (node && editor.contains(node)) { - if (isNodeOfType(node, NodeType.Element) && node.getAttribute?.('style')) { - return; - } else if (isBlockElement(node)) { - break; - } else { - node = node.parentNode; - } - } - - formatWithContentModel(editor, 'input', (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 && - startPos - ) { - 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') { - this.applyDefaultFormat(editor, marker.format, startPos); - } - } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { - this.applyDefaultFormat(editor, marker.format, startPos); - } - - // 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; - } - }); - } - - private applyDefaultFormat( - editor: IContentModelEditor, - currentFormat: ContentModelSegmentFormat, - startPos: NodePosition - ) { - const pendingFormat = getPendingFormat(editor) || {}; - const defaultFormat = editor.getContentModelDefaultFormat(); - const newFormat: ContentModelSegmentFormat = { - ...defaultFormat, - ...pendingFormat, - ...currentFormat, - }; - - setPendingFormat(editor, newFormat, startPos); - } - - private shouldDeleteWithContentModel(range: Range | null, rawEvent: KeyboardEvent) { - return !( - range?.collapsed && - range.startContainer.nodeType == NodeType.Text && - !isModifierKey(rawEvent) && - (this.canDeleteBefore(rawEvent, range) || this.canDeleteAfter(rawEvent, range)) - ); - } - - private canDeleteBefore(rawEvent: KeyboardEvent, range: Range) { - return ( - rawEvent.which == Keys.BACKSPACE && - (range.startOffset > 1 || range.startContainer.previousSibling) - ); - } - - private canDeleteAfter(rawEvent: KeyboardEvent, range: Range) { - return ( - rawEvent.which == Keys.DELETE && - (range.startOffset < (range.startContainer.nodeValue?.length ?? 0) - 1 || - range.startContainer.nextSibling) - ); - } +/** + * @internal + * Create a new instance of ContentModelEditPlugin class. + * This is mostly for unit test + */ +export function createContentModelEditPlugin() { + return new ContentModelEditPlugin(); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelFormatPlugin.ts index 76ef9b2870b..5b615d322ee 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelFormatPlugin.ts @@ -1,8 +1,13 @@ +import applyDefaultFormat from '../../publicApi/format/applyDefaultFormat'; import applyPendingFormat from '../../publicApi/format/applyPendingFormat'; import { canApplyPendingFormat, clearPendingFormat } from '../../modelApi/format/pendingFormat'; import { EditorPlugin, IEditor, Keys, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { getObjectKeys, isCharacterValue } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +// During IME input, KeyDown event will have "Process" as key +const ProcessKey = 'Process'; + /** * ContentModelFormat plugins helps editor to do formatting on top of content model. * This includes: @@ -10,6 +15,7 @@ import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; */ export default class ContentModelFormatPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; + private hasDefaultFormat = false; /** * Get name of this plugin @@ -27,6 +33,11 @@ export default class ContentModelFormatPlugin implements EditorPlugin { initialize(editor: IEditor) { // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor as IContentModelEditor; + + const defaultFormat = this.editor.getContentModelDefaultFormat(); + this.hasDefaultFormat = + getObjectKeys(defaultFormat).filter(x => typeof defaultFormat[x] !== 'undefined') + .length > 0; } /** @@ -65,6 +76,11 @@ export default class ContentModelFormatPlugin implements EditorPlugin { case PluginEventType.KeyDown: if (event.rawEvent.which >= Keys.PAGEUP && event.rawEvent.which <= Keys.DOWN) { clearPendingFormat(this.editor); + } else if ( + this.hasDefaultFormat && + (isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) + ) { + applyDefaultFormat(this.editor); } break; @@ -85,3 +101,12 @@ export default class ContentModelFormatPlugin implements EditorPlugin { } } } + +/** + * @internal + * Create a new instance of ContentModelFormatPlugin. + * This is mostly for unit test + */ +export function createContentModelFormatPlugin() { + return new ContentModelFormatPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts index c6e81e6238a..c13919d1aa8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts @@ -20,8 +20,7 @@ export function handleKeyboardEventResult( switch (result) { case DeleteResult.NotDeleted: - // We have not delete anything, we will let browser handle this event, so clear cached model if any since the content will be changed by browser - editor.cacheContentModel(null); + // We have not delete anything, we will let browser handle this event return false; case DeleteResult.NothingToDelete: 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 67531e9a789..b07c19724a5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -75,6 +75,7 @@ export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as paste } from './publicApi/utils/paste'; export { default as insertEntity } from './publicApi/entity/insertEntity'; export { formatWithContentModel } from './publicApi/utils/formatWithContentModel'; +export { default as keyboardDelete } from './publicApi/editing/keyboardDelete'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; export { default as isContentModelEditor } from './editor/isContentModelEditor'; @@ -83,6 +84,8 @@ export { default as ContentModelEditPlugin } from './editor/plugins/ContentModel export { default as ContentModelPastePlugin } from './editor/plugins/PastePlugin/ContentModelPastePlugin'; export { default as ContentModelTypeInContainerPlugin } from './editor/corePlugins/ContentModelTypeInContainerPlugin'; export { default as ContentModelCopyPastePlugin } from './editor/corePlugins/ContentModelCopyPastePlugin'; +export { default as ContentModelCachePlugin } from './editor/corePlugins/ContentModelCachePlugin'; + export { createContentModelEditorCore, promoteToContentModelEditorCore, @@ -91,3 +94,6 @@ export { combineBorderValue, extractBorderValues } from './domUtils/borderValues export { updateImageMetadata } from './domUtils/metadata/updateImageMetadata'; export { updateTableCellMetadata } from './domUtils/metadata/updateTableCellMetadata'; export { updateTableMetadata } from './domUtils/metadata/updateTableMetadata'; + +export { ContentModelCachePluginState } from './publicTypes/pluginState/ContentModelCachePluginState'; +export { ContentModelPluginState } from './publicTypes/pluginState/ContentModelPluginState'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts deleted file mode 100644 index 6b477dd2e42..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Browser } from 'roosterjs-editor-dom'; -import { ChangeSource, Keys } from 'roosterjs-editor-types'; -import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { - handleKeyboardEventResult, - shouldDeleteAllSegmentsBefore, - shouldDeleteWord, -} from '../../editor/utils/handleKeyboardEventCommon'; -import { - backwardDeleteWordSelection, - forwardDeleteWordSelection, -} from '../../modelApi/edit/deleteSteps/deleteWordSelection'; -import { - backwardDeleteCollapsedSelection, - forwardDeleteCollapsedSelection, -} from '../../modelApi/edit/deleteSteps/deleteCollapsedSelection'; - -/** - * @internal - * Handle KeyDown event - * Currently only DELETE and BACKSPACE keys are supported - */ -export default function handleKeyDownEvent(editor: IContentModelEditor, rawEvent: KeyboardEvent) { - const which = rawEvent.which; - - formatWithContentModel( - editor, - which == Keys.DELETE ? 'handleDeleteKey' : 'handleBackspaceKey', - (model, context) => { - const result = deleteSelection(model, getDeleteSteps(rawEvent), context).deleteResult; - - return handleKeyboardEventResult(editor, model, rawEvent, result, context); - }, - { - rawEvent, - changeSource: ChangeSource.Keyboard, - getChangeData: () => which, - } - ); -} - -function getDeleteSteps(rawEvent: KeyboardEvent): (DeleteSelectionStep | null)[] { - const isForward = rawEvent.which == Keys.DELETE; - const deleteAllSegmentBeforeStep = - shouldDeleteAllSegmentsBefore(rawEvent) && !isForward ? deleteAllSegmentBefore : null; - const deleteWordSelection = shouldDeleteWord(rawEvent, !!Browser.isMac) - ? isForward - ? forwardDeleteWordSelection - : backwardDeleteWordSelection - : null; - const deleteCollapsedSelection = isForward - ? forwardDeleteCollapsedSelection - : backwardDeleteCollapsedSelection; - return [deleteAllSegmentBeforeStep, deleteWordSelection, deleteCollapsedSelection]; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts new file mode 100644 index 00000000000..3259fcdbc0f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts @@ -0,0 +1,99 @@ +import { Browser, isModifierKey } from 'roosterjs-editor-dom'; +import { ChangeSource, Keys, NodeType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; +import { DeleteResult, DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; +import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { + handleKeyboardEventResult, + shouldDeleteAllSegmentsBefore, + shouldDeleteWord, +} from '../../editor/utils/handleKeyboardEventCommon'; +import { + backwardDeleteWordSelection, + forwardDeleteWordSelection, +} from '../../modelApi/edit/deleteSteps/deleteWordSelection'; +import { + backwardDeleteCollapsedSelection, + forwardDeleteCollapsedSelection, +} from '../../modelApi/edit/deleteSteps/deleteCollapsedSelection'; + +/** + * Do keyboard event handling for DELETE/BACKSPACE key + * @param editor The Content Model Editor + * @param rawEvent DOM keyboard event + * @returns True if the event is handled with this function, otherwise false + */ +export default function keyboardDelete( + editor: IContentModelEditor, + rawEvent: KeyboardEvent +): boolean { + const which = rawEvent.which; + const rangeEx = editor.getSelectionRangeEx(); + const range = rangeEx.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; + let isDeleted = false; + + if (shouldDeleteWithContentModel(range, rawEvent)) { + formatWithContentModel( + editor, + which == Keys.DELETE ? 'handleDeleteKey' : 'handleBackspaceKey', + (model, context) => { + const result = deleteSelection(model, getDeleteSteps(rawEvent), context) + .deleteResult; + + isDeleted = result != DeleteResult.NotDeleted; + + return handleKeyboardEventResult(editor, model, rawEvent, result, context); + }, + { + rawEvent, + changeSource: ChangeSource.Keyboard, + getChangeData: () => which, + } + ); + + return true; + } + + return isDeleted; +} + +function getDeleteSteps(rawEvent: KeyboardEvent): (DeleteSelectionStep | null)[] { + const isForward = rawEvent.which == Keys.DELETE; + const deleteAllSegmentBeforeStep = + shouldDeleteAllSegmentsBefore(rawEvent) && !isForward ? deleteAllSegmentBefore : null; + const deleteWordSelection = shouldDeleteWord(rawEvent, !!Browser.isMac) + ? isForward + ? forwardDeleteWordSelection + : backwardDeleteWordSelection + : null; + const deleteCollapsedSelection = isForward + ? forwardDeleteCollapsedSelection + : backwardDeleteCollapsedSelection; + return [deleteAllSegmentBeforeStep, deleteWordSelection, deleteCollapsedSelection]; +} + +function shouldDeleteWithContentModel(range: Range | null, rawEvent: KeyboardEvent) { + return !( + range?.collapsed && + range.startContainer.nodeType == NodeType.Text && + !isModifierKey(rawEvent) && + (canDeleteBefore(rawEvent, range) || canDeleteAfter(rawEvent, range)) + ); +} + +function canDeleteBefore(rawEvent: KeyboardEvent, range: Range) { + return ( + rawEvent.which == Keys.BACKSPACE && + (range.startOffset > 1 || range.startContainer.previousSibling) + ); +} + +function canDeleteAfter(rawEvent: KeyboardEvent, range: Range) { + return ( + rawEvent.which == Keys.DELETE && + (range.startOffset < (range.startContainer.nodeValue?.length ?? 0) - 1 || + range.startContainer.nextSibling) + ); +} 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 new file mode 100644 index 00000000000..84f88c319b7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts @@ -0,0 +1,94 @@ +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; +import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; +import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { isBlockElement, Position } from 'roosterjs-editor-dom'; +import { isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { NodePosition, NodeType, SelectionRangeTypes } from 'roosterjs-editor-types'; + +/** + * @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 + */ +export default function applyDefaultFormat(editor: IContentModelEditor) { + const rangeEx = editor.getSelectionRangeEx(); + const range = rangeEx?.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; + const startPos = range ? Position.getStart(range) : null; + let node: Node | null = startPos?.node ?? null; + + while (node && editor.contains(node)) { + if (isNodeOfType(node, NodeType.Element) && node.getAttribute?.('style')) { + return; + } else if (isBlockElement(node)) { + break; + } else { + node = node.parentNode; + } + } + + formatWithContentModel(editor, 'input', (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 && + startPos + ) { + 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, marker.format, startPos); + } + } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { + internalApplyDefaultFormat(editor, marker.format, startPos); + } + + // 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; + } + }); +} + +function internalApplyDefaultFormat( + editor: IContentModelEditor, + currentFormat: ContentModelSegmentFormat, + startPos: NodePosition +) { + const pendingFormat = getPendingFormat(editor) || {}; + const defaultFormat = editor.getContentModelDefaultFormat(); + const newFormat: ContentModelSegmentFormat = { + ...defaultFormat, + ...pendingFormat, + ...currentFormat, + }; + + setPendingFormat(editor, newFormat, startPos); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts index c5ef00c1274..327d33b0c58 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts @@ -78,8 +78,6 @@ export function formatWithContentModel( } ); } - - editor.cacheContentModel?.(model); } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index d0d05679a74..b3176b9b775 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -1,3 +1,4 @@ +import { ContentModelPluginState } from './pluginState/ContentModelPluginState'; import { CoreApiMap, EditorCore, SelectionRangeEx } from 'roosterjs-editor-types'; import { ContentModelDocument, @@ -72,7 +73,7 @@ export interface ContentModelCoreApiMap extends CoreApiMap { /** * Represents the core data structure of a Content Model editor */ -export interface ContentModelEditorCore extends EditorCore { +export interface ContentModelEditorCore extends EditorCore, ContentModelPluginState { /** * Core API map of this editor */ @@ -83,16 +84,6 @@ export interface ContentModelEditorCore extends EditorCore { */ readonly originalApi: ContentModelCoreApiMap; - /** - * When reuse Content Model is allowed, we cache the Content Model object here after created - */ - cachedModel?: ContentModelDocument; - - /** - * Cached selection range ex. This range only exist when cached model exists and it has selection - */ - cachedRangeEx?: SelectionRangeEx; - /** * Default format used by Content Model. This is calculated from lifecycle.defaultFormat */ 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 4b002f5bdb7..3c84db57c89 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 @@ -37,10 +37,9 @@ export interface IContentModelEditor extends IEditor { ): void; /** - * Cache a content model object. Next time when format with content model, we can reuse it. - * @param model + * Notify editor the current cache may be invalid */ - cacheContentModel(model: ContentModelDocument | null): void; + invalidateCache(): void; /** * Get default format as ContentModelSegmentFormat. diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts new file mode 100644 index 00000000000..ee20ac33156 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts @@ -0,0 +1,17 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { SelectionRangeEx } from 'roosterjs-editor-types'; + +/** + * Plugin state for ContentModelEditPlugin + */ +export interface ContentModelCachePluginState { + /** + * Cached selection range + */ + cachedRangeEx?: SelectionRangeEx | undefined; + + /** + * When reuse Content Model is allowed, we cache the Content Model object here after created + */ + cachedModel?: ContentModelDocument; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts new file mode 100644 index 00000000000..63e8ccd721d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts @@ -0,0 +1,18 @@ +import { ContentModelCachePluginState } from './ContentModelCachePluginState'; +import { CopyPastePluginState } from 'roosterjs-editor-types'; + +/** + * Temporary core plugin state for Content Model editor + * TODO: Create Content Model plugin state from all core plugins once we have standalone Content Model Editor + */ +export interface ContentModelPluginState { + /** + * Plugin state for ContentModelCachePlugin + */ + cache: ContentModelCachePluginState; + + /** + * Plugin state for ContentModelCopyPastePlugin + */ + copyPaste: CopyPastePluginState; +} 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 8658b765505..7c2c90ad4ac 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 @@ -198,7 +198,7 @@ describe('ContentModelEditor', () => { const editor = new ContentModelEditor(div); const cachedModel = 'MODEL' as any; - (editor as any).core.cachedModel = cachedModel; + (editor as any).core.cache.cachedModel = cachedModel; spyOn(domToContentModel, 'domToContentModel'); @@ -208,20 +208,6 @@ describe('ContentModelEditor', () => { expect(domToContentModel.domToContentModel).not.toHaveBeenCalled(); }); - it('cache model', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const cachedModel = 'MODEL' as any; - - editor.cacheContentModel(cachedModel); - - expect((editor as any).core.cachedModel).toBe(cachedModel); - - editor.cacheContentModel(null); - - expect((editor as any).core.cachedModel).toBe(undefined); - }); - it('default format', () => { const div = document.createElement('div'); const editor = new ContentModelEditor(div, { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts index df6eee1d475..61cf54bfa8c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts @@ -40,13 +40,15 @@ describe('createContentModel', () => { createEditorContext, getSelectionRangeEx, }, - cachedModel: mockedCachedMode, + cache: { + cachedModel: mockedCachedMode, + }, lifecycle: {}, } as any) as ContentModelEditorCore; }); it('Reuse model, no cache, no shadow edit', () => { - core.cachedModel = undefined; + core.cache.cachedModel = undefined; const model = createContentModel(core); @@ -102,6 +104,7 @@ describe('createContentModel with selection', () => { getSelectionRangeEx: getSelectionRangeExSpy, createEditorContext: createEditorContextSpy, }, + cache: {}, }; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts index 42c48cd32fa..91afa5bd6b9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts @@ -47,6 +47,7 @@ describe('setContentModel', () => { }, lifecycle: {}, defaultModelToDomConfig: mockedConfig, + cache: {}, } as any) as ContentModelEditorCore; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts index e2c79d1f08c..0991c08f96d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts @@ -27,17 +27,18 @@ describe('switchShadowEdit', () => { }, lifecycle: {}, contentDiv: document.createElement('div'), + cache: {}, } as any) as ContentModelEditorCore; }); describe('was off', () => { it('no cache, isOn', () => { - core.cachedModel = undefined; + core.cache.cachedModel = undefined; switchShadowEdit(core, true); expect(createContentModel).toHaveBeenCalledWith(core); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(mockedModel); + expect(core.cache.cachedModel).toBe(mockedModel); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, @@ -51,13 +52,13 @@ describe('switchShadowEdit', () => { }); it('with cache, isOn', () => { - core.cachedModel = mockedCachedModel; + core.cache.cachedModel = mockedCachedModel; switchShadowEdit(core, true); expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(mockedCachedModel); + expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( @@ -76,19 +77,19 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(undefined); + expect(core.cache.cachedModel).toBe(undefined); expect(triggerEvent).not.toHaveBeenCalled(); }); it('with cache, isOff', () => { - core.cachedModel = mockedCachedModel; + core.cache.cachedModel = mockedCachedModel; switchShadowEdit(core, false); expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(mockedCachedModel); + expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).not.toHaveBeenCalled(); }); @@ -104,19 +105,19 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(undefined); + expect(core.cache.cachedModel).toBe(undefined); expect(triggerEvent).not.toHaveBeenCalled(); }); it('with cache, isOn', () => { - core.cachedModel = mockedCachedModel; + core.cache.cachedModel = mockedCachedModel; switchShadowEdit(core, true); expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(mockedCachedModel); + expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).not.toHaveBeenCalled(); }); @@ -126,7 +127,7 @@ describe('switchShadowEdit', () => { expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(core.cachedModel).toBe(undefined); + expect(core.cache.cachedModel).toBe(undefined); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( @@ -139,13 +140,13 @@ describe('switchShadowEdit', () => { }); it('with cache, isOff', () => { - core.cachedModel = mockedCachedModel; + core.cache.cachedModel = mockedCachedModel; switchShadowEdit(core, false); expect(createContentModel).not.toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledWith(core, mockedCachedModel); - expect(core.cachedModel).toBe(mockedCachedModel); + expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( 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 849f5842b49..a046a61d533 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 @@ -1,9 +1,10 @@ +import * as ContentModelCachePlugin from '../../lib/editor/corePlugins/ContentModelCachePlugin'; +import * as ContentModelEditPlugin from '../../lib/editor/plugins/ContentModelEditPlugin'; +import * as ContentModelFormatPlugin from '../../lib/editor/plugins/ContentModelFormatPlugin'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createEditorCore from 'roosterjs-editor-core/lib/editor/createEditorCore'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as isFeatureEnabled from 'roosterjs-editor-core/lib/editor/isFeatureEnabled'; -import ContentModelEditPlugin from '../../lib/editor/plugins/ContentModelEditPlugin'; -import ContentModelFormatPlugin from '../../lib/editor/plugins/ContentModelFormatPlugin'; import ContentModelTypeInContainerPlugin from '../../lib/editor/corePlugins/ContentModelTypeInContainerPlugin'; import { createContentModel } from '../../lib/editor/coreApi/createContentModel'; import { createContentModelEditorCore } from '../../lib/editor/createContentModelEditorCore'; @@ -21,6 +22,9 @@ const mockedDomToModelConfig = { const mockedModelToDomConfig = { config: 'mockedModelToDomConfig', } as any; +const mockedFormatPlugin = 'FORMATPLUGIN' as any; +const mockedEditPlugin = 'EDITPLUGIN' as any; +const mockedCachePlugin = 'CACHPLUGIN' as any; describe('createContentModelEditorCore', () => { let createEditorCoreSpy: jasmine.Spy; @@ -50,6 +54,15 @@ describe('createContentModelEditorCore', () => { createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.returnValue( mockedCore ); + spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( + mockedFormatPlugin + ); + spyOn(ContentModelEditPlugin, 'createContentModelEditPlugin').and.returnValue( + mockedEditPlugin + ); + spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( + mockedCachePlugin + ); spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue( mockedDomToModelConfig @@ -68,7 +81,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -112,6 +125,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); @@ -131,7 +146,7 @@ describe('createContentModelEditorCore', () => { expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { defaultDomToModelOptions, defaultModelToDomOptions, - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -176,6 +191,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); @@ -199,7 +216,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -251,6 +268,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); @@ -264,7 +283,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -309,6 +328,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); @@ -330,7 +351,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [new ContentModelFormatPlugin(), new ContentModelEditPlugin()], + plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -374,6 +395,8 @@ describe('createContentModelEditorCore', () => { contentDiv: { style: {}, }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, } as any); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts index 10a9e296223..b1a482b0f6a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts @@ -1,30 +1,17 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; -import * as handleKeyDownEvent from '../../../lib/publicApi/editing/handleKeyDownEvent'; -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; +import * as keyboardDelete from '../../../lib/publicApi/editing/keyboardDelete'; import ContentModelEditPlugin from '../../../lib/editor/plugins/ContentModelEditPlugin'; +import { EntityOperation, Keys, PluginEventType } from 'roosterjs-editor-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { Position } from 'roosterjs-editor-dom'; -import { - EntityOperation, - Keys, - PluginEventType, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; describe('ContentModelEditPlugin', () => { let editor: IContentModelEditor; - let cacheContentModel: jasmine.Spy; - let getContentModelDefaultFormat: jasmine.Spy; + let invalidateCache: jasmine.Spy; beforeEach(() => { - cacheContentModel = jasmine.createSpy('cacheContentModel'); - getContentModelDefaultFormat = jasmine - .createSpy('getContentModelDefaultFormat') - .and.returnValue({}); + invalidateCache = jasmine.createSpy('invalidateCache'); editor = ({ - cacheContentModel, - getContentModelDefaultFormat, + invalidateCache, getSelectionRangeEx: () => ({ type: -1, @@ -33,10 +20,10 @@ describe('ContentModelEditPlugin', () => { }); describe('onPluginEvent', () => { - let handleKeyDownEventSpy: jasmine.Spy; + let keyboardDeleteSpy: jasmine.Spy; beforeEach(() => { - handleKeyDownEventSpy = spyOn(handleKeyDownEvent, 'default'); + keyboardDeleteSpy = spyOn(keyboardDelete, 'default').and.returnValue(true); }); it('Backspace', () => { @@ -50,8 +37,8 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(cacheContentModel).not.toHaveBeenCalled(); + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(invalidateCache).not.toHaveBeenCalled(); }); it('Delete', () => { @@ -65,8 +52,8 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(cacheContentModel).not.toHaveBeenCalled(); + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(invalidateCache).not.toHaveBeenCalled(); }); it('Other key', () => { @@ -80,8 +67,8 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledWith(null); + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(invalidateCache).not.toHaveBeenCalled(); }); it('Default prevented', () => { @@ -94,8 +81,8 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledWith(null); + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(invalidateCache).toHaveBeenCalled(); }); it('Trigger entity event first', () => { @@ -118,7 +105,7 @@ describe('ContentModelEditPlugin', () => { rawEvent: { which: Keys.DELETE } as any, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, { + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { which: Keys.DELETE, } as any); @@ -127,11 +114,11 @@ describe('ContentModelEditPlugin', () => { rawEvent: { which: Keys.DELETE } as any, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledTimes(2); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, { + expect(keyboardDeleteSpy).toHaveBeenCalledTimes(2); + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { which: Keys.DELETE, } as any); - expect(cacheContentModel).not.toHaveBeenCalled(); + expect(invalidateCache).not.toHaveBeenCalled(); }); it('SelectionChanged event should clear cached model', () => { @@ -143,439 +130,21 @@ describe('ContentModelEditPlugin', () => { selectionRangeEx: null!, }); - expect(cacheContentModel).toHaveBeenCalledWith(null); + expect(invalidateCache).not.toHaveBeenCalled(); }); - }); - - describe('onPluginEvent, no need to go through Content Model', () => { - let handleKeyDownEventSpy: jasmine.Spy; - let range: any; - - beforeEach(() => { - handleKeyDownEventSpy = spyOn(handleKeyDownEvent, 'default'); - range = { - collapsed: true, - startContainer: document.createTextNode('test'), - startOffset: 2, - }; - - editor.getSelectionRangeEx = () => - ({ - type: SelectionRangeTypes.Normal, - areAllCollapsed: true, - ranges: [range], - } as any); - }); - - it('Backspace', () => { + it('keyboardDelete returns false', () => { const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: Keys.BACKSPACE } as any; - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - expect(handleKeyDownEventSpy).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledTimes(1); - expect(cacheContentModel).toHaveBeenCalledWith(null); - }); - - it('Delete', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: Keys.DELETE } as any; + keyboardDeleteSpy.and.returnValue(false); plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(handleKeyDownEventSpy).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledTimes(1); - expect(cacheContentModel).toHaveBeenCalledWith(null); - }); - - it('Backspace from the beginning', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: Keys.BACKSPACE } as any; - - plugin.initialize(editor); - - range = { - collapsed: true, - startContainer: document.createTextNode('test'), - startOffset: 0, - }; - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(cacheContentModel).not.toHaveBeenCalled(); - }); - - it('Delete from the last', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: Keys.DELETE } as any; - - plugin.initialize(editor); - - range = { - collapsed: true, - startContainer: document.createTextNode('test'), - startOffset: 4, - }; - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null!, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(cacheContentModel).not.toHaveBeenCalled(); - }); - }); -}); - -describe('ContentModelEditPlugin for default format', () => { - let editor: IContentModelEditor; - let contentDiv: HTMLDivElement; - let getSelectionRangeEx: jasmine.Spy; - let getPendingFormatSpy: jasmine.Spy; - let setPendingFormatSpy: jasmine.Spy; - let cacheContentModelSpy: jasmine.Spy; - let addUndoSnapshotSpy: jasmine.Spy; - - beforeEach(() => { - setPendingFormatSpy = spyOn(pendingFormat, 'setPendingFormat'); - getPendingFormatSpy = spyOn(pendingFormat, 'getPendingFormat'); - getSelectionRangeEx = jasmine.createSpy('getSelectionRangeEx'); - cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); - addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); - - contentDiv = document.createElement('div'); - - editor = ({ - contains: (e: Node) => contentDiv != e && contentDiv.contains(e), - getSelectionRangeEx, - getContentModelDefaultFormat: () => ({ - fontFamily: 'Arial', - }), - cacheContentModel: cacheContentModelSpy, - addUndoSnapshot: addUndoSnapshotSpy, - } as any) as IContentModelEditor; - }); - - it('Collapsed range, text input, under editor directly', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'a' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, + expect(invalidateCache).not.toHaveBeenCalled(); }); - - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - new Position(contentDiv, 0) - ); - }); - - it('Expanded range, text input, under editor directly', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'a' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: false, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).not.toHaveBeenCalled(); - expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); - }); - - it('Collapsed range, IME input, under editor directly', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'Process' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - new Position(contentDiv, 0) - ); - }); - - it('Collapsed range, other input, under editor directly', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'Up' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).not.toHaveBeenCalled(); - }); - - it('Collapsed range, normal input, not under editor directly, no style', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'a' } as any; - const div = document.createElement('div'); - - contentDiv.appendChild(div); - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: div, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - new Position(div, 0) - ); - }); - - it('Collapsed range, text input, under editor directly, has pending format', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'a' } as any; - - getSelectionRangeEx.and.returnValue({ - type: SelectionRangeTypes.Normal, - ranges: [ - { - collapsed: true, - startContainer: contentDiv, - startOffset: 0, - }, - ], - }); - - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); - - getPendingFormatSpy.and.returnValue({ - fontSize: '10pt', - }); - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: PluginEventType.KeyDown, - rawEvent, - }); - - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial', fontSize: '10pt' }, - new Position(contentDiv, 0) - ); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts index a40cd0fb142..b56b989f1d8 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts @@ -1,7 +1,9 @@ +import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import ContentModelFormatPlugin from '../../../lib/editor/plugins/ContentModelFormatPlugin'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { PluginEventType } from 'roosterjs-editor-types'; +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { Position } from 'roosterjs-editor-dom'; import { addSegment, createContentModelDocument, @@ -17,6 +19,7 @@ describe('ContentModelFormatPlugin', () => { const editor = ({ cacheContentModel: () => {}, isDarkMode: () => false, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -46,6 +49,7 @@ describe('ContentModelFormatPlugin', () => { setContentModel, isInIME: () => false, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); const model = createContentModelDocument(); @@ -79,6 +83,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -111,6 +116,7 @@ describe('ContentModelFormatPlugin', () => { setContentModel, isInIME: () => false, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -151,6 +157,7 @@ describe('ContentModelFormatPlugin', () => { }, cacheContentModel: () => {}, isDarkMode: () => false, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -215,6 +222,7 @@ describe('ContentModelFormatPlugin', () => { }, cacheContentModel: () => {}, isDarkMode: () => false, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -274,6 +282,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -306,6 +315,7 @@ describe('ContentModelFormatPlugin', () => { callback(); }, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -336,6 +346,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -366,6 +377,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getContentModelDefaultFormat: () => ({}), } as any) as IContentModelEditor; const plugin = new ContentModelFormatPlugin(); @@ -381,3 +393,339 @@ describe('ContentModelFormatPlugin', () => { expect(pendingFormat.canApplyPendingFormat).toHaveBeenCalledTimes(1); }); }); + +describe('ContentModelFormatPlugin for default format', () => { + let editor: IContentModelEditor; + let contentDiv: HTMLDivElement; + let getSelectionRangeEx: jasmine.Spy; + let getPendingFormatSpy: jasmine.Spy; + let setPendingFormatSpy: jasmine.Spy; + let cacheContentModelSpy: jasmine.Spy; + let addUndoSnapshotSpy: jasmine.Spy; + + beforeEach(() => { + setPendingFormatSpy = spyOn(pendingFormat, 'setPendingFormat'); + getPendingFormatSpy = spyOn(pendingFormat, 'getPendingFormat'); + getSelectionRangeEx = jasmine.createSpy('getSelectionRangeEx'); + cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); + addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + contentDiv = document.createElement('div'); + + editor = ({ + contains: (e: Node) => contentDiv != e && contentDiv.contains(e), + getSelectionRangeEx, + getContentModelDefaultFormat: () => ({ + fontFamily: 'Arial', + }), + cacheContentModel: cacheContentModelSpy, + addUndoSnapshot: addUndoSnapshotSpy, + } as any) as IContentModelEditor; + }); + + it('Collapsed range, text input, under editor directly', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'a' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { fontFamily: 'Arial' }, + new Position(contentDiv, 0) + ); + }); + + it('Expanded range, text input, under editor directly', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'a' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: false, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + }); + + it('Collapsed range, IME input, under editor directly', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'Process' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { fontFamily: 'Arial' }, + new Position(contentDiv, 0) + ); + }); + + it('Collapsed range, other input, under editor directly', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'Up' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).not.toHaveBeenCalled(); + }); + + it('Collapsed range, normal input, not under editor directly, no style', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'a' } as any; + const div = document.createElement('div'); + + contentDiv.appendChild(div); + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: div, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { fontFamily: 'Arial' }, + new Position(div, 0) + ); + }); + + it('Collapsed range, text input, under editor directly, has pending format', () => { + const plugin = new ContentModelFormatPlugin(); + const rawEvent = { key: 'a' } as any; + + getSelectionRangeEx.and.returnValue({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: true, + startContainer: contentDiv, + startOffset: 0, + }, + ], + }); + + spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( + (_1: any, _2: any, callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + } + ); + + getPendingFormatSpy.and.returnValue({ + fontSize: '10pt', + }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { fontFamily: 'Arial', fontSize: '10pt' }, + new Position(contentDiv, 0) + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts index 41931b35e92..c5db86d346e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts @@ -52,8 +52,6 @@ describe(ID, () => { paste(editor, clipboardData, false, false, true); - editor.cacheContentModel(null); - const model = editor.createContentModel({ processorOverride: { table: tableProcessor, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts index 74e44490a06..cc5eddaedb3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts @@ -79,7 +79,7 @@ describe(ID, () => { blockType: 'Table', rows: [ { - height: 0, + height: jasmine.anything() as any, format: {}, cells: [ { @@ -102,9 +102,6 @@ describe(ID, () => { fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '11pt', - italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', lineHeight: @@ -121,9 +118,6 @@ describe(ID, () => { marginTop: '0px', marginBottom: '0px', }, - segmentFormat: { - fontWeight: 'normal', - }, decorator: { tagName: 'p', format: {}, @@ -183,9 +177,6 @@ describe(ID, () => { fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '11pt', - italic: false, - fontWeight: - 'normal', textColor: 'rgb(0, 0, 0)', lineHeight: @@ -202,9 +193,6 @@ describe(ID, () => { marginTop: '0px', marginBottom: '0px', }, - segmentFormat: { - fontWeight: 'normal', - }, decorator: { tagName: 'p', format: {}, @@ -248,6 +236,7 @@ describe(ID, () => { }, ], format: { + useBorderBox: true, direction: 'ltr', textAlign: 'start', whiteSpace: 'normal', @@ -260,7 +249,7 @@ describe(ID, () => { tableLayout: 'fixed', borderCollapse: true, }, - widths: [], + widths: [jasmine.anything() as any, jasmine.anything() as any], dataset: { tablestyle: 'MsoTableGrid', tablelook: '1696', @@ -268,9 +257,6 @@ describe(ID, () => { }, ], format: { - direction: 'ltr', - textAlign: 'start', - whiteSpace: 'normal', marginTop: '2px', marginRight: '0px', marginBottom: '2px', @@ -278,9 +264,6 @@ describe(ID, () => { }, ], format: { - direction: 'ltr', - textAlign: 'start', - whiteSpace: 'normal', backgroundColor: 'rgb(255, 255, 255)', marginTop: '0px', marginRight: '0px', @@ -304,8 +287,6 @@ describe(ID, () => { fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', fontSize: '12pt', - italic: false, - fontWeight: 'normal', textColor: 'rgb(0, 0, 0)', lineHeight: '20.925px', }, @@ -320,9 +301,6 @@ describe(ID, () => { marginBottom: '0px', marginLeft: '0px', }, - segmentFormat: { - fontWeight: 'normal', - }, decorator: { tagName: 'p', format: {}, @@ -330,9 +308,6 @@ describe(ID, () => { }, ], format: { - direction: 'ltr', - textAlign: 'start', - whiteSpace: 'normal', backgroundColor: 'rgb(255, 255, 255)', marginTop: '0px', marginRight: '0px', diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts index f95dee1e6ec..7a44986901e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts @@ -67,7 +67,7 @@ describe(ID, () => { ], segmentFormat: undefined, blockType: 'Paragraph', - format: {}, + format: { marginTop: '0px', marginBottom: '0px' }, decorator: { tagName: 'p', format: {} }, }, { @@ -83,13 +83,12 @@ describe(ID, () => { { isSelected: true, segmentType: 'SelectionMarker', - format: {}, + format: { fontFamily: 'Calibri, sans-serif', fontSize: '11pt' }, }, ], segmentFormat: undefined, blockType: 'Paragraph', format: { - lineHeight: undefined, marginTop: '0in', marginRight: '0in', marginBottom: '8pt', @@ -127,10 +126,10 @@ describe(ID, () => { blockGroupType: 'Document', blocks: [ { - widths: [], + widths: [jasmine.anything() as any, jasmine.anything() as any], rows: [ { - height: 0, + height: jasmine.anything() as any, cells: [ { spanAbove: false, @@ -202,7 +201,6 @@ describe(ID, () => { borderTop: '1pt solid', borderRight: '1pt solid', borderBottom: '1pt solid', - borderLeft: '', paddingTop: '0in', paddingRight: '5.4pt', paddingBottom: '0in', @@ -218,11 +216,8 @@ describe(ID, () => { ], blockType: 'Table', format: { - borderTop: '', - borderRight: '', - borderBottom: '', - borderLeft: '', borderCollapse: true, + useBorderBox: true, }, dataset: {}, }, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts index faa405fd541..c98997eef24 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts @@ -77,7 +77,7 @@ describe('handleKeyboardEventResult', () => { expect(preventDefault).not.toHaveBeenCalled(); expect(triggerContentChangedEvent).not.toHaveBeenCalled(); expect(normalizeContentModel.normalizeContentModel).not.toHaveBeenCalled(); - expect(cacheContentModel).toHaveBeenCalledWith(null); + expect(cacheContentModel).not.toHaveBeenCalledWith(null); expect(triggerPluginEvent).not.toHaveBeenCalled(); expect(context.skipUndoSnapshot).toBeTrue(); }); 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 be7642aab81..3ae6329b1b8 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 @@ -36,7 +36,7 @@ export function editingTestCommon( setContentModel, triggerPluginEvent, isDisposed: () => false, - getFocusedPosition: () => null as NodePosition, + getFocusedPosition: () => null! as NodePosition, triggerContentChangedEvent, isDarkMode: () => false, } as any) as IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts similarity index 74% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts rename to packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts index 9e54e3e3a24..f94c51cbdb5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts @@ -1,8 +1,8 @@ import * as deleteSelection from '../../../lib/modelApi/edit/deleteSelection'; import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as handleKeyboardEventResult from '../../../lib/editor/utils/handleKeyboardEventCommon'; -import handleKeyDownEvent from '../../../lib/publicApi/editing/handleKeyDownEvent'; -import { ChangeSource, Keys } from 'roosterjs-editor-types'; +import keyboardDelete from '../../../lib/publicApi/editing/keyboardDelete'; +import { ChangeSource, Keys, SelectionRangeEx, SelectionRangeTypes } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { deleteAllSegmentBefore } from '../../../lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { editingTestCommon } from './editingTestCommon'; @@ -20,7 +20,7 @@ import { forwardDeleteCollapsedSelection, } from '../../../lib/modelApi/edit/deleteSteps/deleteCollapsedSelection'; -describe('handleKeyDownEvent', () => { +describe('keyboardDelete', () => { let deleteSelectionSpy: jasmine.Spy; beforeEach(() => { @@ -51,7 +51,18 @@ describe('handleKeyDownEvent', () => { 'handleBackspaceKey', newEditor => { editor = newEditor; - handleKeyDownEvent(editor, mockedEvent); + + editor.getSelectionRangeEx = () => ({ + type: SelectionRangeTypes.Normal, + ranges: [ + { + collapsed: false, + }, + ], + }); + const result = keyboardDelete(editor, mockedEvent); + + expect(result).toBeTrue(); }, input, expectedResult, @@ -366,13 +377,17 @@ describe('handleKeyDownEvent', () => { const editor = ({ addUndoSnapshot, + getSelectionRangeEx: () => ({ + type: SelectionRangeTypes.Normal, + ranges: [{ collapsed: false }], + }), } as any) as IContentModelEditor; const which = Keys.DELETE; const event = { which, } as any; - handleKeyDownEvent(editor, event); + keyboardDelete(editor, event); expect(spy.calls.argsFor(0)[0]).toBe(editor); expect(spy.calls.argsFor(0)[1]).toBe('handleDeleteKey'); @@ -385,18 +400,121 @@ describe('handleKeyDownEvent', () => { const spy = spyOn(formatWithContentModel, 'formatWithContentModel'); const preventDefault = jasmine.createSpy('preventDefault'); - const editor = 'EDITOR' as any; + const editor = { + getSelectionRangeEx: () => ({ + type: SelectionRangeTypes.Normal, + ranges: [{ collapsed: false }], + }), + } as any; const which = Keys.BACKSPACE; const event = { which, preventDefault, } as any; - handleKeyDownEvent(editor, event); + keyboardDelete(editor, event); expect(spy.calls.argsFor(0)[0]).toBe(editor); expect(spy.calls.argsFor(0)[1]).toBe('handleBackspaceKey'); expect(spy.calls.argsFor(0)[3]?.changeSource).toBe(ChangeSource.Keyboard); expect(spy.calls.argsFor(0)[3]?.getChangeData?.()).toBe(which); }); + + it('No need to delete - Backspace', () => { + const rawEvent = { which: Keys.BACKSPACE } as any; + const range: SelectionRangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + ({ + collapsed: true, + startContainer: document.createTextNode('test'), + startOffset: 2, + } as any) as Range, + ], + areAllCollapsed: true, + }; + const editor = { + getSelectionRangeEx: () => range, + } as any; + const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); + + const result = keyboardDelete(editor, rawEvent); + + expect(result).toBeFalse(); + expect(formatWithContentModelSpy).not.toHaveBeenCalled(); + }); + + it('No need to delete - Delete', () => { + const rawEvent = { which: Keys.DELETE } as any; + const range: SelectionRangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + ({ + collapsed: true, + startContainer: document.createTextNode('test'), + startOffset: 2, + } as any) as Range, + ], + areAllCollapsed: true, + }; + const editor = { + getSelectionRangeEx: () => range, + } as any; + const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); + + const result = keyboardDelete(editor, rawEvent); + + expect(result).toBeFalse(); + expect(formatWithContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace from the beginning', () => { + const rawEvent = { which: Keys.BACKSPACE } as any; + const range: SelectionRangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + ({ + collapsed: true, + startContainer: document.createTextNode('test'), + startOffset: 0, + } as any) as Range, + ], + areAllCollapsed: true, + }; + + const editor = { + getSelectionRangeEx: () => range, + } as any; + const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); + + const result = keyboardDelete(editor, rawEvent); + + expect(result).toBeTrue(); + expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); + }); + + it('Delete from the last', () => { + const rawEvent = { which: Keys.DELETE } as any; + const range: SelectionRangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + ({ + collapsed: true, + startContainer: document.createTextNode('test'), + startOffset: 4, + } as any) as Range, + ], + areAllCollapsed: true, + }; + + const editor = { + getSelectionRangeEx: () => range, + } as any; + const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); + + const result = keyboardDelete(editor, rawEvent); + + expect(result).toBeTrue(); + expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); + }); }); 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 9ed35c2eaef..7d7e5bda988 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 @@ -582,6 +582,7 @@ describe('Paste with clipboardData', () => { }); afterEach(() => { + editor.dispose(); document.getElementById(ID)?.remove(); }); @@ -614,7 +615,10 @@ describe('Paste with clipboardData', () => { format: {}, }, ], - format: {}, + format: { + marginTop: '0px', + marginBottom: '0px', + }, decorator: { tagName: 'p', format: {}, @@ -690,6 +694,13 @@ describe('Paste with clipboardData', () => { isSelected: true, segmentType: 'SelectionMarker', format: {}, + link: { + format: { + underline: true, + href: 'https://github.com/microsoft/roosterjs', + }, + dataset: {}, + }, }, ], blockType: 'Paragraph',