diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 00bbcdf204a..c7f0fb2bead 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -20,6 +20,7 @@ import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyBut import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; import { arrayPush } from 'roosterjs-editor-dom'; +import { AutoFormatPlugin, EditPlugin, PastePlugin } from 'roosterjs-content-model-plugins'; import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; import { boldButton } from './ribbonButtons/contentModel/boldButton'; @@ -29,11 +30,11 @@ import { clearFormatButton } from './ribbonButtons/contentModel/clearFormatButto import { codeButton } from './ribbonButtons/contentModel/codeButton'; import { ContentModelRibbon } from './ribbonButtons/contentModel/ContentModelRibbon'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; -import { ContentModelSegmentFormat, Snapshots } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin } from 'roosterjs-react'; import { darkMode } from './ribbonButtons/contentModel/darkMode'; import { decreaseFontSizeButton } from './ribbonButtons/contentModel/decreaseFontSizeButton'; import { decreaseIndentButton } from './ribbonButtons/contentModel/decreaseIndentButton'; +import { EditorAdapter, EditorAdapterOptions } from 'roosterjs-editor-adapter'; import { EditorPlugin } from 'roosterjs-editor-types'; import { exportContent } from './ribbonButtons/contentModel/export'; import { fontButton } from './ribbonButtons/contentModel/fontButton'; @@ -79,6 +80,11 @@ import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { underlineButton } from './ribbonButtons/contentModel/underlineButton'; import { undoButton } from './ribbonButtons/contentModel/undoButton'; import { zoom } from './ribbonButtons/contentModel/zoom'; +import { + ContentModelSegmentFormat, + IStandaloneEditor, + Snapshots, +} from 'roosterjs-content-model-types'; import { spaceAfterButton, spaceBeforeButton, @@ -91,16 +97,6 @@ import { tableMergeButton, tableSplitButton, } from './ribbonButtons/contentModel/tableEditButtons'; -import { - ContentModelAutoFormatPlugin, - ContentModelEditPlugin, - ContentModelPastePlugin, -} from 'roosterjs-content-model-plugins'; -import { - ContentModelEditor, - ContentModelEditorOptions, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; const styles = require('./ContentModelEditorMainPane.scss'); @@ -159,7 +155,7 @@ const DarkTheme: PartialTheme = { }; interface ContentModelMainPaneState extends MainPaneBaseState { - editorCreator: (div: HTMLDivElement, options: ContentModelEditorOptions) => IContentModelEditor; + editorCreator: (div: HTMLDivElement, options: EditorAdapterOptions) => IStandaloneEditor; } class ContentModelEditorMainPane extends MainPaneBase { @@ -168,15 +164,15 @@ class ContentModelEditorMainPane extends MainPaneBase private eventViewPlugin: ContentModelEventViewPlugin; private apiPlaygroundPlugin: ApiPlaygroundPlugin; private contentModelPanePlugin: ContentModelPanePlugin; - private contentModelEditPlugin: ContentModelEditPlugin; - private contentModelAutoFormatPlugin: ContentModelAutoFormatPlugin; + private contentModelEditPlugin: EditPlugin; + private contentModelAutoFormatPlugin: AutoFormatPlugin; private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private formatPainterPlugin: ContentModelFormatPainterPlugin; - private pastePlugin: ContentModelPastePlugin; + private pastePlugin: PastePlugin; private sampleEntityPlugin: SampleEntityPlugin; private snapshots: Snapshots; private buttons: ContentModelRibbonButton[] = [ @@ -261,13 +257,13 @@ class ContentModelEditorMainPane extends MainPaneBase this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); - this.contentModelEditPlugin = new ContentModelEditPlugin(); - this.contentModelAutoFormatPlugin = new ContentModelAutoFormatPlugin(); + this.contentModelEditPlugin = new EditPlugin(); + this.contentModelAutoFormatPlugin = new AutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); - this.pastePlugin = new ContentModelPastePlugin(); + this.pastePlugin = new PastePlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); this.state = { showSidePane: window.location.hash != '', @@ -343,8 +339,8 @@ class ContentModelEditorMainPane extends MainPaneBase resetEditor() { this.toggleablePlugins = null; this.setState({ - editorCreator: (div: HTMLDivElement, options: ContentModelEditorOptions) => - new ContentModelEditor(div, { + editorCreator: (div: HTMLDivElement, options: EditorAdapterOptions) => + new EditorAdapter(div, { ...options, cacheModel: this.state.initState.cacheModel, }), diff --git a/demo/scripts/controls/StandaloneEditorMainPane.tsx b/demo/scripts/controls/StandaloneEditorMainPane.tsx index 8cae61e11d4..e2bf0f19e7b 100644 --- a/demo/scripts/controls/StandaloneEditorMainPane.tsx +++ b/demo/scripts/controls/StandaloneEditorMainPane.tsx @@ -17,6 +17,7 @@ import { alignCenterButton } from './ribbonButtons/contentModel/alignCenterButto import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyButton'; import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; +import { AutoFormatPlugin, EditPlugin } from 'roosterjs-content-model-plugins'; import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; import { boldButton } from './ribbonButtons/contentModel/boldButton'; @@ -93,10 +94,6 @@ import { tableMergeButton, tableSplitButton, } from './ribbonButtons/contentModel/tableEditButtons'; -import { - ContentModelAutoFormatPlugin, - ContentModelEditPlugin, -} from 'roosterjs-content-model-plugins'; const styles = require('./StandaloneEditorMainPane.scss'); @@ -164,9 +161,9 @@ class ContentModelEditorMainPane extends MainPaneBase private eventViewPlugin: ContentModelEventViewPlugin; private apiPlaygroundPlugin: ApiPlaygroundPlugin; private contentModelPanePlugin: ContentModelPanePlugin; - private contentModelEditPlugin: ContentModelEditPlugin; + private contentModelEditPlugin: EditPlugin; private contentModelRibbonPlugin: RibbonPlugin; - private contentAutoFormatPlugin: ContentModelAutoFormatPlugin; + private contentAutoFormatPlugin: AutoFormatPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; private formatPainterPlugin: ContentModelFormatPainterPlugin; private snapshots: Snapshots; @@ -252,8 +249,8 @@ class ContentModelEditorMainPane extends MainPaneBase this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); - this.contentModelEditPlugin = new ContentModelEditPlugin(); - this.contentAutoFormatPlugin = new ContentModelAutoFormatPlugin(); + this.contentModelEditPlugin = new EditPlugin(); + this.contentAutoFormatPlugin = new AutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); this.state = { diff --git a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx index 717ec81bcfa..a0d5f6cb501 100644 --- a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx +++ b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { ContentModelEditor, ContentModelEditorOptions } from 'roosterjs-content-model-editor'; import { createUIUtilities, ReactEditorPlugin, UIUtilities } from 'roosterjs-react'; import { divProperties, getNativeProps } from '@fluentui/react/lib/Utilities'; +import { EditorAdapter, EditorAdapterOptions } from 'roosterjs-editor-adapter'; import { useTheme } from '@fluentui/react/lib/Theme'; import { EditorPlugin, @@ -14,7 +14,7 @@ import type { EditorPlugin as LegacyEditorPlugin } from 'roosterjs-editor-types' * Properties for Rooster react component */ export interface ContentModelRoosterProps - extends ContentModelEditorOptions, + extends EditorAdapterOptions, React.HTMLAttributes { /** * Creator function used for creating the instance of roosterjs editor. @@ -86,8 +86,8 @@ function setUIUtilities( }); } -function defaultEditorCreator(div: HTMLDivElement, options: ContentModelEditorOptions) { - return new ContentModelEditor(div, options); +function defaultEditorCreator(div: HTMLDivElement, options: EditorAdapterOptions) { + return new EditorAdapter(div, options); } function isReactEditorPlugin( diff --git a/demo/scripts/controls/ribbonButtons/contentModel/export.ts b/demo/scripts/controls/ribbonButtons/contentModel/export.ts index b9db78b0149..0c09ac7edea 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/export.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/export.ts @@ -1,11 +1,5 @@ import ContentModelRibbonButton from './ContentModelRibbonButton'; -import { cloneModel } from 'roosterjs-content-model-core'; -import { ContentModelEntityFormat } from 'roosterjs-content-model-types'; -import { - contentModelToDom, - createModelToDomContext, - parseEntityClassName, -} from 'roosterjs-content-model-dom'; +import { exportContent as exportContentApi } from 'roosterjs-content-model-core'; /** * Key of localized strings of Zoom button @@ -21,53 +15,8 @@ export const exportContent: ContentModelRibbonButton = { iconName: 'Export', flipWhenRtl: true, onClick: editor => { - // TODO: We need a export function in dev code to handle this feature const win = editor.getDocument().defaultView.open(); - - editor.formatContentModel(model => { - const clonedModel = cloneModel(model, { - includeCachedElement: (node, type) => { - switch (type) { - case 'cache': - return undefined; - - case 'general': - return node.cloneNode() as HTMLElement; - - case 'entity': - const clonedRoot = node.cloneNode(true) as HTMLElement; - const format: ContentModelEntityFormat = {}; - let isEntity = false; - - clonedRoot.classList.forEach(name => { - isEntity = parseEntityClassName(name, format) || isEntity; - }); - - if (isEntity && format.id && format.entityType) { - editor.triggerEvent('entityOperation', { - operation: 'replaceTemporaryContent', - entity: { - wrapper: clonedRoot, - id: format.id, - type: format.entityType, - isReadonly: !!format.isReadonly, - }, - }); - } - - return clonedRoot; - } - }, - }); - - contentModelToDom( - win.document, - win.document.body, - clonedModel, - createModelToDomContext() - ); - - return false; - }); + const html = exportContentApi(editor); + win.document.write(editor.getTrustedHTMLHandler()(html)); }, }; diff --git a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts index 7fb8c1ad82b..b392e4c38cd 100644 --- a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts +++ b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts @@ -1,8 +1,8 @@ import ContentModelPane, { ContentModelPaneProps } from './ContentModelPane'; import SidePanePluginImpl from '../SidePanePluginImpl'; import { ContentModelRibbonPlugin } from '../../ribbonButtons/contentModel/ContentModelRibbonPlugin'; -import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { setCurrentContentModel } from './currentModel'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -20,7 +20,7 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< initialize(editor: IEditor): void { super.initialize(editor); - this.contentModelRibbon.initialize(editor as IContentModelEditor); // Temporarily use IContentModelEditor here. TODO: Port side pane to use IStandaloneEditor + this.contentModelRibbon.initialize(editor as IEditor & IStandaloneEditor); // TODO: Port side pane to use IStandaloneEditor editor.getDocument().addEventListener('selectionchange', this.onModelChangeFromSelection); } @@ -37,7 +37,9 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< if (e.eventType == PluginEventType.ContentChanged && e.source == 'RefreshModel') { this.getComponent(component => { // TODO: Port to use IStandaloneEditor and remove type cast here - const model = (this.editor as IContentModelEditor).getContentModelCopy('connected'); + const model = (this.editor as IEditor & IStandaloneEditor).getContentModelCopy( + 'connected' + ); component.setContentModel(model); setCurrentContentModel(model); }); @@ -72,7 +74,9 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< private onModelChange = () => { this.getComponent(component => { // TODO: Port to use IStandaloneEditor and remove type cast here - const model = (this.editor as IContentModelEditor).getContentModelCopy('connected'); + const model = (this.editor as IEditor & IStandaloneEditor).getContentModelCopy( + 'connected' + ); component.setContentModel(model); setCurrentContentModel(model); }); diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx index 47bba88757b..e380bafbb40 100644 --- a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; import ApiPaneProps from '../ApiPaneProps'; -import { Entity } from 'roosterjs-editor-types'; +import { Entity, IEditor } from 'roosterjs-editor-types'; import { getEntityFromElement, getEntitySelector } from 'roosterjs-editor-dom'; -import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { insertEntity } from 'roosterjs-content-model-api'; -import { InsertEntityOptions } from 'roosterjs-content-model-types'; +import { InsertEntityOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; import { trustedHTMLHandler } from '../../../../utils/trustedHTMLHandler'; const styles = require('./InsertEntityPane.scss'); @@ -106,37 +105,36 @@ export default class InsertEntityPane extends React.Component { - const options: InsertEntityOptions = { - contentNode: node, - focusAfterEntity: focusAfterEntity, - }; + editor.focus(); - if (isBlock) { - insertEntity( - editor as IContentModelEditor, - entityType, - true, - insertAtRoot - ? 'root' - : insertAtTop - ? 'begin' - : insertAtBottom - ? 'end' - : 'focus', - options - ); - } else { - insertEntity( - editor as IContentModelEditor, - entityType, - isBlock, - insertAtTop ? 'begin' : insertAtBottom ? 'end' : 'focus', - options - ); - } - }); + if (isBlock) { + insertEntity( + editor as IStandaloneEditor & IEditor, + entityType, + true, + insertAtRoot + ? 'root' + : insertAtTop + ? 'begin' + : insertAtBottom + ? 'end' + : 'focus', + options + ); + } else { + insertEntity( + editor as IStandaloneEditor & IEditor, + entityType, + isBlock, + insertAtTop ? 'begin' : insertAtBottom ? 'end' : 'focus', + options + ); + } } }; diff --git a/demo/scripts/controls/sidePane/editorOptions/codes/ContentModelEditorCode.ts b/demo/scripts/controls/sidePane/editorOptions/codes/ContentModelEditorCode.ts index 00acba0e3a3..cb5ad80ba38 100644 --- a/demo/scripts/controls/sidePane/editorOptions/codes/ContentModelEditorCode.ts +++ b/demo/scripts/controls/sidePane/editorOptions/codes/ContentModelEditorCode.ts @@ -38,7 +38,7 @@ export default class ContentModelEditorCode extends CodeElement { : ''; code += darkMode ? this.indent(`getDarkColor: ${darkMode},\n`) : ''; code += '};\n'; - code += 'let editor = new roosterjsContentModel.ContentModelEditor(contentDiv, options);\n'; + code += 'let editor = new roosterjsContentModel.StandaloneEditor(contentDiv, options);\n'; code += this.buttons ? this.buttons.getCode() : ''; return code; diff --git a/demo/scripts/controls/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controls/sidePane/editorOptions/codes/SimplePluginCode.ts index fc2f013995a..442679efc19 100644 --- a/demo/scripts/controls/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controls/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -18,7 +18,7 @@ export class PasteCode extends SimplePluginCode { export class ContentModelPasteCode extends SimplePluginCode { constructor() { - super('ContentModelPastePlugin', 'roosterjsContentModel'); + super('PastePlugin', 'roosterjsContentModel'); } } diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 9e9a172339e..0118762dd9e 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -4,12 +4,28 @@ import { getObjectKeys } from 'roosterjs-editor-dom'; export default function getDefaultContentEditFeatureSettings(): ContentEditFeatureSettings { const allFeatures = getAllFeatures(); + return { ...getObjectKeys(allFeatures).reduce((settings, key) => { settings[key] = !allFeatures[key].defaultDisabled; return settings; }, {}), - indentWhenAltShiftRight: true, - outdentWhenAltShiftLeft: true, + ...listFeatures, }; } + +const listFeatures = { + autoBullet: false, + indentWhenTab: false, + outdentWhenShiftTab: false, + outdentWhenBackspaceOnEmptyFirstLine: false, + outdentWhenEnterOnEmptyLine: false, + mergeInNewLineWhenBackspaceOnFirstChar: false, + maintainListChain: false, + maintainListChainWhenDelete: false, + autoNumberingList: false, + autoBulletList: false, + mergeListOnBackspaceAfterList: false, + outdentWhenAltShiftLeft: false, + indentWhenAltShiftRight: false, +}; diff --git a/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx index 4fbe6876ec9..06a70871549 100644 --- a/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx +++ b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx @@ -249,6 +249,9 @@ export default class ContentModelEventViewPane extends React.Component< case PluginEventType.BeforeKeyboardEditing: return Key code={event.rawEvent.which}; + case PluginEventType.Input: + return Input type={event.rawEvent.inputType}; + default: return null; } diff --git a/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts b/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts index 1cec23429bf..523031c369d 100644 --- a/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts +++ b/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts @@ -1,8 +1,8 @@ import FormatStatePlugin from './FormatStatePlugin'; -import { FormatState } from 'roosterjs-editor-types'; +import { FormatState, IEditor } from 'roosterjs-editor-types'; import { getFormatState } from 'roosterjs-content-model-api'; import { getPositionRect } from 'roosterjs-editor-dom'; -import { IContentModelEditor } from 'roosterjs-content-model-editor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; export default class ContentModelFormatStatePlugin extends FormatStatePlugin { protected getFormatState() { @@ -10,7 +10,9 @@ export default class ContentModelFormatStatePlugin extends FormatStatePlugin { return null; } - const format = (getFormatState(this.editor as IContentModelEditor) as any) as FormatState; + const format = (getFormatState( + this.editor as IStandaloneEditor & IEditor + ) as any) as FormatState; const position = this.editor && this.editor.getFocusedPosition(); const rect = position && getPositionRect(position); return { diff --git a/demo/scripts/tsconfig.json b/demo/scripts/tsconfig.json index be116dab8c8..80082fd426a 100644 --- a/demo/scripts/tsconfig.json +++ b/demo/scripts/tsconfig.json @@ -49,12 +49,8 @@ "roosterjs-content-model-api/lib/*": [ "packages-content-model/roosterjs-content-model-api/lib/*" ], - "roosterjs-content-model-editor": [ - "packages-content-model/roosterjs-content-model-editor/lib/index" - ], - "roosterjs-content-model-editor/lib/*": [ - "packages-content-model/roosterjs-content-model-editor/lib/*" - ], + "roosterjs-editor-adapter": ["packages/roosterjs-editor-adapter/lib/index"], + "roosterjs-editor-adapter/lib/*": ["packages/roosterjs-editor-adapter/lib/*"], "roosterjs-content-model-plugins": [ "packages-content-model/roosterjs-content-model-plugins/lib/index" ], diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts index b5bd38fc378..f27263ca785 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -40,4 +40,10 @@ export { default as adjustImageSelection } from './publicApi/image/adjustImageSe export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as insertEntity } from './publicApi/entity/insertEntity'; +export { insertTableRow } from './modelApi/table/insertTableRow'; +export { insertTableColumn } from './modelApi/table/insertTableColumn'; + +export { formatTableWithContentModel } from './publicApi/utils/formatTableWithContentModel'; export { setListType } from './modelApi/list/setListType'; +export { findListItemsInSameThread } from './modelApi/list/findListItemsInSameThread'; +export { setModelIndentation } from './modelApi/block/setModelIndentation'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 97fa93cc7a8..4d09cfa42a7 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -1,6 +1,15 @@ import { createListLevel, parseValueWithUnit } from 'roosterjs-content-model-dom'; -import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; +import { findListItemsInSameThread } from '../list/findListItemsInSameThread'; +import { + getOperationalBlocks, + isBlockGroupOfType, + updateListMetadata, +} from 'roosterjs-content-model-core'; + import type { + ContentModelBlock, + ContentModelBlockFormat, + ContentModelBlockGroup, ContentModelDocument, ContentModelListItem, ContentModelListLevel, @@ -9,7 +18,10 @@ import type { const IndentStepInPixel = 40; /** - * @internal + * @param model The content model to set indentation + * @param indentation The indentation type, 'indent' to indent, 'outdent' to outdent + * @param length The length of indentation in pixel, default value is 40 + * Set indentation for selected list items or paragraphs */ export function setModelIndentation( model: ContentModelDocument, @@ -22,41 +34,143 @@ export function setModelIndentation( ['TableCell'] ); const isIndent = indentation == 'indent'; + const modifiedBlocks: ContentModelBlock[] = []; - paragraphOrListItem.forEach(({ block }) => { + paragraphOrListItem.forEach(({ block, parent, path }) => { if (isBlockGroupOfType(block, 'ListItem')) { - if (isIndent) { - const lastLevel = block.levels[block.levels.length - 1]; - const newLevel: ContentModelListLevel = createListLevel( - lastLevel?.listType || 'UL', - lastLevel?.format - ); - - // New level is totally new, no need to have these attributes for now - delete newLevel.format.startNumberOverride; - - block.levels.push(newLevel); - } else { - block.levels.pop(); + const thread = findListItemsInSameThread(model, block); + const firstItem = thread[0]; + //if the first item is selected and has only one level, we should add margin to the whole list + if (isSelected(firstItem) && firstItem.levels.length == 1) { + const level = block.levels[0]; + const { format } = level; + const { marginLeft, marginRight } = format; + const newValue = calculateMarginValue(format, isIndent, length); + const isRtl = format.direction == 'rtl'; + const originalValue = parseValueWithUnit(isRtl ? marginRight : marginLeft); + + if (!isIndent && originalValue == 0) { + block.levels.pop(); + } else if (newValue !== null) { + if (isRtl) { + level.format.marginRight = newValue + 'px'; + } else { + level.format.marginLeft = newValue + 'px'; + } + } + //if block has only one level, there is not need to check if it is multilevel selection + } else if (block.levels.length == 1 || !isMultilevelSelection(model, block, parent)) { + if (isIndent) { + const lastLevel = block.levels[block.levels.length - 1]; + const newLevel: ContentModelListLevel = createListLevel( + lastLevel?.listType || 'UL', + lastLevel?.format + ); + + updateListMetadata(newLevel, metadata => { + metadata = metadata || {}; + metadata.applyListStyleFromLevel = true; + return metadata; + }); + + // New level is totally new, no need to have these attributes for now + delete newLevel.format.startNumberOverride; + + block.levels.push(newLevel); + } else { + block.levels.pop(); + } } } else if (block) { - const { format } = block; - const { marginLeft, marginRight, direction } = format; - const isRtl = direction == 'rtl'; - const originalValue = parseValueWithUnit(isRtl ? marginRight : marginLeft); - let newValue = (isIndent ? Math.ceil : Math.floor)(originalValue / length) * length; - - if (newValue == originalValue) { - newValue = Math.max(newValue + length * (isIndent ? 1 : -1), 0); - } + let currentBlock: ContentModelBlock = block; + let currentParent: ContentModelBlockGroup = parent; + + while (currentParent && modifiedBlocks.indexOf(currentBlock) < 0) { + const index = path.indexOf(currentParent); + const { format } = currentBlock; + const newValue = calculateMarginValue(format, isIndent, length); - if (isRtl) { - format.marginRight = newValue + 'px'; - } else { - format.marginLeft = newValue + 'px'; + if (newValue !== null) { + const isRtl = format.direction == 'rtl'; + + if (isRtl) { + format.marginRight = newValue + 'px'; + } else { + format.marginLeft = newValue + 'px'; + } + + modifiedBlocks.push(currentBlock); + + break; + } else if (currentParent.blockGroupType == 'FormatContainer' && index >= 0) { + delete currentParent.cachedElement; + + currentBlock = currentParent; + currentParent = path[index + 1]; + } else { + break; + } } } }); return paragraphOrListItem.length > 0; } + +function isSelected(listItem: ContentModelListItem) { + return listItem.blocks.some(block => { + if (block.blockType == 'Paragraph') { + return block.segments.some(segment => segment.isSelected); + } + }); +} + +/* + * Check if the selection has list items with different levels and the first item of the list is selected, do not create a sub list. + * Otherwise, the margin of the first item will be changed, and the sub list will be created, creating a unintentional margin difference between the list items. + */ +function isMultilevelSelection( + model: ContentModelDocument, + listItem: ContentModelListItem, + parent: ContentModelBlockGroup +) { + const listIndex = parent.blocks.indexOf(listItem); + for (let i = listIndex - 1; i >= 0; i--) { + const block = parent.blocks[i]; + if ( + isBlockGroupOfType(block, 'ListItem') && + block.levels.length == 1 && + isSelected(block) + ) { + const firstItem = findListItemsInSameThread(model, block)[0]; + return isSelected(firstItem); + } + + if (!isBlockGroupOfType(block, 'ListItem')) { + return false; + } + } + return false; +} + +function calculateMarginValue( + format: ContentModelBlockFormat, + isIndent: boolean, + length: number = IndentStepInPixel +): number | null { + const { marginLeft, marginRight, direction } = format; + const isRtl = direction == 'rtl'; + const originalValue = parseValueWithUnit(isRtl ? marginRight : marginLeft); + let newValue = (isIndent ? Math.ceil : Math.floor)(originalValue / length) * length; + + if (newValue == originalValue) { + newValue = Math.max(newValue + length * (isIndent ? 1 : -1), 0); + } + + if (newValue == originalValue) { + // Return null to let caller know nothing is changed + return null; + } else { + return newValue; + } +} diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts index 6272112a10c..d1cc624b43c 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts @@ -70,6 +70,10 @@ function isQuote(block: ContentModelBlock): block is ContentModelFormatContainer function areAllBlockQuotes( blockAndParents: OperationalBlocks[] -): blockAndParents is { block: ContentModelFormatContainer; parent: ContentModelBlockGroup }[] { +): blockAndParents is { + block: ContentModelFormatContainer; + parent: ContentModelBlockGroup; + path: ContentModelBlockGroup[]; +}[] { return blockAndParents.every(blockAndParent => isQuote(blockAndParent.block)); } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts index 5a52bfb959e..cf3f41e9df8 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts @@ -16,7 +16,7 @@ import type { ContentModelEntity, ContentModelParagraph, DeleteSelectionResult, - FormatWithContentModelContext, + FormatContentModelContext, InsertEntityPosition, } from 'roosterjs-content-model-types'; @@ -29,7 +29,7 @@ export function insertEntityModel( position: InsertEntityPosition, isBlock: boolean, focusAfterEntity?: boolean, - context?: FormatWithContentModelContext + context?: FormatContentModelContext ) { let blockParent: ContentModelBlockGroup | undefined; let blockIndex = -1; @@ -38,6 +38,10 @@ export function insertEntityModel( if (position == 'begin' || position == 'end') { blockParent = model; blockIndex = position == 'begin' ? 0 : model.blocks.length; + + if (!isBlock) { + Object.assign(entityModel.format, model.format); + } } else if ((deleteResult = deleteSelection(model, [], context)).insertPoint) { const { marker, paragraph, path } = deleteResult.insertPoint; @@ -48,6 +52,8 @@ export function insertEntityModel( if (!isBlock) { const index = paragraph.segments.indexOf(marker); + Object.assign(entityModel.format, marker.format); + if (index >= 0) { paragraph.segments.splice(focusAfterEntity ? index : index + 1, 0, entityModel); } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts index 4a59423cd94..55511429be8 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts @@ -5,7 +5,9 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * @param model The content model + * @param currentItem The current list item + * Search for all list items in the same thread as the current list item */ export function findListItemsInSameThread( model: ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index 4ffacd55d76..c824a5a1bbe 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -89,7 +89,7 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') ); if (block.blockType == 'Paragraph') { - block.isImplicit = true; + setParagraphNotImplicit(block); } newListItem.blocks.push(block); diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts new file mode 100644 index 00000000000..8cab099e9a5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts @@ -0,0 +1,26 @@ +import { setSelection } from 'roosterjs-content-model-core'; +import type { ContentModelTable, TableSelectionCoordinates } from 'roosterjs-content-model-types'; + +/** + * Clear selection of a table. + * @internal + * @param table The table model where the selection is to be cleared + * @param sel The selection coordinates to be cleared + */ +export function clearSelectedCells(table: ContentModelTable, sel: TableSelectionCoordinates) { + if ( + sel.firstColumn >= 0 && + sel.firstRow >= 0 && + sel.lastColumn < table.widths.length && + sel.lastRow < table.rows.length + ) { + for (let i = sel.firstRow; i <= sel.lastRow; i++) { + const row = table.rows[i]; + for (let j = sel.firstColumn; j <= sel.lastColumn; j++) { + const cell = row.cells[j]; + cell.isSelected = false; + setSelection(cell); + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts index b736eac36ac..40ef3f854d4 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts @@ -1,3 +1,4 @@ +import { clearSelectedCells } from './clearSelectedCells'; import { createTableCell } from 'roosterjs-content-model-dom'; import { getSelectedCells } from 'roosterjs-content-model-core'; import type { @@ -6,7 +7,9 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Insert a column to the table + * @param table The table model where the column is to be inserted + * @param operation The operation to be performed */ export function insertTableColumn( table: ContentModelTable, @@ -16,21 +19,21 @@ export function insertTableColumn( const insertLeft = operation == 'insertLeft'; if (sel) { + clearSelectedCells(table, sel); for (let i = sel?.firstColumn; i <= sel.lastColumn; i++) { table.rows.forEach(row => { const cell = row.cells[insertLeft ? sel.firstColumn : sel.lastColumn]; - row.cells.splice( - insertLeft ? sel.firstColumn : sel.lastColumn + 1, - 0, - createTableCell( - cell.spanLeft, - cell.spanAbove, - cell.isHeader, - cell.format, - cell.dataset - ) + const newCell = createTableCell( + cell.spanLeft, + cell.spanAbove, + cell.isHeader, + cell.format, + cell.dataset ); + newCell.isSelected = true; + + row.cells.splice(insertLeft ? sel.firstColumn : sel.lastColumn + 1, 0, newCell); }); table.widths.splice( insertLeft ? sel.firstColumn : sel.lastColumn + 1, diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts index 02227e80656..9a09226a11a 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts @@ -1,3 +1,4 @@ +import { clearSelectedCells } from './clearSelectedCells'; import { createTableCell } from 'roosterjs-content-model-dom'; import { getSelectedCells } from 'roosterjs-content-model-core'; import type { @@ -6,27 +7,32 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Insert a row to the table + * @param table The table model where the row is to be inserted + * @param operation The operation to be performed */ export function insertTableRow(table: ContentModelTable, operation: TableVerticalInsertOperation) { const sel = getSelectedCells(table); const insertAbove = operation == 'insertAbove'; if (sel) { + clearSelectedCells(table, sel); for (let i = sel.firstRow; i <= sel.lastRow; i++) { const sourceRow = table.rows[insertAbove ? sel.firstRow : sel.lastRow]; table.rows.splice(insertAbove ? sel.firstRow : sel.lastRow + 1, 0, { format: { ...sourceRow.format }, - cells: sourceRow.cells.map(cell => - createTableCell( + cells: sourceRow.cells.map(cell => { + const newCell = createTableCell( cell.spanLeft, cell.spanAbove, cell.isHeader, cell.format, cell.dataset - ) - ), + ); + newCell.isSelected = true; + return newCell; + }), height: sourceRow.height, }); } diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 2cefb62eb9b..8282c0fcc9f 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -58,16 +58,22 @@ export default function insertEntity( options?: InsertEntityOptions ): ContentModelEntity | null { const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot } = options || {}; - const wrapper = editor.getDocument().createElement(isBlock ? BlockEntityTag : InlineEntityTag); + const document = editor.getDocument(); + const wrapper = document.createElement(isBlock ? BlockEntityTag : InlineEntityTag); const display = wrapperDisplay ?? (isBlock ? undefined : 'inline-block'); wrapper.style.setProperty('display', display || null); + if (display == undefined && isBlock) { + wrapper.style.setProperty('width', '100%'); + wrapper.style.setProperty('display', 'inline-block'); + } + if (contentNode) { wrapper.appendChild(contentNode); } - const entityModel = createEntity(wrapper, true /*isReadonly*/, undefined /*format*/, type); + const entityModel = createEntity(wrapper, true /* isReadonly */, undefined /*format*/, type); editor.formatContentModel( (model, context) => { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index d735115e945..e97cc925430 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,4 +1,4 @@ -import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; +import { retrieveModelFormatState } from 'roosterjs-content-model-core'; import type { IStandaloneEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts index 78adfc5d5f1..5f63f760b54 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts @@ -2,7 +2,7 @@ import { alignTable } from '../../modelApi/table/alignTable'; import { deleteTable } from '../../modelApi/table/deleteTable'; import { deleteTableColumn } from '../../modelApi/table/deleteTableColumn'; import { deleteTableRow } from '../../modelApi/table/deleteTableRow'; -import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; +import { formatTableWithContentModel } from '../utils/formatTableWithContentModel'; import { insertTableColumn } from '../../modelApi/table/insertTableColumn'; import { insertTableRow } from '../../modelApi/table/insertTableRow'; import { mergeTableCells } from '../../modelApi/table/mergeTableCells'; @@ -10,24 +10,11 @@ import { mergeTableColumn } from '../../modelApi/table/mergeTableColumn'; import { mergeTableRow } from '../../modelApi/table/mergeTableRow'; import { splitTableCellHorizontally } from '../../modelApi/table/splitTableCellHorizontally'; import { splitTableCellVertically } from '../../modelApi/table/splitTableCellVertically'; - -import { - hasSelectionInBlock, - applyTableFormat, - getFirstSelectedTable, - normalizeTable, - setSelection, -} from 'roosterjs-content-model-core'; import type { TableOperation, IStandaloneEditor } from 'roosterjs-content-model-types'; import { alignTableCellHorizontally, alignTableCellVertically, } from '../../modelApi/table/alignTableCell'; -import { - createSelectionMarker, - hasMetadata, - setParagraphNotImplicit, -} from 'roosterjs-content-model-dom'; /** * Format current focused table with the given format @@ -37,98 +24,67 @@ import { export default function editTable(editor: IStandaloneEditor, operation: TableOperation) { editor.focus(); - editor.formatContentModel( - model => { - const [tableModel, path] = getFirstSelectedTable(model); - - if (tableModel) { - switch (operation) { - case 'alignCellLeft': - case 'alignCellCenter': - case 'alignCellRight': - alignTableCellHorizontally(tableModel, operation); - break; - case 'alignCellTop': - case 'alignCellMiddle': - case 'alignCellBottom': - alignTableCellVertically(tableModel, operation); - break; - case 'alignCenter': - case 'alignLeft': - case 'alignRight': - alignTable(tableModel, operation); - break; - - case 'deleteColumn': - deleteTableColumn(tableModel); - break; - - case 'deleteRow': - deleteTableRow(tableModel); - break; - - case 'deleteTable': - deleteTable(tableModel); - break; - - case 'insertAbove': - case 'insertBelow': - insertTableRow(tableModel, operation); - break; - - case 'insertLeft': - case 'insertRight': - insertTableColumn(tableModel, operation); - break; - - case 'mergeAbove': - case 'mergeBelow': - mergeTableRow(tableModel, operation); - break; - - case 'mergeCells': - mergeTableCells(tableModel); - break; - - case 'mergeLeft': - case 'mergeRight': - mergeTableColumn(tableModel, operation); - break; - - case 'splitHorizontally': - splitTableCellHorizontally(tableModel); - break; - - case 'splitVertically': - splitTableCellVertically(tableModel); - break; - } - - if (!hasSelectionInBlock(tableModel)) { - const paragraph = ensureFocusableParagraphForTable(model, path, tableModel); - - if (paragraph) { - const marker = createSelectionMarker(model.format); - - paragraph.segments.unshift(marker); - setParagraphNotImplicit(paragraph); - setSelection(model, marker); - } - } - - normalizeTable(tableModel, model.format); - - if (hasMetadata(tableModel)) { - applyTableFormat(tableModel, undefined /*newFormat*/, true /*keepCellShade*/); - } - - return true; - } else { - return false; - } - }, - { - apiName: 'editTable', + formatTableWithContentModel(editor, 'editTable', tableModel => { + switch (operation) { + case 'alignCellLeft': + case 'alignCellCenter': + case 'alignCellRight': + alignTableCellHorizontally(tableModel, operation); + break; + case 'alignCellTop': + case 'alignCellMiddle': + case 'alignCellBottom': + alignTableCellVertically(tableModel, operation); + break; + case 'alignCenter': + case 'alignLeft': + case 'alignRight': + alignTable(tableModel, operation); + break; + + case 'deleteColumn': + deleteTableColumn(tableModel); + break; + + case 'deleteRow': + deleteTableRow(tableModel); + break; + + case 'deleteTable': + deleteTable(tableModel); + break; + + case 'insertAbove': + case 'insertBelow': + insertTableRow(tableModel, operation); + break; + + case 'insertLeft': + case 'insertRight': + insertTableColumn(tableModel, operation); + break; + + case 'mergeAbove': + case 'mergeBelow': + mergeTableRow(tableModel, operation); + break; + + case 'mergeCells': + mergeTableCells(tableModel); + break; + + case 'mergeLeft': + case 'mergeRight': + mergeTableColumn(tableModel, operation); + break; + + case 'splitHorizontally': + splitTableCellHorizontally(tableModel); + break; + + case 'splitVertically': + splitTableCellVertically(tableModel); + break; } - ); + }); } diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts index 91a3a121696..45f9653674e 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -7,6 +7,7 @@ import type { ContentModelSegmentFormat, IStandaloneEditor, } from 'roosterjs-content-model-types'; + /** * @internal */ diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatTableWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatTableWithContentModel.ts new file mode 100644 index 00000000000..fa33f6ed282 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatTableWithContentModel.ts @@ -0,0 +1,68 @@ +import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; +import { + createSelectionMarker, + hasMetadata, + setParagraphNotImplicit, +} from 'roosterjs-content-model-dom'; +import { + hasSelectionInBlock, + applyTableFormat, + getFirstSelectedTable, + normalizeTable, + setSelection, +} from 'roosterjs-content-model-core'; +import type { + ContentModelTable, + IStandaloneEditor, + TableSelection, +} from 'roosterjs-content-model-types'; + +/** + * Invoke a callback to format the selected table using Content Model + * @param editor The editor object + * @param apiName Name of API this calling this function. This is mostly for logging. + * @param callback The callback to format the table. It will be called with current selected table. If no table is selected, it will not be called. + * @param selectionOverride Override the current selection. If we want to format a table even currently it is not selected, we can use this parameter to override current selection + */ +export function formatTableWithContentModel( + editor: IStandaloneEditor, + apiName: string, + callback: (tableModel: ContentModelTable) => void, + selectionOverride?: TableSelection +) { + editor.formatContentModel( + model => { + const [tableModel, path] = getFirstSelectedTable(model); + + if (tableModel) { + callback(tableModel); + + if (!hasSelectionInBlock(tableModel)) { + const paragraph = ensureFocusableParagraphForTable(model, path, tableModel); + + if (paragraph) { + const marker = createSelectionMarker(model.format); + + paragraph.segments.unshift(marker); + setParagraphNotImplicit(paragraph); + setSelection(model, marker); + } + } + + normalizeTable(tableModel, model.format); + + if (hasMetadata(tableModel)) { + applyTableFormat(tableModel, undefined /*newFormat*/, true /*keepCellShade*/); + } + + return true; + } else { + return false; + } + }, + { + apiName, + selectionOverride, + } + ); +} diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index 5d475c47c98..f3b72328752 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -1,6 +1,7 @@ import { setModelIndentation } from '../../../lib/modelApi/block/setModelIndentation'; import { createContentModelDocument, + createFormatContainer, createListItem, createListLevel, createParagraph, @@ -283,8 +284,13 @@ describe('indent', () => { blockGroupType: 'ListItem', blockType: 'BlockGroup', levels: [ - { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, ], blocks: [para1, para2, para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -344,9 +350,9 @@ describe('indent', () => { }, format: { startNumberOverride: 2, + marginLeft: '40px', }, }, - { listType: 'OL', dataset: {}, format: {} }, ], blocks: [para1, para2, para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -384,7 +390,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -433,7 +443,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para1, para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -444,7 +458,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -492,7 +510,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para1, para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -502,8 +524,13 @@ describe('indent', () => { blockGroupType: 'ListItem', blockType: 'BlockGroup', levels: [ - { listType: 'UL', dataset: {}, format: {} }, - { listType: 'UL', dataset: {}, format: {} }, + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, ], blocks: [para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -550,8 +577,13 @@ describe('indent', () => { blockGroupType: 'ListItem', blockType: 'BlockGroup', levels: [ - { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, ], blocks: [para1], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -561,8 +593,13 @@ describe('indent', () => { blockGroupType: 'ListItem', blockType: 'BlockGroup', levels: [ - { listType: 'UL', dataset: {}, format: {} }, - { listType: 'UL', dataset: {}, format: {} }, + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, ], blocks: [para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -607,6 +644,77 @@ describe('indent', () => { }); expect(result).toBeTrue(); }); + + it('Group with list with first item selected', () => { + const group = createContentModelDocument(); + const listItem = createListItem([createListLevel('UL')]); + const listItem2 = createListItem([createListLevel('UL')]); + const listItem3 = createListItem([createListLevel('UL')]); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + text1.isSelected = true; + text2.isSelected = true; + text3.isSelected = true; + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + listItem.blocks.push(para1); + listItem2.blocks.push(para2); + listItem3.blocks.push(para3); + group.blocks.push(listItem); + group.blocks.push(listItem2); + group.blocks.push(listItem3); + + const result = setModelIndentation(group, 'indent'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + ...listItem, + levels: [ + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, + ], + }, + { + ...listItem2, + levels: [ + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, + ], + }, + { + ...listItem3, + levels: [ + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, + ], + }, + ], + }); + + expect(result).toBeTrue(); + }); }); describe('outdent', () => { @@ -1007,4 +1115,184 @@ describe('outdent', () => { }); expect(result).toBeTrue(); }); + + it('Group with list with no indention selected', () => { + const group = createContentModelDocument(); + const listItem = createListItem([createListLevel('UL')]); + const listItem2 = createListItem([createListLevel('UL')]); + const listItem3 = createListItem([createListLevel('UL')]); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + text1.isSelected = true; + text2.isSelected = true; + text3.isSelected = true; + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + listItem.blocks.push(para1); + listItem2.blocks.push(para2); + listItem3.blocks.push(para3); + group.blocks.push(listItem); + group.blocks.push(listItem2); + group.blocks.push(listItem3); + + const result = setModelIndentation(group, 'outdent'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + ...listItem, + levels: [], + }, + { + ...listItem2, + levels: [], + }, + { + ...listItem3, + levels: [], + }, + ], + }); + + expect(result).toBeTrue(); + }); + + it('Outdent parent format container, ltr', () => { + const group = createContentModelDocument(); + const formatContainer = createFormatContainer('div'); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + + text2.isSelected = true; + formatContainer.format.marginLeft = '100px'; + formatContainer.format.marginRight = '60px'; + + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + + formatContainer.blocks.push(para1, para2); + group.blocks.push(formatContainer, para3); + + const result = setModelIndentation(group, 'outdent'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: { marginLeft: '80px', marginRight: '60px' }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test3', format: {} }], + format: {}, + }, + ], + }); + + expect(result).toBeTrue(); + }); + + it('Outdent parent format container, rtl', () => { + const group = createContentModelDocument(); + const formatContainer = createFormatContainer('div'); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + + text1.isSelected = true; + text2.isSelected = true; + formatContainer.format.marginLeft = '100px'; + formatContainer.format.marginRight = '60px'; + formatContainer.format.direction = 'rtl'; + + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + + formatContainer.blocks.push(para1, para2); + group.blocks.push(formatContainer, para3); + + const result = setModelIndentation(group, 'outdent'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: { marginLeft: '100px', marginRight: '40px', direction: 'rtl' }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test3', format: {} }], + format: {}, + }, + ], + }); + + expect(result).toBeTrue(); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts index 49cf8bb1a6b..612895b5b50 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts @@ -10,8 +10,6 @@ import { createText, } from 'roosterjs-content-model-dom'; -const Entity = 'Entity' as any; - function runTestGlobal( model: ContentModelDocument, pos: InsertEntityPosition, @@ -19,6 +17,10 @@ function runTestGlobal( isBlock: boolean, focusAfterEntity: boolean ) { + const Entity = { + format: {}, + } as any; + insertEntityModel(model, Entity, pos, isBlock, focusAfterEntity); expect(model).toEqual(expectedResult, pos); @@ -55,7 +57,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [], @@ -71,7 +75,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -120,7 +126,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [txt1, marker, txt2], @@ -136,7 +144,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -152,7 +162,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -168,7 +180,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -199,7 +213,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [txt1, txt2], @@ -215,7 +231,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -231,7 +249,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -247,7 +267,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -275,7 +297,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -301,7 +325,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -317,7 +343,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [], @@ -333,7 +361,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [], @@ -361,7 +391,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -379,7 +411,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { format: {}, }, divider, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -395,7 +429,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, divider, ], }, @@ -407,7 +443,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, divider, ], } @@ -431,7 +469,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -449,7 +489,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { format: {}, }, entity2, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -465,7 +507,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -482,7 +526,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -515,7 +561,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [txt1, marker, txt2], @@ -532,7 +580,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -550,7 +600,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -568,7 +620,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -612,7 +666,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -628,7 +684,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -676,7 +734,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, txt1, txt2], @@ -692,7 +752,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -708,7 +770,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -724,7 +788,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -754,7 +820,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, txt1, txt2], @@ -770,7 +838,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -786,7 +856,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -802,7 +874,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -828,7 +902,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -854,7 +930,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -870,7 +948,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -886,7 +966,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -913,7 +995,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -931,7 +1015,9 @@ describe('insertEntityModel, block element, focus after entity', () => { format: {}, }, divider, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -947,7 +1033,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -964,7 +1052,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -992,7 +1082,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -1010,7 +1102,9 @@ describe('insertEntityModel, block element, focus after entity', () => { format: {}, }, entity2, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -1026,7 +1120,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -1043,7 +1139,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -1077,7 +1175,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, txt1, txt2], @@ -1094,7 +1194,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker2, br], @@ -1112,7 +1214,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker2, br], @@ -1130,7 +1234,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker2, br], @@ -1175,7 +1281,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1195,7 +1305,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1242,7 +1356,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1262,7 +1380,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1272,7 +1394,14 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity, txt2], + segments: [ + txt1, + marker, + { + format: {}, + } as any, + txt2, + ], format: {}, }, ], @@ -1282,7 +1411,14 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity, txt2], + segments: [ + txt1, + marker, + { + format: {}, + } as any, + txt2, + ], format: {}, }, ], @@ -1311,7 +1447,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1331,7 +1471,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1341,7 +1485,13 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity], + segments: [ + txt1, + marker, + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1351,7 +1501,13 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity], + segments: [ + txt1, + marker, + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1376,7 +1532,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1406,7 +1566,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1416,7 +1580,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, { @@ -1431,7 +1600,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, { @@ -1462,7 +1636,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1484,7 +1662,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { divider, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1494,7 +1676,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, divider, @@ -1505,7 +1692,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, divider, @@ -1532,7 +1724,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1554,7 +1750,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { entity2, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1564,7 +1764,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, entity2, @@ -1575,7 +1780,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, entity2, @@ -1606,7 +1816,7 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [{ format } as any], format: {}, segmentFormat: format, }, @@ -1628,7 +1838,7 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [{ format } as any], format: {}, segmentFormat: format, }, @@ -1640,7 +1850,7 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity, txt2], + segments: [txt1, marker, { format: {} } as any, txt2], format: {}, }, ], @@ -1651,7 +1861,7 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity, txt2], + segments: [txt1, marker, { format: {} } as any, txt2], format: {}, }, ], @@ -1692,7 +1902,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1712,7 +1927,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1759,7 +1979,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1779,7 +2004,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1789,7 +2019,14 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker, txt2], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + txt2, + ], format: {}, }, ], @@ -1799,7 +2036,14 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker, txt2], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + txt2, + ], format: {}, }, ], @@ -1828,7 +2072,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1848,7 +2097,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1858,7 +2112,13 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1868,7 +2128,13 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1893,7 +2159,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1923,7 +2194,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1933,7 +2209,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1948,7 +2229,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1979,7 +2265,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -2001,7 +2292,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { divider, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -2011,7 +2307,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, divider, @@ -2022,7 +2323,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, divider, @@ -2049,7 +2355,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -2071,7 +2382,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { entity2, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -2081,7 +2397,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, entity2, @@ -2092,7 +2413,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, entity2, @@ -2124,7 +2450,7 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker2], + segments: [{ format } as any, marker2], format: {}, segmentFormat: format, }, @@ -2146,7 +2472,7 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker2], + segments: [{ format } as any, marker2], format: {}, segmentFormat: format, }, @@ -2158,7 +2484,14 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker, txt2], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + txt2, + ], format: {}, }, ], @@ -2169,7 +2502,14 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker, txt2], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + txt2, + ], format: {}, }, ], diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts index 12ab4e5aab5..b457c35abff 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts @@ -99,7 +99,6 @@ describe('indent', () => { expect(para).toEqual({ blockType: 'Paragraph', format: {}, - isImplicit: true, segments: [ { segmentType: 'Text', @@ -392,7 +391,6 @@ describe('indent', () => { expect(para).toEqual({ blockType: 'Paragraph', format: { direction: 'rtl', textAlign: 'start', backgroundColor: 'yellow' }, - isImplicit: true, segments: [ { segmentType: 'Text', diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/clearSelectedCellsTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/clearSelectedCellsTest.ts new file mode 100644 index 00000000000..5ceb418b561 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/clearSelectedCellsTest.ts @@ -0,0 +1,207 @@ +import { clearSelectedCells } from '../../../lib/modelApi/table/clearSelectedCells'; +import { createSelectionMarker, createTable, createTableCell } from 'roosterjs-content-model-dom'; + +describe('clearSelectedCells', () => { + it('invalid selection to clear', () => { + const table = createTable(2); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows.forEach(row => { + row.cells.push(selectedCell, selectedCell), (row.height = 200); + }); + table.widths = [100, 100]; + + clearSelectedCells(table, { firstRow: 0, lastRow: 2, firstColumn: 0, lastColumn: 2 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [selectedCell, selectedCell], + }, + { + format: {}, + height: 200, + cells: [selectedCell, selectedCell], + }, + ], + widths: [100, 100], + dataset: {}, + }); + }); + + it('no cells selected - clear all', () => { + const table = createTable(2); + const unselectedCell = createTableCell(); + unselectedCell.isSelected = false; + table.rows.forEach(row => { + row.cells.push(unselectedCell, unselectedCell), (row.height = 200); + }); + table.widths = [100, 100]; + + clearSelectedCells(table, { firstRow: 0, lastRow: 1, firstColumn: 0, lastColumn: 1 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell], + }, + ], + widths: [100, 100], + dataset: {}, + }); + }); + + it('all cells selected - clear all', () => { + const table = createTable(4); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows.forEach(row => { + row.cells.push(selectedCell, selectedCell, selectedCell, selectedCell), + (row.height = 200); + }); + table.widths = [100, 100, 100, 100]; + + const unselectedCell = { ...selectedCell, isSelected: false }; + + clearSelectedCells(table, { firstRow: 0, lastRow: 3, firstColumn: 0, lastColumn: 3 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell, unselectedCell], + }, + ], + widths: [100, 100, 100, 100], + dataset: {}, + }); + }); + + it('all cells selected - clear centre', () => { + const table = createTable(4); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows.forEach(row => { + row.cells.push(selectedCell, selectedCell, selectedCell, selectedCell), + (row.height = 200); + }); + table.widths = [100, 100, 100, 100]; + + const unselectedCell = { ...selectedCell, isSelected: false }; + + clearSelectedCells(table, { firstRow: 1, lastRow: 2, firstColumn: 1, lastColumn: 2 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [selectedCell, selectedCell, selectedCell, selectedCell], + }, + { + format: {}, + height: 200, + cells: [selectedCell, unselectedCell, unselectedCell, selectedCell], + }, + { + format: {}, + height: 200, + cells: [selectedCell, unselectedCell, unselectedCell, selectedCell], + }, + { + format: {}, + height: 200, + cells: [selectedCell, selectedCell, selectedCell, selectedCell], + }, + ], + widths: [100, 100, 100, 100], + dataset: {}, + }); + }); + + it('clear selection marker', () => { + const table = createTable(3); + const unselectedCell = createTableCell(); + unselectedCell.isSelected = false; + table.rows.forEach(row => { + row.cells.push({ ...unselectedCell }, { ...unselectedCell }, { ...unselectedCell }), + (row.height = 200); + }); + table.widths = [100, 100, 100]; + + table.rows[1].cells[1].blocks = [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + createSelectionMarker(), + { segmentType: 'Text', format: {}, text: 'Text' }, + ], + }, + ]; + + const centreWithoutMarker = createTableCell(); + centreWithoutMarker.isSelected = false; + centreWithoutMarker.blocks = [ + { + blockType: 'Paragraph', + format: {}, + segments: [{ segmentType: 'Text', format: {}, text: 'Text' }], + }, + ]; + + clearSelectedCells(table, { firstRow: 1, lastRow: 1, firstColumn: 1, lastColumn: 1 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, centreWithoutMarker, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell], + }, + ], + widths: [100, 100, 100], + dataset: {}, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts index 814ab74aa62..57bb4ebbc8f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts @@ -49,20 +49,20 @@ describe('insertTableColumn', () => { it('table with single selection', () => { const table = createTable(1); - const cell1 = createTableCell(); - cell1.isSelected = true; - table.rows[0].cells.push(cell1); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }); table.widths = [100]; table.rows[0].height = 200; - const cell2 = { ...cell1 }; - delete cell2.isSelected; + const unselectedCell = { ...selectedCell }; + unselectedCell.isSelected = false; insertTableColumn(table, 'insertLeft'); expect(table).toEqual({ blockType: 'Table', format: {}, - rows: [{ format: {}, height: 200, cells: [cell2, cell1] }], + rows: [{ format: {}, height: 200, cells: [selectedCell, unselectedCell] }], widths: [100, 100], dataset: {}, }); @@ -71,73 +71,95 @@ describe('insertTableColumn', () => { expect(table).toEqual({ blockType: 'Table', format: {}, - rows: [{ format: {}, height: 200, cells: [cell2, cell1, cell2] }], + rows: [ + { format: {}, height: 200, cells: [unselectedCell, selectedCell, unselectedCell] }, + ], widths: [100, 100, 100], dataset: {}, }); }); - it('table with multi selection', () => { + it('table with multi selection - insertLeft', () => { const table = createTable(1); - const cell1 = createTableCell(); - const cell2 = createTableCell(false, false, true); - cell1.isSelected = true; - cell2.isSelected = true; - table.rows[0].cells.push(cell1, cell2); + const selectedCell = createTableCell(); + const selectedHeader = createTableCell(false, false, true); + selectedCell.isSelected = true; + selectedHeader.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }, { ...selectedHeader }); table.widths = [100, 200]; table.rows[0].height = 300; - const cell3 = { ...cell1 }; - delete cell3.isSelected; - - const cell4 = { ...cell2 }; - delete cell4.isSelected; + const unselectedCell = { ...selectedCell, isSelected: false }; + const unselectedHeader = { ...selectedHeader, isSelected: false }; insertTableColumn(table, 'insertLeft'); expect(table).toEqual({ blockType: 'Table', format: {}, - rows: [{ format: {}, height: 300, cells: [cell3, cell3, cell1, cell2] }], + rows: [ + { + format: {}, + height: 300, + cells: [selectedCell, selectedCell, unselectedCell, unselectedHeader], + }, + ], widths: [100, 100, 100, 200], dataset: {}, }); + }); + + it('table with multi selection - insertRight', () => { + const table = createTable(1); + const selectedCell = createTableCell(); + const selectedHeader = createTableCell(false, false, true); + selectedCell.isSelected = true; + selectedHeader.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }, { ...selectedHeader }); + table.widths = [100, 200]; + table.rows[0].height = 300; + + const unselectedCell = { ...selectedCell, isSelected: false }; + const unselectedHeader = { ...selectedHeader, isSelected: false }; insertTableColumn(table, 'insertRight'); expect(table).toEqual({ blockType: 'Table', format: {}, - rows: [{ format: {}, height: 300, cells: [cell3, cell3, cell1, cell2, cell4, cell4] }], - widths: [100, 100, 100, 200, 200, 200], + rows: [ + { + format: {}, + height: 300, + cells: [unselectedCell, unselectedHeader, selectedHeader, selectedHeader], + }, + ], + widths: [100, 200, 200, 200], dataset: {}, }); }); it('table with multi selection in multi row', () => { const table = createTable(2); - const cell1 = createTableCell(false, false, true); - const cell2 = createTableCell(false, true); + const selectedHeader = createTableCell(false, false, true); + const selectedSpanAbove = createTableCell(false, true); - cell1.isSelected = true; - cell2.isSelected = true; - table.rows[0].cells.push(cell1); - table.rows[1].cells.push(cell2); + selectedHeader.isSelected = true; + selectedSpanAbove.isSelected = true; + table.rows[0].cells.push({ ...selectedHeader }); + table.rows[1].cells.push({ ...selectedSpanAbove }); table.widths = [100]; table.rows[0].height = 200; table.rows[1].height = 300; - const cell3 = { ...cell1 }; - delete cell3.isSelected; - - const cell4 = { ...cell2 }; - delete cell4.isSelected; + const unselectedHeader = { ...selectedHeader, isSelected: false }; + const unselectedSpanAbove = { ...selectedSpanAbove, isSelected: false }; insertTableColumn(table, 'insertLeft'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell3, cell1] }, - { format: {}, height: 300, cells: [cell4, cell2] }, + { format: {}, height: 200, cells: [selectedHeader, unselectedHeader] }, + { format: {}, height: 300, cells: [selectedSpanAbove, unselectedSpanAbove] }, ], widths: [100, 100], dataset: {}, @@ -148,92 +170,150 @@ describe('insertTableColumn', () => { blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell3, cell1, cell3] }, - { format: {}, height: 300, cells: [cell4, cell2, cell4] }, + { + format: {}, + height: 200, + cells: [unselectedHeader, selectedHeader, unselectedHeader], + }, + { + format: {}, + height: 300, + cells: [unselectedSpanAbove, selectedSpanAbove, unselectedSpanAbove], + }, ], widths: [100, 100, 100], dataset: {}, }); }); - it('table with complex scenario', () => { - const table = createTable(3); + it('table with complex scenario - insertLeft', () => { + const table = createTable(4); const cell1 = createTableCell(false, false, false, { backgroundColor: '1' }); const cell2 = createTableCell(false, false, false, { backgroundColor: '2' }); const cell3 = createTableCell(false, false, false, { backgroundColor: '3' }); const cell4 = createTableCell(false, false, false, { backgroundColor: '4' }); - const cell5 = createTableCell(false, false, false, { backgroundColor: '5' }); - const cell6 = createTableCell(false, false, false, { backgroundColor: '6' }); - const cell7 = createTableCell(true, false, false, { backgroundColor: '7' }); - const cell8 = createTableCell(false, false, false, { backgroundColor: '8' }); - const cell9 = createTableCell(false, false, false, { backgroundColor: '9' }); - const cell10 = createTableCell(false, true, false, { backgroundColor: '10' }); - const cell11 = createTableCell(true, true, false, { backgroundColor: '11' }); + const selectedCell5 = createTableCell(false, false, false, { backgroundColor: '5' }); + const cell6 = createTableCell(true, false, false, { backgroundColor: '6' }); + const cell7 = createTableCell(false, false, false, { backgroundColor: '7' }); + const cell8 = createTableCell(false, true, false, { backgroundColor: '8' }); + const selectedCell9 = createTableCell(true, true, false, { backgroundColor: '9' }); + const cell10 = createTableCell(false, false, false, { backgroundColor: '10' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); - cell6.isSelected = true; - cell11.isSelected = true; - table.rows[0].cells.push(cell1, cell2, cell3, cell4); - table.rows[1].cells.push(cell5, cell6, cell7, cell8); - table.rows[2].cells.push(cell9, cell10, cell11, cell12); - table.widths = [100, 200, 300, 400]; - table.rows[0].height = 500; - table.rows[1].height = 600; - table.rows[2].height = 700; - - const cell6Clone = { ...cell6 }; - const cell11Clone = { ...cell11 }; - delete cell6Clone.isSelected; - delete cell11Clone.isSelected; + selectedCell5.isSelected = true; + selectedCell9.isSelected = true; + table.rows[0].cells.push(cell1, cell2, cell3); + table.rows[1].cells.push(cell4, { ...selectedCell5 }, cell6); + table.rows[2].cells.push(cell7, cell8, { ...selectedCell9 }); + table.rows[3].cells.push(cell10, cell11, cell12); + table.widths = [100, 200, 300]; + table.rows[0].height = 400; + table.rows[1].height = 500; + table.rows[2].height = 600; + table.rows[3].height = 700; + + const unselectedCell5 = { ...selectedCell5, isSelected: false }; + const unselectedCell9 = { ...selectedCell9, isSelected: false }; insertTableColumn(table, 'insertLeft'); + + const selectedCell2 = { ...cell2, isSelected: true }; + const selectedCell8 = { ...cell8, isSelected: true }; + const selectedCell11 = { ...cell11, isSelected: true }; + expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 500, cells: [cell1, cell2, cell2, cell2, cell3, cell4] }, + { + format: {}, + height: 400, + cells: [cell1, selectedCell2, selectedCell2, cell2, cell3], + }, + { + format: {}, + height: 500, + cells: [cell4, selectedCell5, selectedCell5, unselectedCell5, cell6], + }, { format: {}, height: 600, - cells: [cell5, cell6Clone, cell6Clone, cell6, cell7, cell8], + cells: [cell7, selectedCell8, selectedCell8, cell8, unselectedCell9], + }, + { + format: {}, + height: 700, + cells: [cell10, selectedCell11, selectedCell11, cell11, cell12], }, - { format: {}, height: 700, cells: [cell9, cell10, cell10, cell10, cell11, cell12] }, ], - widths: [100, 200, 200, 200, 300, 400], + widths: [100, 200, 200, 200, 300], dataset: {}, }); + }); + + it('table with complex scenario - insertRight', () => { + const table = createTable(4); + const cell1 = createTableCell(false, false, false, { backgroundColor: '1' }); + const cell2 = createTableCell(false, false, false, { backgroundColor: '2' }); + const cell3 = createTableCell(false, false, false, { backgroundColor: '3' }); + const cell4 = createTableCell(false, false, false, { backgroundColor: '4' }); + const selectedCell5 = createTableCell(false, false, false, { backgroundColor: '5' }); + const cell6 = createTableCell(true, false, false, { backgroundColor: '6' }); + const cell7 = createTableCell(false, false, false, { backgroundColor: '7' }); + const cell8 = createTableCell(false, true, false, { backgroundColor: '8' }); + const selectedCell9 = createTableCell(true, true, false, { backgroundColor: '9' }); + const cell10 = createTableCell(false, false, false, { backgroundColor: '10' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); + + selectedCell5.isSelected = true; + selectedCell9.isSelected = true; + table.rows[0].cells.push(cell1, cell2, cell3); + table.rows[1].cells.push(cell4, { ...selectedCell5 }, cell6); + table.rows[2].cells.push(cell7, cell8, { ...selectedCell9 }); + table.rows[3].cells.push(cell10, cell11, cell12); + table.widths = [100, 200, 300]; + table.rows[0].height = 400; + table.rows[1].height = 500; + table.rows[2].height = 600; + table.rows[3].height = 700; + + const unselectedCell5 = { ...selectedCell5, isSelected: false }; + const unselectedCell9 = { ...selectedCell9, isSelected: false }; insertTableColumn(table, 'insertRight'); + + const selectedCell3 = { ...cell3, isSelected: true }; + const selectedCell6 = { ...cell6, isSelected: true }; + const selectedCell12 = { ...cell12, isSelected: true }; + expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ + { + format: {}, + height: 400, + cells: [cell1, cell2, cell3, selectedCell3, selectedCell3], + }, { format: {}, height: 500, - cells: [cell1, cell2, cell2, cell2, cell3, cell3, cell3, cell4], + cells: [cell4, unselectedCell5, cell6, selectedCell6, selectedCell6], }, { format: {}, height: 600, - cells: [cell5, cell6Clone, cell6Clone, cell6, cell7, cell7, cell7, cell8], + cells: [cell7, cell8, unselectedCell9, selectedCell9, selectedCell9], }, { format: {}, height: 700, - cells: [ - cell9, - cell10, - cell10, - cell10, - cell11, - cell11Clone, - cell11Clone, - cell12, - ], + cells: [cell10, cell11, cell12, selectedCell12, selectedCell12], }, ], - widths: [100, 200, 200, 200, 300, 300, 300, 400], + widths: [100, 200, 300, 300, 300], dataset: {}, }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts index 35c49e4d7ea..8da63101b8f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts @@ -49,22 +49,21 @@ describe('insertTableRow', () => { it('table with single selection', () => { const table = createTable(1); - const cell1 = createTableCell(); - cell1.isSelected = true; - table.rows[0].cells.push(cell1); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }); table.widths = [100]; table.rows[0].height = 200; - const cell2 = { ...cell1 }; - delete cell2.isSelected; + const unselectedCell = { ...selectedCell, isSelected: false }; insertTableRow(table, 'insertAbove'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell2] }, - { format: {}, height: 200, cells: [cell1] }, + { format: {}, height: 200, cells: [selectedCell] }, + { format: {}, height: 200, cells: [unselectedCell] }, ], widths: [100], dataset: {}, @@ -75,58 +74,69 @@ describe('insertTableRow', () => { blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell2] }, - { format: {}, height: 200, cells: [cell1] }, - { format: {}, height: 200, cells: [cell2] }, + { format: {}, height: 200, cells: [unselectedCell] }, + { format: {}, height: 200, cells: [selectedCell] }, + { format: {}, height: 200, cells: [unselectedCell] }, ], widths: [100], dataset: {}, }); }); - it('table with multi selection', () => { + it('table with multi selection - insertAbove', () => { const table = createTable(2); - const cell1 = createTableCell(); - const cell2 = createTableCell(false, false, true); - cell1.isSelected = true; - cell2.isSelected = true; - table.rows[0].cells.push(cell1); - table.rows[1].cells.push(cell2); + const selectedCell = createTableCell(); + const selectedHeader = createTableCell(false, false, true); + selectedCell.isSelected = true; + selectedHeader.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }); + table.rows[1].cells.push({ ...selectedHeader }); table.widths = [100]; table.rows[0].height = 200; table.rows[1].height = 300; - const cell3 = { ...cell1 }; - delete cell3.isSelected; - - const cell4 = { ...cell2 }; - delete cell4.isSelected; + const unselectedCell = { ...selectedCell, isSelected: false }; + const unselectedHeader = { ...selectedHeader, isSelected: false }; insertTableRow(table, 'insertAbove'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell3] }, - { format: {}, height: 200, cells: [cell3] }, - { format: {}, height: 200, cells: [cell1] }, - { format: {}, height: 300, cells: [cell2] }, + { format: {}, height: 200, cells: [selectedCell] }, + { format: {}, height: 200, cells: [selectedCell] }, + { format: {}, height: 200, cells: [unselectedCell] }, + { format: {}, height: 300, cells: [unselectedHeader] }, ], widths: [100], dataset: {}, }); + }); + + it('table with multi selection - insertBelow', () => { + const table = createTable(2); + const selectedCell = createTableCell(); + const selectedHeader = createTableCell(false, false, true); + selectedCell.isSelected = true; + selectedHeader.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }); + table.rows[1].cells.push({ ...selectedHeader }); + table.widths = [100]; + table.rows[0].height = 200; + table.rows[1].height = 300; + + const unselectedCell = { ...selectedCell, isSelected: false }; + const unselectedHeader = { ...selectedHeader, isSelected: false }; insertTableRow(table, 'insertBelow'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell3] }, - { format: {}, height: 200, cells: [cell3] }, - { format: {}, height: 200, cells: [cell1] }, - { format: {}, height: 300, cells: [cell2] }, - { format: {}, height: 300, cells: [cell4] }, - { format: {}, height: 300, cells: [cell4] }, + { format: {}, height: 200, cells: [unselectedCell] }, + { format: {}, height: 300, cells: [unselectedHeader] }, + { format: {}, height: 300, cells: [selectedHeader] }, + { format: {}, height: 300, cells: [selectedHeader] }, ], widths: [100], dataset: {}, @@ -135,28 +145,25 @@ describe('insertTableRow', () => { it('table with multi selection in multi column', () => { const table = createTable(1); - const cell1 = createTableCell(false, false, true); - const cell2 = createTableCell(false, true); + const selectedHeader = createTableCell(false, false, true); + const selectedSpanAbove = createTableCell(false, true); - cell1.isSelected = true; - cell2.isSelected = true; - table.rows[0].cells.push(cell1, cell2); + selectedHeader.isSelected = true; + selectedSpanAbove.isSelected = true; + table.rows[0].cells.push({ ...selectedHeader }, { ...selectedSpanAbove }); table.widths = [100, 200]; table.rows[0].height = 300; - const cell3 = { ...cell1 }; - delete cell3.isSelected; - - const cell4 = { ...cell2 }; - delete cell4.isSelected; + const unselectedHeader = { ...selectedHeader, isSelected: false }; + const unselectedSpanAbove = { ...selectedSpanAbove, isSelected: false }; insertTableRow(table, 'insertAbove'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 300, cells: [cell3, cell4] }, - { format: {}, height: 300, cells: [cell1, cell2] }, + { format: {}, height: 300, cells: [selectedHeader, selectedSpanAbove] }, + { format: {}, height: 300, cells: [unselectedHeader, unselectedSpanAbove] }, ], widths: [100, 200], dataset: {}, @@ -167,35 +174,35 @@ describe('insertTableRow', () => { blockType: 'Table', format: {}, rows: [ - { format: {}, height: 300, cells: [cell3, cell4] }, - { format: {}, height: 300, cells: [cell1, cell2] }, - { format: {}, height: 300, cells: [cell3, cell4] }, + { format: {}, height: 300, cells: [unselectedHeader, unselectedSpanAbove] }, + { format: {}, height: 300, cells: [selectedHeader, selectedSpanAbove] }, + { format: {}, height: 300, cells: [unselectedHeader, unselectedSpanAbove] }, ], widths: [100, 200], dataset: {}, }); }); - it('table with complex scenario', () => { + it('table with complex scenario - insertAbove', () => { const table = createTable(4); const cell1 = createTableCell(false, false, false, { backgroundColor: '1' }); const cell2 = createTableCell(false, false, false, { backgroundColor: '2' }); const cell3 = createTableCell(false, false, false, { backgroundColor: '3' }); const cell4 = createTableCell(false, false, false, { backgroundColor: '4' }); - const cell5 = createTableCell(false, false, false, { backgroundColor: '5' }); + const selectedCell5 = createTableCell(false, false, false, { backgroundColor: '5' }); const cell6 = createTableCell(true, false, false, { backgroundColor: '6' }); const cell7 = createTableCell(false, false, false, { backgroundColor: '7' }); const cell8 = createTableCell(false, true, false, { backgroundColor: '8' }); - const cell9 = createTableCell(true, true, false, { backgroundColor: '9' }); + const selectedCell9 = createTableCell(true, true, false, { backgroundColor: '9' }); const cell10 = createTableCell(false, false, false, { backgroundColor: '10' }); const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); - cell5.isSelected = true; - cell9.isSelected = true; + selectedCell5.isSelected = true; + selectedCell9.isSelected = true; table.rows[0].cells.push(cell1, cell2, cell3); - table.rows[1].cells.push(cell4, cell5, cell6); - table.rows[2].cells.push(cell7, cell8, cell9); + table.rows[1].cells.push(cell4, { ...selectedCell5 }, cell6); + table.rows[2].cells.push(cell7, cell8, { ...selectedCell9 }); table.rows[3].cells.push(cell10, cell11, cell12); table.widths = [100, 200, 300]; table.rows[0].height = 400; @@ -203,39 +210,74 @@ describe('insertTableRow', () => { table.rows[2].height = 600; table.rows[3].height = 700; - const cell5Clone = { ...cell5 }; - const cell9Clone = { ...cell9 }; - delete cell5Clone.isSelected; - delete cell9Clone.isSelected; + const unselectedCell5 = { ...selectedCell5, isSelected: false }; + const unselectedCell9 = { ...selectedCell9, isSelected: false }; insertTableRow(table, 'insertAbove'); + + const selectedCell4 = { ...cell4, isSelected: true }; + const selectedCell6 = { ...cell6, isSelected: true }; + expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ { format: {}, height: 400, cells: [cell1, cell2, cell3] }, - { format: {}, height: 500, cells: [cell4, cell5Clone, cell6] }, - { format: {}, height: 500, cells: [cell4, cell5Clone, cell6] }, - { format: {}, height: 500, cells: [cell4, cell5, cell6] }, - { format: {}, height: 600, cells: [cell7, cell8, cell9] }, + { format: {}, height: 500, cells: [selectedCell4, selectedCell5, selectedCell6] }, + { format: {}, height: 500, cells: [selectedCell4, selectedCell5, selectedCell6] }, + { format: {}, height: 500, cells: [cell4, unselectedCell5, cell6] }, + { format: {}, height: 600, cells: [cell7, cell8, unselectedCell9] }, { format: {}, height: 700, cells: [cell10, cell11, cell12] }, ], widths: [100, 200, 300], dataset: {}, }); + }); + + it('table with complex scenario - insertBelow', () => { + const table = createTable(4); + const cell1 = createTableCell(false, false, false, { backgroundColor: '1' }); + const cell2 = createTableCell(false, false, false, { backgroundColor: '2' }); + const cell3 = createTableCell(false, false, false, { backgroundColor: '3' }); + const cell4 = createTableCell(false, false, false, { backgroundColor: '4' }); + const selectedCell5 = createTableCell(false, false, false, { backgroundColor: '5' }); + const cell6 = createTableCell(true, false, false, { backgroundColor: '6' }); + const cell7 = createTableCell(false, false, false, { backgroundColor: '7' }); + const cell8 = createTableCell(false, true, false, { backgroundColor: '8' }); + const selectedCell9 = createTableCell(true, true, false, { backgroundColor: '9' }); + const cell10 = createTableCell(false, false, false, { backgroundColor: '10' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); + + selectedCell5.isSelected = true; + selectedCell9.isSelected = true; + table.rows[0].cells.push(cell1, cell2, cell3); + table.rows[1].cells.push(cell4, { ...selectedCell5 }, cell6); + table.rows[2].cells.push(cell7, cell8, { ...selectedCell9 }); + table.rows[3].cells.push(cell10, cell11, cell12); + table.widths = [100, 200, 300]; + table.rows[0].height = 400; + table.rows[1].height = 500; + table.rows[2].height = 600; + table.rows[3].height = 700; + + const unselectedCell5 = { ...selectedCell5, isSelected: false }; + const unselectedCell9 = { ...selectedCell9, isSelected: false }; insertTableRow(table, 'insertBelow'); + + const selectedCell7 = { ...cell7, isSelected: true }; + const selectedCell8 = { ...cell8, isSelected: true }; + expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ { format: {}, height: 400, cells: [cell1, cell2, cell3] }, - { format: {}, height: 500, cells: [cell4, cell5Clone, cell6] }, - { format: {}, height: 500, cells: [cell4, cell5Clone, cell6] }, - { format: {}, height: 500, cells: [cell4, cell5, cell6] }, - { format: {}, height: 600, cells: [cell7, cell8, cell9] }, - { format: {}, height: 600, cells: [cell7, cell8, cell9Clone] }, - { format: {}, height: 600, cells: [cell7, cell8, cell9Clone] }, + { format: {}, height: 500, cells: [cell4, unselectedCell5, cell6] }, + { format: {}, height: 600, cells: [cell7, cell8, unselectedCell9] }, + { format: {}, height: 600, cells: [selectedCell7, selectedCell8, selectedCell9] }, + { format: {}, height: 600, cells: [selectedCell7, selectedCell8, selectedCell9] }, { format: {}, height: 700, cells: [cell10, cell11, cell12] }, ], widths: [100, 200, 300], diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts index c521a94a52f..fff6d7b07d2 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts @@ -2,7 +2,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; export function paragraphTestCommon( @@ -15,7 +15,7 @@ export function paragraphTestCommon( let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { formatResult = callback(model, { newEntities: [], deletedEntities: [], diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts index bb8a056b683..a2bc884ffc4 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts @@ -8,7 +8,7 @@ import { ContentModelListItem, ContentModelTable, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('setAlignment', () => { @@ -444,15 +444,13 @@ describe('setAlignment in table', () => { editor.formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); setAlignment(editor, alignment); @@ -844,16 +842,14 @@ describe('setAlignment in list', () => { editor.formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - result = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + }); setAlignment(editor, alignment); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts index adddd59431e..a016d66f7db 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts @@ -1,16 +1,13 @@ import * as setModelIndentation from '../../../lib/modelApi/block/setModelIndentation'; import setIndentation from '../../../lib/publicApi/block/setIndentation'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { - ContentModelFormatter, - FormatWithContentModelContext, -} from 'roosterjs-content-model-types'; +import { ContentModelFormatter, FormatContentModelContext } from 'roosterjs-content-model-types'; describe('setIndentation', () => { const fakeModel: any = { a: 'b' }; let editor: IStandaloneEditor; let formatContentModelSpy: jasmine.Spy; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; beforeEach(() => { context = undefined!; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts index a90649ddaf8..5479f7988f0 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts @@ -1,16 +1,13 @@ import * as toggleModelBlockQuote from '../../../lib/modelApi/block/toggleModelBlockQuote'; import toggleBlockQuote from '../../../lib/publicApi/block/toggleBlockQuote'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { - ContentModelFormatter, - FormatWithContentModelContext, -} from 'roosterjs-content-model-types'; +import { ContentModelFormatter, FormatContentModelContext } from 'roosterjs-content-model-types'; describe('toggleBlockQuote', () => { const fakeModel: any = { a: 'b' }; let editor: IStandaloneEditor; let formatContentModelSpy: jasmine.Spy; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; beforeEach(() => { context = undefined!; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index d820c74f355..2753aaaeb2f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -1,16 +1,17 @@ +import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import insertEntity from '../../../lib/publicApi/entity/insertEntity'; import { ChangeSource } from 'roosterjs-content-model-core'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('insertEntity', () => { let editor: IStandaloneEditor; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; let wrapper: HTMLElement; const model = 'MockedModel' as any; @@ -44,11 +45,14 @@ describe('insertEntity', () => { setProperty: setPropertySpy, }, appendChild: appendChildSpy, + classList: { + add: () => {}, + }, } as any; formatWithContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake((formatter: Function, options: FormatWithContentModelOptions) => { + .and.callFake((formatter: Function, options: FormatContentModelOptions) => { formatter(model, context); }); @@ -65,6 +69,8 @@ describe('insertEntity', () => { isDarkMode: isDarkModeSpy, formatContentModel: formatWithContentModelSpy, } as any; + + spyOn(entityUtils, 'addDelimiters').and.returnValue([]); }); it('insert inline entity to top', () => { @@ -115,8 +121,9 @@ describe('insertEntity', () => { const entity = insertEntity(editor, type, true, 'root'); expect(createElementSpy).toHaveBeenCalledWith('div'); - expect(setPropertySpy).toHaveBeenCalledWith('display', null); - expect(appendChildSpy).not.toHaveBeenCalled(); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); + expect(setPropertySpy).toHaveBeenCalledWith('width', '100%'); + expect(appendChildSpy).toHaveBeenCalledTimes(0); expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( ChangeSource.InsertEntity @@ -167,6 +174,8 @@ describe('insertEntity', () => { expect(createElementSpy).toHaveBeenCalledWith('div'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'none'); + expect(setPropertySpy).not.toHaveBeenCalledWith('display', 'inline-block'); + expect(setPropertySpy).not.toHaveBeenCalledWith('width', '100%'); expect(appendChildSpy).toHaveBeenCalledWith(contentNode); expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts index 7a5893e74dd..2ab4ed941fe 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts @@ -5,7 +5,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('clearFormat', () => { @@ -13,12 +13,10 @@ describe('clearFormat', () => { const model = ('Model' as any) as ContentModelDocument; const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('clearFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('clearFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); const editor = ({ focus: () => {}, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index cd065e35e3b..5ba4c29395c 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -1,4 +1,4 @@ -import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; +import * as retrieveModelFormatState from 'roosterjs-content-model-core/lib/publicApi/format/retrieveModelFormatState'; import getFormatState from '../../../lib/publicApi/format/getFormatState'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { ContentModelFormatState } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts index 2f5e29fdea4..65aa6f3d944 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts @@ -4,7 +4,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addSegment, @@ -33,16 +33,14 @@ describe('changeImage', () => { let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + }); const editor = ({ focus: jasmine.createSpy(), diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts index 81043a949a1..fb061333a62 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts @@ -4,7 +4,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addSegment, @@ -26,15 +26,13 @@ describe('insertImage', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); const editor = ({ focus: jasmine.createSpy(), isDisposed: () => false, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts index 496055259fb..ea34577e818 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts @@ -4,7 +4,7 @@ import { ContentModelDocument, ContentModelLink, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addLink, @@ -28,15 +28,13 @@ describe('adjustLinkSelection', () => { formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(mockedModel, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(mockedModel, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor = ({ formatContentModel, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index 96e9ed0ac03..7d12c8d9c99 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -5,7 +5,7 @@ import { ContentModelDocument, ContentModelLink, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addSegment, @@ -37,15 +37,13 @@ describe('insertLink', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor.formatContentModel = formatContentModel; @@ -412,15 +410,13 @@ describe('insertLink', () => { let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(doc, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(doc, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor.formatContentModel = formatContentModel; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts index d50ab264da6..7ee4421aa21 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts @@ -4,7 +4,7 @@ import { ContentModelDocument, ContentModelLink, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addLink, @@ -28,16 +28,14 @@ describe('removeLink', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + }); editor.formatContentModel = formatContentModel; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts index a049ab7ad31..3e2dddecb53 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts @@ -2,7 +2,7 @@ import setListStartNumber from '../../../lib/publicApi/list/setListStartNumber'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('setListStartNumber', () => { @@ -13,18 +13,16 @@ describe('setListStartNumber', () => { ) { let formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toBe('setListStartNumber'); - const result = callback(input, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toBe('setListStartNumber'); + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); - expect(result).toBe(expectedResult); - } - ); + expect(result).toBe(expectedResult); + }); setListStartNumber( { diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts index 243258dc5f9..aff7fbd3260 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts @@ -4,8 +4,8 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('toggleBullet', () => { @@ -13,7 +13,7 @@ describe('toggleBullet', () => { let formatContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; @@ -21,17 +21,15 @@ describe('toggleBullet', () => { context = undefined!; formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - context = { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }; - callback(mockedModel, context); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + context = { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }; + callback(mockedModel, context); + }); focus = jasmine.createSpy('focus'); editor = ({ diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts index 20f6f6f92da..131a7d011cc 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts @@ -4,15 +4,15 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('toggleNumbering', () => { let editor = ({} as any) as IStandaloneEditor; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; @@ -22,17 +22,15 @@ describe('toggleNumbering', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - context = { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }; - callback(mockedModel, context); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + context = { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }; + callback(mockedModel, context); + }); editor = ({ focus, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts index b0ce78a6754..647b6db1e06 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts @@ -7,7 +7,7 @@ import { ContentModelDocument, ContentModelSegmentFormat, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('changeFontSize', () => { @@ -349,15 +349,13 @@ describe('changeFontSize', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); const editor = ({ formatContentModel, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts index 99205b8e38b..fcb001b9300 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts @@ -2,7 +2,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; export function segmentTestCommon( @@ -15,7 +15,7 @@ export function segmentTestCommon( let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { expect(options.apiName).toBe(apiName); formatResult = callback(model, { newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts index 6d65809ef1b..17208920825 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts @@ -9,7 +9,7 @@ import { ContentModelTable, ContentModelTableCell, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('applyTableBorderFormat', () => { @@ -59,15 +59,13 @@ describe('applyTableBorderFormat', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor.formatContentModel = formatContentModel; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts new file mode 100644 index 00000000000..7593be0a2ed --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts @@ -0,0 +1,243 @@ +import * as alignTable from '../../../lib/modelApi/table/alignTable'; +import * as alignTableCell from '../../../lib/modelApi/table/alignTableCell'; +import * as deleteTable from '../../../lib/modelApi/table/deleteTable'; +import * as deleteTableColumn from '../../../lib/modelApi/table/deleteTableColumn'; +import * as deleteTableRow from '../../../lib/modelApi/table/deleteTableRow'; +import * as formatTableWithContentModel from '../../../lib/publicApi/utils/formatTableWithContentModel'; +import * as insertTableColumn from '../../../lib/modelApi/table/insertTableColumn'; +import * as insertTableRow from '../../../lib/modelApi/table/insertTableRow'; +import * as mergeTableCells from '../../../lib/modelApi/table/mergeTableCells'; +import * as mergeTableColumn from '../../../lib/modelApi/table/mergeTableColumn'; +import * as mergeTableRow from '../../../lib/modelApi/table/mergeTableRow'; +import * as splitTableCellHorizontally from '../../../lib/modelApi/table/splitTableCellHorizontally'; +import * as splitTableCellVertically from '../../../lib/modelApi/table/splitTableCellVertically'; +import editTable from '../../../lib/publicApi/table/editTable'; +import { IStandaloneEditor, TableOperation } from 'roosterjs-content-model-types'; + +describe('editTable', () => { + let editor: IStandaloneEditor; + let focusSpy: jasmine.Spy; + let formatTableWithContentModelSpy: jasmine.Spy; + const mockedTable = 'TABLE' as any; + + function runTest(operation: TableOperation, expectedSpy: jasmine.Spy, ...parameters: string[]) { + editTable(editor, operation); + + expect(formatTableWithContentModelSpy).toHaveBeenCalledWith( + editor, + 'editTable', + jasmine.anything() + ); + expect(expectedSpy).toHaveBeenCalledWith(mockedTable, ...parameters); + } + + beforeEach(() => { + focusSpy = jasmine.createSpy('focus'); + formatTableWithContentModelSpy = spyOn( + formatTableWithContentModel, + 'formatTableWithContentModel' + ).and.callFake((editorParam, apiParam, callback) => { + callback(mockedTable); + }); + + editor = { + focus: focusSpy, + } as any; + }); + + describe('alignTableCellHorizontally', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(alignTableCell, 'alignTableCellHorizontally'); + }); + + it('alignCellLeft', () => { + runTest('alignCellLeft', spy, 'alignCellLeft'); + }); + + it('alignCellCenter', () => { + runTest('alignCellCenter', spy, 'alignCellCenter'); + }); + + it('alignCellRight', () => { + runTest('alignCellRight', spy, 'alignCellRight'); + }); + }); + + describe('alignTableCellVertically', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(alignTableCell, 'alignTableCellVertically'); + }); + + it('alignCellTop', () => { + runTest('alignCellTop', spy, 'alignCellTop'); + }); + + it('alignCellMiddle', () => { + runTest('alignCellMiddle', spy, 'alignCellMiddle'); + }); + + it('alignCellBottom', () => { + runTest('alignCellBottom', spy, 'alignCellBottom'); + }); + }); + + describe('alignTable', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(alignTable, 'alignTable'); + }); + + it('alignCenter', () => { + runTest('alignCenter', spy, 'alignCenter'); + }); + + it('alignLeft', () => { + runTest('alignLeft', spy, 'alignLeft'); + }); + + it('alignRight', () => { + runTest('alignRight', spy, 'alignRight'); + }); + }); + + describe('deleteTableColumn', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(deleteTableColumn, 'deleteTableColumn'); + }); + + it('deleteColumn', () => { + runTest('deleteColumn', spy); + }); + }); + + describe('deleteTableRow', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(deleteTableRow, 'deleteTableRow'); + }); + + it('deleteRow', () => { + runTest('deleteRow', spy); + }); + }); + + describe('deleteTable', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(deleteTable, 'deleteTable'); + }); + + it('deleteTable', () => { + runTest('deleteTable', spy); + }); + }); + + describe('insertTableRow', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(insertTableRow, 'insertTableRow'); + }); + + it('insertAbove', () => { + runTest('insertAbove', spy, 'insertAbove'); + }); + + it('insertBelow', () => { + runTest('insertBelow', spy, 'insertBelow'); + }); + }); + + describe('insertTableColumn', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(insertTableColumn, 'insertTableColumn'); + }); + + it('insertLeft', () => { + runTest('insertLeft', spy, 'insertLeft'); + }); + + it('insertRight', () => { + runTest('insertRight', spy, 'insertRight'); + }); + }); + + describe('mergeTableRow', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(mergeTableRow, 'mergeTableRow'); + }); + + it('mergeAbove', () => { + runTest('mergeAbove', spy, 'mergeAbove'); + }); + + it('mergeBelow', () => { + runTest('mergeBelow', spy, 'mergeBelow'); + }); + }); + + describe('mergeTableCells', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(mergeTableCells, 'mergeTableCells'); + }); + + it('mergeCells', () => { + runTest('mergeCells', spy); + }); + }); + + describe('mergeTableColumn', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(mergeTableColumn, 'mergeTableColumn'); + }); + + it('mergeLeft', () => { + runTest('mergeLeft', spy, 'mergeLeft'); + }); + + it('mergeRight', () => { + runTest('mergeRight', spy, 'mergeRight'); + }); + }); + + describe('splitTableCellHorizontally', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(splitTableCellHorizontally, 'splitTableCellHorizontally'); + }); + + it('splitHorizontally', () => { + runTest('splitHorizontally', spy); + }); + }); + + describe('splitTableCellVertically', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(splitTableCellVertically, 'splitTableCellVertically'); + }); + + it('splitVertically', () => { + runTest('splitVertically', spy); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts index e1b7c52b50c..c3ae1bbbaf6 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts @@ -5,7 +5,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelTable, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('setTableCellShade', () => { @@ -31,15 +31,13 @@ describe('setTableCellShade', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor.formatContentModel = formatContentModel; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts index 37f40684079..987776d7e06 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -4,7 +4,7 @@ import { ContentModelDocument, ContentModelImage, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addSegment, @@ -204,7 +204,7 @@ function segmentTestForPluginEvent( let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { expect(options.apiName).toBe(apiName); formatResult = callback(model, { newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts index d5e2cdc1823..341e6d3a259 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -4,8 +4,8 @@ import { ContentModelDocument, ContentModelParagraph, ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { createContentModelDocument, @@ -16,7 +16,7 @@ import { describe('formatParagraphWithContentModel', () => { let editor: IStandaloneEditor; let model: ContentModelDocument; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; const mockedContainer = 'C' as any; const mockedOffset = 'O' as any; @@ -28,18 +28,16 @@ describe('formatParagraphWithContentModel', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - context = { - newEntities: [], - newImages: [], - deletedEntities: [], - rawEvent: options.rawEvent, - }; - - callback(model, context); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + context = { + newEntities: [], + newImages: [], + deletedEntities: [], + rawEvent: options.rawEvent, + }; + + callback(model, context); + }); editor = ({ getFocusedPosition: () => ({ node: mockedContainer, offset: mockedOffset }), diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts index 40c96e29b0b..3b87fecf7b5 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -4,8 +4,8 @@ import { ContentModelDocument, ContentModelSegmentFormat, ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { createContentModelDocument, @@ -14,13 +14,13 @@ import { createText, } from 'roosterjs-content-model-dom'; -describe('formatSegmentWithContentModel', () => { +describe('formatSegment', () => { let editor: IStandaloneEditor; let focus: jasmine.Spy; let model: ContentModelDocument; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; - let context: FormatWithContentModelContext | undefined; + let context: FormatContentModelContext | undefined; const apiName = 'mockedApi'; @@ -31,16 +31,14 @@ describe('formatSegmentWithContentModel', () => { formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - context = { - newEntities: [], - deletedEntities: [], - newImages: [], - }; - formatResult = callback(model, context); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + context = { + newEntities: [], + deletedEntities: [], + newImages: [], + }; + formatResult = callback(model, context); + }); editor = ({ focus, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatTableWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatTableWithContentModelTest.ts new file mode 100644 index 00000000000..377317b6d43 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatTableWithContentModelTest.ts @@ -0,0 +1,284 @@ +import * as applyTableFormat from 'roosterjs-content-model-core/lib/publicApi/table/applyTableFormat'; +import * as ensureFocusableParagraphForTable from '../../../lib/modelApi/table/ensureFocusableParagraphForTable'; +import * as hasSelectionInBlock from 'roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlock'; +import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; +import { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { formatTableWithContentModel } from '../../../lib/publicApi/utils/formatTableWithContentModel'; +import { + createContentModelDocument, + createTable, + createTableCell, +} from 'roosterjs-content-model-dom'; + +describe('formatTableWithContentModel', () => { + let editor: IStandaloneEditor; + let formatContentModelSpy: jasmine.Spy; + let model: ContentModelDocument; + let formatResult: boolean | undefined; + + beforeEach(() => { + formatResult = undefined; + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + formatResult = callback(model); + }); + editor = { + formatContentModel: formatContentModelSpy, + } as any; + }); + + it('Empty model', () => { + model = createContentModelDocument(); + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect(formatResult).toBeFalse(); + }); + + it('Model with table but not selected', () => { + model = createContentModelDocument(); + const table = createTable(1); + const tableCell = createTableCell(); + + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect(formatResult).toBeFalse(); + }); + + it('Model with selected table, has selection in block, no metadata', () => { + model = createContentModelDocument(); + const table = createTable(1); + const tableCell = createTableCell(); + + tableCell.isSelected = true; + + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + spyOn(hasSelectionInBlock, 'default').and.returnValue(true); + spyOn(ensureFocusableParagraphForTable, 'ensureFocusableParagraphForTable'); + spyOn(normalizeTable, 'normalizeTable'); + spyOn(applyTableFormat, 'applyTableFormat'); + + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).toHaveBeenCalledWith(table); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect( + ensureFocusableParagraphForTable.ensureFocusableParagraphForTable + ).not.toHaveBeenCalled(); + expect(normalizeTable.normalizeTable).toHaveBeenCalledWith(table, undefined); + expect(applyTableFormat.applyTableFormat).not.toHaveBeenCalled(); + expect(formatResult).toBeTrue(); + }); + + it('Model with selected table, no selection in block, no metadata', () => { + model = createContentModelDocument(); + const table = createTable(1); + const tableCell = createTableCell(); + + tableCell.isSelected = true; + + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + spyOn(hasSelectionInBlock, 'default').and.returnValue(false); + spyOn( + ensureFocusableParagraphForTable, + 'ensureFocusableParagraphForTable' + ).and.callThrough(); + spyOn(normalizeTable, 'normalizeTable'); + spyOn(applyTableFormat, 'applyTableFormat'); + + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).toHaveBeenCalledWith(table); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect( + ensureFocusableParagraphForTable.ensureFocusableParagraphForTable + ).toHaveBeenCalledWith(model, [model], table); + expect(normalizeTable.normalizeTable).toHaveBeenCalledWith(table, undefined); + expect(applyTableFormat.applyTableFormat).not.toHaveBeenCalled(); + expect(formatResult).toBeTrue(); + expect(tableCell).toEqual({ + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }); + }); + + it('Model with selected table, no selection in block, has metadata', () => { + model = createContentModelDocument(); + const table = createTable(1); + const tableCell = createTableCell(); + + tableCell.isSelected = true; + + table.dataset.editingInfo = '{}'; + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + spyOn(hasSelectionInBlock, 'default').and.returnValue(false); + spyOn( + ensureFocusableParagraphForTable, + 'ensureFocusableParagraphForTable' + ).and.callThrough(); + spyOn(normalizeTable, 'normalizeTable'); + spyOn(applyTableFormat, 'applyTableFormat'); + + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).toHaveBeenCalledWith(table); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect( + ensureFocusableParagraphForTable.ensureFocusableParagraphForTable + ).toHaveBeenCalledWith(model, [model], table); + expect(normalizeTable.normalizeTable).toHaveBeenCalledWith(table, undefined); + expect(applyTableFormat.applyTableFormat).toHaveBeenCalledWith(table, undefined, true); + expect(formatResult).toBeTrue(); + expect(tableCell).toEqual({ + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }); + }); + + it('With default format and additional parameters', () => { + model = createContentModelDocument({ + fontSize: '10pt', + }); + const table = createTable(1); + const tableCell = createTableCell(); + + tableCell.isSelected = true; + + table.dataset.editingInfo = '{}'; + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + spyOn(hasSelectionInBlock, 'default').and.returnValue(false); + spyOn( + ensureFocusableParagraphForTable, + 'ensureFocusableParagraphForTable' + ).and.callThrough(); + spyOn(normalizeTable, 'normalizeTable'); + spyOn(applyTableFormat, 'applyTableFormat'); + + const callback = jasmine.createSpy('callback'); + const mockedSelection = 'SELECTION' as any; + + formatTableWithContentModel(editor, 'editTable', callback, mockedSelection); + + expect(callback).toHaveBeenCalledWith(table); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: mockedSelection, + }); + expect( + ensureFocusableParagraphForTable.ensureFocusableParagraphForTable + ).toHaveBeenCalledWith(model, [model], table); + expect(normalizeTable.normalizeTable).toHaveBeenCalledWith(table, { fontSize: '10pt' }); + expect(applyTableFormat.applyTableFormat).toHaveBeenCalledWith(table, undefined, true); + expect(formatResult).toBeTrue(); + expect(tableCell).toEqual({ + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '10pt', + }, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts index 2073a4403f5..34973eb1afb 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts @@ -1,4 +1,11 @@ -import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model-types'; +import { parseValueWithUnit } from 'roosterjs-content-model-dom'; +import type { + EditorContext, + CreateEditorContext, + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; + +const DefaultRootFontSize = 16; /** * @internal @@ -16,6 +23,8 @@ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { allowCacheElement: true, domIndexer: saveIndex ? cache.domIndexer : undefined, zoomScale: domHelper.calculateZoomScale(), + rootFontSize: + parseValueWithUnit(getRootComputedStyle(core)?.fontSize) || DefaultRootFontSize, }; checkRootRtl(contentDiv, context); @@ -30,3 +39,9 @@ function checkRootRtl(element: HTMLElement, context: EditorContext) { context.isRootRtl = true; } } + +function getRootComputedStyle(core: StandaloneEditorCore) { + const document = core.contentDiv.ownerDocument; + const rootComputedStyle = document.defaultView?.getComputedStyle(document.documentElement); + return rootComputedStyle; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index dd11ef571ac..a75c9ab498c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -4,7 +4,7 @@ import type { ContentChangedEvent, DOMSelection, FormatContentModel, - FormatWithContentModelContext, + FormatContentModelContext, StandaloneEditorCore, } from 'roosterjs-content-model-types'; @@ -16,13 +16,13 @@ import type { * If there is cached model, it will be used and updated. * @param core The StandaloneEditorCore object * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ export const formatContentModel: FormatContentModel = (core, formatter, options) => { const { apiName, onNodeCreated, getChangeData, changeSource, rawEvent, selectionOverride } = options || {}; const model = core.api.createContentModel(core, undefined /*option*/, selectionOverride); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], rawEvent, @@ -60,32 +60,33 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) handlePendingFormat(core, context, selection); + const eventData: ContentChangedEvent = { + eventType: 'contentChanged', + contentModel: clearModelCache ? undefined : model, + selection: clearModelCache ? undefined : selection, + source: changeSource || ChangeSource.Format, + data: getChangeData?.(), + formatApiName: apiName, + changedEntities: getChangedEntities(context, rawEvent), + }; + + core.api.triggerEvent(core, eventData, true /*broadcast*/); + + if (canUndoByBackspace && selection?.type == 'range') { + core.undo.posContainer = selection.range.startContainer; + core.undo.posOffset = selection.range.startOffset; + } + if (shouldAddSnapshot) { core.api.addUndoSnapshot(core, !!canUndoByBackspace, entityStates); + } else { + core.undo.snapshotsManager.hasNewContent = true; } } finally { if (!isNested) { core.undo.isNested = false; } } - - const eventData: ContentChangedEvent = { - eventType: 'contentChanged', - contentModel: clearModelCache ? undefined : model, - selection: clearModelCache ? undefined : selection, - source: changeSource || ChangeSource.Format, - data: getChangeData?.(), - formatApiName: apiName, - changedEntities: getChangedEntities(context, rawEvent), - }; - - core.api.triggerEvent(core, eventData, true /*broadcast*/); - - if (canUndoByBackspace && selection?.type == 'range') { - core.undo.snapshotsManager.hasNewContent = false; - core.undo.posContainer = selection.range.startContainer; - core.undo.posOffset = selection.range.startOffset; - } } else { if (clearModelCache) { core.cache.cachedModel = undefined; @@ -96,7 +97,7 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) } }; -function handleImages(core: StandaloneEditorCore, context: FormatWithContentModelContext) { +function handleImages(core: StandaloneEditorCore, context: FormatContentModelContext) { if (context.newImages.length > 0) { const viewport = core.api.getVisibleViewport(core); @@ -113,7 +114,7 @@ function handleImages(core: StandaloneEditorCore, context: FormatWithContentMode function handlePendingFormat( core: StandaloneEditorCore, - context: FormatWithContentModelContext, + context: FormatContentModelContext, selection?: DOMSelection | null ) { const pendingFormat = @@ -130,10 +131,7 @@ function handlePendingFormat( } } -function getChangedEntities( - context: FormatWithContentModelContext, - rawEvent?: Event -): ChangedEntity[] { +function getChangedEntities(context: FormatContentModelContext, rawEvent?: Event): ChangedEntity[] { return context.newEntities .map( (entity): ChangedEntity => ({ diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts index aee4fbf2dc3..d229c830a10 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts @@ -1,5 +1,5 @@ import { cloneModel } from '../publicApi/model/cloneModel'; -import { convertInlineCss } from '../utils/paste/convertInlineCss'; +import { convertInlineCss } from '../utils/convertInlineCss'; import { createPasteFragment } from '../utils/paste/createPasteFragment'; import { generatePasteOptionFromPlugins } from '../utils/paste/generatePasteOptionFromPlugins'; import { mergePasteContent } from '../utils/paste/mergePasteContent'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts index 5d42d070a08..90c2c28980b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts @@ -1,8 +1,8 @@ import { areSameSelection } from './utils/areSameSelection'; -import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; import { createTextMutationObserver } from './utils/textMutationObserver'; +import { domIndexerImpl } from './utils/domIndexerImpl'; import type { - ContentModelCachePluginState, + CachePluginState, IStandaloneEditor, PluginEvent, PluginWithState, @@ -12,19 +12,19 @@ import type { /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ -class ContentModelCachePlugin implements PluginWithState { +class CachePlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; - private state: ContentModelCachePluginState; + private state: CachePluginState; /** - * Construct a new instance of ContentModelEditPlugin class + * Construct a new instance of CachePlugin class * @param option The editor option * @param contentDiv The editor content DIV */ constructor(option: StandaloneEditorOptions, contentDiv: HTMLDivElement) { this.state = option.cacheModel ? { - domIndexer: contentModelDomIndexer, + domIndexer: domIndexerImpl, textMutationObserver: createTextMutationObserver(contentDiv, this.onMutation), } : {}; @@ -34,7 +34,7 @@ class ContentModelCachePlugin implements PluginWithState { - return new ContentModelCachePlugin(option, contentDiv); +): PluginWithState { + return new CachePlugin(option, contentDiv); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts index 0b79d4cb758..45834288230 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts @@ -5,6 +5,7 @@ import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from '../utils/extractClipboardItems'; import { getSelectedCells } from '../publicApi/table/getSelectedCells'; import { iterateSelections } from '../publicApi/selection/iterateSelections'; +import { onCreateCopyEntityNode } from '../override/pasteCopyBlockEntityParser'; import { contentModelToDom, @@ -34,7 +35,7 @@ import type { /** * Copy and paste plugin for handling onCopy and onPaste event */ -class ContentModelCopyPastePlugin implements PluginWithState { +class CopyPastePlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; private disposer: (() => void) | null = null; private state: CopyPastePluginState; @@ -54,7 +55,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { +export const onNodeCreated: OnNodeCreated = (modelElement, node): void => { if (isNodeOfType(node, 'ELEMENT_NODE') && isElementOfType(node, 'table')) { wrap(node.ownerDocument, node, 'div'); } if (isNodeOfType(node, 'ELEMENT_NODE') && !node.isContentEditable) { node.removeAttribute('contenteditable'); } + onCreateCopyEntityNode(modelElement, node); }; /** @@ -332,11 +334,11 @@ export function preprocessTable(table: ContentModelTable) { /** * @internal - * Create a new instance of ContentModelCopyPastePlugin + * Create a new instance of CopyPastePlugin * @param option The editor option */ -export function createContentModelCopyPastePlugin( +export function createCopyPastePlugin( option: StandaloneEditorOptions ): PluginWithState { - return new ContentModelCopyPastePlugin(option); + return new CopyPastePlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts index fc2757d21e2..f3f08524a5f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts @@ -7,9 +7,14 @@ import type { DOMEventRecord, StandaloneEditorOptions, PluginWithState, - PluginEventType, } from 'roosterjs-content-model-types'; +const EventTypeMap: Record = { + keydown: 'keyDown', + keyup: 'keyUp', + keypress: 'keyPress', +}; + /** * DOMEventPlugin handles customized DOM events, including: * 1. Keyboard event @@ -60,9 +65,10 @@ class DOMEventPlugin implements PluginWithState { { [P in keyof HTMLElementEventMap]: DOMEventRecord } > = { // 1. Keyboard event - keypress: this.getEventHandler('keyPress'), - keydown: this.getEventHandler('keyDown'), - keyup: this.getEventHandler('keyUp'), + keypress: this.keyboardEventHandler, + keydown: this.keyboardEventHandler, + keyup: this.keyboardEventHandler, + input: this.inputEventHandler, // 2. Mouse event mousedown: { beforeDispatch: this.onMouseDown }, @@ -74,9 +80,6 @@ class DOMEventPlugin implements PluginWithState { // 4. Drag and Drop event dragstart: { beforeDispatch: this.onDragStart }, drop: { beforeDispatch: this.onDrop }, - - // 5. Input event - input: this.getEventHandler('input'), }; this.disposer = this.editor.attachDomEvent(>eventHandlers); @@ -140,28 +143,34 @@ class DOMEventPlugin implements PluginWithState { }); }; - private getEventHandler(eventType: PluginEventType): DOMEventRecord { - const beforeDispatch = (event: Event) => - eventType == 'input' - ? this.onInputEvent(event) - : this.onKeyboardEvent(event); + private keyboardEventHandler: DOMEventRecord = { + beforeDispatch: event => { + const eventType = EventTypeMap[event.type]; - return { - pluginEventType: eventType, - beforeDispatch, - }; - } + if (isCharacterValue(event) || isCursorMovingKey(event)) { + // Stop propagation for Character keys and Up/Down/Left/Right/Home/End/PageUp/PageDown + // since editor already handles these keys and no need to propagate to parents + event.stopPropagation(); + } - private onKeyboardEvent = (event: KeyboardEvent) => { - if (isCharacterValue(event) || isCursorMovingKey(event)) { - // Stop propagation for Character keys and Up/Down/Left/Right/Home/End/PageUp/PageDown - // since editor already handles these keys and no need to propagate to parents - event.stopPropagation(); - } + if (this.editor && eventType && !event.isComposing && !this.state.isInIME) { + this.editor.triggerEvent(eventType, { + rawEvent: event, + }); + } + }, }; - private onInputEvent = (event: InputEvent) => { - event.stopPropagation(); + private inputEventHandler: DOMEventRecord = { + beforeDispatch: event => { + event.stopPropagation(); + + if (this.editor && !(event as InputEvent).isComposing && !this.state.isInIME) { + this.editor.triggerEvent('input', { + rawEvent: event as InputEvent, + }); + } + }, }; private onMouseDown = (event: MouseEvent) => { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts index cdb1545b433..b49687c2f07 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -1,5 +1,10 @@ import { findAllEntities } from './utils/findAllEntities'; import { transformColor } from '../publicApi/color/transformColor'; +import { + handleCompositionEndEvent, + handleDelimiterContentChangedEvent, + handleDelimiterKeyDownEvent, +} from './utils/entityDelimiterUtils'; import { createEntity, generateEntityClassNames, @@ -81,7 +86,12 @@ class EntityPlugin implements PluginWithState { case 'contentChanged': this.handleContentChangedEvent(this.editor, event); break; - + case 'keyDown': + handleDelimiterKeyDownEvent(this.editor, event); + break; + case 'compositionEnd': + handleCompositionEndEvent(this.editor, event); + break; case 'editorReady': this.handleContentChangedEvent(this.editor); break; @@ -170,6 +180,8 @@ class EntityPlugin implements PluginWithState { ); } }); + + handleDelimiterContentChangedEvent(editor); } private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts similarity index 55% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts index 4219a3728c4..1fd6c7164a1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts @@ -1,30 +1,44 @@ import { applyDefaultFormat } from './utils/applyDefaultFormat'; import { applyPendingFormat } from './utils/applyPendingFormat'; -import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { getObjectKeys, isBlockElement, isNodeOfType } from 'roosterjs-content-model-dom'; import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; import type { - ContentModelFormatPluginState, + BackgroundColorFormat, + FontFamilyFormat, + FontSizeFormat, + FormatPluginState, IStandaloneEditor, PluginEvent, PluginWithState, StandaloneEditorOptions, + TextColorFormat, } from 'roosterjs-content-model-types'; // During IME input, KeyDown event will have "Process" as key const ProcessKey = 'Process'; +const DefaultStyleKeyMap: Record< + keyof (FontFamilyFormat & FontSizeFormat & TextColorFormat & BackgroundColorFormat), + keyof CSSStyleDeclaration +> = { + backgroundColor: 'backgroundColor', + textColor: 'color', + fontFamily: 'fontFamily', + fontSize: 'fontSize', +}; /** - * ContentModelFormat plugins helps editor to do formatting on top of content model. + * FormatPlugin plugins helps editor to do formatting on top of content model. * This includes: * 1. Handle pending format changes when selection is collapsed */ -class ContentModelFormatPlugin implements PluginWithState { +class FormatPlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; - private hasDefaultFormat = false; - private state: ContentModelFormatPluginState; + private defaultFormatKeys: Set; + private state: FormatPluginState; + private lastCheckedNode: Node | null = null; /** - * Construct a new instance of ContentModelEditPlugin class + * Construct a new instance of FormatPlugin class * @param option The editor option */ constructor(option: StandaloneEditorOptions) { @@ -32,13 +46,21 @@ class ContentModelFormatPlugin implements PluginWithState(); + + getObjectKeys(DefaultStyleKeyMap).forEach(key => { + if (this.state.defaultFormat[key]) { + this.defaultFormatKeys.add(DefaultStyleKeyMap[key]); + } + }); } /** * Get name of this plugin */ getName() { - return 'ContentModelFormat'; + return 'Format'; } /** @@ -49,10 +71,6 @@ class ContentModelFormatPlugin implements PluginWithState typeof this.state.defaultFormat[x] !== 'undefined' - ).length > 0; } /** @@ -67,7 +85,7 @@ class ContentModelFormatPlugin implements PluginWithState 0 && + (isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) && + this.shouldApplyDefaultFormat(this.editor) ) { applyDefaultFormat(this.editor, this.state.defaultFormat); } @@ -113,6 +126,8 @@ class ContentModelFormatPlugin implements PluginWithState(); + + while (element?.parentElement && domHelper.isNodeInEditor(element.parentElement)) { + if (element.getAttribute?.('style')) { + const style = element.style; + this.defaultFormatKeys.forEach(key => { + if (style[key]) { + foundFormatKeys.add(key); + } + }); + + if (foundFormatKeys.size == this.defaultFormatKeys.size) { + return false; + } + } + + if (isBlockElement(element)) { + break; + } + + element = element.parentElement; + } + + return true; + } else { + return false; + } + } } /** * @internal - * Create a new instance of ContentModelFormatPlugin. + * Create a new instance of FormatPlugin. * @param option The editor option */ -export function createContentModelFormatPlugin( +export function createFormatPlugin( option: StandaloneEditorOptions -): PluginWithState { - return new ContentModelFormatPlugin(option); +): PluginWithState { + return new FormatPlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts index 1a12973cd42..7e35ff5c0f1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts @@ -85,8 +85,7 @@ class UndoPlugin implements PluginWithState { * @param event PluginEvent object */ onPluginEvent(event: PluginEvent): void { - // if editor is in IME, don't do anything - if (!this.editor || this.editor.isInIME()) { + if (!this.editor) { return; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index c329d7a33d1..eb8692305e5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -1,9 +1,9 @@ -import { createContentModelCachePlugin } from './ContentModelCachePlugin'; -import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; -import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; +import { createCachePlugin } from './CachePlugin'; import { createContextMenuPlugin } from './ContextMenuPlugin'; +import { createCopyPastePlugin } from './CopyPastePlugin'; import { createDOMEventPlugin } from './DOMEventPlugin'; import { createEntityPlugin } from './EntityPlugin'; +import { createFormatPlugin } from './FormatPlugin'; import { createLifecyclePlugin } from './LifecyclePlugin'; import { createSelectionPlugin } from './SelectionPlugin'; import { createUndoPlugin } from './UndoPlugin'; @@ -22,9 +22,9 @@ export function createStandaloneEditorCorePlugins( contentDiv: HTMLDivElement ): StandaloneEditorCorePlugins { return { - cache: createContentModelCachePlugin(options, contentDiv), - format: createContentModelFormatPlugin(options), - copyPaste: createContentModelCopyPastePlugin(options), + cache: createCachePlugin(options, contentDiv), + format: createFormatPlugin(options), + copyPaste: createCopyPastePlugin(options), domEvent: createDOMEventPlugin(options, contentDiv), lifecycle: createLifecyclePlugin(options, contentDiv), entity: createEntityPlugin(), diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts index 3ddd87d15cb..263848843b7 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts @@ -1,5 +1,5 @@ import { deleteSelection } from '../../publicApi/selection/deleteSelection'; -import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -12,29 +12,6 @@ export function applyDefaultFormat( editor: IStandaloneEditor, defaultFormat: ContentModelSegmentFormat ) { - const selection = editor.getDOMSelection(); - const range = selection?.type == 'range' ? selection.range : null; - const posContainer = range?.startContainer ?? null; - const posOffset = range?.startOffset ?? null; - - if (posContainer) { - let node: Node | null = posContainer; - - while (node && editor.getDOMHelper().isNodeInEditor(node)) { - if (isNodeOfType(node, 'ELEMENT_NODE')) { - if (node.getAttribute?.('style')) { - return; - } else if (isBlockElement(node)) { - break; - } - } - - node = node.parentNode; - } - } else { - return; - } - editor.formatContentModel((model, context) => { const result = deleteSelection(model, [], context); @@ -44,12 +21,7 @@ export function applyDefaultFormat( editor.takeSnapshot(); return true; - } else if ( - result.deleteResult == 'notDeleted' && - result.insertPoint && - posContainer && - posOffset !== null - ) { + } else if (result.deleteResult == 'notDeleted' && result.insertPoint) { const { paragraph, path, marker } = result.insertPoint; const blocks = path[0].blocks; const blockCount = blocks.length; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/domIndexerImpl.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/domIndexerImpl.ts index 4b15da73392..fe26cd3e311 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/domIndexerImpl.ts @@ -2,13 +2,13 @@ import { createSelectionMarker, createText, isNodeOfType } from 'roosterjs-conte import { setSelection } from '../../publicApi/selection/setSelection'; import type { ContentModelDocument, - ContentModelDomIndexer, ContentModelParagraph, ContentModelSegment, ContentModelSelectionMarker, ContentModelTable, ContentModelTableRow, ContentModelText, + DomIndexer, DOMSelection, Selectable, } from 'roosterjs-content-model-types'; @@ -268,9 +268,9 @@ function reconcileTextSelection( /** * @internal - * Implementation of ContentModelDomIndexer + * Implementation of DomIndexer */ -export const contentModelDomIndexer: ContentModelDomIndexer = { +export const domIndexerImpl: DomIndexer = { onSegment, onParagraph, onTable, diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts new file mode 100644 index 00000000000..e2f8f1953c1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -0,0 +1,305 @@ +import { isCharacterValue } from '../../publicApi/domUtils/eventUtils'; +import { iterateSelections } from '../../publicApi/selection/iterateSelections'; +import type { + CompositionEndEvent, + ContentModelBlockGroup, + ContentModelFormatter, + ContentModelParagraph, + ContentModelSegmentFormat, + IStandaloneEditor, + KeyDownEvent, + RangeSelection, +} from 'roosterjs-content-model-types'; +import { + addDelimiters, + createBr, + createModelToDomContext, + createParagraph, + isEntityDelimiter, + isEntityElement, + isNodeOfType, +} from 'roosterjs-content-model-dom'; + +const DelimiterBefore = 'entityDelimiterBefore'; +const DelimiterAfter = 'entityDelimiterAfter'; +const DelimiterSelector = '.' + DelimiterAfter + ',.' + DelimiterBefore; +const ZeroWidthSpace = '\u200B'; +const EntityInfoName = '_Entity'; +const InlineEntitySelector = 'span.' + EntityInfoName; +const BlockEntityContainer = '_E_EBlockEntityContainer'; +const BlockEntityContainerSelector = '.' + BlockEntityContainer; + +/** + * @internal exported only for unit test + */ +export function preventTypeInDelimiter(node: HTMLElement, editor: IStandaloneEditor) { + const isAfter = node.classList.contains(DelimiterAfter); + const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; + if (entitySibling && isEntityElement(entitySibling)) { + removeInvalidDelimiters( + [entitySibling.previousElementSibling, entitySibling.nextElementSibling].filter( + element => !!element + ) as HTMLElement[] + ); + editor.formatContentModel((model, context) => { + iterateSelections(model, (_path, _tableContext, block, _segments) => { + if (block?.blockType == 'Paragraph') { + block.segments.forEach(segment => { + if (segment.segmentType == 'Text') { + segment.text = segment.text.replace(ZeroWidthSpace, ''); + } + }); + } + }); + + context.skipUndoSnapshot = true; + + return true; + }); + } +} + +function addDelimitersIfNeeded( + nodes: Element[] | NodeListOf, + format: ContentModelSegmentFormat | null +) { + if (nodes.length > 0) { + const context = createModelToDomContext(); + nodes.forEach(node => { + if ( + isNodeOfType(node, 'ELEMENT_NODE') && + isEntityElement(node) && + !node.isContentEditable + ) { + addDelimiters(node.ownerDocument, node as HTMLElement, format, context); + } + }); + } +} + +function removeNode(el: Node | undefined | null) { + el?.parentElement?.removeChild(el); +} + +function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { + nodes.forEach(node => { + if (!isNodeOfType(node, 'ELEMENT_NODE')) { + return; + } + if (isEntityDelimiter(node)) { + const sibling = node.classList.contains(DelimiterBefore) + ? node.nextElementSibling + : node.previousElementSibling; + if (!(isNodeOfType(sibling, 'ELEMENT_NODE') && isEntityElement(sibling))) { + removeNode(node); + } + } else { + removeDelimiterAttr(node); + } + }); +} + +function removeDelimiterAttr(node: Element | undefined | null, checkEntity: boolean = true) { + if (!node) { + return; + } + + const isAfter = node.classList.contains(DelimiterAfter); + const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; + if (checkEntity && entitySibling && isEntityElement(entitySibling)) { + return; + } + + node.classList.remove(DelimiterAfter, DelimiterBefore); + + node.normalize(); + node.childNodes.forEach(cn => { + const index = cn.textContent?.indexOf(ZeroWidthSpace) ?? -1; + if (index >= 0) { + const range = new Range(); + range.setStart(cn, index); + range.setEnd(cn, index + 1); + range.deleteContents(); + } + }); +} + +function getFocusedElement( + selection: RangeSelection, + existingTextInDelimiter?: string +): HTMLElement | null { + const { range, isReverted } = selection; + let node: Node | null = isReverted ? range.startContainer : range.endContainer; + let offset = isReverted ? range.startOffset : range.endOffset; + + while (node?.lastChild) { + if (offset == node.childNodes.length) { + node = node.lastChild; + offset = node.childNodes.length; + } else { + node = node.childNodes[offset]; + offset = 0; + } + } + + if (!isNodeOfType(node, 'ELEMENT_NODE')) { + const textToCheck = existingTextInDelimiter + ? ZeroWidthSpace + existingTextInDelimiter + : ZeroWidthSpace; + + if (node.textContent != textToCheck && (node.textContent || '').length == offset) { + node = node.nextSibling ?? node.parentElement?.closest(DelimiterSelector) ?? null; + } else { + node = node?.parentElement?.closest(DelimiterSelector) ?? null; + } + } else { + node = node.childNodes.length == offset ? node : node.childNodes.item(offset); + } + if (node && !node.hasChildNodes()) { + node = node.nextSibling; + } + return isNodeOfType(node, 'ELEMENT_NODE') ? node : null; +} + +/** + * @internal + */ +export function handleDelimiterContentChangedEvent(editor: IStandaloneEditor) { + const helper = editor.getDOMHelper(); + removeInvalidDelimiters(helper.queryElements(DelimiterSelector)); + addDelimitersIfNeeded(helper.queryElements(InlineEntitySelector), editor.getPendingFormat()); +} + +/** + * @internal + */ +export function handleCompositionEndEvent(editor: IStandaloneEditor, event: CompositionEndEvent) { + const selection = editor.getDOMSelection(); + + if (selection?.type == 'range' && selection.range.collapsed) { + const node = getFocusedElement(selection, event.rawEvent.data); + + if ( + node?.firstChild && + isNodeOfType(node.firstChild, 'TEXT_NODE') && + node.matches(DelimiterSelector) && + node.textContent == ZeroWidthSpace + event.rawEvent.data + ) { + preventTypeInDelimiter(node, editor); + } + } +} + +/** + * @internal + */ +export function handleDelimiterKeyDownEvent(editor: IStandaloneEditor, event: KeyDownEvent) { + const selection = editor.getDOMSelection(); + + const { rawEvent } = event; + if (!selection || selection.type != 'range') { + return; + } + const isEnter = rawEvent.key === 'Enter'; + if (selection.range.collapsed && (isCharacterValue(rawEvent) || isEnter)) { + const helper = editor.getDOMHelper(); + const node = getFocusedElement(selection); + if (node && isEntityDelimiter(node) && helper.isNodeInEditor(node)) { + const blockEntityContainer = node.closest(BlockEntityContainerSelector); + if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { + const isAfter = node.classList.contains(DelimiterAfter); + + if (isAfter) { + selection.range.setStartAfter(blockEntityContainer); + } else { + selection.range.setStartBefore(blockEntityContainer); + } + selection.range.collapse(true /* toStart */); + + if (isEnter) { + event.rawEvent.preventDefault(); + } + + editor.formatContentModel(handleKeyDownInBlockDelimiter, { + selectionOverride: { + type: 'range', + isReverted: false, + range: selection.range, + }, + }); + } else { + if (isEnter) { + event.rawEvent.preventDefault(); + editor.formatContentModel(handleEnterInlineEntity); + } else { + editor.takeSnapshot(); + editor + .getDocument() + .defaultView?.requestAnimationFrame(() => + preventTypeInDelimiter(node, editor) + ); + } + } + } + } +} + +/** + * @internal Exported Only for unit test + * @returns + */ +export const handleKeyDownInBlockDelimiter: ContentModelFormatter = (model, context) => { + iterateSelections(model, (_path, _tableContext, block) => { + if (block?.blockType == 'Paragraph') { + delete block.isImplicit; + const selectionMarker = block.segments.find(w => w.segmentType == 'SelectionMarker'); + if (selectionMarker?.segmentType == 'SelectionMarker') { + block.segmentFormat = { ...selectionMarker.format }; + context.newPendingFormat = { ...selectionMarker.format }; + } + block.segments.unshift(createBr()); + } + }); + return true; +}; + +/** + * @internal Exported Only for unit test + * @returns + */ +export const handleEnterInlineEntity: ContentModelFormatter = model => { + let selectionBlock: ContentModelParagraph | undefined; + let selectionBlockParent: ContentModelBlockGroup | undefined; + + iterateSelections(model, (path, _tableContext, block) => { + if (block?.blockType == 'Paragraph') { + selectionBlock = block; + selectionBlockParent = path[path.length - 1]; + } + }); + + if (selectionBlock && selectionBlockParent) { + const selectionMarker = selectionBlock.segments.find( + segment => segment.segmentType == 'SelectionMarker' + ); + if (selectionMarker) { + const markerIndex = selectionBlock.segments.indexOf(selectionMarker); + const segmentsAfterMarker = selectionBlock.segments.splice(markerIndex); + + const newPara = createParagraph( + false, + selectionBlock.format, + selectionBlock.segmentFormat, + selectionBlock.decorator + ); + newPara.segments.push(...segmentsAfterMarker); + + const selectionBlockIndex = selectionBlockParent.blocks.indexOf(selectionBlock); + if (selectionBlockIndex >= 0) { + selectionBlockParent.blocks.splice(selectionBlockIndex + 1, 0, newPara); + } + } + } + + return true; +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts index 7a49f6bbcce..998887d351c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts @@ -8,6 +8,10 @@ class DOMHelperImpl implements DOMHelper { return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[]; } + getTextContent(): string { + return this.contentDiv.textContent || ''; + } + isNodeInEditor(node: Node): boolean { return this.contentDiv.contains(node); } @@ -20,6 +24,22 @@ class DOMHelperImpl implements DOMHelper { ? Math.round((originalWidth / visualWidth) * 100) / 100 : 1; } + + setDomAttribute(name: string, value: string | null) { + if (value === null) { + this.contentDiv.removeAttribute(name); + } else { + this.contentDiv.setAttribute(name, value); + } + } + + getDomAttribute(name: string): string | null { + return this.contentDiv.getAttribute(name); + } + + getDomStyle(style: T): CSSStyleDeclaration[T] { + return this.contentDiv.style[style]; + } } /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index 43095b2d18a..37b6ba45a90 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -15,7 +15,7 @@ import type { DOMHelper, DOMSelection, EditorEnvironment, - FormatWithContentModelOptions, + FormatContentModelOptions, IStandaloneEditor, PasteType, PluginEventData, @@ -26,6 +26,7 @@ import type { StandaloneEditorCore, StandaloneEditorOptions, TrustedHTMLHandler, + Rect, } from 'roosterjs-content-model-types'; /** @@ -39,15 +40,9 @@ export class StandaloneEditor implements IStandaloneEditor { * @param contentDiv The DIV HTML element which will be the container element of editor * @param options An optional options object to customize the editor */ - constructor( - contentDiv: HTMLDivElement, - options: StandaloneEditorOptions = {}, - onBeforeInitializePlugins?: () => void - ) { + constructor(contentDiv: HTMLDivElement, options: StandaloneEditorOptions = {}) { this.core = createStandaloneEditorCore(contentDiv, options); - onBeforeInitializePlugins?.(); - const initialModel = options.initialModel ?? createEmptyModel(options.defaultSegmentFormat); this.core.api.setContentModel(this.core, initialModel, { ignoreSelection: true }); @@ -152,11 +147,11 @@ export class StandaloneEditor implements IStandaloneEditor { * to do format change. Then according to the return value, write back the modified content model into editor. * If there is cached model, it will be used and updated. * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ formatContentModel( formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions + options?: FormatContentModelOptions ): void { const core = this.getCore(); @@ -301,14 +296,6 @@ export class StandaloneEditor implements IStandaloneEditor { } } - /** - * Check if editor is in IME input sequence - * @returns True if editor is in IME input sequence, otherwise false - */ - isInIME(): boolean { - return this.getCore().domEvent.isInIME; - } - /** * Check if editor is in Shadow Edit mode */ @@ -365,6 +352,20 @@ export class StandaloneEditor implements IStandaloneEditor { return this.getCore().trustedHTMLHandler; } + /** + * Get the scroll container of the editor + */ + getScrollContainer(): HTMLElement { + return this.getCore().domEvent.scrollContainer; + } + + /** + * Retrieves the rect of the visible viewport of the editor. + */ + getVisibleViewport(): Rect | null { + return this.getCore().api.getVisibleViewport(this.getCore()); + } + /** * @returns the current StandaloneEditorCore object * @throws a standard Error if there's no core object diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index b4680abdb9e..53e629732dd 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -69,9 +69,27 @@ function createEditorEnvironment(contentDiv: HTMLElement): EditorEnvironment { userAgent.indexOf('Safari') >= 0 && userAgent.indexOf('Chrome') < 0 && userAgent.indexOf('Android') < 0, + isMobileOrTablet: getIsMobileOrTablet(userAgent), }; } +function getIsMobileOrTablet(userAgent: string) { + // Reference: http://detectmobilebrowsers.com/ + // The default regex on the website doesn't consider tablet. + // To support tablet, add |android|ipad|playbook|silk to the first regex according to the info in /about page + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test( + userAgent + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + userAgent.substring(0, 4) + ) + ) { + return true; + } + return false; +} + /** * @internal export for test only */ diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index b1489354c9c..743e4314697 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -7,6 +7,7 @@ export { } from './publicApi/model/getClosestAncestorBlockGroupIndex'; export { isBold } from './publicApi/model/isBold'; export { createModelFromHtml } from './publicApi/model/createModelFromHtml'; +export { exportContent } from './publicApi/model/exportContent'; export { iterateSelections, @@ -47,6 +48,8 @@ export { undo } from './publicApi/undo/undo'; export { redo } from './publicApi/undo/redo'; export { transformColor } from './publicApi/color/transformColor'; +export { retrieveModelFormatState } from './publicApi/format/retrieveModelFormatState'; + export { updateImageMetadata } from './metadata/updateImageMetadata'; export { updateTableCellMetadata } from './metadata/updateTableCellMetadata'; export { updateTableMetadata } from './metadata/updateTableMetadata'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts index 2fafbc9cc07..562a36304d5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts @@ -1,7 +1,11 @@ import { BulletListType } from '../constants/BulletListType'; -import { createNumberDefinition, createObjectDefinition } from './definitionCreators'; import { getObjectKeys, updateMetadata } from 'roosterjs-content-model-dom'; import { NumberingListType } from '../constants/NumberingListType'; +import { + createBooleanDefinition, + createNumberDefinition, + createObjectDefinition, +} from './definitionCreators'; import type { ContentModelListItemFormat, ContentModelListItemLevelFormat, @@ -123,6 +127,7 @@ const listMetadataDefinition = createObjectDefinition( BulletListType.Min, BulletListType.Max ), + applyListStyleFromLevel: createBooleanDefinition(true /*isOptional*/), }, true /** isOptional */, true /** allowNull */ @@ -133,15 +138,19 @@ function shouldApplyToItem(listStyleType: string) { } function getRawListStyleType(listType: 'OL' | 'UL', metadata: ListMetadataFormat, depth: number) { - const { orderedStyleType, unorderedStyleType } = metadata; + const { orderedStyleType, unorderedStyleType, applyListStyleFromLevel } = metadata; if (listType == 'OL') { - return orderedStyleType === undefined + return typeof orderedStyleType == 'number' + ? OrderedMap[orderedStyleType] + : applyListStyleFromLevel ? DefaultOrderedListStyles[depth % DefaultOrderedListStyles.length] - : OrderedMap[orderedStyleType]; + : undefined; } else { - return unorderedStyleType === undefined + return typeof unorderedStyleType == 'number' + ? UnorderedMap[unorderedStyleType] + : applyListStyleFromLevel ? DefaultUnorderedListStyles[depth % DefaultUnorderedListStyles.length] - : UnorderedMap[unorderedStyleType]; + : undefined; } } @@ -176,16 +185,18 @@ export const listItemMetadataApplier: MetadataApplier< const listType = context.listFormat.nodeStack[depth + 1].listType ?? 'OL'; const listStyleType = getRawListStyleType(listType, metadata ?? {}, depth); - if (listStyleType && shouldApplyToItem(listStyleType)) { - format.listStyleType = - listType == 'OL' - ? getOrderedListStyleValue( - listStyleType, - context.listFormat.threadItemCounts[depth] - ) - : listStyleType; - } else { - delete format.listStyleType; + if (listStyleType) { + if (shouldApplyToItem(listStyleType)) { + format.listStyleType = + listType == 'OL' + ? getOrderedListStyleValue( + listStyleType, + context.listFormat.threadItemCounts[depth] + ) + : listStyleType; + } else { + delete format.listStyleType; + } } } }, @@ -206,10 +217,12 @@ export const listLevelMetadataApplier: MetadataApplier< const listType = context.listFormat.nodeStack[depth + 1].listType ?? 'OL'; const listStyleType = getRawListStyleType(listType, metadata ?? {}, depth); - if (listStyleType && !shouldApplyToItem(listStyleType)) { - format.listStyleType = listStyleType; - } else { - delete format.listStyleType; + if (listStyleType) { + if (!shouldApplyToItem(listStyleType)) { + format.listStyleType = listStyleType; + } else { + delete format.listStyleType; + } } } }, diff --git a/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts index 568938abbbb..8edc146005a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts @@ -9,7 +9,7 @@ import type { ContentModelParagraph, ContentModelSelectionMarker, DeleteSelectionContext, - FormatWithContentModelContext, + FormatContentModelContext, InsertPoint, TableSelectionContext, } from 'roosterjs-content-model-types'; @@ -33,7 +33,7 @@ const DeleteSelectionIteratingOptions: IterateSelectionsOption = { */ export function deleteExpandedSelection( model: ContentModelDocument, - formatContext?: FormatWithContentModelContext + formatContext?: FormatContentModelContext ): DeleteSelectionContext { const context: DeleteSelectionContext = { deleteResult: 'notDeleted', diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts new file mode 100644 index 00000000000..8a4a567c542 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts @@ -0,0 +1,43 @@ +import { isBlockElement, isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; +import type { + ContentModelEntity, + EntityInfoFormat, + FormatParser, + OnNodeCreated, +} from 'roosterjs-content-model-types'; + +const BlockEntityClass = '_EBlock'; +const OneHundredPercent = '100%'; +const InlineBlock = 'inline-block'; + +/** + * @internal + */ +export const onCreateCopyEntityNode: OnNodeCreated = (model, node) => { + const entityModel = model as ContentModelEntity; + if ( + entityModel && + entityModel.wrapper && + entityModel.blockType == 'Entity' && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'div') && + !isBlockElement(entityModel.wrapper) && + entityModel.wrapper.style.display == InlineBlock && + entityModel.wrapper.style.width == OneHundredPercent + ) { + node.classList.add(BlockEntityClass); + node.style.display = 'block'; + node.style.width = ''; + } +}; + +/** + * @internal + */ +export const pasteBlockEntityParser: FormatParser = (_, element) => { + if (element.classList.contains(BlockEntityClass)) { + element.classList.remove(BlockEntityClass); + element.style.display = InlineBlock; + element.style.width = OneHundredPercent; + } +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts index 8424d684dec..433ab5bbc5e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts @@ -1,6 +1,6 @@ import { AllowedTags, DisallowedTags, sanitizeElement } from '../utils/sanitizeElement'; import type { - DomToModelOptionForPaste, + DomToModelOptionForSanitizing, ElementProcessor, ValueSanitizer, } from 'roosterjs-content-model-types'; @@ -13,7 +13,7 @@ const DefaultStyleSanitizers: Readonly> = { * @internal */ export function createPasteEntityProcessor( - options: DomToModelOptionForPaste + options: DomToModelOptionForSanitizing ): ElementProcessor { const allowedTags = AllowedTags.concat(options.additionalAllowedTags); const disallowedTags = DisallowedTags.concat(options.additionalDisallowedTags); diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts index 025601b625a..43fce8da116 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts @@ -1,7 +1,7 @@ import { AllowedTags, createSanitizedElement, DisallowedTags } from '../utils/sanitizeElement'; import { moveChildNodes } from 'roosterjs-content-model-dom'; import type { - DomToModelOptionForPaste, + DomToModelOptionForSanitizing, ElementProcessor, ValueSanitizer, } from 'roosterjs-content-model-types'; @@ -22,7 +22,7 @@ const DefaultStyleSanitizers: Readonly> = { * @internal */ export function createPasteGeneralProcessor( - options: DomToModelOptionForPaste + options: DomToModelOptionForSanitizing ): ElementProcessor { const allowedTags = AllowedTags.concat(options.additionalAllowedTags); const disallowedTags = DisallowedTags.concat(options.additionalDisallowedTags); diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/format/retrieveModelFormatState.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/format/retrieveModelFormatState.ts index d04ba929bcf..80f4653d4b7 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/format/retrieveModelFormatState.ts @@ -1,11 +1,9 @@ +import { extractBorderValues } from '../domUtils/borderValues'; +import { getClosestAncestorBlockGroupIndex } from '../model/getClosestAncestorBlockGroupIndex'; +import { isBold } from '../model/isBold'; +import { iterateSelections } from '../selection/iterateSelections'; import { parseValueWithUnit } from 'roosterjs-content-model-dom'; -import { - extractBorderValues, - getClosestAncestorBlockGroupIndex, - isBold, - iterateSelections, - updateTableMetadata, -} from 'roosterjs-content-model-core'; +import { updateTableMetadata } from '../../metadata/updateTableMetadata'; import type { ContentModelFormatState, ContentModelBlock, @@ -20,7 +18,10 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Retrieve format state from the given Content Model + * @param model The Content Model to retrieve format state from + * @param pendingFormat Existing pending format, if any + * @param formatState Existing format state object, used for receiving the result */ export function retrieveModelFormatState( model: ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts index fe5fd1d8e4e..d9b8bb2f76d 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts @@ -1,4 +1,6 @@ -import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; +import { convertInlineCss, retrieveCssRules } from '../../utils/convertInlineCss'; +import { createDomToModelContextForSanitizing } from '../../utils/createDomToModelContextForSanitizing'; +import { createEmptyModel, domToContentModel, parseFormat } from 'roosterjs-content-model-dom'; import type { ContentModelDocument, ContentModelSegmentFormat, @@ -18,18 +20,20 @@ export function createModelFromHtml( options?: DomToModelOption, trustedHTMLHandler?: TrustedHTMLHandler, defaultSegmentFormat?: ContentModelSegmentFormat -): ContentModelDocument | undefined { - const doc = new DOMParser().parseFromString(trustedHTMLHandler?.(html) ?? html, 'text/html'); +): ContentModelDocument { + const doc = html + ? new DOMParser().parseFromString(trustedHTMLHandler?.(html) ?? html, 'text/html') + : null; - return doc?.body - ? domToContentModel( - doc.body, - createDomToModelContext( - { - defaultFormat: defaultSegmentFormat, - }, - options - ) - ) - : undefined; + if (doc?.body) { + const context = createDomToModelContextForSanitizing(defaultSegmentFormat, options); + const cssRules = doc ? retrieveCssRules(doc) : []; + + convertInlineCss(doc, cssRules); + parseFormat(doc.body, context.formatParsers.segmentOnBlock, context.segmentFormat, context); + + return domToContentModel(doc.body, context); + } else { + return createEmptyModel(defaultSegmentFormat); + } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/exportContent.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/exportContent.ts new file mode 100644 index 00000000000..83e3c8c6184 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/exportContent.ts @@ -0,0 +1,35 @@ +import { + contentModelToDom, + contentModelToText, + createModelToDomContext, +} from 'roosterjs-content-model-dom'; +import type { ExportContentMode, IStandaloneEditor } from 'roosterjs-content-model-types'; + +/** + * Export string content of editor + * @param editor The editor to get content from + * @param mode Mode of content to export. It supports: + * - HTML: Export HTML content. If there are entities, this will cause EntityOperation event with option = 'replaceTemporaryContent' to get a dehydrated entity + * - PlainText: Export plain text content + * - PlainTextFast: Export plain text using editor's textContent property directly + */ +export function exportContent(editor: IStandaloneEditor, mode: ExportContentMode = 'HTML'): string { + if (mode == 'PlainTextFast') { + return editor.getDOMHelper().getTextContent(); + } else { + const model = editor.getContentModelCopy('disconnected'); + + if (mode == 'PlainText') { + return contentModelToText(model); + } else { + const doc = editor.getDocument(); + const div = doc.createElement('div'); + + contentModelToDom(doc, div, model, createModelToDomContext()); + + editor.triggerEvent('extractContentWithDom', { clonedRoot: div }, true /*broadcast*/); + + return div.innerHTML; + } + } +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts index abb7648dd89..2a6621fa30f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts @@ -20,7 +20,7 @@ import type { ContentModelParagraph, ContentModelSegmentFormat, ContentModelTable, - FormatWithContentModelContext, + FormatContentModelContext, InsertPoint, } from 'roosterjs-content-model-types'; @@ -66,7 +66,7 @@ export interface MergeModelOption { export function mergeModel( target: ContentModelDocument, source: ContentModelDocument, - context?: FormatWithContentModelContext, + context?: FormatContentModelContext, options?: MergeModelOption ): InsertPoint | null { const insertPosition = @@ -131,7 +131,7 @@ function mergeParagraph( markerPosition: InsertPoint, newPara: ContentModelParagraph, mergeToCurrentParagraph: boolean, - context?: FormatWithContentModelContext, + context?: FormatContentModelContext, option?: MergeModelOption ) { const { paragraph, marker } = markerPosition; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts index bf9ce4cd9b8..b70b54c1d08 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts @@ -28,6 +28,11 @@ export type OperationalBlocks = { * The child block */ block: ContentModelBlock | T; + + /** + * Selection path of this block + */ + path: ContentModelBlockGroup[]; }; /** @@ -119,6 +124,7 @@ export function getOperationalBlocks( result.push({ parent: path[groupIndex + 1], block: path[groupIndex] as T, + path: path.slice(groupIndex + 1), }); } break; @@ -126,6 +132,7 @@ export function getOperationalBlocks( result.push({ parent: path[0], block: block, + path, }); break; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts index df023246d3f..0b48a50f51e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts @@ -1,7 +1,7 @@ import type { ContentModelBlock, EntityRemovalOperation, - FormatWithContentModelContext, + FormatContentModelContext, } from 'roosterjs-content-model-types'; /** @@ -17,7 +17,7 @@ export function deleteBlock( blocks: ContentModelBlock[], blockToDelete: ContentModelBlock, replacement?: ContentModelBlock, - context?: FormatWithContentModelContext, + context?: FormatContentModelContext, direction?: 'forward' | 'backward' ): boolean { const index = blocks.indexOf(blockToDelete); diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts index de146b5d777..64bccd128ff 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts @@ -5,7 +5,7 @@ import type { ContentModelParagraph, ContentModelSegment, EntityRemovalOperation, - FormatWithContentModelContext, + FormatContentModelContext, } from 'roosterjs-content-model-types'; /** @@ -19,7 +19,7 @@ import type { export function deleteSegment( paragraph: ContentModelParagraph, segmentToDelete: ContentModelSegment, - context?: FormatWithContentModelContext, + context?: FormatContentModelContext, direction?: 'forward' | 'backward' ): boolean { const segments = paragraph.segments; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts index e011679664d..144dd8e5d1e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts @@ -4,7 +4,7 @@ import type { DeleteSelectionContext, DeleteSelectionResult, DeleteSelectionStep, - FormatWithContentModelContext, + FormatContentModelContext, ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; @@ -18,7 +18,7 @@ import type { export function deleteSelection( model: ContentModelDocument, additionalSteps: (DeleteSelectionStep | null)[] = [], - formatContext?: FormatWithContentModelContext + formatContext?: FormatContentModelContext ): DeleteSelectionResult { const context = deleteExpandedSelection(model, formatContext); diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/convertInlineCss.ts similarity index 50% rename from packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts rename to packages-content-model/roosterjs-content-model-core/lib/utils/convertInlineCss.ts index 1ec7973f0ae..576cf8e563b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/convertInlineCss.ts @@ -1,5 +1,39 @@ import { toArray } from 'roosterjs-content-model-dom'; -import type { CssRule } from './retrieveHtmlInfo'; + +/** + * @internal + */ +export interface CssRule { + selectors: string[]; + text: string; +} + +/** + * @internal + */ +export function retrieveCssRules(doc: Document): CssRule[] { + const styles = toArray(doc.querySelectorAll('style')); + const result: CssRule[] = []; + + styles.forEach(styleNode => { + const sheet = styleNode.sheet as CSSStyleSheet; + + for (let ruleIndex = 0; ruleIndex < sheet.cssRules.length; ruleIndex++) { + const rule = sheet.cssRules[ruleIndex] as CSSStyleRule; + + if (rule.type == CSSRule.STYLE_RULE && rule.selectorText) { + result.push({ + selectors: rule.selectorText.split(','), + text: rule.style.cssText, + }); + } + } + + styleNode.parentNode?.removeChild(styleNode); + }); + + return result; +} /** * @internal diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts new file mode 100644 index 00000000000..ec0471f3098 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts @@ -0,0 +1,59 @@ +import { containerSizeFormatParser } from '../override/containerSizeFormatParser'; +import { createDomToModelContext } from 'roosterjs-content-model-dom'; +import { createPasteEntityProcessor } from '../override/pasteEntityProcessor'; +import { createPasteGeneralProcessor } from '../override/pasteGeneralProcessor'; +import { pasteBlockEntityParser } from '../override/pasteCopyBlockEntityParser'; +import { pasteDisplayFormatParser } from '../override/pasteDisplayFormatParser'; +import { pasteTextProcessor } from '../override/pasteTextProcessor'; +import type { + ContentModelSegmentFormat, + DomToModelContext, + DomToModelOption, + DomToModelOptionForSanitizing, +} from 'roosterjs-content-model-types'; + +const DefaultSanitizingOption: DomToModelOptionForSanitizing = { + processorOverride: {}, + formatParserOverride: {}, + additionalFormatParsers: {}, + additionalAllowedTags: [], + additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, +}; + +/** + * @internal + */ +export function createDomToModelContextForSanitizing( + defaultFormat?: ContentModelSegmentFormat, + defaultOption?: DomToModelOption, + additionalSanitizingOption?: DomToModelOptionForSanitizing +): DomToModelContext { + const sanitizingOption: DomToModelOptionForSanitizing = { + ...DefaultSanitizingOption, + ...additionalSanitizingOption, + }; + + return createDomToModelContext( + { + defaultFormat, + }, + defaultOption, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: createPasteEntityProcessor(sanitizingOption), + '*': createPasteGeneralProcessor(sanitizingOption), + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerSizeFormatParser], + entity: [pasteBlockEntityParser], + }, + }, + sanitizingOption + ); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts index 1e230b5da39..0d73aace66e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts @@ -2,7 +2,7 @@ import type { HtmlFromClipboard } from './retrieveHtmlInfo'; import type { BeforePasteEvent, ClipboardData, - DomToModelOptionForPaste, + DomToModelOptionForSanitizing, PasteType, StandaloneEditorCore, } from 'roosterjs-content-model-types'; @@ -17,7 +17,7 @@ export function generatePasteOptionFromPlugins( htmlFromClipboard: HtmlFromClipboard, pasteType: PasteType ): BeforePasteEvent { - const domToModelOption: DomToModelOptionForPaste = { + const domToModelOption: DomToModelOptionForSanitizing = { additionalAllowedTags: [], additionalDisallowedTags: [], additionalFormatParsers: {}, diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts index 57796bbb053..905ac41b5c4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts @@ -1,13 +1,10 @@ import { ChangeSource } from '../../constants/ChangeSource'; -import { containerSizeFormatParser } from '../../override/containerSizeFormatParser'; -import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; -import { createPasteEntityProcessor } from '../../override/pasteEntityProcessor'; -import { createPasteGeneralProcessor } from '../../override/pasteGeneralProcessor'; +import { createDomToModelContextForSanitizing } from '../createDomToModelContextForSanitizing'; +import { domToContentModel } from 'roosterjs-content-model-dom'; import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFormat'; import { getSelectedSegments } from '../../publicApi/selection/collectSelections'; import { mergeModel } from '../../publicApi/model/mergeModel'; -import { pasteDisplayFormatParser } from '../../override/pasteDisplayFormatParser'; -import { pasteTextProcessor } from '../../override/pasteTextProcessor'; + import type { MergeModelOption } from '../../publicApi/model/mergeModel'; import type { BeforePasteEvent, @@ -45,22 +42,9 @@ export function mergePasteContent( core, (model, context) => { const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; - const domToModelContext = createDomToModelContext( - undefined /*editorContext*/, + const domToModelContext = createDomToModelContextForSanitizing( + undefined /*defaultFormat*/, core.domToModelSettings.customized, - { - processorOverride: { - '#text': pasteTextProcessor, - entity: createPasteEntityProcessor(domToModelOption), - '*': createPasteGeneralProcessor(domToModelOption), - }, - formatParserOverride: { - display: pasteDisplayFormatParser, - }, - additionalFormatParsers: { - container: [containerSizeFormatParser], - }, - }, domToModelOption ); diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts index e0a1aefe773..7e49a3f783b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts @@ -1,17 +1,11 @@ import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import { retrieveCssRules } from '../convertInlineCss'; +import type { CssRule } from '../convertInlineCss'; import type { ClipboardData } from 'roosterjs-content-model-types'; const START_FRAGMENT = ''; const END_FRAGMENT = ''; -/** - * @internal - */ -export interface CssRule { - selectors: string[]; - text: string; -} - /** * @internal */ @@ -80,30 +74,6 @@ function retrieveMetadata(doc: Document): Record { return result; } -function retrieveCssRules(doc: Document): CssRule[] { - const styles = toArray(doc.querySelectorAll('style')); - const result: CssRule[] = []; - - styles.forEach(styleNode => { - const sheet = styleNode.sheet as CSSStyleSheet; - - for (let ruleIndex = 0; ruleIndex < sheet.cssRules.length; ruleIndex++) { - const rule = sheet.cssRules[ruleIndex] as CSSStyleRule; - - if (rule.type == CSSRule.STYLE_RULE && rule.selectorText) { - result.push({ - selectors: rule.selectorText.split(','), - text: rule.style.cssText, - }); - } - } - - styleNode.parentNode?.removeChild(styleNode); - }); - - return result; -} - function retrieveHtmlStrings( clipboardData: Partial ): { diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts index cdd92a87f4d..3b05a30be00 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts @@ -12,6 +12,8 @@ import type { ContentModelEntityFormat, } from 'roosterjs-content-model-types'; +const BlockEntityContainer = '_E_EBlockEntityContainer'; + /** * @internal */ @@ -75,15 +77,38 @@ function tryGetEntityElement( ): HTMLElement | null { let result: HTMLElement | null = null; - if (isNodeOfType(node, 'ELEMENT_NODE') && isEntityElement(node)) { - const format: ContentModelEntityFormat = {}; + if (isNodeOfType(node, 'ELEMENT_NODE')) { + if (isEntityElement(node)) { + const format: ContentModelEntityFormat = {}; - node.classList.forEach(name => { - parseEntityClassName(name, format); - }); + node.classList.forEach(name => { + parseEntityClassName(name, format); + }); - result = (format.id && entityMap[format.id]?.element) || null; + result = (format.id && entityMap[format.id]?.element) || null; + } else if (isBlockEntityContainer(node)) { + result = tryGetEntityFromContainer(node, entityMap); + } } return result; } +function isBlockEntityContainer(node: HTMLElement) { + return node.classList.contains(BlockEntityContainer); +} + +function tryGetEntityFromContainer( + element: HTMLElement, + entityMap: Record +): HTMLElement | null { + const format: ContentModelEntityFormat = {}; + element.childNodes.forEach(node => { + if (isEntityElement(node) && isNodeOfType(node, 'ELEMENT_NODE')) { + node.classList.forEach(name => parseEntityClassName(name, format)); + } + }); + + const parent = format.id ? entityMap[format.id]?.element.parentElement : null; + + return isNodeOfType(parent, 'ELEMENT_NODE') && isBlockEntityContainer(parent) ? parent : null; +} diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts index 7efe27aa876..bdf2f92ba06 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts @@ -46,6 +46,7 @@ describe('createEditorContext', () => { domIndexer: undefined, pendingFormat: undefined, zoomScale: 1, + rootFontSize: 16, }); }); @@ -93,6 +94,7 @@ describe('createEditorContext', () => { domIndexer, pendingFormat: undefined, zoomScale: 1, + rootFontSize: 16, }); }); @@ -139,6 +141,7 @@ describe('createEditorContext', () => { domIndexer: undefined, pendingFormat: mockedPendingFormat, zoomScale: 1, + rootFontSize: 16, }); }); }); @@ -193,6 +196,7 @@ describe('createEditorContext - checkZoomScale', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: undefined, + rootFontSize: 16, }); }); }); @@ -248,6 +252,7 @@ describe('createEditorContext - checkRootDir', () => { domIndexer: undefined, pendingFormat: undefined, zoomScale: 1, + rootFontSize: 16, }); }); @@ -268,6 +273,7 @@ describe('createEditorContext - checkRootDir', () => { domIndexer: undefined, pendingFormat: undefined, zoomScale: 1, + rootFontSize: 16, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts index b8255fdf113..96aaf2b74c3 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts @@ -5,7 +5,7 @@ import { formatContentModel } from '../../lib/coreApi/formatContentModel'; import { ContentModelDocument, ContentModelSegmentFormat, - FormatWithContentModelContext, + FormatContentModelContext, StandaloneEditorCore, } from 'roosterjs-content-model-types'; @@ -741,12 +741,10 @@ describe('formatContentModel', () => { const mockedEntityState = 'STATE' as any; const callback = jasmine .createSpy('callback') - .and.callFake( - (model: ContentModelDocument, context: FormatWithContentModelContext) => { - context.entityStates = mockedEntityState; - return true; - } - ); + .and.callFake((model: ContentModelDocument, context: FormatContentModelContext) => { + context.entityStates = mockedEntityState; + return true; + }); formatContentModel(core, callback); @@ -765,12 +763,10 @@ describe('formatContentModel', () => { it('trigger addUndoSnapshot when has canUndoByBackspace', () => { const callback = jasmine .createSpy('callback') - .and.callFake( - (model: ContentModelDocument, context: FormatWithContentModelContext) => { - context.canUndoByBackspace = true; - return true; - } - ); + .and.callFake((model: ContentModelDocument, context: FormatContentModelContext) => { + context.canUndoByBackspace = true; + return true; + }); formatContentModel(core, callback); @@ -788,12 +784,10 @@ describe('formatContentModel', () => { it('trigger addUndoSnapshot when has canUndoByBackspace and has valid range selection', () => { const callback = jasmine .createSpy('callback') - .and.callFake( - (model: ContentModelDocument, context: FormatWithContentModelContext) => { - context.canUndoByBackspace = true; - return true; - } - ); + .and.callFake((model: ContentModelDocument, context: FormatContentModelContext) => { + context.canUndoByBackspace = true; + return true; + }); setContentModel.and.returnValue({ type: 'range', @@ -812,9 +806,7 @@ describe('formatContentModel', () => { expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); expect(core.undo).toEqual({ isNested: false, - snapshotsManager: { - hasNewContent: false, - }, + snapshotsManager: {}, posContainer: mockedContainer, posOffset: mockedOffset, } as any); @@ -833,7 +825,9 @@ describe('formatContentModel', () => { expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); expect(core.undo).toEqual({ isNested: true, - snapshotsManager: {}, + snapshotsManager: { + hasNewContent: true, + }, } as any); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index 491ace4082f..3592b897491 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -9,8 +9,8 @@ import * as PPT from 'roosterjs-content-model-plugins/lib/paste/PowerPoint/proce import * as setProcessorF from 'roosterjs-content-model-plugins/lib/paste/utils/setProcessor'; import * as WacComponents from 'roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from 'roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ContentModelPastePlugin } from 'roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { expectEqual, initEditor } from 'roosterjs-content-model-plugins/test/paste/e2e/testUtils'; +import { PastePlugin } from 'roosterjs-content-model-plugins/lib/paste/PastePlugin'; import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; import { ClipboardData, @@ -73,7 +73,7 @@ describe('Paste ', () => { ]); editor = new StandaloneEditor(div, { - plugins: [new ContentModelPastePlugin()], + plugins: [new PastePlugin()], coreApiOverride: { focus, createContentModel, @@ -112,7 +112,7 @@ describe('paste with content model & paste plugin', () => { div = document.createElement('div'); document.body.appendChild(div); editor = new StandaloneEditor(div, { - plugins: [new ContentModelPastePlugin()], + plugins: [new PastePlugin()], }); spyOn(addParserF, 'default').and.callThrough(); spyOn(setProcessorF, 'setProcessor').and.callThrough(); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts index d75dc60e4e4..29463824edc 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts @@ -1,16 +1,16 @@ import * as textMutationObserver from '../../lib/corePlugin/utils/textMutationObserver'; -import { contentModelDomIndexer } from '../../lib/corePlugin/utils/contentModelDomIndexer'; -import { createContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; +import { createCachePlugin } from '../../lib/corePlugin/CachePlugin'; +import { domIndexerImpl } from '../../lib/corePlugin/utils/domIndexerImpl'; import { - ContentModelCachePluginState, - ContentModelDomIndexer, + CachePluginState, + DomIndexer, IStandaloneEditor, PluginWithState, StandaloneEditorOptions, } from 'roosterjs-content-model-types'; -describe('ContentModelCachePlugin', () => { - let plugin: PluginWithState; +describe('CachePlugin', () => { + let plugin: PluginWithState; let editor: IStandaloneEditor; let addEventListenerSpy: jasmine.Spy; @@ -18,7 +18,7 @@ describe('ContentModelCachePlugin', () => { let getDOMSelectionSpy: jasmine.Spy; let reconcileSelectionSpy: jasmine.Spy; let isInShadowEditSpy: jasmine.Spy; - let domIndexer: ContentModelDomIndexer; + let domIndexer: DomIndexer; let contentDiv: HTMLDivElement; function init(option: StandaloneEditorOptions) { @@ -45,7 +45,7 @@ describe('ContentModelCachePlugin', () => { }, } as any) as IStandaloneEditor; - plugin = createContentModelCachePlugin(option, contentDiv); + plugin = createCachePlugin(option, contentDiv); plugin.initialize(editor); } @@ -75,7 +75,7 @@ describe('ContentModelCachePlugin', () => { }); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); expect(plugin.getState()).toEqual({ - domIndexer: contentModelDomIndexer, + domIndexer: domIndexerImpl, textMutationObserver: mockedObserver, }); expect(startObservingSpy).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts index 8c9552d8519..cf529029dd4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts @@ -1,5 +1,6 @@ import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +import * as copyPasteEntityOverride from '../../lib/override/pasteCopyBlockEntityParser'; import * as deleteSelectionsFile from '../../lib/publicApi/selection/deleteSelection'; import * as extractClipboardItemsFile from '../../lib/utils/extractClipboardItems'; import * as iterateSelectionsFile from '../../lib/publicApi/selection/iterateSelections'; @@ -11,7 +12,7 @@ import { ContentModelDocument, DOMSelection, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, IStandaloneEditor, DOMEventRecord, ClipboardData, @@ -21,10 +22,10 @@ import { } from 'roosterjs-content-model-types'; import { adjustSelectionForCopyCut, - createContentModelCopyPastePlugin, + createCopyPastePlugin, onNodeCreated, preprocessTable, -} from '../../lib/corePlugin/ContentModelCopyPastePlugin'; +} from '../../lib/corePlugin/CopyPastePlugin'; const modelValue = 'model' as any; const pasteModelValue = 'pasteModelValue' as any; @@ -33,9 +34,9 @@ const deleteResultValue = 'deleteResult' as any; const allowedCustomPasteType = ['Test']; -describe('ContentModelCopyPastePlugin.Ctor', () => { +describe('CopyPastePlugin.Ctor', () => { it('Ctor without options', () => { - const plugin = createContentModelCopyPastePlugin({}); + const plugin = createCopyPastePlugin({}); const state = plugin.getState(); expect(state).toEqual({ @@ -45,7 +46,7 @@ describe('ContentModelCopyPastePlugin.Ctor', () => { }); it('Ctor with options', () => { - const plugin = createContentModelCopyPastePlugin({ + const plugin = createCopyPastePlugin({ allowedCustomPasteType, }); const state = plugin.getState(); @@ -57,7 +58,7 @@ describe('ContentModelCopyPastePlugin.Ctor', () => { }); }); -describe('ContentModelCopyPastePlugin |', () => { +describe('CopyPastePlugin |', () => { let editor: IStandaloneEditor = null!; let plugin: PluginWithState; let domEvents: Record = {}; @@ -99,20 +100,18 @@ describe('ContentModelCopyPastePlugin |', () => { mockedDarkColorHandler = 'DARKCOLORHANDLER' as any; formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - modelResult = modelValue; - formatResult = callback(modelResult!, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + modelResult = modelValue; + formatResult = callback(modelResult!, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); spyOn(addRangeToSelection, 'addRangeToSelection'); - plugin = createContentModelCopyPastePlugin({ + plugin = createCopyPastePlugin({ allowedCustomPasteType, }); plugin.getState().tempDiv = div; @@ -624,17 +623,20 @@ describe('ContentModelCopyPastePlugin |', () => { it('onNodeCreated with table', () => { const div = document.createElement('div'); const table = document.createElement('table'); + spyOn(copyPasteEntityOverride, 'onCreateCopyEntityNode').and.callThrough(); div.appendChild(table); onNodeCreated(null!, table); expect(div.innerHTML).toEqual('
'); + expect(copyPasteEntityOverride.onCreateCopyEntityNode).toHaveBeenCalled(); }); it('onNodeCreated with readonly element', () => { const div = document.createElement('div'); div.contentEditable = 'true'; + spyOn(copyPasteEntityOverride, 'onCreateCopyEntityNode').and.callThrough(); const span = document.createElement('span'); div.appendChild(span); @@ -642,6 +644,7 @@ describe('ContentModelCopyPastePlugin |', () => { onNodeCreated(null!, span); + expect(copyPasteEntityOverride.onCreateCopyEntityNode).toHaveBeenCalled(); expect(div.innerHTML).toBe(''); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts index ea5590641fd..e65bbddcf5c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts @@ -97,6 +97,7 @@ describe('DOMEventPlugin', () => { describe('DOMEventPlugin verify event handlers while disallow keyboard event propagation', () => { let eventMap: Record; let plugin: PluginWithState; + let triggerEventSpy: jasmine.Spy; beforeEach(() => { const div = { @@ -104,6 +105,8 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro removeEventListener: jasmine.createSpy('removeEventListener'), }; + triggerEventSpy = jasmine.createSpy('triggerEvent'); + plugin = createDOMEventPlugin({}, div); plugin.initialize(({ getDocument, @@ -112,6 +115,7 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro return jasmine.createSpy('disposer'); }, getEnvironment: () => ({}), + triggerEvent: triggerEventSpy, })); }); @@ -122,10 +126,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro it('check events are mapped', () => { expect(eventMap).toBeDefined(); - expect(eventMap.keypress.pluginEventType).toBe('keyPress'); - expect(eventMap.keydown.pluginEventType).toBe('keyDown'); - expect(eventMap.keyup.pluginEventType).toBe('keyUp'); - expect(eventMap.input.pluginEventType).toBe('input'); expect(eventMap.keypress.beforeDispatch).toBeDefined(); expect(eventMap.keydown.beforeDispatch).toBeDefined(); expect(eventMap.keyup.beforeDispatch).toBeDefined(); @@ -150,28 +150,101 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro it('verify keydown event for character value', () => { spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); const stopPropagation = jasmine.createSpy(); - eventMap.keydown.beforeDispatch(({ + const mockedEvent = { stopPropagation, - })); + type: 'keydown', + } as any; + + eventMap.keydown.beforeDispatch(mockedEvent); + + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).toHaveBeenCalledWith('keyDown', { rawEvent: mockedEvent }); + }); + + it('verify keydown event within IME 1', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + const mockedEvent = { + stopPropagation, + type: 'keydown', + } as any; + + plugin.getState().isInIME = true; + + eventMap.keydown.beforeDispatch(mockedEvent); + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); + }); + + it('verify keydown event within IME 2', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + const mockedEvent = { + stopPropagation, + isComposing: true, + type: 'keydown', + } as any; + + eventMap.keydown.beforeDispatch(mockedEvent); + + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); }); it('verify input event for non-character value', () => { spyOn(eventUtils, 'isCharacterValue').and.returnValue(false); const stopPropagation = jasmine.createSpy(); - eventMap.input.beforeDispatch(({ + const mockedEvent = { stopPropagation, - })); + } as any; + + eventMap.input.beforeDispatch(mockedEvent); + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).toHaveBeenCalledWith('input', { rawEvent: mockedEvent }); }); it('verify input event for character value', () => { spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); const stopPropagation = jasmine.createSpy(); - eventMap.input.beforeDispatch(({ + const mockedEvent = { stopPropagation, - })); + } as any; + + eventMap.input.beforeDispatch(mockedEvent); + + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).toHaveBeenCalledWith('input', { rawEvent: mockedEvent }); + }); + + it('verify input event for character value in IME 1', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + const mockedEvent = { + stopPropagation, + } as any; + + plugin.getState().isInIME = true; + + eventMap.input.beforeDispatch(mockedEvent); + + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); + }); + + it('verify input event for character value in IME 2', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + const mockedEvent = { + stopPropagation, + isComposing: true, + } as any; + + eventMap.input.beforeDispatch(mockedEvent); + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts index 2f3b43156df..bb723cfc8d2 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -1,3 +1,4 @@ +import * as DelimiterUtils from '../../lib/corePlugin/utils/entityDelimiterUtils'; import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { createContentModelDocument, createEntity } from '../../../roosterjs-content-model-dom/lib'; @@ -55,6 +56,9 @@ describe('EntityPlugin', () => { }); describe('EditorReady event', () => { + beforeEach(() => { + spyOn(DelimiterUtils, 'handleDelimiterContentChangedEvent').and.callFake(() => {}); + }); it('empty doc', () => { mockedModel = createContentModelDocument(); @@ -67,6 +71,7 @@ describe('EntityPlugin', () => { entityMap: {}, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Doc with entity', () => { @@ -107,6 +112,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Doc with entity, can persist', () => { @@ -150,10 +156,14 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); }); describe('ContentChanged event', () => { + beforeEach(() => { + spyOn(DelimiterUtils, 'handleDelimiterContentChangedEvent').and.callFake(() => {}); + }); it('No changedEntity param', () => { const wrapper = document.createElement('div'); const entity = createEntity(wrapper, true, undefined, 'Entity1'); @@ -192,6 +202,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('New entity in dark mode', () => { @@ -239,6 +250,7 @@ describe('EntityPlugin', () => { 'lightToDark', mockedDarkColorHandler ); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('No changedEntity param, has deleted entity', () => { @@ -301,6 +313,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Do not trigger event for already deleted entity', () => { @@ -331,6 +344,7 @@ describe('EntityPlugin', () => { }); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Add back a deleted entity', () => { @@ -376,6 +390,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Has changedEntities parameter', () => { @@ -452,6 +467,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Handle conflict id', () => { @@ -510,6 +526,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('With content state', () => { @@ -555,6 +572,7 @@ describe('EntityPlugin', () => { }, state: entityState, }); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts similarity index 77% rename from packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts index bff9bb60866..dd5a92fc80b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts @@ -1,19 +1,17 @@ +import * as applyDefaultFormat from '../../lib/corePlugin/utils/applyDefaultFormat'; import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; -import { createContentModelFormatPlugin } from '../../lib/corePlugin/ContentModelFormatPlugin'; +import { createContentModelDocument } from 'roosterjs-content-model-dom'; +import { createFormatPlugin } from '../../lib/corePlugin/FormatPlugin'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { - addSegment, - createContentModelDocument, - createSelectionMarker, -} from 'roosterjs-content-model-dom'; -describe('ContentModelFormatPlugin', () => { +describe('FormatPlugin', () => { const mockedFormat = { fontSize: '10px', }; + let applyPendingFormatSpy: jasmine.Spy; beforeEach(() => { - spyOn(applyPendingFormat, 'applyPendingFormat'); + applyPendingFormatSpy = spyOn(applyPendingFormat, 'applyPendingFormat'); }); it('no pending format, trigger key down event', () => { @@ -21,7 +19,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, isDarkMode: () => false, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); plugin.initialize(editor); plugin.onPluginEvent({ @@ -31,7 +29,7 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(plugin.getState().pendingFormat).toBeNull(); }); @@ -43,7 +41,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const model = createContentModelDocument(); const state = plugin.getState(); @@ -60,46 +58,11 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledTimes(1); - expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledWith( - editor, - 'a', - mockedFormat - ); + expect(applyPendingFormatSpy).toHaveBeenCalledTimes(1); + expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'a', mockedFormat); expect(state.pendingFormat).toBeNull(); }); - it('with pending format and selection, trigger input event with isComposing = true', () => { - const model = createContentModelDocument(); - const marker = createSelectionMarker(); - - addSegment(model, marker); - - const editor = ({ - createContentModel: () => model, - cacheContentModel: () => {}, - getEnvironment: () => ({}), - } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); - plugin.initialize(editor); - - const state = plugin.getState(); - - state.pendingFormat = { - format: mockedFormat, - } as any; - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: ({ data: 'a', isComposing: true } as any) as InputEvent, - }); - plugin.dispose(); - - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); - expect(state.pendingFormat).toEqual({ - format: mockedFormat, - } as any); - }); - it('with pending format and selection, trigger CompositionEnd event', () => { const triggerEvent = jasmine.createSpy('triggerEvent'); const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); @@ -114,7 +77,7 @@ describe('ContentModelFormatPlugin', () => { triggerEvent, getVisibleViewport, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const state = plugin.getState(); state.pendingFormat = { @@ -128,11 +91,7 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledWith( - editor, - 'test', - mockedFormat - ); + expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'test', mockedFormat); expect(state.pendingFormat).toBeNull(); }); @@ -144,7 +103,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); plugin.initialize(editor); const state = plugin.getState(); @@ -159,7 +118,7 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, } as any); @@ -176,7 +135,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const state = plugin.getState(); state.pendingFormat = { @@ -192,7 +151,7 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toBeNull(); expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); @@ -204,7 +163,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const state = plugin.getState(); @@ -221,7 +180,7 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toBeNull(); expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); @@ -234,7 +193,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const state = plugin.getState(); state.pendingFormat = { @@ -250,7 +209,7 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, } as any); @@ -258,7 +217,7 @@ describe('ContentModelFormatPlugin', () => { }); }); -describe('ContentModelFormatPlugin for default format', () => { +describe('FormatPlugin for default format', () => { let editor: IStandaloneEditor; let contentDiv: HTMLDivElement; let getDOMSelection: jasmine.Spy; @@ -273,12 +232,11 @@ describe('ContentModelFormatPlugin for default format', () => { cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); formatContentModelSpy = jasmine.createSpy('formatContentModelSpy'); - contentDiv = document.createElement('div'); editor = ({ getDOMHelper: () => ({ - isNodeInEditor: (e: Node) => contentDiv != e && contentDiv.contains(e), + isNodeInEditor: (e: Node) => contentDiv.contains(e), }), getDOMSelection, getPendingFormat: getPendingFormatSpy, @@ -289,7 +247,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, text input, under editor directly', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -345,7 +303,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Expanded range, text input, under editor directly', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -398,7 +356,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, IME input, under editor directly', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -453,7 +411,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, other input, under editor directly', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -504,7 +462,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, normal input, not under editor directly, no style', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -561,7 +519,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, text input, under editor directly, has pending format', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -621,4 +579,106 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); }); + + it('Collapsed range, already have style but not enough', () => { + const defaultFormat = { + fontFamily: 'Arial', + fontSize: '20px', + textColor: 'red', + }; + const plugin = createFormatPlugin({ + defaultSegmentFormat: defaultFormat, + }); + const rawEvent = { key: 'a' } as any; + const applyDefaultFormatSpy = spyOn(applyDefaultFormat, 'applyDefaultFormat'); + const div = document.createElement('div'); + + contentDiv.appendChild(div); + div.style.fontFamily = 'Arial'; + div.style.fontSize = '10px'; + + (editor as any).defaultFormatKeys = new Set(['fontFamily', 'fontSize', 'textColor']); + + getDOMSelection.and.returnValue({ + type: 'range', + range: { + collapsed: true, + startContainer: div, + startOffset: 0, + }, + }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(applyDefaultFormatSpy).toHaveBeenCalledTimes(1); + expect(applyDefaultFormatSpy).toHaveBeenCalledWith(editor, defaultFormat); + + // Trigger event again under the same node, no need to apply again + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(applyDefaultFormatSpy).toHaveBeenCalledTimes(1); + + // Trigger event again under the same node, no need to apply again + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { key: 'ArrowUp' } as any, + }); + + expect(applyDefaultFormatSpy).toHaveBeenCalledTimes(1); + + // Trigger event again under after moving cursor, should check again + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(applyDefaultFormatSpy).toHaveBeenCalledTimes(2); + }); + + it('Collapsed range, already have style and is enough', () => { + const defaultFormat = { + fontFamily: 'Arial', + fontSize: '20px', + textColor: 'red', + }; + const plugin = createFormatPlugin({ + defaultSegmentFormat: defaultFormat, + }); + const rawEvent = { key: 'a' } as any; + const applyDefaultFormatSpy = spyOn(applyDefaultFormat, 'applyDefaultFormat'); + const div = document.createElement('div'); + + contentDiv.appendChild(div); + div.style.fontFamily = 'Arial'; + div.style.fontSize = '10px'; + div.style.color = 'green'; + + (editor as any).defaultFormatKeys = new Set(['fontFamily', 'fontSize', 'textColor']); + + getDOMSelection.and.returnValue({ + type: 'range', + range: { + collapsed: true, + startContainer: div, + startOffset: 0, + }, + }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(applyDefaultFormatSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts index 47abee4f5f7..c6f5823f1e1 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts @@ -5,8 +5,8 @@ import { ContentModelDocument, ContentModelFormatter, ContentModelSegmentFormat, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, IStandaloneEditor, InsertPoint, } from 'roosterjs-content-model-types'; @@ -27,8 +27,9 @@ describe('applyDefaultFormat', () => { let normalizeContentModelSpy: jasmine.Spy; let takeSnapshotSpy: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; + let isNodeInEditorSpy: jasmine.Spy; - let context: FormatWithContentModelContext | undefined; + let context: FormatContentModelContext | undefined; let model: ContentModelDocument; let formatResult: boolean | undefined; @@ -48,11 +49,12 @@ describe('applyDefaultFormat', () => { normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); + isNodeInEditorSpy = jasmine.createSpy('isNodeInEditor'); formatContentModelSpy = jasmine .createSpy('formatContentModelSpy') .and.callFake( - (formatter: ContentModelFormatter, options: FormatWithContentModelOptions) => { + (formatter: ContentModelFormatter, options: FormatContentModelOptions) => { context = { deletedEntities: [], newEntities: [], @@ -65,7 +67,7 @@ describe('applyDefaultFormat', () => { editor = { getDOMHelper: () => ({ - isNodeInEditor: () => true, + isNodeInEditor: isNodeInEditorSpy, }), getDOMSelection: getDOMSelectionSpy, formatContentModel: formatContentModelSpy, @@ -76,16 +78,22 @@ describe('applyDefaultFormat', () => { it('No selection', () => { getDOMSelectionSpy.and.returnValue(null); + deleteSelectionSpy.and.returnValue({}); applyDefaultFormat(editor, defaultFormat); - expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); }); it('Selection already has style', () => { + const contentDiv = document.createElement('div'); const node = document.createElement('div'); node.style.fontFamily = 'Tahoma'; + contentDiv.appendChild(node); + + isNodeInEditorSpy.and.callFake(node => contentDiv.contains(node)); + getDOMSelectionSpy.and.returnValue({ type: 'range', range: { @@ -93,10 +101,38 @@ describe('applyDefaultFormat', () => { startOffset: 0, }, }); + deleteSelectionSpy.and.returnValue({ + deleteResult: '', + }); applyDefaultFormat(editor, defaultFormat); - expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + }); + + it('text under content div directly', () => { + const contentDiv = document.createElement('div'); + const text = document.createTextNode('test'); + + contentDiv.style.fontFamily = 'Tahoma'; + contentDiv.appendChild(text); + + isNodeInEditorSpy.and.callFake(node => contentDiv.contains(node)); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: text, + startOffset: 0, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: '', + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalled(); }); it('Good selection, delete range ', () => { @@ -131,6 +167,8 @@ describe('applyDefaultFormat', () => { it('Good selection, NothingToDelete ', () => { const node = document.createElement('div'); + isNodeInEditorSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue({ type: 'range', range: { @@ -159,6 +197,7 @@ describe('applyDefaultFormat', () => { it('Good selection, SingleChar ', () => { const node = document.createElement('div'); + isNodeInEditorSpy.and.returnValue(true); getDOMSelectionSpy.and.returnValue({ type: 'range', @@ -192,6 +231,7 @@ describe('applyDefaultFormat', () => { const text = createText('test'); const para = createParagraph(); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(text, marker); model.blocks.push(para); @@ -233,6 +273,7 @@ describe('applyDefaultFormat', () => { const img = createImage('test'); const para = createParagraph(); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(img, marker); model.blocks.push(para); @@ -275,6 +316,7 @@ describe('applyDefaultFormat', () => { const paraPrev = createParagraph(); const para = createParagraph(true /*isImplicit*/); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(marker); model.blocks.push(paraPrev, para); @@ -316,6 +358,7 @@ describe('applyDefaultFormat', () => { const divider = createDivider('hr'); const para = createParagraph(true /*isImplicit*/); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(marker); model.blocks.push(divider, para); @@ -361,6 +404,7 @@ describe('applyDefaultFormat', () => { const img = createImage('test'); const para = createParagraph(); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(img, marker); model.blocks.push(para); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts index 01b26ea8e36..78bbab041f0 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts @@ -7,7 +7,7 @@ import { ContentModelSelectionMarker, ContentModelText, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, IStandaloneEditor, } from 'roosterjs-content-model-types'; import { @@ -41,16 +41,14 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('applyPendingFormat'); - callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); const editor = ({ formatContentModel: formatContentModelSpy, @@ -119,12 +117,10 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); const editor = ({ formatContentModel: formatContentModelSpy, @@ -238,12 +234,10 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); const editor = ({ formatContentModel: formatContentModelSpy, @@ -289,12 +283,10 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); const editor = ({ formatContentModel: formatContentModelSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts new file mode 100644 index 00000000000..8b2a2fb17da --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts @@ -0,0 +1,1132 @@ +import * as DelimiterFile from '../../../lib/corePlugin/utils/entityDelimiterUtils'; +import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import { + handleDelimiterContentChangedEvent, + handleDelimiterKeyDownEvent, +} from '../../../lib/corePlugin/utils/entityDelimiterUtils'; +import { + contentModelToDom, + createEntity, + createModelToDomContext, +} from 'roosterjs-content-model-dom'; +import { + ContentModelDocument, + DOMSelection, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; + +const ZeroWidthSpace = '\u200B'; +const BlockEntityContainer = '_E_EBlockEntityContainer'; + +describe('EntityDelimiterUtils |', () => { + let queryElementsSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let mockedEditor: any; + beforeEach(() => { + mockedEditor = ({ + getDOMHelper: () => ({ + queryElements: queryElementsSpy, + isNodeInEditor: () => true, + }), + getPendingFormat: ((): any => null), + }) as Partial; + }); + + describe('contentChanged |', () => { + it('remove invalid delimiters', () => { + const div = document.createElement('div'); + const entityWrapper = document.createElement('span'); + entityWrapper.style.width = '100%'; + entityWrapper.style.display = 'inline-block'; + + contentModelToDom( + document, + div, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + entityFormat: { + isReadonly: true, + entityType: 'Test', + id: 'Id', + }, + format: {}, + segmentType: 'Entity', + wrapper: entityWrapper, + }, + ], + }, + createModelToDomContext({ + addDelimiterForEntity: true, + }) + ); + queryElementsSpy = jasmine + .createSpy('queryElement') + .and.callFake(sel => div.querySelectorAll(sel)); + + entityWrapper.remove(); + + handleDelimiterContentChangedEvent(mockedEditor); + + expect(queryElementsSpy).toHaveBeenCalledTimes(2); + expect(div.firstElementChild?.childElementCount).toEqual(0); + }); + + it('add delimiters', () => { + const div = document.createElement('div'); + const entityWrapper = document.createElement('span'); + entityWrapper.style.width = '100%'; + entityWrapper.style.display = 'inline-block'; + + contentModelToDom( + document, + div, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + entityFormat: { + isReadonly: true, + entityType: 'Test', + id: 'Id', + }, + format: {}, + segmentType: 'Entity', + wrapper: entityWrapper, + }, + ], + }, + createModelToDomContext({}) + ); + queryElementsSpy = jasmine + .createSpy('queryElement') + .and.callFake(sel => div.querySelectorAll(sel)); + + handleDelimiterContentChangedEvent(mockedEditor); + + expect(queryElementsSpy).toHaveBeenCalledTimes(2); + expect(entityWrapper.parentElement!.childElementCount).toEqual(3); + }); + + it('Remove delimiter info', () => { + const div = document.createElement('div'); + const entityWrapper = document.createElement('span'); + entityWrapper.style.width = '100%'; + entityWrapper.style.display = 'inline-block'; + + contentModelToDom( + document, + div, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + entityFormat: { + isReadonly: true, + entityType: 'Test', + id: 'Id', + }, + format: {}, + segmentType: 'Entity', + wrapper: entityWrapper, + }, + ], + }, + createModelToDomContext({ + addDelimiterForEntity: true, + }) + ); + queryElementsSpy = jasmine + .createSpy('queryElement') + .and.callFake(sel => div.querySelectorAll(sel)); + + const invalidDelimiter = entityWrapper.previousElementSibling; + invalidDelimiter?.appendChild(document.createTextNode('_')); + + handleDelimiterContentChangedEvent(mockedEditor); + + expect(queryElementsSpy).toHaveBeenCalledTimes(2); + expect(entityWrapper.parentElement!.childElementCount).toEqual(4); + expect( + invalidDelimiter && entityUtils.isEntityDelimiter(invalidDelimiter) + ).toBeFalsy(); + }); + }); + + describe('onKeyDown |', () => { + let mockedSelection: DOMSelection; + let rafSpy: jasmine.Spy; + let takeSnapshotSpy: jasmine.Spy; + + beforeEach(() => { + mockedSelection = undefined!; + rafSpy = jasmine.createSpy('requestAnimationFrame'); + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + + mockedEditor = ({ + getDOMSelection: () => mockedSelection, + getDocument: () => + { + defaultView: { + requestAnimationFrame: rafSpy, + }, + }, + formatContentModel: formatContentModelSpy, + getDOMHelper: () => ({ + queryElements: queryElementsSpy, + isNodeInEditor: () => true, + }), + takeSnapshot: takeSnapshotSpy, + }) as Partial; + spyOn(DelimiterFile, 'preventTypeInDelimiter').and.callThrough(); + }); + + it('Dont handle, no selection', () => { + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(DelimiterFile.preventTypeInDelimiter).not.toHaveBeenCalled(); + }); + + it('Dont handle, image selection', () => { + mockedSelection = { + type: 'image', + }; + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(DelimiterFile.preventTypeInDelimiter).not.toHaveBeenCalled(); + }); + + it('Dont handle, table selection', () => { + mockedSelection = { + type: 'table', + }; + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(DelimiterFile.preventTypeInDelimiter).not.toHaveBeenCalled(); + }); + + it('Dont handle, range selection & no delimiter & no entity', () => { + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(false); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(DelimiterFile.preventTypeInDelimiter).not.toHaveBeenCalled(); + }); + + it('Handle, range selection & delimiter', () => { + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).toHaveBeenCalled(); + }); + + it('Handle, range selection on upper container & delimiter', () => { + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + mockedSelection = { + type: 'range', + range: { + endContainer: parent, + endOffset: 1, + collapsed: true, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).toHaveBeenCalled(); + }); + + it('Handle, range selection & delimiter before wrapped in block entity', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setStartBeforeSpy).toHaveBeenCalled(); + expect(collapseSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith( + DelimiterFile.handleKeyDownInBlockDelimiter, + { + selectionOverride: mockedSelection, + } + ); + }); + + it('Handle, range selection & delimiter after wrapped in block entity', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterAfter'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalled(); + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(collapseSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith( + DelimiterFile.handleKeyDownInBlockDelimiter, + { + selectionOverride: mockedSelection, + } + ); + }); + + it('Handle, range selection & delimiter before wrapped in block entity | Enter Key', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'Enter', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setStartBeforeSpy).toHaveBeenCalled(); + expect(collapseSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith( + DelimiterFile.handleKeyDownInBlockDelimiter, + { + selectionOverride: mockedSelection, + } + ); + }); + + it('Handle, range selection & delimiter after wrapped in block entity', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterAfter'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'Enter', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalled(); + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(collapseSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith( + DelimiterFile.handleKeyDownInBlockDelimiter, + { + selectionOverride: mockedSelection, + } + ); + }); + }); +}); + +describe('preventTypeInDelimiter', () => { + let mockedEditor: any; + let mockedModel: ContentModelDocument; + let context: any; + + beforeEach(() => { + context = {}; + + mockedModel = { + blockGroupType: 'Document', + blocks: [], + }; + mockedEditor = { + formatContentModel: formatter => { + formatter(mockedModel, context); + }, + } as Partial; + }); + + it('handle delimiter after entity', () => { + const entityWrapper = document.createElement('span'); + entityWrapper.className = BlockEntityContainer; + + mockedModel = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + createEntity(entityWrapper, true), + { + segmentType: 'Text', + format: {}, + text: 'a' + ZeroWidthSpace, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const root = document.createElement('div'); + contentModelToDom( + document, + root, + mockedModel, + createModelToDomContext({ + addDelimiterForEntity: true, + }) + ); + + DelimiterFile.preventTypeInDelimiter( + entityWrapper.nextElementSibling as HTMLElement, + mockedEditor + ); + + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + }, + { + segmentType: 'Text', + format: {}, + text: 'a', + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + expect(context).toEqual({ + skipUndoSnapshot: true, + }); + }); + + it('handle delimiter before entity', () => { + const entityWrapper = document.createElement('span'); + entityWrapper.className = BlockEntityContainer; + + mockedModel = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: {}, + text: ZeroWidthSpace + 'Test', + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + createEntity(entityWrapper, true), + ], + format: {}, + }, + ], + format: {}, + }; + + const root = document.createElement('div'); + contentModelToDom( + document, + root, + mockedModel, + createModelToDomContext({ + addDelimiterForEntity: true, + }) + ); + + DelimiterFile.preventTypeInDelimiter( + entityWrapper.previousElementSibling as HTMLElement, + mockedEditor + ); + + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'Test', + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + }, + ], + format: {}, + }, + ], + format: {}, + }); + expect(context).toEqual({ + skipUndoSnapshot: true, + }); + }); +}); + +describe('handleKeyDownInBlockDelimiter', () => { + it('handle after block entity', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: false, + }, + wrapper: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + isImplicit: true, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleKeyDownInBlockDelimiter(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: false, + }, + wrapper: jasmine.anything(), + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); + + it('handle before block entity', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: false, + }, + wrapper: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleKeyDownInBlockDelimiter(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: false, + }, + wrapper: jasmine.anything(), + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); +}); + +describe('handleEnterInlineEntity', () => { + it('handle after entity', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleEnterInlineEntity(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: jasmine.anything(), + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); + + it('handle before entity', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: {}, + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleEnterInlineEntity(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: jasmine.anything(), + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts index 447252d85d0..90e0b391221 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts @@ -1,6 +1,6 @@ import * as setSelection from '../../../lib/publicApi/selection/setSelection'; -import { contentModelDomIndexer } from '../../../lib/corePlugin/utils/contentModelDomIndexer'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; +import { domIndexerImpl } from '../../../lib/corePlugin/utils/domIndexerImpl'; import { ContentModelDocument, ContentModelSegment, @@ -17,13 +17,13 @@ import { createText, } from 'roosterjs-content-model-dom'; -describe('contentModelDomIndexer.onSegment', () => { +describe('domIndexerImpl.onSegment', () => { it('onSegment', () => { const node = {} as any; const paragraph = 'Paragraph' as any; const segment = 'Segment' as any; - contentModelDomIndexer.onSegment(node, paragraph, [segment]); + domIndexerImpl.onSegment(node, paragraph, [segment]); expect(node).toEqual({ __roosterjsContentModel: { paragraph: 'Paragraph', segments: ['Segment'] }, @@ -31,11 +31,11 @@ describe('contentModelDomIndexer.onSegment', () => { }); }); -describe('contentModelDomIndexer.onParagraph', () => { +describe('domIndexerImpl.onParagraph', () => { it('Paragraph, no child', () => { const node = document.createElement('div'); - contentModelDomIndexer.onParagraph(node); + domIndexerImpl.onParagraph(node); expect(node.outerHTML).toBe('
'); }); @@ -52,7 +52,7 @@ describe('contentModelDomIndexer.onParagraph', () => { }; node.appendChild(text); - contentModelDomIndexer.onParagraph(node); + domIndexerImpl.onParagraph(node); expect(text.__roosterjsContentModel).toEqual({ paragraph, @@ -87,7 +87,7 @@ describe('contentModelDomIndexer.onParagraph', () => { node.appendChild(text2); node.appendChild(text3); - contentModelDomIndexer.onParagraph(node); + domIndexerImpl.onParagraph(node); expect(text1.__roosterjsContentModel).toEqual({ paragraph, @@ -139,7 +139,7 @@ describe('contentModelDomIndexer.onParagraph', () => { node.appendChild(text3); node.appendChild(text4); - contentModelDomIndexer.onParagraph(node); + domIndexerImpl.onParagraph(node); expect(text1.__roosterjsContentModel).toEqual({ paragraph, @@ -161,7 +161,7 @@ describe('contentModelDomIndexer.onParagraph', () => { }); }); -describe('contentModelDomIndexer.onTable', () => { +describe('domIndexerImpl.onTable', () => { it('onTable', () => { const node = {} as any; const rows = 'ROWS' as any; @@ -169,7 +169,7 @@ describe('contentModelDomIndexer.onTable', () => { rows: rows, } as any; - contentModelDomIndexer.onTable(node, table); + domIndexerImpl.onTable(node, table); expect(node).toEqual({ __roosterjsContentModel: { tableRows: rows }, @@ -177,7 +177,7 @@ describe('contentModelDomIndexer.onTable', () => { }); }); -describe('contentModelDomIndexer.reconcileSelection', () => { +describe('domIndexerImpl.reconcileSelection', () => { let setSelectionSpy: jasmine.Spy; let model: ContentModelDocument; @@ -189,7 +189,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { it('no old range, fake range', () => { const newRangeEx = {} as any; - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeFalse(); expect(setSelectionSpy).not.toHaveBeenCalled(); @@ -203,7 +203,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { isReverted: false, }; - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeFalse(); expect(setSelectionSpy).not.toHaveBeenCalled(); @@ -220,9 +220,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const segment = createText(''); paragraph.segments.push(segment); - contentModelDomIndexer.onSegment(node, paragraph, [segment]); + domIndexerImpl.onSegment(node, paragraph, [segment]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -267,9 +267,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const segment = createText(''); paragraph.segments.push(segment); - contentModelDomIndexer.onSegment(node, paragraph, [segment]); + domIndexerImpl.onSegment(node, paragraph, [segment]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -319,11 +319,11 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldSegment2 = createText(''); paragraph.segments.push(oldSegment1, oldSegment2); - contentModelDomIndexer.onSegment(node1, paragraph, [oldSegment1]); - contentModelDomIndexer.onSegment(node2, paragraph, [oldSegment2]); + domIndexerImpl.onSegment(node1, paragraph, [oldSegment1]); + domIndexerImpl.onSegment(node2, paragraph, [oldSegment2]); model.blocks.push(paragraph); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -389,11 +389,11 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldSegment2 = createBr(); paragraph.segments.push(oldSegment1, oldSegment2); - contentModelDomIndexer.onSegment(node1, paragraph, [oldSegment1]); - contentModelDomIndexer.onSegment(node2, paragraph, [oldSegment2]); + domIndexerImpl.onSegment(node1, paragraph, [oldSegment1]); + domIndexerImpl.onSegment(node2, paragraph, [oldSegment2]); model.blocks.push(paragraph); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -445,10 +445,10 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldSegment1 = createImage('test'); paragraph.segments.push(oldSegment1); - contentModelDomIndexer.onSegment(node1, paragraph, [oldSegment1]); + domIndexerImpl.onSegment(node1, paragraph, [oldSegment1]); model.blocks.push(paragraph); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeFalse(); expect(node1.__roosterjsContentModel).toEqual({ @@ -501,10 +501,10 @@ describe('contentModelDomIndexer.reconcileSelection', () => { tableModel.rows[1].cells.push(cell10, cell11, cell12); tableModel.rows[2].cells.push(cell20, cell21, cell22); - contentModelDomIndexer.onTable(node1, tableModel); + domIndexerImpl.onTable(node1, tableModel); model.blocks.push(tableModel); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeFalse(); expect(node1.__roosterjsContentModel).toEqual({ @@ -532,9 +532,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const segment = createBr({ fontFamily: 'Arial' }); paragraph.segments.push(segment); - contentModelDomIndexer.onSegment(node, paragraph, [segment]); + domIndexerImpl.onSegment(node, paragraph, [segment]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeTrue(); expect(node.__roosterjsContentModel).toEqual({ @@ -566,9 +566,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldSegment2 = createText('st'); paragraph.segments.push(oldSegment1, createSelectionMarker(), oldSegment2); - contentModelDomIndexer.onSegment(node, paragraph, [oldSegment1, oldSegment2]); + domIndexerImpl.onSegment(node, paragraph, [oldSegment1, oldSegment2]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx, oldRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx, oldRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -631,9 +631,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { }; paragraph.segments.push(oldSegment1, oldSegment2, oldSegment3); - contentModelDomIndexer.onSegment(node, paragraph, [oldSegment1, oldSegment2, oldSegment3]); + domIndexerImpl.onSegment(node, paragraph, [oldSegment1, oldSegment2, oldSegment3]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx, oldRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx, oldRangeEx); const segment1 = createText('te'); const segment2 = createText('st'); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts index a3913ec6610..0774387e1e3 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts @@ -33,6 +33,18 @@ describe('DOMHelperImpl', () => { expect(querySelectorAllSpy).toHaveBeenCalledWith(mockedSelector); }); + it('getTextContent', () => { + const mockedTextContent = 'TEXT'; + const mockedDiv: HTMLDivElement = { + textContent: mockedTextContent, + } as any; + const domHelper = createDOMHelper(mockedDiv); + + const result = domHelper.getTextContent(); + + expect(result).toBe(mockedTextContent); + }); + it('calculateZoomScale 1', () => { const mockedDiv = { getBoundingClientRect: () => ({ @@ -60,4 +72,55 @@ describe('DOMHelperImpl', () => { expect(zoomScale).toBe(1); }); + + it('getDomAttribute', () => { + const mockedAttr = 'ATTR'; + const mockedValue = 'VALUE'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedValue); + const mockedDiv = { + getAttribute: getAttributeSpy, + } as any; + + const domHelper = createDOMHelper(mockedDiv); + const result = domHelper.getDomAttribute(mockedAttr); + + expect(result).toBe(mockedValue); + expect(getAttributeSpy).toHaveBeenCalledWith(mockedAttr); + }); + + it('setDomAttribute', () => { + const mockedAttr1 = 'ATTR1'; + const mockedAttr2 = 'ATTR2'; + const mockedValue = 'VALUE'; + const setAttributeSpy = jasmine.createSpy('setAttribute'); + const removeAttributeSpy = jasmine.createSpy('removeAttribute'); + const mockedDiv = { + setAttribute: setAttributeSpy, + removeAttribute: removeAttributeSpy, + } as any; + + const domHelper = createDOMHelper(mockedDiv); + domHelper.setDomAttribute(mockedAttr1, mockedValue); + + expect(setAttributeSpy).toHaveBeenCalledWith(mockedAttr1, mockedValue); + expect(removeAttributeSpy).not.toHaveBeenCalled(); + + domHelper.setDomAttribute(mockedAttr2, null); + expect(removeAttributeSpy).toHaveBeenCalledWith(mockedAttr2); + }); + + it('getDomStyle', () => { + const mockedValue = 'COLOR' as any; + const styleName: keyof CSSStyleDeclaration = 'backgroundColor'; + const styleSpy = jasmine.createSpyObj('style', [styleName]); + styleSpy[styleName] = mockedValue; + const mockedDiv = { + style: styleSpy, + } as any; + + const domHelper = createDOMHelper(mockedDiv); + const result = domHelper.getDomStyle(styleName); + + expect(result).toBe(mockedValue); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts index 304b3834b68..21f333d1427 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -3,6 +3,7 @@ import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/crea import * as createStandaloneEditorCore from '../../lib/editor/createStandaloneEditorCore'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { ChangeSource } from '../../lib/constants/ChangeSource'; +import { Rect, StandaloneEditorCore } from 'roosterjs-content-model-types'; import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; import { tableProcessor } from 'roosterjs-content-model-dom'; @@ -34,7 +35,6 @@ describe('StandaloneEditor', () => { expect(editor.isDisposed()).toBeFalse(); expect(editor.getDocument()).toBe(document); expect(editor.isDarkMode()).toBeFalse(); - expect(editor.isInIME()).toBeFalse(); expect(editor.isInShadowEdit()).toBeFalse(); expect(createEmptyModelSpy).toHaveBeenCalledWith(undefined); @@ -81,7 +81,6 @@ describe('StandaloneEditor', () => { expect(editor.isDisposed()).toBeFalse(); expect(editor.getDocument()).toBe(document); expect(editor.isDarkMode()).toBeTrue(); - expect(editor.isInIME()).toBeFalse(); expect(editor.isInShadowEdit()).toBeFalse(); expect(createEmptyModelSpy).not.toHaveBeenCalled(); expect(setContentModelSpy).toHaveBeenCalledWith( @@ -863,4 +862,63 @@ describe('StandaloneEditor', () => { expect(() => editor.isDarkMode()).toThrow(); expect(() => editor.setDarkModeState()).toThrow(); }); + + it('getScrollContainer', () => { + const div = document.createElement('div'); + const mockedScrollContainer = 'SCROLLCONTAINER' as any; + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { setContentModel: setContentModelSpy }, + domEvent: { scrollContainer: mockedScrollContainer }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.getScrollContainer(); + + expect(result).toBe(mockedScrollContainer); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.getScrollContainer()).toThrow(); + }); + + it('getVisibleViewport', () => { + const div = document.createElement('div'); + const mockedScrollContainer: Rect = { top: 0, bottom: 100, left: 0, right: 100 }; + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { + setContentModel: setContentModelSpy, + getVisibleViewport: (core: StandaloneEditorCore) => { + return mockedScrollContainer; + }, + }, + domEvent: { scrollContainer: mockedScrollContainer }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.getVisibleViewport(); + + expect(result).toBe(mockedScrollContainer); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.getVisibleViewport()).toThrow(); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts index a7e4e383390..3d6a523dd40 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts @@ -85,6 +85,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: false, isSafari: false, + isMobileOrTablet: false, }, darkColorHandler: mockedDarkColorHandler, trustedHTMLHandler: defaultTrustHtmlHandler, @@ -203,6 +204,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: true, isSafari: false, + isMobileOrTablet: true, }, }); @@ -233,6 +235,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: true, isSafari: false, + isMobileOrTablet: true, }, }); @@ -263,6 +266,7 @@ describe('createEditorCore', () => { isMac: true, isAndroid: false, isSafari: false, + isMobileOrTablet: false, }, }); @@ -293,6 +297,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: false, isSafari: true, + isMobileOrTablet: false, }, }); @@ -323,6 +328,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: false, isSafari: false, + isMobileOrTablet: false, }, }); diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts index 3ec21ecffb4..9793327b3bf 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts @@ -244,7 +244,7 @@ describe('handleListItem with metadata', () => { handleListItem(document, parent, listItem, context, null); const expectedResult = [ - '
', + '
', ]; expectHtml(parent.outerHTML, expectedResult); @@ -257,9 +257,7 @@ describe('handleListItem with metadata', () => { { node: parent.firstChild as HTMLOListElement, listType: 'OL', - format: { - listStyleType: 'decimal', - }, + format: {}, dataset: {}, }, ], diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts index 2f713dab8d1..93c99464b63 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts @@ -46,7 +46,7 @@ describe('handleList with metadata', () => { handleList(document, parent, listItem, context, null); - expect(parent.outerHTML).toBe('
    '); + expect(parent.outerHTML).toBe('
      '); expect(context.listFormat).toEqual({ threadItemCounts: [], nodeStack: [ @@ -57,9 +57,7 @@ describe('handleList with metadata', () => { listType: 'UL', node: parent.firstChild as HTMLElement, dataset: {}, - format: { - listStyleType: 'disc', - }, + format: {}, }, ], }); @@ -69,9 +67,7 @@ describe('handleList with metadata', () => { const listItem = createListItem([createListLevel('OL')]); handleList(document, parent, listItem, context, null); - const possibleResults = [ - '
        ', - ]; + const possibleResults = ['
          ']; expectHtml(parent.outerHTML, possibleResults); expect(context.listFormat).toEqual({ @@ -84,9 +80,7 @@ describe('handleList with metadata', () => { listType: 'OL', node: parent.firstChild as HTMLElement, dataset: {}, - format: { - listStyleType: 'decimal', - }, + format: {}, }, ], }); @@ -211,7 +205,7 @@ describe('handleList with metadata', () => { expectHtml(parent.outerHTML, [ '
              ', - '
                  ', + '
                      ', ]); expect(context.listFormat).toEqual({ threadItemCounts: [1, 0], @@ -223,17 +217,13 @@ describe('handleList with metadata', () => { listType: 'OL', node: existingOL.nextSibling as HTMLElement, dataset: { editingInfo: JSON.stringify({ unorderedStyleType: 3 }) }, - format: { - listStyleType: 'decimal', - }, + format: {}, }, { listType: 'OL', node: (existingOL.nextSibling as HTMLElement).firstChild as HTMLElement, dataset: {}, - format: { - listStyleType: 'lower-alpha', - }, + format: {}, }, ], }); @@ -287,9 +277,7 @@ describe('handleList with metadata', () => { ]; handleList(document, parent, listItem, context, null); - const possibleResults = [ - '
                          ', - ]; + const possibleResults = ['
                              ']; expectHtml(parent.outerHTML, possibleResults); @@ -303,9 +291,7 @@ describe('handleList with metadata', () => { listType: 'OL', node: existingOL1.nextSibling as HTMLElement, dataset: {}, - format: { - listStyleType: 'decimal', - }, + format: {}, }, ], }); @@ -331,9 +317,7 @@ describe('handleList with metadata', () => { handleList(document, parent, listItem, context, null); - const possibleResults = [ - '
                                  ', - ]; + const possibleResults = ['
                                      ']; expectHtml(parent.outerHTML, possibleResults); @@ -353,7 +337,6 @@ describe('handleList with metadata', () => { dataset: {}, format: { startNumberOverride: 3, - listStyleType: 'lower-alpha', }, }, ], @@ -377,7 +360,7 @@ describe('handleList with metadata', () => { handleList(document, parent, listItem, context, null); expect(parent.outerHTML).toBe( - '
                                        ' + '
                                          ' ); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts index 64ba07e807a..07297d6f726 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts @@ -308,6 +308,111 @@ describe('listItemMetadataApplier', () => { }); }); + it('Has metadata, has start number, apply list style from level, no existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + listItemMetadataApplier.applierFunction( + { + applyListStyleFromLevel: true, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({}); + }); + + it('Has metadata, has start number, apply list style from level, has existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + format.listStyleType = 'test'; + + listItemMetadataApplier.applierFunction( + { + applyListStyleFromLevel: true, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({}); + }); + + it('Has metadata, has start number, do not apply list style from level, has existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + format.listStyleType = 'test'; + + listItemMetadataApplier.applierFunction( + { + applyListStyleFromLevel: false, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({ + listStyleType: 'test', + }); + }); + it('UL has metadata', () => { context.listFormat.nodeStack = [ { @@ -660,6 +765,115 @@ describe('listLevelMetadataApplier', () => { expect(format).toEqual({}); }); + it('Has metadata, has start number, apply list style from level, no existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + listLevelMetadataApplier.applierFunction( + { + applyListStyleFromLevel: true, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({ + listStyleType: 'decimal', + }); + }); + + it('Has metadata, has start number, apply list style from level, has existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + format.listStyleType = 'test'; + + listLevelMetadataApplier.applierFunction( + { + applyListStyleFromLevel: true, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({ + listStyleType: 'decimal', + }); + }); + + it('Has metadata, has start number, do not apply list style from level, has existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + format.listStyleType = 'test'; + + listLevelMetadataApplier.applierFunction( + { + applyListStyleFromLevel: false, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({ + listStyleType: 'test', + }); + }); + it('UL has metadata', () => { context.listFormat.nodeStack = [ { diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts new file mode 100644 index 00000000000..e1fec607ff1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts @@ -0,0 +1,85 @@ +import * as entityUtilsFile from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import { ContentModelEntity } from 'roosterjs-content-model-types'; +import { + onCreateCopyEntityNode, + pasteBlockEntityParser, +} from '../../lib/override/pasteCopyBlockEntityParser'; + +describe('onCreateCopyEntityNode', () => { + it('handle', () => { + const div = document.createElement('div'); + div.style.width = '100%'; + div.style.display = 'inline-block'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: div, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, div); + + expect(div.style.display).toEqual('block'); + expect(div.style.width).toEqual(''); + expect(div.classList.contains('_EBlock')).toBeTrue(); + }); + + it('Dont handle, no 100% width', () => { + const div = document.createElement('div'); + div.style.display = 'inline-block'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: div, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, div); + + expect(div.style.display).not.toEqual('block'); + expect(div.classList.contains('_EBlock')).not.toBeTrue(); + }); + + it('Dont handle, not inline block', () => { + const div = document.createElement('div'); + div.style.width = '100%'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: div, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, div); + + expect(div.style.display).not.toEqual('block'); + expect(div.classList.contains('_EBlock')).not.toBeTrue(); + }); +}); + +describe('pasteBlockEntityParser', () => { + it('handle', () => { + const div = document.createElement('div'); + div.classList.add('_EBlock'); + spyOn(entityUtilsFile, 'addDelimiters'); + + pasteBlockEntityParser({}, div, {}, {}); + + expect(div.style.width).toEqual('100%'); + expect(div.style.display).toEqual('inline-block'); + expect(div.classList.contains('_EBlock')).toBeFalse(); + }); + + it('Dont handle', () => { + const div = document.createElement('div'); + + pasteBlockEntityParser({}, div, {}, {}); + + expect(div.style.width).not.toEqual('100%'); + expect(div.style.display).not.toEqual('inline-block'); + expect(div.classList.contains('_EBlock')).toBeFalse(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/format/retrieveModelFormatStateTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/format/retrieveModelFormatStateTest.ts index 21dc7d91fd5..dc51343582e 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/format/retrieveModelFormatStateTest.ts @@ -1,7 +1,7 @@ import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; import { applyTableFormat } from 'roosterjs-content-model-core'; import { ContentModelFormatState, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { retrieveModelFormatState } from '../../../lib/modelApi/common/retrieveModelFormatState'; +import { retrieveModelFormatState } from '../../../lib/publicApi/format/retrieveModelFormatState'; import { addCode, addSegment, diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/createModelFromHtmlTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/createModelFromHtmlTest.ts new file mode 100644 index 00000000000..b700d4de8e4 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/createModelFromHtmlTest.ts @@ -0,0 +1,201 @@ +import * as convertInlineCss from '../../../lib/utils/convertInlineCss'; +import * as createDomToModelContextForSanitizing from '../../../lib/utils/createDomToModelContextForSanitizing'; +import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; +import * as parseFormat from 'roosterjs-content-model-dom/lib/domToModel/utils/parseFormat'; +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { createModelFromHtml } from '../../../lib/publicApi/model/createModelFromHtml'; + +describe('createModelFromHtml', () => { + it('Empty html, no options', () => { + const html = ''; + const model = createModelFromHtml(html); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Valid html, no options', () => { + const html = '
                                          test
                                          '; + const model = createModelFromHtml(html); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontSize: '20px' }, + }, + ], + segmentFormat: { fontSize: '20px' }, + format: {}, + }, + ], + }); + }); + + it('Valid html with style on BODY and global CSS, no options', () => { + const html = + '
                                          test
                                          '; + const model = createModelFromHtml(html); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontSize: '20px', textColor: 'red', fontFamily: 'Arial' }, + }, + ], + segmentFormat: { fontSize: '20px', fontFamily: 'Arial' }, + format: {}, + }, + ], + }); + }); + + it('Valid html, with options', () => { + const html = '
                                          test
                                          '; + const mockedOptions = 'OPTIONS' as any; + const mockedContext = { + formatParsers: { + segmentOnBlock: 'PARSERS', + }, + segmentFormat: 'SEGMENT', + } as any; + const createContextSpy = spyOn( + createDomToModelContextForSanitizing, + 'createDomToModelContextForSanitizing' + ).and.returnValue(mockedContext); + const parseFormatSpy = spyOn(parseFormat, 'parseFormat'); + const mockedDoc = { + body: 'BODY', + } as any; + const mockedModel = 'MODEL' as any; + const domToContentModelSpy = spyOn(domToContentModel, 'domToContentModel').and.returnValue( + mockedModel + ); + const domParserSpy = spyOn(DOMParser.prototype, 'parseFromString').and.returnValue( + mockedDoc + ); + const mockedRules = 'RULES' as any; + const retrieveCssRulesSpy = spyOn(convertInlineCss, 'retrieveCssRules').and.returnValue( + mockedRules + ); + const convertInlineCssSpy = spyOn(convertInlineCss, 'convertInlineCss'); + const mockedTrustedHtmlHandler = jasmine + .createSpy('trustHandler') + .and.returnValue('TRUSTEDHTML'); + const mockedDefaultSegmentFormat = 'FORMAT' as any; + + const model = createModelFromHtml( + html, + mockedOptions, + mockedTrustedHtmlHandler, + mockedDefaultSegmentFormat + ); + + expect(model).toEqual(mockedModel); + expect(mockedTrustedHtmlHandler).toHaveBeenCalledWith(html); + expect(domParserSpy).toHaveBeenCalledWith('TRUSTEDHTML', 'text/html'); + expect(parseFormatSpy).toHaveBeenCalledTimes(1); + expect(parseFormatSpy).toHaveBeenCalledWith( + 'BODY' as any, + 'PARSERS' as any, + 'SEGMENT' as any, + mockedContext + ); + expect(createContextSpy).toHaveBeenCalledTimes(1); + expect(createContextSpy).toHaveBeenCalledWith(mockedDefaultSegmentFormat, mockedOptions); + expect(domToContentModelSpy).toHaveBeenCalledWith('BODY' as any, mockedContext); + expect(retrieveCssRulesSpy).toHaveBeenCalledWith(mockedDoc); + expect(convertInlineCssSpy).toHaveBeenCalledWith(mockedDoc, mockedRules); + }); + + it('Empty html, with options', () => { + const mockedOptions = 'OPTIONS' as any; + const mockedContext = { + formatParsers: { + segmentOnBlock: 'PARSERS', + }, + segmentFormat: 'SEGMENT', + } as any; + const createContextSpy = spyOn( + createDomToModelContextForSanitizing, + 'createDomToModelContextForSanitizing' + ).and.returnValue(mockedContext); + const parseFormatSpy = spyOn(parseFormat, 'parseFormat'); + const mockedDoc = { + body: 'BODY', + } as any; + const mockedModel = 'MODEL' as any; + const domToContentModelSpy = spyOn(domToContentModel, 'domToContentModel').and.returnValue( + mockedModel + ); + const domParserSpy = spyOn(DOMParser.prototype, 'parseFromString').and.returnValue( + mockedDoc + ); + const mockedRules = 'RULES' as any; + const retrieveCssRulesSpy = spyOn(convertInlineCss, 'retrieveCssRules').and.returnValue( + mockedRules + ); + const convertInlineCssSpy = spyOn(convertInlineCss, 'convertInlineCss'); + const segmentFormat: ContentModelSegmentFormat = { fontSize: '10pt' }; + + const model = createModelFromHtml('', mockedOptions, undefined, segmentFormat); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '10pt' }, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + }, + ], + format: { fontSize: '10pt' }, + }); + expect(domParserSpy).not.toHaveBeenCalled(); + expect(parseFormatSpy).not.toHaveBeenCalled(); + expect(createContextSpy).not.toHaveBeenCalled(); + expect(domToContentModelSpy).not.toHaveBeenCalled(); + expect(retrieveCssRulesSpy).not.toHaveBeenCalled(); + expect(convertInlineCssSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/exportContentTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/exportContentTest.ts new file mode 100644 index 00000000000..c944d4f93de --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/exportContentTest.ts @@ -0,0 +1,88 @@ +import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +import * as contentModelToText from 'roosterjs-content-model-dom/lib/modelToText/contentModelToText'; +import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; +import { exportContent } from '../../../lib/publicApi/model/exportContent'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; + +describe('exportContent', () => { + it('PlainTextFast', () => { + const mockedTextContent = 'TEXT'; + const getTextContentSpy = jasmine + .createSpy('getTextContent') + .and.returnValue(mockedTextContent); + const editor: IStandaloneEditor = { + getDOMHelper: () => ({ + getTextContent: getTextContentSpy, + }), + } as any; + + const text = exportContent(editor, 'PlainTextFast'); + + expect(text).toBe(mockedTextContent); + expect(getTextContentSpy).toHaveBeenCalledTimes(1); + }); + + it('PlainText', () => { + const mockedModel = 'MODEL' as any; + const getContentModelCopySpy = jasmine + .createSpy('getContentModelCopy') + .and.returnValue(mockedModel); + const editor: IStandaloneEditor = { + getContentModelCopy: getContentModelCopySpy, + } as any; + const mockedText = 'TEXT'; + const contentModelToTextSpy = spyOn( + contentModelToText, + 'contentModelToText' + ).and.returnValue(mockedText); + + const text = exportContent(editor, 'PlainText'); + + expect(text).toBe(mockedText); + expect(getContentModelCopySpy).toHaveBeenCalledWith('disconnected'); + expect(contentModelToTextSpy).toHaveBeenCalledWith(mockedModel); + }); + + it('HTML', () => { + const mockedModel = 'MODEL' as any; + const getContentModelCopySpy = jasmine + .createSpy('getContentModelCopy') + .and.returnValue(mockedModel); + const mockedHTML = 'HTML'; + const mockedDiv = { + innerHTML: mockedHTML, + } as any; + const mockedDoc = { + createElement: () => mockedDiv, + } as any; + const triggerEventSpy = jasmine.createSpy('triggerEvent'); + const editor: IStandaloneEditor = { + getContentModelCopy: getContentModelCopySpy, + getDocument: () => mockedDoc, + triggerEvent: triggerEventSpy, + } as any; + const contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); + const mockedContext = 'CONTEXT' as any; + const createModelToDomContextSpy = spyOn( + createModelToDomContext, + 'createModelToDomContext' + ).and.returnValue(mockedContext); + + const html = exportContent(editor, 'HTML'); + + expect(html).toBe(mockedHTML); + expect(getContentModelCopySpy).toHaveBeenCalledWith('disconnected'); + expect(createModelToDomContextSpy).toHaveBeenCalledWith(); + expect(contentModelToDomSpy).toHaveBeenCalledWith( + mockedDoc, + mockedDiv, + mockedModel, + mockedContext + ); + expect(triggerEventSpy).toHaveBeenCalledWith( + 'extractContentWithDom', + { clonedRoot: mockedDiv }, + true + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts index 90401cf5b54..83e0c4112d6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts @@ -9,7 +9,7 @@ import { ContentModelSelectionMarker, ContentModelTable, ContentModelTableCell, - FormatWithContentModelContext, + FormatContentModelContext, } from 'roosterjs-content-model-types'; import { createBr, @@ -3057,7 +3057,7 @@ describe('mergeModel', () => { textColor: 'aliceblue', italic: true, }); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newImages: [], newEntities: [], @@ -3153,7 +3153,7 @@ describe('mergeModel', () => { newPara.segments.push(newEntity1, text, newEntity2); sourceModel.blocks.push(newPara); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newImages: [], newEntities: [], @@ -3218,7 +3218,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newImages: [], newEntities: [], @@ -3289,7 +3289,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newImages: [], newEntities: [], @@ -3361,7 +3361,7 @@ describe('mergeModel', () => { para1.segments.push(image, marker); majorModel.blocks.push(para1); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newEntities: [], newImages: [image], diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts index e1d56510259..afc3fe35601 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts @@ -715,7 +715,7 @@ describe('getOperationalBlocks', () => { ['ListItem'], ['TableCell'], false, - [{ block: para, parent: group }] + [{ block: para, parent: group, path: [group] }] ); }); @@ -737,10 +737,12 @@ describe('getOperationalBlocks', () => { { block: listItem, parent: group, + path: [group], }, { block: para2, parent: group, + path: [group], }, ] ); @@ -763,8 +765,8 @@ describe('getOperationalBlocks', () => { ['TableCell'], false, [ - { block: listItem, parent: group }, - { block: para3, parent: group }, + { block: listItem, parent: group, path: [group] }, + { block: para3, parent: group, path: [group] }, ] ); }); @@ -786,8 +788,8 @@ describe('getOperationalBlocks', () => { ['FormatContainer'], false, [ - { block: listItem1, parent: group }, - { block: para2, parent: quote }, + { block: listItem1, parent: group, path: [group] }, + { block: para2, parent: quote, path: [quote, listItem2, group] }, ] ); }); @@ -811,8 +813,9 @@ describe('getOperationalBlocks', () => { { block: listItem, parent: group, + path: [group], }, - { block: quote, parent: group }, + { block: quote, parent: group, path: [group] }, ] ); }); @@ -834,8 +837,8 @@ describe('getOperationalBlocks', () => { ['TableCell'], false, [ - { block: quote1, parent: listItem }, - { block: quote2, parent: group }, + { block: quote1, parent: listItem, path: [listItem, group] }, + { block: quote2, parent: group, path: [group] }, ] ); }); @@ -857,8 +860,8 @@ describe('getOperationalBlocks', () => { ['TableCell'], true, [ - { block: listItem, parent: group }, - { block: quote2, parent: group }, + { block: listItem, parent: group, path: [group] }, + { block: quote2, parent: group, path: [group] }, ] ); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts rename to packages-content-model/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts index 89fba0c41c8..a87322bab5c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts @@ -1,5 +1,4 @@ -import { convertInlineCss } from '../../../lib/utils/paste/convertInlineCss'; -import { CssRule } from '../../../lib/utils/paste/retrieveHtmlInfo'; +import { convertInlineCss, CssRule } from '../../lib/utils/convertInlineCss'; describe('convertInlineCss', () => { it('Empty DOM, empty CSS', () => { diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts new file mode 100644 index 00000000000..f99a0bc17e3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts @@ -0,0 +1,115 @@ +import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; +import * as createPasteEntityProcessor from '../../lib/override/pasteEntityProcessor'; +import * as createPasteGeneralProcessor from '../../lib/override/pasteGeneralProcessor'; +import { containerSizeFormatParser } from '../../lib/override/containerSizeFormatParser'; +import { createDomToModelContextForSanitizing } from '../../lib/utils/createDomToModelContextForSanitizing'; +import { DomToModelOptionForSanitizing } from 'roosterjs-content-model-types'; +import { pasteBlockEntityParser } from '../../lib/override/pasteCopyBlockEntityParser'; +import { pasteDisplayFormatParser } from '../../lib/override/pasteDisplayFormatParser'; +import { pasteTextProcessor } from '../../lib/override/pasteTextProcessor'; + +describe('createDomToModelContextForSanitizing', () => { + const mockedPasteGeneralProcessor = 'GENERALPROCESSOR' as any; + const mockedPasteEntityProcessor = 'ENTITYPROCESSOR' as any; + const mockedResult = 'CONTEXT' as any; + const defaultOptions: DomToModelOptionForSanitizing = { + processorOverride: {}, + formatParserOverride: {}, + additionalFormatParsers: {}, + additionalAllowedTags: [], + additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, + }; + let createDomToModelContextSpy: jasmine.Spy; + + let createPasteGeneralProcessorSpy: jasmine.Spy; + let createPasteEntityProcessorSpy: jasmine.Spy; + + beforeEach(() => { + createPasteGeneralProcessorSpy = spyOn( + createPasteGeneralProcessor, + 'createPasteGeneralProcessor' + ).and.returnValue(mockedPasteGeneralProcessor); + createPasteEntityProcessorSpy = spyOn( + createPasteEntityProcessor, + 'createPasteEntityProcessor' + ).and.returnValue(mockedPasteEntityProcessor); + + createDomToModelContextSpy = spyOn( + createDomToModelContext, + 'createDomToModelContext' + ).and.returnValue(mockedResult); + }); + + it('no options', () => { + const context = createDomToModelContextForSanitizing(); + + expect(context).toBe(mockedResult); + expect(createDomToModelContextSpy).toHaveBeenCalledWith( + { + defaultFormat: undefined, + }, + undefined, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: mockedPasteEntityProcessor, + '*': mockedPasteGeneralProcessor, + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerSizeFormatParser], + entity: [pasteBlockEntityParser], + }, + }, + defaultOptions + ); + expect(createPasteGeneralProcessorSpy).toHaveBeenCalledWith(defaultOptions); + expect(createPasteEntityProcessorSpy).toHaveBeenCalledWith(defaultOptions); + }); + + it('with options', () => { + const mockedDefaultFormat = 'FORMAT' as any; + const mockedOption = 'OPTION' as any; + const mockedAdditionalOption = { a: 'b' } as any; + + const context = createDomToModelContextForSanitizing( + mockedDefaultFormat, + mockedOption, + mockedAdditionalOption + ); + + const additionalOption = { + ...defaultOptions, + ...mockedAdditionalOption, + }; + + expect(context).toBe(mockedResult); + expect(createDomToModelContextSpy).toHaveBeenCalledWith( + { + defaultFormat: mockedDefaultFormat, + }, + mockedOption, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: mockedPasteEntityProcessor, + '*': mockedPasteGeneralProcessor, + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerSizeFormatParser], + entity: [pasteBlockEntityParser], + }, + }, + additionalOption + ); + expect(createPasteGeneralProcessorSpy).toHaveBeenCalledWith(additionalOption); + expect(createPasteEntityProcessorSpy).toHaveBeenCalledWith(additionalOption); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts index c0e8c7df151..b3b2c51feac 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts @@ -1,26 +1,21 @@ -import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; -import * as createPasteEntityProcessor from '../../../lib/override/pasteEntityProcessor'; -import * as createPasteGeneralProcessor from '../../../lib/override/pasteGeneralProcessor'; +import * as createDomToModelContextForSanitizing from '../../../lib/utils/createDomToModelContextForSanitizing'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as mergeModelFile from '../../../lib/publicApi/model/mergeModel'; -import { containerSizeFormatParser } from '../../../lib/override/containerSizeFormatParser'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { mergePasteContent } from '../../../lib/utils/paste/mergePasteContent'; -import { pasteDisplayFormatParser } from '../../../lib/override/pasteDisplayFormatParser'; -import { pasteTextProcessor } from '../../../lib/override/pasteTextProcessor'; import { ContentModelDocument, ContentModelFormatter, ContentModelSegmentFormat, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, InsertPoint, StandaloneEditorCore, } from 'roosterjs-content-model-types'; describe('mergePasteContent', () => { let formatResult: boolean | undefined; - let context: FormatWithContentModelContext | undefined; + let context: FormatContentModelContext | undefined; let formatContentModel: jasmine.Spy; let sourceModel: ContentModelDocument; let core: StandaloneEditorCore; @@ -36,7 +31,7 @@ describe('mergePasteContent', () => { ( core: any, callback: ContentModelFormatter, - options: FormatWithContentModelOptions + options: FormatContentModelOptions ) => { context = { newEntities: [], @@ -348,8 +343,6 @@ describe('mergePasteContent', () => { paragraph: null!, path: [], }; - const mockedPasteGeneralProcessor = 'GENERALPROCESSOR' as any; - const mockedPasteEntityProcessor = 'ENTITYPROCESSOR' as any; const mockedDomToModelContext = { name: 'DOMTOMODELCONTEXT', } as any; @@ -358,17 +351,9 @@ describe('mergePasteContent', () => { pasteModel ); const mergeModelSpy = spyOn(mergeModelFile, 'mergeModel').and.returnValue(insertPoint); - const createPasteGeneralProcessorSpy = spyOn( - createPasteGeneralProcessor, - 'createPasteGeneralProcessor' - ).and.returnValue(mockedPasteGeneralProcessor); - const createPasteEntityProcessorSpy = spyOn( - createPasteEntityProcessor, - 'createPasteEntityProcessor' - ).and.returnValue(mockedPasteEntityProcessor); const createDomToModelContextSpy = spyOn( - createDomToModelContext, - 'createDomToModelContext' + createDomToModelContextForSanitizing, + 'createDomToModelContextForSanitizing' ).and.returnValue(mockedDomToModelContext); const mockedDomToModelOptions = 'OPTION1' as any; @@ -413,24 +398,9 @@ describe('mergePasteContent', () => { mergeFormat: 'none', mergeTable: false, }); - expect(createPasteGeneralProcessorSpy).toHaveBeenCalledWith(mockedDefaultDomToModelOptions); - expect(createPasteEntityProcessorSpy).toHaveBeenCalledWith(mockedDefaultDomToModelOptions); expect(createDomToModelContextSpy).toHaveBeenCalledWith( undefined, mockedDomToModelOptions, - { - processorOverride: { - '#text': pasteTextProcessor, - entity: mockedPasteEntityProcessor, - '*': mockedPasteGeneralProcessor, - }, - formatParserOverride: { - display: pasteDisplayFormatParser, - }, - additionalFormatParsers: { - container: [containerSizeFormatParser], - }, - }, mockedDefaultDomToModelOptions ); expect(mockedDomToModelContext.segmentFormat).toEqual({ lineHeight: '1pt' }); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts index d673bc8e91b..0fba26ea47e 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts @@ -1,5 +1,6 @@ import { restoreSnapshotHTML } from '../../lib/utils/restoreSnapshotHTML'; import { Snapshot, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { wrap } from 'roosterjs-content-model-dom'; describe('restoreSnapshotHTML', () => { let core: StandaloneEditorCore; @@ -420,4 +421,660 @@ describe('restoreSnapshotHTML', () => { ); expect(div.childNodes[1].firstChild).toBe(entityWrapper); }); + + it('HTML with block entity at root level, cannot match | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: '
                                          test1

                                          test2
                                          ', + } as any; + + const entityWrapper = document.createElement('DIV'); + wrapInContainer(entityWrapper); + + entityWrapper.id = 'div2'; + core.entity.entityMap.C = { + element: entityWrapper, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1

                                          test2
                                          ' + ); + }); + + it('HTML with block entity at root level, can match | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2
                                          ', + } as any; + + const entityWrapper = document.createElement('DIV'); + const container = wrapInContainer(entityWrapper); + + entityWrapper.id = 'div2'; + core.entity.entityMap.B = { + element: entityWrapper, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          ' + ); + expect(div.childNodes[1]).toBe(container); + expect(div.childNodes[1].firstChild).toBe(entityWrapper); + }); + + it('HTML with block entity at root level, entity is already in editor | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2
                                          ', + } as any; + + const entityWrapper = document.createElement('DIV'); + const container = wrapInContainer(entityWrapper); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container); + div.appendChild(document.createTextNode('test2')); + + entityWrapper.id = 'div2'; + core.entity.entityMap.B = { + element: entityWrapper, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          ' + ); + expect(div.childNodes[1]).toBe(container); + expect(div.childNodes[1].firstChild).toBe(entityWrapper); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2

                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container1); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order, continuous in original DOM | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2

                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container1); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order, continuous in snapshot | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1


                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container1); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2

                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in original DOM | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2

                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(container1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in snapshot | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1


                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in both | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1


                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(container1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in both, no other nodes | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '


                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(container2); + div.appendChild(container1); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          ' + ); + expect(div.childNodes[0]).toBe(container1); + expect(div.childNodes[1]).toBe(container2); + expect(div.childNodes[0].firstChild).toBe(entityWrapper1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order | blockEntityContainer and non container', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2

                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order, continuous in original DOM | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2

                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(entityWrapper1); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order, continuous in snapshot | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1


                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2

                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test2')); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in original DOM | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1

                                          test2

                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test2
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in snapshot | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1


                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test2')); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in both | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                          test1


                                          test3
                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          test1
                                          test3
                                          ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in both, no other nodes | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '


                                          ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(container2); + div.appendChild(entityWrapper1); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                          ' + ); + expect(div.childNodes[0]).toBe(entityWrapper1); + expect(div.childNodes[1]).toBe(container2); + expect(div.childNodes[1].firstChild).toBe(entityWrapper2); + }); }); + +function wrapInContainer(entity: HTMLElement) { + const el = wrap(entity.ownerDocument, entity, 'div'); + el.className = '_E_EBlockEntityContainer'; + return el; +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts b/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts index 49559477a1e..5ee760791ee 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts @@ -77,7 +77,7 @@ export const defaultHTMLStyleMap: DefaultStyleMap = { }, main: blockElement, nav: blockElement, - ol: blockElement, + ol: { ...blockElement, paddingInlineStart: '40px' }, p: { display: 'block', marginTop: '1em', @@ -121,5 +121,5 @@ export const defaultHTMLStyleMap: DefaultStyleMap = { u: { textDecoration: 'underline', }, - ul: blockElement, + ul: { ...blockElement, paddingInlineStart: '40px' }, }; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts index feff4584d73..a5ee6271385 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts @@ -13,7 +13,9 @@ import type { ElementProcessor } from 'roosterjs-content-model-types'; * @param context DOM to Content Model context */ export const entityProcessor: ElementProcessor = (group, element, context) => { - const isBlockEntity = isBlockElement(element); + const isBlockEntity = + isBlockElement(element) || + (element.style.display == 'inline-block' && element.style.width == '100%'); stackFormat( context, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/generalProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/generalProcessor.ts index edc8afd725b..64fc34ae27e 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/generalProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/generalProcessor.ts @@ -4,6 +4,7 @@ import { addSegment } from '../../modelApi/common/addSegment'; import { createGeneralBlock } from '../../modelApi/creators/createGeneralBlock'; import { createGeneralSegment } from '../../modelApi/creators/createGeneralSegment'; import { isBlockElement } from '../utils/isBlockElement'; +import { parseFormat } from '../utils/parseFormat'; import { stackFormat } from '../utils/stackFormat'; import type { ElementProcessor } from 'roosterjs-content-model-types'; @@ -21,6 +22,8 @@ const generalBlockProcessor: ElementProcessor = (group, element, co () => { addBlock(group, block); + parseFormat(element, context.formatParsers.general, block.format, context); + context.elementProcessors.child(block, element, context); } ); @@ -45,6 +48,8 @@ const generalSegmentProcessor: ElementProcessor = (group, element, 'empty' /*clearFormat, General segment will include all properties and styles when generate back to HTML, so no need to carry over existing segment format*/, }, () => { + parseFormat(element, context.formatParsers.general, segment.format, context); + context.elementProcessors.child(segment, element, context); } ); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts index dc0a493603e..a95979139dd 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -1,7 +1,12 @@ import toArray from './toArray'; +import { applyFormat } from '../modelToDom/utils/applyFormat'; import { isElementOfType } from './isElementOfType'; import { isNodeOfType } from './isNodeOfType'; -import type { ContentModelEntityFormat } from 'roosterjs-content-model-types'; +import type { + ContentModelEntityFormat, + ContentModelSegmentFormat, + ModelToDomContext, +} from 'roosterjs-content-model-types'; const ENTITY_INFO_NAME = '_Entity'; const ENTITY_TYPE_PREFIX = '_EType_'; @@ -61,7 +66,9 @@ export function generateEntityClassNames(format: ContentModelEntityFormat): stri } /** - * @internal + * Checks whether the node provided is a Entity delimiter + * @param node the node to check + * @return true if it is a delimiter */ export function isEntityDelimiter(element: HTMLElement): boolean { return ( @@ -75,16 +82,29 @@ export function isEntityDelimiter(element: HTMLElement): boolean { /** * Adds delimiters to the element provided. If the delimiters already exists, will not be added * @param element the node to add the delimiters + * @param format format to set to the delimiters, so when typing inside of one the format is not lost + * @param context Model to Dom context to use. */ -export function addDelimiters(doc: Document, element: HTMLElement): HTMLElement[] { +export function addDelimiters( + doc: Document, + element: HTMLElement, + format?: ContentModelSegmentFormat | null, + context?: ModelToDomContext +): HTMLElement[] { let [delimiterAfter, delimiterBefore] = getDelimiters(element); if (!delimiterAfter) { delimiterAfter = insertDelimiter(doc, element, true /*isAfter*/); + if (context && format) { + applyFormat(delimiterAfter, context.formatAppliers.segment, format, context); + } } if (!delimiterBefore) { delimiterBefore = insertDelimiter(doc, element, false /*isAfter*/); + if (context && format) { + applyFormat(delimiterBefore, context.formatAppliers.segment, format, context); + } } return [delimiterAfter, delimiterBefore]; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts index 33338ca3638..afaa6dbd19a 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts @@ -1,5 +1,6 @@ +import { directionFormatHandler } from './directionFormatHandler'; import type { FormatHandler } from '../FormatHandler'; -import type { PaddingFormat } from 'roosterjs-content-model-types'; +import type { DirectionFormat, PaddingFormat } from 'roosterjs-content-model-types'; const PaddingKeys: (keyof PaddingFormat & keyof CSSStyleDeclaration)[] = [ 'paddingTop', @@ -8,16 +9,42 @@ const PaddingKeys: (keyof PaddingFormat & keyof CSSStyleDeclaration)[] = [ 'paddingLeft', ]; +const AlternativeKeyLtr: Partial> = { + paddingLeft: 'paddingInlineStart', +}; + +const AlternativeKeyRtl: Partial> = { + paddingRight: 'paddingInlineStart', +}; + /** * @internal */ -export const paddingFormatHandler: FormatHandler = { - parse: (format, element, _, defaultStyle) => { +export const paddingFormatHandler: FormatHandler = { + parse: (format, element, context, defaultStyle) => { + directionFormatHandler.parse(format, element, context, defaultStyle); + PaddingKeys.forEach(key => { let value = element.style[key]; - const defaultValue = defaultStyle[key] ?? '0px'; + const alterativeKey = (format.direction == 'rtl' + ? AlternativeKeyRtl + : AlternativeKeyLtr)[key]; + const defaultValue: string = + (defaultStyle[key] ?? + (alterativeKey ? defaultStyle[alterativeKey] : undefined) ?? + '0px') + ''; - if (value == '0') { + if (!value) { + value = defaultValue; + } + + if (!value || value == '0') { value = '0px'; } @@ -26,10 +53,21 @@ export const paddingFormatHandler: FormatHandler = { } }); }, - apply: (format, element) => { + apply: (format, element, context) => { PaddingKeys.forEach(key => { const value = format[key]; - if (value) { + let defaultValue: string | undefined = undefined; + + if (element.tagName == 'OL' || element.tagName == 'UL') { + if ( + (format.direction == 'rtl' && key == 'paddingRight') || + (format.direction != 'rtl' && key == 'paddingLeft') + ) { + defaultValue = '40px'; + } + } + + if (value && value != defaultValue) { element.style[key] = value; } }); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index a5b55f69d23..4d2ac993d31 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -204,6 +204,7 @@ export const defaultFormatKeysPerCategory: { divider: [...sharedBlockFormats, ...sharedContainerFormats, 'display', 'size', 'htmlAlign'], container: [...sharedContainerFormats, 'htmlAlign', 'size', 'display'], entity: ['entity'], + general: ['textColor', 'backgroundColor'], // General model still need to do color transformation in dark mode }; /** diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts index 4c4b8bffecc..61434860976 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts @@ -1,6 +1,6 @@ import { isSuperOrSubScript } from './superOrSubScriptFormatHandler'; import { parseValueWithUnit } from '../utils/parseValueWithUnit'; -import type { FontSizeFormat } from 'roosterjs-content-model-types'; +import type { EditorContext, FontSizeFormat } from 'roosterjs-content-model-types'; import type { FormatHandler } from '../FormatHandler'; /** @@ -15,7 +15,11 @@ export const fontSizeFormatHandler: FormatHandler = { // the font size will be handled by superOrSubScript handler if (fontSize && !isSuperOrSubScript(fontSize, verticalAlign) && fontSize != 'inherit') { if (element.style.fontSize) { - format.fontSize = normalizeFontSize(fontSize, context.segmentFormat.fontSize); + format.fontSize = normalizeFontSize( + fontSize, + context.segmentFormat.fontSize, + context + ); } else if (defaultStyle.fontSize) { format.fontSize = fontSize; } @@ -40,7 +44,11 @@ const KnownFontSizes: Record = { 'xxx-large': '36pt', }; -function normalizeFontSize(fontSize: string, contextFont: string | undefined): string | undefined { +function normalizeFontSize( + fontSize: string, + contextFont: string | undefined, + context: EditorContext +): string | undefined { const knownFontSize = KnownFontSizes[fontSize]; if (knownFontSize) { @@ -49,12 +57,17 @@ function normalizeFontSize(fontSize: string, contextFont: string | undefined): s fontSize == 'smaller' || fontSize == 'larger' || fontSize.endsWith('em') || - fontSize.endsWith('%') + fontSize.endsWith('%') || + fontSize.endsWith('rem') ) { if (!contextFont) { return undefined; } else { - const existingFontSize = parseValueWithUnit(contextFont, undefined /*element*/, 'px'); + const existingFontSize = parseValueWithUnit( + contextFont, + fontSize.endsWith('rem') ? context.rootFontSize : undefined /*element*/, + 'px' + ); if (existingFontSize) { switch (fontSize) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts index f78aaad8ed9..3120b432958 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts @@ -3,6 +3,8 @@ const MarginValueRegex = /(-?\d+(\.\d+)?)([a-z]+|%)/; // According to https://developer.mozilla.org/en-US/docs/Glossary/CSS_pixel, 1in = 96px const PixelPerInch = 96; +const DefaultRootFontSize = 16; + /** * Parse unit value with its unit * @param value The source value to parse @@ -29,7 +31,6 @@ export function parseValueWithUnit( result = ptToPx(num); break; case 'em': - case 'rem': result = getFontSize(currentSizePxOrElement) * num; break; case 'ex': @@ -41,6 +42,9 @@ export function parseValueWithUnit( case 'in': result = num * PixelPerInch; break; + case 'rem': + result = (getFontSize(currentSizePxOrElement) || DefaultRootFontSize) * num; + break; } } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index 551ce399585..0ffe238f6e4 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -1,5 +1,6 @@ export { domToContentModel } from './domToModel/domToContentModel'; export { contentModelToDom } from './modelToDom/contentModelToDom'; +export { contentModelToText } from './modelToText/contentModelToText'; export { childProcessor, @@ -26,6 +27,7 @@ export { parseEntityClassName, generateEntityClassNames, addDelimiters, + isEntityDelimiter, } from './domUtils/entityUtils'; export { reuseCachedElement } from './domUtils/reuseCachedElement'; export { isWhiteSpacePreserved } from './domUtils/isWhiteSpacePreserved'; @@ -51,6 +53,7 @@ export { createEmptyModel } from './modelApi/creators/createEmptyModel'; export { addBlock } from './modelApi/common/addBlock'; export { addCode } from './modelApi/common/addDecorators'; export { addLink } from './modelApi/common/addDecorators'; +export { normalizeParagraph } from './modelApi/common/normalizeParagraph'; export { normalizeContentModel } from './modelApi/common/normalizeContentModel'; export { isGeneralSegment } from './modelApi/common/isGeneralSegment'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index 1edd90acb8b..8f9a9d41849 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -4,8 +4,10 @@ import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import { normalizeAllSegments } from './normalizeSegment'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; + /** - * @internal + * @param paragraph The paragraph to normalize + * Normalize a paragraph. If it is empty, add a BR segment to make sure it can insert content */ export function normalizeParagraph(paragraph: ContentModelParagraph) { const segments = paragraph.segments; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 740118c4e64..eb5f392d8c8 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -6,14 +6,18 @@ import { wrap } from '../../domUtils/wrap'; import type { ContentModelBlockHandler, ContentModelEntity, + ContentModelSegmentFormat, ContentModelSegmentHandler, + ModelToDomContext, } from 'roosterjs-content-model-types'; +const BlockEntityContainer = '_E_EBlockEntityContainer'; + /** * @internal */ export const handleEntityBlock: ContentModelBlockHandler = ( - _, + doc, parent, entityModel, context, @@ -23,7 +27,23 @@ export const handleEntityBlock: ContentModelBlockHandler = ( applyFormat(wrapper, context.formatAppliers.entity, entityFormat, context); - refNode = reuseCachedElement(parent, wrapper, refNode); + const isCursorAroundEntity = + context.addDelimiterForEntity && + wrapper.style.display == 'inline-block' && + wrapper.style.width == '100%'; + const isContained = wrapper.parentElement?.classList.contains(BlockEntityContainer); + const elementToReuse = isContained && isCursorAroundEntity ? wrapper.parentElement! : wrapper; + + refNode = reuseCachedElement(parent, elementToReuse, refNode); + + if (isCursorAroundEntity) { + if (!isContained) { + const element = wrap(doc, wrapper, 'div'); + element.classList.add(BlockEntityContainer); + } + addDelimiters(doc, wrapper, getSegmentFormat(context), context); + } + context.onNodeCreated?.(entityModel, wrapper); return refNode; @@ -53,7 +73,7 @@ export const handleEntitySegment: ContentModelSegmentHandler applyFormat(wrapper, context.formatAppliers.entity, entityFormat, context); if (context.addDelimiterForEntity && entityFormat.isReadonly) { - const [after, before] = addDelimiters(doc, wrapper); + const [after, before] = addDelimiters(doc, wrapper, getSegmentFormat(context), context); newSegments?.push(after, before); context.regularSelection.current.segment = after; @@ -63,3 +83,11 @@ export const handleEntitySegment: ContentModelSegmentHandler context.onNodeCreated?.(entityModel, wrapper); }; +function getSegmentFormat( + context: ModelToDomContext +): ContentModelSegmentFormat | null | undefined { + return { + ...context.pendingFormat?.format, + ...context.defaultFormat, + }; +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts index a21a4f8acc8..42dae971d96 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts @@ -1,3 +1,4 @@ +import { applyFormat } from '../utils/applyFormat'; import { handleSegmentCommon } from '../utils/handleSegmentCommon'; import { isNodeOfType } from '../../domUtils/isNodeOfType'; import { reuseCachedElement } from '../../domUtils/reuseCachedElement'; @@ -19,14 +20,16 @@ export const handleGeneralBlock: ContentModelBlockHandler { - let node: Node = group.element; + let node: HTMLElement = group.element; if (refNode && node.parentNode == parent) { refNode = reuseCachedElement(parent, node, refNode); } else { - node = node.cloneNode(); + node = node.cloneNode() as HTMLElement; group.element = node as HTMLElement; + applyFormat(node, context.formatAppliers.general, group.format, context); + parent.insertBefore(node, refNode); } @@ -54,6 +57,8 @@ export const handleGeneralSegment: ContentModelSegmentHandler { + switch (block.blockType) { + case 'Paragraph': + let text = ''; + + block.segments.forEach(segment => { + switch (segment.segmentType) { + case 'Br': + textArray.push(text); + text = ''; + break; + + case 'Entity': + text += segment.wrapper.textContent || ''; + break; + + case 'General': + text += segment.element.textContent || ''; + break; + + case 'Text': + text += segment.text; + break; + + case 'Image': + text += ' '; + break; + } + }); + textArray.push(text); + break; + + case 'Divider': + case 'Entity': + textArray.push(''); + break; + + case 'Table': + block.rows.forEach(row => + row.cells.forEach(cell => { + contentModelToTextArray(cell, textArray); + }) + ); + break; + + case 'BlockGroup': + contentModelToTextArray(block, textArray); + break; + } + }); +} diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts index 4ff587c7ffd..52367fcc9ba 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts @@ -3,7 +3,7 @@ import { createContentModelDocument } from '../../../lib/modelApi/creators/creat import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { ContentModelBr, - ContentModelDomIndexer, + DomIndexer, ContentModelParagraph, DomToModelContext, } from 'roosterjs-content-model-types'; @@ -69,7 +69,7 @@ describe('brProcessor', () => { const doc = createContentModelDocument(); const br = document.createElement('br'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts index 26826c063a4..06284b767ef 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts @@ -1,9 +1,10 @@ +import * as addBlock from '../../../lib/modelApi/common/addBlock'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { entityProcessor } from '../../../lib/domToModel/processors/entityProcessor'; import { setEntityElementClasses } from '../../domUtils/entityUtilTest'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelEntity, ContentModelParagraph, DomToModelContext, @@ -253,7 +254,7 @@ describe('entityProcessor', () => { setEntityElementClasses(span, 'entity', true, 'entity_1'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, @@ -284,4 +285,34 @@ describe('entityProcessor', () => { }); expect(onSegmentSpy).toHaveBeenCalledWith(span, paragraphModel, [entityModel]); }); + + it('Block element entity with Display: inline-block and width: 100%', () => { + const group = createContentModelDocument(); + const span = document.createElement('span'); + span.style.display = 'inline-block'; + span.style.width = '100%'; + spyOn(addBlock, 'addBlock').and.callThrough(); + + setEntityElementClasses(span, 'entity', true, 'entity_1'); + + entityProcessor(group, span, context); + + expect(addBlock.addBlock).toHaveBeenCalled(); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + entityFormat: { + id: 'entity_1', + entityType: 'entity', + isReadonly: true, + }, + wrapper: span, + }, + ], + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts index c52c730bfe2..b7fa18ff970 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts @@ -5,7 +5,7 @@ import { createContentModelDocument } from '../../../lib/modelApi/creators/creat import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { generalProcessor } from '../../../lib/domToModel/processors/generalProcessor'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelGeneralBlock, ContentModelGeneralSegment, ContentModelParagraph, @@ -50,6 +50,32 @@ describe('generalProcessor', () => { expect(childProcessor).toHaveBeenCalledWith(block, div, context); }); + it('Process a DIV element with color', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + + div.style.color = 'red'; + div.style.backgroundColor = 'green'; + + generalProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'General', + element: div, + blocks: [], + format: { + textColor: 'red', + backgroundColor: 'green', + }, + }, + ], + }); + }); + it('Process a SPAN element', () => { const doc = createContentModelDocument(); const span = document.createElement('span'); @@ -83,6 +109,40 @@ describe('generalProcessor', () => { expect(childProcessor).toHaveBeenCalledWith(segment, span, context); }); + it('Process a SPAN element with color', () => { + const doc = createContentModelDocument(); + const span = document.createElement('span'); + + span.style.color = 'red'; + span.style.backgroundColor = 'green'; + + generalProcessor(doc, span, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + format: {}, + segments: [ + { + segmentType: 'General', + blockType: 'BlockGroup', + blockGroupType: 'General', + element: span, + blocks: [], + format: { + textColor: 'red', + backgroundColor: 'green', + }, + }, + ], + }, + ], + }); + }); + it('Process a SPAN element with format', () => { const doc = createContentModelDocument(); const span = document.createElement('span'); @@ -329,7 +389,7 @@ describe('generalProcessor', () => { spyOn(createGeneralSegment, 'createGeneralSegment').and.returnValue(segment); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts index 4fc31b81bbb..b3fda940b4d 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts @@ -2,7 +2,7 @@ import { createContentModelDocument } from '../../../lib/modelApi/creators/creat import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { imageProcessor } from '../../../lib/domToModel/processors/imageProcessor'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelImage, ContentModelParagraph, DomToModelContext, @@ -318,7 +318,7 @@ describe('imageProcessor', () => { const doc = createContentModelDocument(); const img = document.createElement('img'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index aced3efb708..8b3563d4c5a 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -8,7 +8,7 @@ import { createTableCell } from '../../../lib/modelApi/creators/createTableCell' import { tableProcessor } from '../../../lib/domToModel/processors/tableProcessor'; import { ContentModelBlock, - ContentModelDomIndexer, + DomIndexer, ContentModelTable, DomToModelContext, ElementProcessor, @@ -285,7 +285,7 @@ describe('tableProcessor', () => { const doc = createContentModelDocument(); const div = document.createElement('div'); const onTableSpy = jasmine.createSpy('onTable'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: null!, onTable: onTableSpy, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts index 2bfb3db8ad0..b84b8a14172 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -9,7 +9,7 @@ import { createSelectionMarker } from '../../../lib/modelApi/creators/createSele import { createText } from '../../../lib/modelApi/creators/createText'; import { textProcessor } from '../../../lib/domToModel/processors/textProcessor'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelParagraph, ContentModelText, DomToModelContext, @@ -572,7 +572,7 @@ describe('textProcessor', () => { const doc = createContentModelDocument(); const text = document.createTextNode('test'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, @@ -606,7 +606,7 @@ describe('textProcessor', () => { const doc = createContentModelDocument(); const text = document.createTextNode('test'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, @@ -651,7 +651,7 @@ describe('textProcessor', () => { const doc = createContentModelDocument(); const text = document.createTextNode('test'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts index b86beaf0ddc..37c555d2953 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -1,6 +1,6 @@ import * as createGeneralBlock from '../lib/modelApi/creators/createGeneralBlock'; import { contentModelToDom } from '../lib/modelToDom/contentModelToDom'; -import { createDomToModelContext, createModelToDomContext } from '../lib'; +import { contentModelToText, createDomToModelContext, createModelToDomContext } from '../lib'; import { domToContentModel } from '../lib/domToModel/domToContentModel'; import { expectHtml } from './testUtils'; import { @@ -9,12 +9,12 @@ import { ContentModelGeneralBlock, } from 'roosterjs-content-model-types'; -describe('End to end test for DOM => Model', () => { +describe('End to end test for DOM => Model => DOM/TEXT', () => { function runTest( html: string, expectedModel: ContentModelDocument, - expectedHtml: string, - expectedHTMLFirefox?: string + expectedText: string, + ...expectedHtml: string[] ) { const div1 = document.createElement('div'); div1.innerHTML = html; @@ -26,12 +26,10 @@ describe('End to end test for DOM => Model', () => { const div2 = document.createElement('div'); contentModelToDom(document, div2, model, createModelToDomContext()); - const possibleHTML = [ - expectedHtml, //chrome or firefox - expectedHTMLFirefox, //firefox - ]; + const text = contentModelToText(model); - expectHtml(div2.innerHTML, possibleHTML); + expect(text).toBe(expectedText); + expectHtml(div2.innerHTML, expectedHtml); } it('List with margin', () => { @@ -126,6 +124,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + '1\r\n2', '
                                          • 1
                                          • 2
                                          ' ); }); @@ -223,6 +222,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + '1\r\na\r\nb\r\n2', '
                                          1. 1
                                            1. a
                                          2. b
                                          3. 2
                                          ' ); }); @@ -297,6 +297,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\nbb\r\ncc\ndd\r\nee', '
                                          aa\nbb
                                          cc\ndd
                                          ee
                                          ' ); }); @@ -357,6 +358,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test1\r\ntest2', '
                                          test1
                                          test2
                                          ' ); }); @@ -412,6 +414,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', 'aa
                                          bb
                                          cc' ); }); @@ -492,6 +495,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', 'aa
                                          bb
                                          cc' ); }); @@ -581,6 +585,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', '
                                          aa
                                          bb
                                          cc
                                          ' ); }); @@ -634,6 +639,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\naa', '
                                          aa
                                          aa
                                          ' ); }); @@ -666,6 +672,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa', '
                                          aa
                                          ' ); }); @@ -714,6 +721,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb', '

                                          aaa

                                          bbb

                                          ' ); }); @@ -766,6 +774,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb', '

                                          aaa

                                          bbb

                                          ' ); }); @@ -837,6 +846,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', '

                                          aa

                                          bb

                                          cc

                                          ' ); }); @@ -864,6 +874,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', '

                                          test

                                          ' ); }); @@ -933,6 +944,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\nbbb\r\naaa\nbb', '
                                          aaa\nbbb
                                          aaa\nbb
                                          ' ); }); @@ -999,6 +1011,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaabbbccc\r\naaabbbccc', '
                                          aaabbbccc
                                          aaabbbccc
                                          ' ); }); @@ -1111,6 +1124,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaaa\r\nbbbbbb\r\ncccc\r\naaaa\r\nbbbbbb\r\ncccc', '
                                          aaaa
                                          bbbbbb
                                          cccc
                                          aaaa
                                          bbbbbb
                                          cccc
                                          ' ); }); @@ -1178,6 +1192,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb\r\nccc', '
                                          aaa
                                          bbb
                                          ccc
                                          ' ); }); @@ -1219,6 +1234,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb', '
                                          aaa
                                          bbb
                                          ' ); }); @@ -1258,6 +1274,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test1\r\ntest2', '
                                          test1
                                          test2', '
                                          test1
                                          test2' ); @@ -1359,6 +1376,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test1\r\ntest2\r\ntest3', '
                                          test1
                                          test2
                                          test3
                                          ' ); @@ -1444,6 +1462,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb\r\nccc\r\nddd\r\neee', '
                                          aaa
                                          bbb
                                          ccc
                                          ddd
                                          eee
                                          ' ); }); @@ -1471,6 +1490,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', '
                                          test
                                          ' ); }); @@ -1519,6 +1539,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + '', '
                                          ' ); }); @@ -1552,6 +1573,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', 'test', 'test' ); @@ -1587,6 +1609,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', '
                                          test
                                          ' ); }); @@ -1624,6 +1647,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaabbb\r\nccc\r\ndddeeee', 'aaabbb
                                          ccc
                                          dddeeee' ); }); @@ -1651,6 +1675,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'www.bing.com', '' ); }); @@ -1701,6 +1726,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb', '

                                          aaa

                                          bbb

                                          ' ); }); @@ -1747,6 +1773,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'beforetestafter', 'beforetestafter' ); }); @@ -1775,6 +1802,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', '
                                          aa
                                          bb
                                          cc' ); }); @@ -1803,6 +1831,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', '
                                          aa
                                          bb
                                          cc
                                          ' ); }); @@ -1895,6 +1924,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'a\r\nb\r\nc', '
                                          abc
                                          ' ); }); @@ -1987,7 +2017,57 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'a\r\nb\r\nc', '
                                          abc
                                          ' ); }); + + it('list with list style', () => { + runTest( + '
                                            1. test
                                          ', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + isImplicit: true, + format: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + { + listType: 'OL', + format: { listStyleType: '"1) "' }, + dataset: {}, + }, + ], + format: {}, + }, + ], + }, + 'test', + '
                                            1. test
                                          ' + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts index 501a476b8cd..20b54725185 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts @@ -1,11 +1,17 @@ import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { DomToModelContext, ModelToDomContext, PaddingFormat } from 'roosterjs-content-model-types'; +import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; import { paddingFormatHandler } from '../../../lib/formatHandlers/block/paddingFormatHandler'; +import { + DirectionFormat, + DomToModelContext, + ModelToDomContext, + PaddingFormat, +} from 'roosterjs-content-model-types'; describe('paddingFormatHandler.parse', () => { let div: HTMLElement; - let format: PaddingFormat; + let format: PaddingFormat & DirectionFormat; let context: DomToModelContext; beforeEach(() => { @@ -47,11 +53,89 @@ describe('paddingFormatHandler.parse', () => { paddingBottom: '20px', }); }); + + it('Default padding in OL, LTR', () => { + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ol!); + + expect(format).toEqual({}); + }); + + it('Default padding in OL, RTL', () => { + div.style.direction = 'rtl'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ol!); + + expect(format).toEqual({ + direction: 'rtl', + }); + }); + + it('Default padding in UL, LTR', () => { + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ul!); + + expect(format).toEqual({}); + }); + + it('Default padding in UL, RTL', () => { + div.style.direction = 'rtl'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ul!); + + expect(format).toEqual({ + direction: 'rtl', + }); + }); + + it('Customized padding in OL, LTR', () => { + div.style.paddingLeft = '0'; + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ol!); + + expect(format).toEqual({ + paddingLeft: '0px', + }); + }); + + it('Customized padding in OL, RTL', () => { + div.style.direction = 'rtl'; + div.style.paddingLeft = '0'; + div.style.paddingRight = '20px'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ol!); + + expect(format).toEqual({ + direction: 'rtl', + paddingRight: '20px', + }); + }); + + it('Customized padding in UL, LTR', () => { + div.style.paddingLeft = '20px'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ul!); + + expect(format).toEqual({ + paddingLeft: '20px', + }); + }); + + it('Customized padding in UL, RTL', () => { + div.style.direction = 'rtl'; + div.style.paddingLeft = '20px'; + div.style.paddingRight = '60px'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ul!); + + expect(format).toEqual({ + direction: 'rtl', + paddingLeft: '20px', + paddingRight: '60px', + }); + }); }); describe('paddingFormatHandler.apply', () => { let div: HTMLElement; - let format: PaddingFormat; + let format: PaddingFormat & DirectionFormat; let context: ModelToDomContext; beforeEach(() => { @@ -75,4 +159,64 @@ describe('paddingFormatHandler.apply', () => { expect(div.outerHTML).toBe('
                                          '); }); + + it('OL has no padding', () => { + const ol = document.createElement('ol'); + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                            '); + }); + + it('OL has default padding', () => { + const ol = document.createElement('ol'); + + format.paddingLeft = '40px'; + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                              '); + }); + + it('OL has padding', () => { + const ol = document.createElement('ol'); + + format.paddingLeft = '60px'; + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                                '); + }); + + it('UL has padding', () => { + const ul = document.createElement('ul'); + + format.paddingLeft = '60px'; + + paddingFormatHandler.apply(format, ul, context); + + expect(ul.outerHTML).toBe('
                                                  '); + }); + + it('OL has padding-left in RTL', () => { + const ol = document.createElement('ol'); + + format.paddingLeft = '40px'; + format.direction = 'rtl'; + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                                    '); + }); + + it('OL has padding-right in RTL', () => { + const ol = document.createElement('ol'); + + format.paddingRight = '40px'; + format.direction = 'rtl'; + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                                      '); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts index c2e25db3bad..0338573847a 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts @@ -9,6 +9,7 @@ describe('parseValueWithUnit with element', () => { fontSize: '15pt', }), }, + querySelector: () => mockedElement, }, offsetWidth: 1000, } as any) as HTMLElement; @@ -48,7 +49,19 @@ describe('parseValueWithUnit with element', () => { }); it('rem', () => { - runTest('rem', [0, 20, 22, -22]); + const unit = 'rem'; + const results = [0, 16, 17.6, -17.6]; + + ['0', '1', '1.1', '-1.1'].forEach((value, i) => { + const input = value + unit; + const result = parseValueWithUnit(input, 16); + + if (Number.isNaN(results[i])) { + expect(result).toBeNaN(); + } else { + expect(result).toEqual(results[i], input); + } + }); }); it('no unit', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts index dc3a20e4785..4afdbb5ab5b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts @@ -131,7 +131,7 @@ describe('handleBlock', () => { expect(parent.innerHTML).toBe(''); expect(parent.firstChild).not.toBe(element); expect(context.regularSelection.current.segment).toBe(parent.firstChild!.firstChild); - expect(applyFormat.applyFormat).not.toHaveBeenCalled(); + expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); runTestWithRefNode(block, '
                                                      '); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts index 71f6472def0..cf98d8daffc 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts @@ -1,6 +1,7 @@ import * as entityUtils from '../../../lib/domUtils/entityUtils'; import { ContentModelEntity, ModelToDomContext } from 'roosterjs-content-model-types'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { itChromeOnly } from '../../testUtils'; import { handleEntityBlock, handleEntitySegment, @@ -44,6 +45,37 @@ describe('handleEntity', () => { expect(entityUtils.addDelimiters).toHaveBeenCalledTimes(0); }); + itChromeOnly('Block entity with display: inline-block & width: 100%', () => { + const div = document.createElement('div'); + div.style.display = 'inline-block'; + div.style.width = '100%'; + + const entityModel: ContentModelEntity = { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + entityFormat: { + id: 'entity_1', + entityType: 'entity', + isReadonly: true, + }, + wrapper: div, + }; + + const parent = document.createElement('div'); + + context.addDelimiterForEntity = true; + handleEntityBlock(document, parent, entityModel, context, null); + + expect(parent.innerHTML).toBe( + '
                                                      ​
                                                      ​
                                                      ' + ); + expect(div.outerHTML).toBe( + '
                                                      ' + ); + expect(entityUtils.addDelimiters).toHaveBeenCalledTimes(1); + }); + it('Fake entity', () => { const div = document.createElement('div'); const entityModel: ContentModelEntity = { @@ -159,6 +191,7 @@ describe('handleEntity', () => { const entityDiv = ({ nextSibling: br, parentNode: parent, + style: {}, } as any) as HTMLElement; const entityModel: ContentModelEntity = { blockType: 'Entity', diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts index b8aade3edeb..dd4f1b3b26f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts @@ -60,7 +60,21 @@ describe('handleBlockGroup', () => { group, context ); - expect(applyFormat.applyFormat).not.toHaveBeenCalled(); + expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); + }); + + it('General block with color', () => { + const child = document.createElement('span'); + const group = createGeneralBlock(child); + + group.format.textColor = 'red'; + group.format.backgroundColor = 'green'; + + handleGeneralBlock(document, parent, group, context, null); + + expect(parent.outerHTML).toBe( + '
                                                      ' + ); }); it('General segment: empty element', () => { @@ -89,6 +103,20 @@ describe('handleBlockGroup', () => { expect(applyFormat.applyFormat).toHaveBeenCalled(); }); + it('General segment: element with color', () => { + const child = document.createElement('span'); + const group = createGeneralSegment(child); + + group.format.textColor = 'red'; + group.format.textColor = 'green'; + + handleGeneralSegment(document, parent, group, context, []); + + expect(parent.outerHTML).toBe( + '
                                                      ' + ); + }); + it('General segment: element with child', () => { const clonedChild = document.createElement('span'); const childMock = ({ @@ -199,7 +227,7 @@ describe('handleBlockGroup', () => { group, context ); - expect(applyFormat.applyFormat).not.toHaveBeenCalled(); + expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); expect(result).toBe(br); expect(group.element).toBe(clonedChild); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 83d9f1566b5..a05f99d3398 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -7,7 +7,7 @@ import { handleParagraph } from '../../../lib/modelToDom/handlers/handleParagrap import { handleSegment as originalHandleSegment } from '../../../lib/modelToDom/handlers/handleSegment'; import { optimize } from '../../../lib/modelToDom/optimizers/optimize'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelParagraph, ContentModelSegment, ContentModelSegmentHandler, @@ -576,7 +576,7 @@ describe('handleParagraph', () => { }; const onSegmentSpy = jasmine.createSpy('onSegment'); const onParagraphSpy = jasmine.createSpy('onParagraph'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: onParagraphSpy, onSegment: onSegmentSpy, onTable: null!, @@ -619,7 +619,7 @@ describe('handleParagraph', () => { }; const onSegmentSpy = jasmine.createSpy('onSegment'); const onParagraphSpy = jasmine.createSpy('onParagraph'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: onParagraphSpy, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index eef92223996..4c437eaef13 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -4,7 +4,7 @@ import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; import { handleTable } from '../../../lib/modelToDom/handlers/handleTable'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelTable, ContentModelTableRow, ModelToDomContext, @@ -596,7 +596,7 @@ describe('handleTable', () => { dataset: {}, }; const onTableSpy = jasmine.createSpy('onTable'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: null!, onTable: onTableSpy, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts new file mode 100644 index 00000000000..d7490edbae5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts @@ -0,0 +1,162 @@ +import { contentModelToText } from '../../lib/modelToText/contentModelToText'; +import { createBr } from '../../lib/modelApi/creators/createBr'; +import { createContentModelDocument } from '../../lib/modelApi/creators/createContentModelDocument'; +import { createDivider } from '../../lib/modelApi/creators/createDivider'; +import { createEntity } from '../../lib/modelApi/creators/createEntity'; +import { createImage } from '../../lib/modelApi/creators/createImage'; +import { createListItem } from '../../lib/modelApi/creators/createListItem'; +import { createListLevel } from '../../lib/modelApi/creators/createListLevel'; +import { createParagraph } from '../../lib/modelApi/creators/createParagraph'; +import { createTable } from '../../lib/modelApi/creators/createTable'; +import { createTableCell } from '../../lib/modelApi/creators/createTableCell'; +import { createText } from '../../lib/modelApi/creators/createText'; + +describe('modelToText', () => { + it('Empty model', () => { + const model = createContentModelDocument(); + + const text = contentModelToText(model); + + expect(text).toBe(''); + }); + + it('model with paragraphs', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + model.blocks.push(para1, para2); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\ntext2'); + }); + + it('model with paragraphs and customized separator', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + model.blocks.push(para1, para2); + + const text = contentModelToText(model, '-'); + + expect(text).toBe('text1-text2'); + }); + + it('model with paragraph and br', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(createText('text1'), createBr(), createText('text2')); + + model.blocks.push(para1); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\ntext2'); + }); + + it('model with paragraph and image', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(createText('text1'), createImage('src'), createText('text2')); + + model.blocks.push(para1); + + const text = contentModelToText(model); + + expect(text).toBe('text1 text2'); + }); + + it('model with divider', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + model.blocks.push(para1, createDivider('div'), para2); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\n\r\ntext2'); + }); + + it('model with list', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const list1 = createListItem([createListLevel('OL')]); + const list2 = createListItem([createListLevel('UL')]); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + list1.blocks.push(para1); + list2.blocks.push(para2); + + model.blocks.push(list1, list2); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\ntext2'); + }); + + it('model with table', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const para4 = createParagraph(); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const cell4 = createTableCell(); + const table = createTable(2); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + para3.segments.push(createText('text3')); + para4.segments.push(createText('text4')); + + cell1.blocks.push(para1); + cell2.blocks.push(para2); + cell3.blocks.push(para3); + cell4.blocks.push(para4); + + table.rows[0].cells.push(cell1, cell2); + table.rows[1].cells.push(cell3, cell4); + + model.blocks.push(table); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\ntext2\r\ntext3\r\ntext4'); + }); + + it('model with entity', () => { + const div = document.createElement('div'); + + div.innerText = 'test entity'; + + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(createText('text1'), createEntity(div), createText('text2')); + + model.blocks.push(para1); + + const text = contentModelToText(model); + + expect(text).toBe('text1test entitytext2'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts deleted file mode 100644 index 6d8ebc84677..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ensureTypeInContainer } from './ensureTypeInContainer'; -import { getContent } from './getContent'; -import { getStyleBasedFormatState } from './getStyleBasedFormatState'; -import { insertNode } from './insertNode'; -import { setContent } from './setContent'; -import type { ContentModelCoreApiMap } from '../publicTypes/ContentModelEditorCore'; - -/** - * @internal - */ -export const coreApiMap: ContentModelCoreApiMap = { - ensureTypeInContainer, - getContent, - getStyleBasedFormatState, - insertNode, - setContent, -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts deleted file mode 100644 index c00066f23ab..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { ContentPosition, KnownCreateElementDataIndex, PositionType } from 'roosterjs-editor-types'; -import { - createElement, - createRange, - findClosestElementAncestor, - getBlockElementAtNode, - isNodeEmpty, - Position, - safeInstanceOf, -} from 'roosterjs-editor-dom'; -import type { EnsureTypeInContainer } from '../publicTypes/ContentModelEditorCore'; - -/** - * @internal - * When typing goes directly under content div, many things can go wrong - * We fix it by wrapping it with a div and reposition cursor within the div - */ -export const ensureTypeInContainer: EnsureTypeInContainer = ( - core, - innerCore, - position, - keyboardEvent -) => { - const { contentDiv, api } = innerCore; - const table = findClosestElementAncestor(position.node, contentDiv, 'table'); - let td: HTMLElement | null; - - if (table && (td = table.querySelector('td,th'))) { - position = new Position(td, PositionType.Begin); - } - position = position.normalize(); - - const block = getBlockElementAtNode(contentDiv, position.node); - let formatNode: HTMLElement | null; - - if (block) { - formatNode = block.collapseToSingleElement(); - if (isNodeEmpty(formatNode, false /* trimContent */, true /* shouldCountBrAsVisible */)) { - const brEl = formatNode.ownerDocument.createElement('br'); - formatNode.append(brEl); - } - // if the block is empty, apply default format - // Otherwise, leave it as it is as we don't want to change the style for existing data - // unless the block was just created by the keyboard event (e.g. ctrl+a & start typing) - const shouldSetNodeStyles = - isNodeEmpty(formatNode) || - (keyboardEvent && wasNodeJustCreatedByKeyboardEvent(keyboardEvent, formatNode)); - formatNode = formatNode && shouldSetNodeStyles ? formatNode : null; - } else { - // Only reason we don't get the selection block is that we have an empty content div - // which can happen when users removes everything (i.e. select all and DEL, or backspace from very end to begin) - // The fix is to add a DIV wrapping, apply default format and move cursor over - formatNode = createElement( - KnownCreateElementDataIndex.EmptyLine, - contentDiv.ownerDocument - ) as HTMLElement; - core.api.insertNode(core, innerCore, formatNode, { - position: ContentPosition.End, - updateCursor: false, - replaceSelection: false, - insertOnNewLine: false, - }); - - // element points to a wrapping node we added "

                                                      ". We should move the selection left to
                                                      - position = new Position(formatNode, PositionType.Begin); - } - - // If this is triggered by a keyboard event, let's select the new position - if (keyboardEvent) { - api.setDOMSelection(innerCore, { - type: 'range', - range: createRange(new Position(position)), - isReverted: false, - }); - } -}; - -function wasNodeJustCreatedByKeyboardEvent(event: KeyboardEvent, formatNode: HTMLElement) { - return ( - safeInstanceOf(event.target, 'Node') && - event.target.contains(formatNode) && - event.key === formatNode.innerText - ); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts deleted file mode 100644 index 3119abb1310..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { GetContentMode } from 'roosterjs-editor-types'; -import { transformColor } from 'roosterjs-content-model-core'; -import { - createRange, - getHtmlWithSelectionPath, - getSelectionPath, - getTextContent, - safeInstanceOf, -} from 'roosterjs-editor-dom'; -import type { GetContent } from '../publicTypes/ContentModelEditorCore'; - -/** - * @internal - * Get current editor content as HTML string - * @param core The StandaloneEditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ -export const getContent: GetContent = (core, innerCore, mode): string => { - let content: string | null = ''; - const triggerExtractContentEvent = mode == GetContentMode.CleanHTML; - const includeSelectionMarker = mode == GetContentMode.RawHTMLWithSelection; - const { lifecycle, contentDiv, api, darkColorHandler } = innerCore; - - // When there is fragment for shadow edit, always use the cached fragment as document since HTML node in editor - // has been changed by uncommitted shadow edit which should be ignored. - const root = lifecycle.shadowEditFragment || contentDiv; - - if (mode == GetContentMode.PlainTextFast) { - content = root.textContent; - } else if (mode == GetContentMode.PlainText) { - content = getTextContent(root); - } else { - const clonedRoot = cloneNode(root); - clonedRoot.normalize(); - - const originalRange = api.getDOMSelection(innerCore); - const path = - !includeSelectionMarker || lifecycle.shadowEditFragment - ? null - : originalRange?.type == 'range' - ? getSelectionPath(contentDiv, originalRange.range) - : null; - const range = path && createRange(clonedRoot, path.start, path.end); - - if (lifecycle.isDarkMode) { - transformColor(clonedRoot, false /*includeSelf*/, 'darkToLight', darkColorHandler); - } - - if (triggerExtractContentEvent) { - api.triggerEvent( - innerCore, - { - eventType: 'extractContentWithDom', - clonedRoot, - }, - true /*broadcast*/ - ); - - content = clonedRoot.innerHTML; - } else if (range) { - // range is not null, which means we want to include a selection path in the content - content = getHtmlWithSelectionPath(clonedRoot, range); - } else { - content = clonedRoot.innerHTML; - } - } - - return content ?? ''; -}; - -function cloneNode(node: HTMLElement | DocumentFragment): HTMLElement { - let clonedNode: HTMLElement; - if (safeInstanceOf(node, 'DocumentFragment')) { - clonedNode = node.ownerDocument.createElement('div'); - clonedNode.appendChild(node.cloneNode(true /*deep*/)); - } else { - clonedNode = node.cloneNode(true /*deep*/) as HTMLElement; - } - - return clonedNode; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts deleted file mode 100644 index 65bf128b95f..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { contains, getComputedStyles } from 'roosterjs-editor-dom'; -import { NodeType } from 'roosterjs-editor-types'; -import type { GetStyleBasedFormatState } from '../publicTypes/ContentModelEditorCore'; - -/** - * @internal - * Get style based format state from current selection, including font name/size and colors - * @param core The StandaloneEditorCore objects - * @param node The node to get style from - */ -export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, innerCore, node) => { - if (!node) { - return {}; - } - - const styles = node - ? getComputedStyles(node, [ - 'font-family', - 'font-size', - 'color', - 'background-color', - 'line-height', - 'margin-top', - 'margin-bottom', - 'text-align', - 'direction', - 'font-weight', - ]) - : []; - const { contentDiv, lifecycle } = innerCore; - const { darkColorHandler } = core; - - let styleTextColor: string | undefined; - let styleBackColor: string | undefined; - - while ( - node && - contains(contentDiv, node, true /*treatSameNodeAsContain*/) && - !(styleTextColor && styleBackColor) - ) { - if (node.nodeType == NodeType.Element) { - const element = node as HTMLElement; - - styleTextColor = styleTextColor || element.style.getPropertyValue('color'); - styleBackColor = styleBackColor || element.style.getPropertyValue('background-color'); - } - node = node.parentNode; - } - - if (!lifecycle.isDarkMode && node == contentDiv) { - styleTextColor = styleTextColor || styles[2]; - styleBackColor = styleBackColor || styles[3]; - } - - const textColor = darkColorHandler.parseColorValue(styleTextColor); - const backColor = darkColorHandler.parseColorValue(styleBackColor); - - return { - fontName: styles[0], - fontSize: styles[1], - textColor: textColor.lightModeColor, - backgroundColor: backColor.lightModeColor, - textColors: textColor.darkModeColor - ? { - lightModeColor: textColor.lightModeColor, - darkModeColor: textColor.darkModeColor, - } - : undefined, - backgroundColors: backColor.darkModeColor - ? { - lightModeColor: backColor.lightModeColor, - darkModeColor: backColor.darkModeColor, - } - : undefined, - lineHeight: styles[4], - marginTop: styles[5], - marginBottom: styles[6], - textAlign: styles[7], - direction: styles[8], - fontWeight: styles[9], - }; -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts deleted file mode 100644 index 2ea1d68e87a..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { ChangeSource, transformColor } from 'roosterjs-content-model-core'; -import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createRange, - extractContentMetadata, - queryElements, - restoreContentWithEntityPlaceholder, -} from 'roosterjs-editor-dom'; -import type { ContentMetadata } from 'roosterjs-editor-types'; -import type { SetContent } from '../publicTypes/ContentModelEditorCore'; -import type { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; - -/** - * @internal - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The ContentModelEditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - * @param metadata @optional Metadata of the content that helps editor know the selection and color mode. - * If not passed, we will treat content as in light mode without selection - */ -export const setContent: SetContent = ( - core, - innerCore, - content, - triggerContentChangedEvent, - metadata -) => { - const { contentDiv, api, entity, trustedHTMLHandler, lifecycle, darkColorHandler } = innerCore; - - let contentChanged = false; - - if (innerCore.contentDiv.innerHTML != content) { - api.triggerEvent( - innerCore, - { - eventType: 'beforeSetContent', - newContent: content, - }, - true /*broadcast*/ - ); - - const entities = entity.entityMap; - const html = content || ''; - const body = new DOMParser().parseFromString( - trustedHTMLHandler?.(html) ?? html, - 'text/html' - ).body; - - restoreContentWithEntityPlaceholder(body, contentDiv, entities); - - const metadataFromContent = extractContentMetadata(contentDiv); - metadata = metadata || metadataFromContent; - selectContentMetadata(innerCore, metadata); - contentChanged = true; - } - - const isDarkMode = lifecycle.isDarkMode; - - if ((!metadata && isDarkMode) || (metadata && !!metadata.isDarkMode != !!isDarkMode)) { - transformColor( - contentDiv, - false /*includeSelf*/, - isDarkMode ? 'lightToDark' : 'darkToLight', - darkColorHandler - ); - contentChanged = true; - } - - if (triggerContentChangedEvent && contentChanged) { - api.triggerEvent( - innerCore, - { - eventType: 'contentChanged', - source: ChangeSource.SetContent, - }, - false /*broadcast*/ - ); - } -}; - -function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMetadata | undefined) { - if (!core.lifecycle.shadowEditFragment && metadata) { - const selection = convertMetadataToDOMSelection(core.contentDiv, metadata); - - if (selection) { - core.api.setDOMSelection(core, selection); - } - } -} - -function convertMetadataToDOMSelection( - contentDiv: HTMLElement, - metadata: ContentMetadata | undefined -): DOMSelection | null { - switch (metadata?.type) { - case SelectionRangeTypes.Normal: - return { - type: 'range', - range: createRange(contentDiv, metadata.start, metadata.end), - isReverted: false, - }; - case SelectionRangeTypes.TableSelection: - const table = queryElements(contentDiv, '#' + metadata.tableId)[0] as HTMLTableElement; - - return table - ? { - type: 'table', - table: table, - firstColumn: metadata.firstCell.x, - firstRow: metadata.firstCell.y, - lastColumn: metadata.lastCell.x, - lastRow: metadata.lastCell.y, - } - : null; - case SelectionRangeTypes.ImageSelection: - const image = queryElements(contentDiv, '#' + metadata.imageId)[0] as HTMLImageElement; - - return image - ? { - type: 'image', - image: image, - } - : null; - - default: - return null; - } -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts deleted file mode 100644 index 95e8b9c6ffe..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { isCharacterValue } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../publicTypes/IContentModelEditor'; -import { - addDelimiters, - isBlockElement, - isEntityElement, - isNodeOfType, -} from 'roosterjs-content-model-dom'; -import { - DelimiterClasses, - Keys, - NodeType, - PluginEventType, - PositionType, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; -import { - Position, - createRange, - getDelimiterFromElement, - getEntityFromElement, - getEntitySelector, - matchesSelector, - splitTextNode, -} from 'roosterjs-editor-dom'; -import type { - EditorPlugin, - IEditor, - PluginEvent, - PluginKeyDownEvent, -} from 'roosterjs-editor-types'; - -const DELIMITER_SELECTOR = - '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; -const ZERO_WIDTH_SPACE = '\u200B'; -const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); - -/** - * @internal - * Entity delimiter plugin helps maintain delimiter elements around an entity so that user can put focus before/after an entity - */ -class EntityDelimiterPlugin implements EditorPlugin { - private editor: IContentModelEditor | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'EntityDelimiter'; - } - - /** - * 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) { - this.editor = editor as IContentModelEditor; - } - - /** - * 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() { - this.editor = null; - } - - /** - * 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) { - switch (event.eventType) { - case PluginEventType.ContentChanged: - case PluginEventType.EditorReady: - normalizeDelimitersInEditor(this.editor); - break; - - case PluginEventType.BeforePaste: - const { fragment } = event; - addDelimitersIfNeeded(fragment.querySelectorAll(INLINE_ENTITY_SELECTOR)); - - break; - - case PluginEventType.ExtractContentWithDom: - case PluginEventType.BeforeCutCopy: - event.clonedRoot.querySelectorAll(DELIMITER_SELECTOR).forEach(node => { - if (getDelimiterFromElement(node)) { - removeNode(node); - } else { - removeDelimiterAttr(node); - } - }); - break; - - case PluginEventType.KeyDown: - handleKeyDownEvent(this.editor, event); - break; - } - } - } -} - -function preventTypeInDelimiter(delimiter: HTMLElement) { - delimiter.normalize(); - const textNode = delimiter.firstChild as Node; - const index = textNode.nodeValue?.indexOf(ZERO_WIDTH_SPACE) ?? -1; - if (index >= 0) { - splitTextNode(textNode, index == 0 ? 1 : index, false /* returnFirstPart */); - let nodeToMove: Node | undefined; - delimiter.childNodes.forEach(node => { - if (node.nodeValue !== ZERO_WIDTH_SPACE) { - nodeToMove = node; - } - }); - if (nodeToMove) { - delimiter.parentElement?.insertBefore( - nodeToMove, - delimiter.className == DelimiterClasses.DELIMITER_BEFORE - ? delimiter - : delimiter.nextSibling - ); - const selection = nodeToMove.ownerDocument?.getSelection(); - - if (selection) { - selection.setPosition( - nodeToMove, - new Position(nodeToMove, PositionType.End).offset - ); - } - } - } -} - -/** - * @internal - */ -export function normalizeDelimitersInEditor(editor: IEditor) { - removeInvalidDelimiters(editor.queryElements(DELIMITER_SELECTOR)); - addDelimitersIfNeeded(editor.queryElements(INLINE_ENTITY_SELECTOR)); -} - -function addDelimitersIfNeeded(nodes: Element[] | NodeListOf) { - nodes.forEach(node => { - if ( - isNodeOfType(node, 'ELEMENT_NODE') && - isEntityElement(node) && - !node.isContentEditable - ) { - addDelimiters(node.ownerDocument, node as HTMLElement); - } - }); -} - -function removeNode(el: Node | undefined | null) { - el?.parentElement?.removeChild(el); -} - -function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { - nodes.forEach(node => { - if (getDelimiterFromElement(node)) { - const sibling = node.classList.contains(DelimiterClasses.DELIMITER_BEFORE) - ? node.nextElementSibling - : node.previousElementSibling; - if (!(isNodeOfType(sibling, 'ELEMENT_NODE') && getEntityFromElement(sibling))) { - removeNode(node); - } - } else { - removeDelimiterAttr(node); - } - }); -} - -function removeDelimiterAttr(node: Element | undefined | null, checkEntity: boolean = true) { - if (!node) { - return; - } - - const isAfter = node.classList.contains(DelimiterClasses.DELIMITER_AFTER); - const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; - if (checkEntity && entitySibling && isEntityElement(entitySibling)) { - return; - } - - node.classList.remove(DelimiterClasses.DELIMITER_AFTER, DelimiterClasses.DELIMITER_BEFORE); - - node.normalize(); - node.childNodes.forEach(cn => { - const index = cn.textContent?.indexOf(ZERO_WIDTH_SPACE) ?? -1; - if (index >= 0) { - createRange(cn, index, cn, index + 1)?.deleteContents(); - } - }); -} - -function handleCollapsedEnter(editor: IEditor, delimiter: HTMLElement) { - const isAfter = delimiter.classList.contains(DelimiterClasses.DELIMITER_AFTER); - const entity = !isAfter ? delimiter.nextSibling : delimiter.previousSibling; - const block = getBlock(editor, delimiter); - - editor.runAsync(() => { - if (!block) { - return; - } - const blockToCheck = isAfter ? block.nextSibling : block.previousSibling; - if (blockToCheck && isNodeOfType(blockToCheck, 'ELEMENT_NODE')) { - const delimiters = blockToCheck.querySelectorAll(DELIMITER_SELECTOR); - // Check if the last or first delimiter still contain the delimiter class and remove it. - const delimiterToCheck = delimiters.item(isAfter ? 0 : delimiters.length - 1); - removeDelimiterAttr(delimiterToCheck); - } - - if (entity && isEntityElement(entity)) { - const entityElement = entity as HTMLElement; - const { nextElementSibling, previousElementSibling } = entityElement; - [nextElementSibling, previousElementSibling].forEach(el => { - // Check if after Enter the ZWS got removed but we still have a element with the class - // Remove the attributes of the element if it is invalid now. - if (el && matchesSelector(el, DELIMITER_SELECTOR) && !getDelimiterFromElement(el)) { - removeDelimiterAttr(el, false /* checkEntity */); - } - }); - - // Add delimiters to the entity if needed because on Enter we can sometimes lose the ZWS of the element. - addDelimiters(entityElement.ownerDocument, entityElement); - } - }); -} - -const getPosition = (container: HTMLElement | null) => { - if (container && getDelimiterFromElement(container)) { - const isAfter = container.classList.contains(DelimiterClasses.DELIMITER_AFTER); - return new Position(container, isAfter ? PositionType.After : PositionType.Before); - } - return undefined; -}; - -function getBlock(editor: IEditor, element: Node | undefined) { - if (!element) { - return undefined; - } - - let block = editor.getBlockElementAtNode(element)?.getStartNode(); - - while (block && (!isNodeOfType(block, 'ELEMENT_NODE') || !isBlockElement(block))) { - block = editor.contains(block.parentElement) ? block.parentElement! : undefined; - } - - return block; -} - -function handleSelectionNotCollapsed(editor: IEditor, range: Range, event: KeyboardEvent) { - const { startContainer, endContainer, startOffset, endOffset } = range; - - const startElement = editor.getElementAtCursor(DELIMITER_SELECTOR, startContainer); - const endElement = editor.getElementAtCursor(DELIMITER_SELECTOR, endContainer); - - const startUpdate = getPosition(startElement); - const endUpdate = getPosition(endElement); - - if (startUpdate || endUpdate) { - editor.select( - startUpdate ?? new Position(startContainer, startOffset), - endUpdate ?? new Position(endContainer, endOffset) - ); - } - editor.runAsync(aEditor => { - const delimiter = aEditor.getElementAtCursor(DELIMITER_SELECTOR); - if (delimiter) { - preventTypeInDelimiter(delimiter); - if (event.which === Keys.ENTER) { - removeDelimiterAttr(delimiter); - } - } - }); -} - -function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { - const range = editor.getSelectionRangeEx(); - const { rawEvent } = event; - if (range.type != SelectionRangeTypes.Normal) { - return; - } - - if (range.areAllCollapsed && (isCharacterValue(rawEvent) || rawEvent.which === Keys.ENTER)) { - const position = editor.getFocusedPosition()?.normalize(); - if (!position) { - return; - } - - const { element, node } = position; - const refNode = element == node ? element.childNodes.item(position.offset) : element; - - const delimiter = editor.getElementAtCursor(DELIMITER_SELECTOR, refNode); - if (!delimiter) { - return; - } - - if (rawEvent.which === Keys.ENTER) { - handleCollapsedEnter(editor, delimiter); - } else if (delimiter.firstChild?.nodeType == NodeType.Text) { - editor.runAsync(() => preventTypeInDelimiter(delimiter)); - } - } else if (!range.areAllCollapsed && !rawEvent.shiftKey && rawEvent.which != Keys.SHIFT) { - const currentRange = range.ranges[0]; - if (!currentRange) { - return; - } - handleSelectionNotCollapsed(editor, currentRange, rawEvent); - } -} - -/** - * @internal - * Create a new instance of EntityDelimiterPlugin. - */ -export function createEntityDelimiterPlugin(): EditorPlugin { - return new EntityDelimiterPlugin(); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts deleted file mode 100644 index 0e1efc7bd85..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { coreApiMap } from '../coreApi/coreApiMap'; -import { createDarkColorHandler } from './DarkColorHandlerImpl'; -import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; -import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; -import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { SizeTransformer } from 'roosterjs-editor-types'; -import type { DarkColorHandler } from 'roosterjs-content-model-types'; - -/** - * @internal - * Create a new instance of Content Model Editor Core - * @param options The editor options - * @param corePluginState Core plugin state for Content Model editor - * @param innerDarkColorHandler Inner dark color handler - * @param sizeTransformer @deprecated A size transformer function to calculate size when editor is zoomed - */ -export function createEditorCore( - options: ContentModelEditorOptions, - corePluginState: ContentModelCorePluginState, - innerDarkColorHandler: DarkColorHandler, - sizeTransformer: SizeTransformer -): ContentModelEditorCore { - const core: ContentModelEditorCore = { - api: { ...coreApiMap, ...options.legacyCoreApiOverride }, - originalApi: coreApiMap, - customData: {}, - experimentalFeatures: options.experimentalFeatures ?? [], - sizeTransformer, - darkColorHandler: createDarkColorHandler(innerDarkColorHandler), - ...corePluginState, - }; - - return core; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts deleted file mode 100644 index 5a89fdcbc6c..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { contains, getObjectKeys, getTagOfNode, Position } from 'roosterjs-editor-dom'; -import { NodeType } from 'roosterjs-editor-types'; -import type { PendableFormatNames } from 'roosterjs-editor-dom'; -import type { NodePosition, PendableFormatState } from 'roosterjs-editor-types'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; - -/** - * @internal - * @param core The StandaloneEditorCore object - * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. - * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. - */ -export function getPendableFormatState(core: StandaloneEditorCore): PendableFormatState { - const selection = core.api.getDOMSelection(core); - const range = selection?.type == 'range' ? selection.range : null; - const currentPosition = range && Position.getStart(range).normalize(); - - return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; -} - -const PendableStyleCheckers: Record< - PendableFormatNames, - (tagName: string, style: CSSStyleDeclaration) => boolean -> = { - isBold: (tag, style) => - tag == 'B' || - tag == 'STRONG' || - tag == 'H1' || - tag == 'H2' || - tag == 'H3' || - tag == 'H4' || - tag == 'H5' || - tag == 'H6' || - parseInt(style.fontWeight) >= 700 || - ['bold', 'bolder'].indexOf(style.fontWeight) >= 0, - isUnderline: (tag, style) => tag == 'U' || style.textDecoration.indexOf('underline') >= 0, - isItalic: (tag, style) => tag == 'I' || tag == 'EM' || style.fontStyle === 'italic', - isSubscript: (tag, style) => tag == 'SUB' || style.verticalAlign === 'sub', - isSuperscript: (tag, style) => tag == 'SUP' || style.verticalAlign === 'super', - isStrikeThrough: (tag, style) => - tag == 'S' || tag == 'STRIKE' || style.textDecoration.indexOf('line-through') >= 0, -}; - -/** - * CssFalsyCheckers checks for non pendable format that might overlay a pendable format, then it can prevent getPendableFormatState return falsy pendable format states. - */ - -const CssFalsyCheckers: Record boolean> = { - isBold: style => - (style.fontWeight !== '' && parseInt(style.fontWeight) < 700) || - style.fontWeight === 'normal', - isUnderline: style => - style.textDecoration !== '' && style.textDecoration.indexOf('underline') < 0, - isItalic: style => style.fontStyle !== '' && style.fontStyle !== 'italic', - isSubscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'sub', - isSuperscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'super', - isStrikeThrough: style => - style.textDecoration !== '' && style.textDecoration.indexOf('line-through') < 0, -}; - -function queryCommandStateFromDOM( - core: StandaloneEditorCore, - currentPosition: NodePosition -): PendableFormatState { - let node: Node | null = currentPosition.node; - const formatState: PendableFormatState = {}; - const pendableKeys: PendableFormatNames[] = []; - while (node && contains(core.contentDiv, node)) { - const tag = getTagOfNode(node); - const style = node.nodeType == NodeType.Element && (node as HTMLElement).style; - if (tag && style) { - getObjectKeys(PendableStyleCheckers).forEach(key => { - if (!(pendableKeys.indexOf(key) >= 0)) { - formatState[key] = formatState[key] || PendableStyleCheckers[key](tag, style); - if (CssFalsyCheckers[key](style)) { - pendableKeys.push(key); - } - } - }); - } - node = node.parentNode; - } - return formatState; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts deleted file mode 100644 index d0a490ed6e3..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { - ContentModelEditorCore, - ContentModelCoreApiMap, - SetContent, - InsertNode, - GetContent, - GetStyleBasedFormatState, - EnsureTypeInContainer, -} from './publicTypes/ContentModelEditorCore'; -export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; -export { ContentModelCorePluginState } from './publicTypes/ContentModelCorePlugins'; -export { ContentModelBeforePasteEvent } from './publicTypes/ContentModelBeforePasteEvent'; - -export { ContentModelEditor } from './editor/ContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts deleted file mode 100644 index 450b843c1e1..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ContextMenuProvider, EditPluginState } from 'roosterjs-editor-types'; - -/** - * Core plugin state for Content Model Editor - */ -export interface ContentModelCorePluginState { - /** - * Plugin state of EditPlugin - */ - readonly edit: EditPluginState; - - /** - * Context Menu providers - */ - readonly contextMenuProviders: ContextMenuProvider[]; -} 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 deleted file mode 100644 index 806ae0b366c..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { ContentModelCorePluginState } from './ContentModelCorePlugins'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { - CustomData, - ExperimentalFeatures, - ContentMetadata, - GetContentMode, - InsertOption, - NodePosition, - StyleBasedFormatState, - SizeTransformer, - DarkColorHandler, -} from 'roosterjs-editor-types'; - -/** - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The ContentModelEditorCore object - * @param innerCore The StandaloneEditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - */ -export type SetContent = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - content: string, - triggerContentChangedEvent: boolean, - metadata?: ContentMetadata -) => void; - -/** - * Get current editor content as HTML string - * @param core The ContentModelEditorCore object - * @param innerCore The StandaloneEditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ -export type GetContent = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - mode: GetContentMode -) => string; - -/** - * Insert a DOM node into editor content - * @param core The ContentModelEditorCore object. No op if null. - * @param innerCore The StandaloneEditorCore object - * @param option An insert option object to specify how to insert the node - */ -export type InsertNode = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - node: Node, - option: InsertOption | null -) => boolean; - -/** - * Get style based format state from current selection, including font name/size and colors - * @param core The ContentModelEditorCore objects - * @param innerCore The StandaloneEditorCore object - * @param node The node to get style from - */ -export type GetStyleBasedFormatState = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - node: Node | null -) => StyleBasedFormatState; - -/** - * Ensure user will type into a container element rather than into the editor content DIV directly - * @param core The ContentModelEditorCore object. - * @param innerCore The StandaloneEditorCore object - * @param position The position that user is about to type to - * @param keyboardEvent Optional keyboard event object - * @param deprecated Deprecated parameter, not used - */ -export type EnsureTypeInContainer = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - position: NodePosition, - keyboardEvent?: KeyboardEvent, - deprecated?: boolean -) => void; - -/** - * Core API map for Content Model editor - */ -export interface ContentModelCoreApiMap { - /** - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The ContentModelEditorCore object - * @param innerCore The StandaloneEditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - */ - setContent: SetContent; - - /** - * Insert a DOM node into editor content - * @param core The ContentModelEditorCore object. No op if null. - * @param innerCore The StandaloneEditorCore object - * @param option An insert option object to specify how to insert the node - */ - insertNode: InsertNode; - - /** - * Get current editor content as HTML string - * @param core The ContentModelEditorCore object - * @param innerCore The StandaloneEditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ - getContent: GetContent; - - /** - * Get style based format state from current selection, including font name/size and colors - * @param core The ContentModelEditorCore objects - * @param innerCore The StandaloneEditorCore object - * @param node The node to get style from - */ - getStyleBasedFormatState: GetStyleBasedFormatState; - - /** - * Ensure user will type into a container element rather than into the editor content DIV directly - * @param core The EditorCore object. - * @param innerCore The StandaloneEditorCore object - * @param position The position that user is about to type to - * @param keyboardEvent Optional keyboard event object - * @param deprecated Deprecated parameter, not used - */ - ensureTypeInContainer: EnsureTypeInContainer; -} - -/** - * Represents the core data structure of a Content Model editor - */ -export interface ContentModelEditorCore extends ContentModelCorePluginState { - /** - * Core API map of this editor - */ - readonly api: ContentModelCoreApiMap; - - /** - * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. - */ - readonly originalApi: ContentModelCoreApiMap; - - /** - * Custom data of this editor - */ - readonly customData: Record; - - /** - * Enabled experimental features - */ - readonly experimentalFeatures: ExperimentalFeatures[]; - - /** - * Dark model handler for the editor, used for variable-based solution. - * If keep it null, editor will still use original dataset-based dark mode solution. - */ - readonly darkColorHandler: DarkColorHandler; - - /** - * @deprecated Use zoomScale instead - */ - readonly sizeTransformer: SizeTransformer; -} 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 deleted file mode 100644 index c15e43ac0a1..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ContentModelCoreApiMap } from './ContentModelEditorCore'; -import type { EditorPlugin, ExperimentalFeatures, IEditor } from 'roosterjs-editor-types'; -import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; - -/** - * An interface of editor with Content Model support. - * (This interface is still under development, and may still be changed in the future with some breaking changes) - */ -export interface IContentModelEditor extends IEditor, IStandaloneEditor {} - -/** - * Options for Content Model editor - */ -export interface ContentModelEditorOptions extends StandaloneEditorOptions { - /** - * Initial HTML content - * Default value is whatever already inside the editor content DIV - */ - initialContent?: string; - - /** - * A function map to override default core API implementation - * Default value is null - */ - legacyCoreApiOverride?: Partial; - - /** - * Specify the enabled experimental features - */ - experimentalFeatures?: ExperimentalFeatures[]; - /** - * Legacy plugins using IEditor interface - */ - legacyPlugins?: EditorPlugin[]; -} diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts deleted file mode 100644 index 7b92620902b..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ /dev/null @@ -1,74 +0,0 @@ -import * as darkColorHandler from '../../lib/editor/DarkColorHandlerImpl'; -import { coreApiMap } from '../../lib/coreApi/coreApiMap'; -import { createEditorCore } from '../../lib/editor/createEditorCore'; - -describe('createEditorCore', () => { - const mockedSizeTransformer = 'TRANSFORMER' as any; - const mockedEditPluginState = 'EDITSTATE' as any; - const mockedInnerHandler = 'INNER' as any; - const mockedDarkHandler = 'DARK' as any; - - beforeEach(() => { - spyOn(darkColorHandler, 'createDarkColorHandler').and.returnValue(mockedDarkHandler); - }); - - it('No additional option', () => { - const core = createEditorCore( - {}, - { - edit: mockedEditPluginState, - contextMenuProviders: [], - }, - mockedInnerHandler, - mockedSizeTransformer - ); - - expect(core).toEqual({ - api: { ...coreApiMap }, - originalApi: { ...coreApiMap }, - customData: {}, - experimentalFeatures: [], - edit: mockedEditPluginState, - contextMenuProviders: [], - sizeTransformer: mockedSizeTransformer, - darkColorHandler: mockedDarkHandler, - }); - expect(darkColorHandler.createDarkColorHandler).toHaveBeenCalledWith(mockedInnerHandler); - }); - - it('With additional plugins', () => { - const mockedPlugin1 = 'P1' as any; - const mockedPlugin2 = 'P2' as any; - const mockedPlugin3 = 'P3' as any; - const mockedFeatures = 'FEATURES' as any; - const mockedCoreApi = { - a: 'b', - } as any; - - const core = createEditorCore( - { - plugins: [mockedPlugin1, mockedPlugin2], - experimentalFeatures: mockedFeatures, - legacyCoreApiOverride: mockedCoreApi, - }, - { - edit: mockedEditPluginState, - contextMenuProviders: [mockedPlugin3], - }, - mockedInnerHandler, - mockedSizeTransformer - ); - - expect(core).toEqual({ - api: { ...coreApiMap, a: 'b' } as any, - originalApi: { ...coreApiMap }, - customData: {}, - contextMenuProviders: [mockedPlugin3], - experimentalFeatures: mockedFeatures, - edit: mockedEditPluginState, - sizeTransformer: mockedSizeTransformer, - darkColorHandler: mockedDarkHandler, - }); - expect(darkColorHandler.createDarkColorHandler).toHaveBeenCalledWith(mockedInnerHandler); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index 63589ea9e99..b4f1a4bd98b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -33,7 +33,7 @@ const DefaultOptions: Required = { * Auto Format plugin handles auto formatting, such as transforming * characters into a bullet list. * It can be customized with options to enable or disable auto list features. */ -export class ContentModelAutoFormatPlugin implements EditorPlugin { +export class AutoFormatPlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; /** @@ -47,7 +47,7 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { * Get name of this plugin */ getName() { - return 'ContentModelAutoFormat'; + return 'AutoFormat'; } /** diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 27398185a5f..900c0c10900 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -2,6 +2,7 @@ import { getListTypeStyle } from './utils/getListTypeStyle'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; + import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -28,6 +29,7 @@ export function keyboardListTrigger( triggerList(editor, model, listType, styleType, index); rawEvent.preventDefault(); normalizeContentModel(model); + return true; } return false; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index 86b8571ae24..89c76020365 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -1,6 +1,5 @@ -import { getIndex } from './getIndex'; +import { findListItemsInSameThread } from 'roosterjs-content-model-api'; import { getNumberingListStyle } from './getNumberingListStyle'; - import type { ContentModelDocument, ContentModelListItem, @@ -49,19 +48,23 @@ export function getListTypeStyle( return { listType: 'UL', styleType: bulletType }; } else if (shouldSearchForNumbering) { const previousList = getPreviousListLevel(model, paragraph); + const previousIndex = getPreviousListIndex(model, previousList); const previousListStyle = getPreviousListStyle(previousList); const numberingType = getNumberingListStyle( listMarker, - previousList?.format?.listStyleType - ? getIndex(previousList.format.listStyleType) - : undefined, + previousIndex, previousListStyle ); if (numberingType) { return { listType: 'OL', styleType: numberingType, - index: previousList?.format?.listStyleType ? getIndex(listMarker) : undefined, + index: + !isNewList(listMarker) && + previousListStyle === numberingType && + previousIndex + ? previousIndex + 1 + : undefined, }; } } @@ -69,22 +72,34 @@ export function getListTypeStyle( return undefined; } +const getPreviousListIndex = ( + model: ContentModelDocument, + previousListItem?: ContentModelListItem +) => { + return previousListItem ? findListItemsInSameThread(model, previousListItem).length : undefined; +}; + const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentModelParagraph) => { - const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + const blocks = getOperationalBlocks( + model, + ['ListItem'], + ['TableCell'] + )[0]; let listItem: ContentModelListItem | undefined = undefined; - const listBlock = blocks.filter(({ block, parent }) => { - return parent.blocks.indexOf(paragraph) > -1; - })[0]; - if (listBlock) { - const length = listBlock.parent.blocks.length; - for (let i = length - 1; i > -1; i--) { - const item = listBlock.parent.blocks[i]; - if (isBlockGroupOfType(item, 'ListItem')) { - listItem = item; - break; + if (blocks) { + const listBlockIndex = blocks.parent.blocks.indexOf(paragraph); + + if (listBlockIndex > -1) { + for (let i = listBlockIndex - 1; i > -1; i--) { + const item = blocks.parent.blocks[i]; + if (isBlockGroupOfType(item, 'ListItem')) { + listItem = item; + break; + } } } } + return listItem; }; @@ -104,3 +119,9 @@ const bulletListType: Record = { '>': BulletListType.ShortArrow, '—': BulletListType.Hyphen, }; + +const isNewList = (listMarker: string) => { + const marker = listMarker.replace(/[^\w\s]/g, ''); + const pattern = /^[1aAiI]$/; + return pattern.test(marker); +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts index bc0de54d63f..b0234547edd 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts @@ -43,24 +43,30 @@ const identifyNumberingType = (text: string, previousListStyle?: number) => { return NumberingTypes.Decimal; } else if (/[a-z]+/g.test(text)) { if ( - (previousListStyle != undefined && + (previousListStyle === NumberingTypes.LowerRoman && lowerRomanTypes.indexOf(previousListStyle) > -1 && lowerRomanNumbers.indexOf(text[0]) > -1) || (!previousListStyle && text === 'i') ) { return NumberingTypes.LowerRoman; - } else if (previousListStyle || (!previousListStyle && text === 'a')) { + } else if ( + previousListStyle === NumberingTypes.LowerAlpha || + (!previousListStyle && text === 'a') + ) { return NumberingTypes.LowerAlpha; } } else if (/[A-Z]+/g.test(text)) { if ( - (previousListStyle != undefined && + (previousListStyle == NumberingTypes.UpperRoman && upperRomanTypes.indexOf(previousListStyle) > -1 && upperRomanNumbers.indexOf(text[0]) > -1) || (!previousListStyle && text === 'I') ) { return NumberingTypes.UpperRoman; - } else if (previousListStyle || (!previousListStyle && text === 'A')) { + } else if ( + previousListStyle == NumberingTypes.UpperAlpha || + (!previousListStyle && text === 'A') + ) { return NumberingTypes.UpperAlpha; } } @@ -140,20 +146,23 @@ export function getNumberingListStyle( //The index is always the characters before the last character const listIndex = isDoubleParenthesis ? trigger.slice(1, -1) : trigger.slice(0, -1); const index = getIndex(listIndex); + const isContinuosList = numberingTriggers.indexOf(listIndex) < 0; if ( !index || index < 1 || - (!previousListIndex && numberingTriggers.indexOf(listIndex) < 0) || - (previousListIndex && - numberingTriggers.indexOf(listIndex) < 0 && - !canAppendList(index, previousListIndex)) + (!previousListIndex && isContinuosList) || + (previousListIndex && isContinuosList && !canAppendList(index, previousListIndex)) ) { return undefined; } const numberingType = isValidNumbering(listIndex) - ? identifyNumberingListType(trigger, isDoubleParenthesis, previousListStyle) + ? identifyNumberingListType( + trigger, + isDoubleParenthesis, + isContinuosList ? previousListStyle : undefined + ) : undefined; return numberingType; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts similarity index 52% rename from packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index a03c145dfc5..43b5c939612 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -1,5 +1,6 @@ import { keyboardDelete } from './keyboardDelete'; import { keyboardInput } from './keyboardInput'; +import { keyboardTab } from './keyboardTab'; import type { EditorPlugin, IStandaloneEditor, @@ -7,20 +8,26 @@ import type { PluginEvent, } from 'roosterjs-content-model-types'; +const BACKSPACE_KEY = 8; +const DELETE_KEY = 46; + /** * ContentModel edit plugins helps editor to do editing operation on top of content model. * This includes: * 1. Delete Key * 2. Backspace Key + * 3. Tab Key */ -export class ContentModelEditPlugin implements EditorPlugin { +export class EditPlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; + private disposer: (() => void) | null = null; + private shouldHandleNextInputEvent = false; /** * Get name of this plugin */ getName() { - return 'ContentModelEdit'; + return 'Edit'; } /** @@ -31,6 +38,13 @@ export class ContentModelEditPlugin implements EditorPlugin { */ initialize(editor: IStandaloneEditor) { this.editor = editor; + if (editor.getEnvironment().isAndroid) { + this.disposer = this.editor.attachDomEvent({ + beforeinput: { + beforeDispatch: e => this.handleBeforeInputEvent(editor, e), + }, + }); + } } /** @@ -40,6 +54,8 @@ export class ContentModelEditPlugin implements EditorPlugin { */ dispose() { this.editor = null; + this.disposer?.(); + this.disposer = null; } /** @@ -70,6 +86,15 @@ export class ContentModelEditPlugin implements EditorPlugin { keyboardDelete(editor, rawEvent); break; + case 'Tab': + keyboardTab(editor, rawEvent); + break; + case 'Unidentified': + if (editor.getEnvironment().isAndroid) { + this.shouldHandleNextInputEvent = true; + } + break; + case 'Enter': default: keyboardInput(editor, rawEvent); @@ -77,4 +102,45 @@ export class ContentModelEditPlugin implements EditorPlugin { } } } + + private handleBeforeInputEvent(editor: IStandaloneEditor, rawEvent: Event) { + // Some Android IMEs doesn't fire correct keydown event for BACKSPACE/DELETE key + // Here we translate input event to BACKSPACE/DELETE keydown event to be compatible with existing logic + if ( + !this.shouldHandleNextInputEvent || + !(rawEvent instanceof InputEvent) || + rawEvent.defaultPrevented + ) { + return; + } + this.shouldHandleNextInputEvent = false; + + let handled = false; + switch (rawEvent.inputType) { + case 'deleteContentBackward': + handled = keyboardDelete( + editor, + new KeyboardEvent('keydown', { + key: 'Backspace', + keyCode: BACKSPACE_KEY, + which: BACKSPACE_KEY, + }) + ); + break; + case 'deleteContentForward': + handled = keyboardDelete( + editor, + new KeyboardEvent('keydown', { + key: 'Delete', + keyCode: DELETE_KEY, + which: DELETE_KEY, + }) + ); + break; + } + + if (handled) { + rawEvent.preventDefault(); + } + } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts index 538b7bc9a8e..5520d6f566a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts @@ -2,7 +2,7 @@ import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { ContentModelDocument, DeleteResult, - FormatWithContentModelContext, + FormatContentModelContext, IStandaloneEditor, } from 'roosterjs-content-model-types'; @@ -15,7 +15,7 @@ export function handleKeyboardEventResult( model: ContentModelDocument, rawEvent: KeyboardEvent, result: DeleteResult, - context: FormatWithContentModelContext + context: FormatContentModelContext ): boolean { context.skipUndoSnapshot = true; context.clearModelCache = false; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts new file mode 100644 index 00000000000..3e5cca47e50 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -0,0 +1,108 @@ +import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; +import { + createBr, + createListItem, + createListLevel, + createParagraph, + normalizeParagraph, + setParagraphNotImplicit, +} from 'roosterjs-content-model-dom'; +import type { + ContentModelBlockGroup, + ContentModelListItem, + DeleteSelectionStep, + InsertPoint, + ValidDeleteSelectionContext, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const handleEnterOnList: DeleteSelectionStep = context => { + if ( + context.deleteResult == 'nothingToDelete' || + context.deleteResult == 'notDeleted' || + context.deleteResult == 'range' + ) { + const { insertPoint, formatContext } = context; + const { path } = insertPoint; + const rawEvent = formatContext?.rawEvent; + const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); + + const listItem = path[index]; + + if (listItem && listItem.blockGroupType === 'ListItem') { + const listParent = path[index + 1]; + if (isEmptyListItem(listItem)) { + listItem.levels.pop(); + } else { + createNewListItem(context, listItem, listParent); + } + rawEvent?.preventDefault(); + context.deleteResult = 'range'; + } + } +}; + +const isEmptyListItem = (listItem: ContentModelListItem) => { + return ( + listItem.blocks.length === 1 && + listItem.blocks[0].blockType === 'Paragraph' && + listItem.blocks[0].segments.length === 2 && + listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' && + listItem.blocks[0].segments[1].segmentType === 'Br' + ); +}; + +const createNewListItem = ( + context: ValidDeleteSelectionContext, + listItem: ContentModelListItem, + listParent: ContentModelBlockGroup +) => { + const { insertPoint } = context; + const listIndex = listParent.blocks.indexOf(listItem); + const newParagraph = createNewParagraph(insertPoint); + const levels = createNewListLevel(listItem); + const newListItem = createListItem(levels, insertPoint.marker.format); + newListItem.blocks.push(newParagraph); + listParent.blocks.splice(listIndex + 1, 0, newListItem); +}; + +const createNewListLevel = (listItem: ContentModelListItem) => { + return listItem.levels.map(level => { + return createListLevel( + level.listType, + { + ...level.format, + startNumberOverride: undefined, + }, + level.dataset + ); + }); +}; + +const createNewParagraph = (insertPoint: InsertPoint) => { + const { paragraph, marker } = insertPoint; + const newParagraph = createParagraph( + false /*isImplicit*/, + paragraph.format, + paragraph.segmentFormat + ); + + const markerIndex = paragraph.segments.indexOf(marker); + const segments = paragraph.segments.splice( + markerIndex, + paragraph.segments.length - markerIndex + ); + + newParagraph.segments.push(...segments); + + setParagraphNotImplicit(paragraph); + + if (paragraph.segments.every(x => x.segmentType == 'SelectionMarker')) { + paragraph.segments.push(createBr(marker.format)); + } + + normalizeParagraph(newParagraph); + return newParagraph; +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index 8e6ac7e22b4..59f27617d77 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -26,8 +26,10 @@ import type { * 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 by content model, otherwise false */ export function keyboardDelete(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { + let handled = false; const selection = editor.getDOMSelection(); if (shouldDeleteWithContentModel(selection, rawEvent)) { @@ -39,7 +41,8 @@ export function keyboardDelete(editor: IStandaloneEditor, rawEvent: KeyboardEven context ).deleteResult; - return handleKeyboardEventResult(editor, model, rawEvent, result, context); + handled = handleKeyboardEventResult(editor, model, rawEvent, result, context); + return handled; }, { rawEvent, @@ -48,9 +51,9 @@ export function keyboardDelete(editor: IStandaloneEditor, rawEvent: KeyboardEven apiName: rawEvent.key == 'Delete' ? 'handleDeleteKey' : 'handleBackspaceKey', } ); - - return true; } + + return handled; } function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelectionStep | null)[] { @@ -65,13 +68,7 @@ function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelecti const deleteCollapsedSelection = isForward ? forwardDeleteCollapsedSelection : backwardDeleteCollapsedSelection; - const deleteListStep = !isForward ? deleteList : null; - return [ - deleteAllSegmentBeforeStep, - deleteWordSelection, - deleteCollapsedSelection, - deleteListStep, - ]; + return [deleteAllSegmentBeforeStep, deleteWordSelection, deleteCollapsedSelection, deleteList]; } function shouldDeleteWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index d72a251e179..b4404bc8db1 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,4 +1,5 @@ import { deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; +import { handleEnterOnList } from './inputSteps/handleEnterOnList'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -8,12 +9,12 @@ import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-ty export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); - if (shouldInputWithContentModel(selection, rawEvent, editor.isInIME())) { + if (shouldInputWithContentModel(selection, rawEvent)) { editor.takeSnapshot(); editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, [], context); + const result = deleteSelection(model, getInputSteps(selection, rawEvent), context); // We have deleted selection then we will let browser to handle the input. // With this combined operation, we don't wan to mass up the cached model so clear it @@ -43,11 +44,11 @@ export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent } } -function shouldInputWithContentModel( - selection: DOMSelection | null, - rawEvent: KeyboardEvent, - isInIME: boolean -) { +function getInputSteps(selection: DOMSelection | null, rawEvent: KeyboardEvent) { + return shouldHandleEnterKey(selection, rawEvent) ? [handleEnterOnList] : []; +} + +function shouldInputWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { if (!selection) { return false; // Nothing to delete } else if ( @@ -56,9 +57,14 @@ function shouldInputWithContentModel( ) { return ( selection.type != 'range' || - (!selection.range.collapsed && !rawEvent.isComposing && !isInIME) + !selection.range.collapsed || + shouldHandleEnterKey(selection, rawEvent) ); } else { return false; } } + +const shouldHandleEnterKey = (selection: DOMSelection | null, rawEvent: KeyboardEvent) => { + return selection && selection.type == 'range' && rawEvent.key == 'Enter' && !rawEvent.shiftKey; +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts new file mode 100644 index 00000000000..733dc7ef582 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -0,0 +1,46 @@ +import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; +import { setModelIndentation } from 'roosterjs-content-model-api'; +import type { + ContentModelDocument, + ContentModelListItem, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function keyboardTab(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { + const selection = editor.getDOMSelection(); + + if (selection?.type == 'range') { + editor.takeSnapshot(); + + editor.formatContentModel((model, _context) => { + return handleTabOnList(model, rawEvent); + }); + + return true; + } +} + +function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { + return ( + listItem.blocks[0].blockType == 'Paragraph' && + listItem.blocks[0].segments[0].segmentType == 'SelectionMarker' + ); +} + +function handleTabOnList(model: ContentModelDocument, rawEvent: KeyboardEvent) { + const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + const listItem = blocks[0].block; + + if ( + isBlockGroupOfType(listItem, 'ListItem') && + isMarkerAtStartOfBlock(listItem) + ) { + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + rawEvent.preventDefault(); + return true; + } + return false; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index 1128be25a61..fe5a21770a7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,6 +1,3 @@ -export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; -export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; -export { - ContentModelAutoFormatPlugin, - AutoFormatOptions, -} from './autoFormat/ContentModelAutoFormatPlugin'; +export { PastePlugin } from './paste/PastePlugin'; +export { EditPlugin } from './edit/EditPlugin'; +export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts index 68aa979ea7e..dac08441684 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts @@ -26,7 +26,7 @@ import type { * 4. Content copied from Power Point * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export class ContentModelPastePlugin implements EditorPlugin { +export class PastePlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; /** @@ -40,7 +40,7 @@ export class ContentModelPastePlugin implements EditorPlugin { * Get name of this plugin */ getName() { - return 'ContentModelPaste'; + return 'Paste'; } /** diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts index 27693949f83..c0402df790a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts @@ -213,7 +213,7 @@ const wacCommentParser: FormatParser = ( * Convert pasted content from Office Online * Once it is known that the document is from WAC * We need to remove the display property and margin from all the list item - * @param ev ContentModelBeforePasteEvent + * @param ev BeforePasteEvent */ export function processPastedContentWacComponents(ev: BeforePasteEvent) { addParser(ev.domToModelOption, 'segment', wacSubSuperParser); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts index 3a9a762e60e..daf6497990e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -22,7 +22,7 @@ const DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE = 120; /** * @internal * Handles Pasted content when source is Word Desktop - * @param ev ContentModelBeforePasteEvent + * @param ev BeforePasteEvent */ export function processPastedContentFromWordDesktop( ev: BeforePasteEvent, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/CreateElementData.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/CreateElementData.ts new file mode 100644 index 00000000000..80fb8b9d425 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/CreateElementData.ts @@ -0,0 +1,41 @@ +/** + * @internal + * An interface represents the data for creating element used by createElement() + */ +export default interface CreateElementData { + /** + * Tag name of this element. + * It can be just a tag, or in format "namespace:tag" + */ + tag: string; + + /** + * Namespace of this tag + */ + namespace?: string; + + /** + * CSS class name + */ + className?: string; + + /** + * CSS style + */ + style?: string; + + /** + * Dataset of this element + */ + dataset?: Record; + + /** + * Additional attributes of this element + */ + attributes?: Record; + + /** + * Child nodes of this element, can be another CreateElementData, or a string which represents a text node + */ + children?: (CreateElementData | string)[]; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/createElement.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/createElement.ts new file mode 100644 index 00000000000..1e331473ad6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/createElement.ts @@ -0,0 +1,58 @@ +import { getObjectKeys, isNodeOfType } from 'roosterjs-content-model-dom'; +import type CreateElementData from './CreateElementData'; + +/** + * @internal + * Create DOM element from the given CreateElementData + * @param elementData The CreateElementData or an index of a known CreateElementData used for creating this element + * @param document The document to create the element from + * @returns The root DOM element just created + */ +export default function createElement( + elementData: CreateElementData, + document: Document +): Element | null { + if (!elementData || !elementData.tag) { + return null; + } + + const { tag, namespace, className, style, dataset, attributes, children } = elementData; + const result = namespace + ? document.createElementNS(namespace, tag) + : document.createElement(tag); + + if (style) { + result.setAttribute('style', style); + } + + if (className) { + result.className = className; + } + + if (dataset && isNodeOfType(result, 'ELEMENT_NODE')) { + getObjectKeys(dataset).forEach(datasetName => { + result.dataset[datasetName] = dataset[datasetName]; + }); + } + + if (attributes) { + getObjectKeys(attributes).forEach(attrName => { + result.setAttribute(attrName, attributes[attrName]); + }); + } + + if (children) { + children.forEach(child => { + if (typeof child === 'string') { + result.appendChild(document.createTextNode(child)); + } else if (child) { + const childElement = createElement(child, document); + if (childElement) { + result.appendChild(childElement); + } + } + }); + } + + return result; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Disposable.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Disposable.ts new file mode 100644 index 00000000000..06a344e0fb3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Disposable.ts @@ -0,0 +1,10 @@ +/** + * @internal + * Represents a disposable object + */ +export default interface Disposable { + /** + * Dispose this object + */ + dispose: () => void; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHandler.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHandler.ts new file mode 100644 index 00000000000..ff1eed6756e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHandler.ts @@ -0,0 +1,57 @@ +/** + * @internal + * Drag and drop handler interface, used for implementing a handler object and pass into DragAndDropHelper class + */ +export default interface DragAndDropHandler { + /** + * A callback that will be called when user starts to drag (mouse down event from the trigger element) + * @param context The context object that was passed into DragAndDropHelper from its constructor. We can use + * this object to communicate between caller code and this handler + * @param event The mouse event that triggers this callback + * @returns An optional object, which will be passed into onDragging and onDragEnd callback. It normally used + * for passing an initial state of the target object + */ + onDragStart?: (context: TContext, event: MouseEvent) => TInitValue; + + /** + * A callback that will be called when user moves mouse and drag the trigger element. + * @param context The context object that was passed into DragAndDropHelper from its constructor. We can use + * this object to communicate between caller code and this handler.If an object is used as context, here it will + * be the same object that passed into constructor of DragAndDropHelper class. Inside this callback you can change + * its sub value so that caller can get the changed result. + * @param event The mouse event that triggers this callback + * @param initValue The initial value that is returned from onDragStart callback. It normally used + * for passing an initial state of the target object + * @param deltaX x delta value. It equals to current event.pageX - initial pageX (captured when mousedown happens) + * @param deltaY y delta value. It equals to current event.pageY - initial pageY (captured when mousedown happens) + * @returns Whether the onSubmit callback passed into constructor of DragAndDropHelper class should be invoked. + * Returns true will invoke the onSubmit callback, it means this is a meaningful dragging action, something (mostly + * under context object) has been changed, and caller should handle this change. Otherwise, return false. + */ + onDragging?: ( + context: TContext, + event: MouseEvent, + initValue: TInitValue, + deltaX: number, + deltaY: number + ) => boolean; + + /** + * A callback that will be called when user stops dragging the trigger element. + * @param context The context object that was passed into DragAndDropHelper from its constructor. We can use + * this object to communicate between caller code and this handler.If an object is used as context, here it will + * be the same object that passed into constructor of DragAndDropHelper class. Inside this callback you can change + * its sub value so that caller can get the changed result. + * @param event The mouse event that triggers this callback + * @param initValue The initial value that is returned from onDragStart callback. It normally used + * for passing an initial state of the target object + * @returns Whether the onSubmit callback passed into constructor of DragAndDropHelper class should be invoked. + * Returns true will invoke the onSubmit callback, it means this is a meaningful dragging action, something (mostly + * under context object) has been changed, and caller should handle this change. Otherwise, return false. + */ + onDragEnd?: ( + context: TContext, + event: MouseEvent, + initValue: TInitValue | undefined + ) => boolean; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHelper.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHelper.ts new file mode 100644 index 00000000000..e15f30000ed --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHelper.ts @@ -0,0 +1,149 @@ +import type Disposable from '../Disposable'; +import type DragAndDropHandler from './DragAndDropHandler'; + +/** + * @internal + */ +interface MouseEventMoves { + MOUSEDOWN: string; + MOUSEMOVE: string; + MOUSEUP: string; +} + +/** + * @internal + */ +interface MouseEventInfo extends MouseEventMoves { + getPageXY: (e: MouseEvent) => number[]; +} + +/** + * @internal + * Compatible mouse event names for different platform + */ +interface TouchEventInfo extends MouseEventMoves { + getPageXY: (e: TouchEvent) => number[]; +} + +/** + * Generate event names and getXY function based on different platforms to be compatible with desktop and mobile browsers + */ +const MOUSE_EVENT_INFO_DESKTOP: MouseEventInfo = (() => { + return { + MOUSEDOWN: 'mousedown', + MOUSEMOVE: 'mousemove', + MOUSEUP: 'mouseup', + getPageXY: getMouseEventPageXY, + }; +})(); + +const MOUSE_EVENT_INFO_MOBILE: TouchEventInfo = (() => { + return { + MOUSEDOWN: 'touchstart', + MOUSEMOVE: 'touchmove', + MOUSEUP: 'touchend', + getPageXY: getTouchEventPageXY, + }; +})(); + +function getMouseEventPageXY(e: MouseEvent): [number, number] { + return [e.pageX, e.pageY]; +} + +function getTouchEventPageXY(e: TouchEvent): [number, number] { + let pageX = 0; + let pageY = 0; + if (e.targetTouches && e.targetTouches.length > 0) { + const touch = e.targetTouches[0]; + pageX = touch.pageX; + pageY = touch.pageY; + } + return [pageX, pageY]; +} + +/** + * @internal + * A helper class to help manage drag and drop to an HTML element + */ +export default class DragAndDropHelper implements Disposable { + private initX: number = 0; + private initY: number = 0; + private initValue: TInitValue | undefined = undefined; + private dndMouse: MouseEventInfo | TouchEventInfo; + + /** + * Create a new instance of DragAndDropHelper class + * @param trigger The trigger element. When user start drag on this element, + * events will be fired to the handler object + * @param context Context object that will be passed to handler function when event is fired, + * so that the handler object knows which element it is triggered from. + * @param onSubmit A callback that will be invoked when event handler in handler object returns true + * @param handler The event handler object, see DragAndDropHandler interface for more information + * @param zoomScale The zoom scale of the editor + * @param forceMobile A boolean to force the use of touch controls for the helper + */ + constructor( + private trigger: HTMLElement, + private context: TContext, + private onSubmit: (context: TContext, trigger: HTMLElement) => void, + private handler: DragAndDropHandler, + private zoomScale: number, + forceMobile?: boolean + ) { + this.dndMouse = forceMobile ? MOUSE_EVENT_INFO_MOBILE : MOUSE_EVENT_INFO_DESKTOP; + trigger.addEventListener(this.dndMouse.MOUSEDOWN, this.onMouseDown); + } + + /** + * Dispose this object, remove all event listeners that has been attached + */ + dispose() { + this.trigger.removeEventListener(this.dndMouse.MOUSEDOWN, this.onMouseDown); + this.removeDocumentEvents(); + } + + public get mouseType(): string { + return this.dndMouse == MOUSE_EVENT_INFO_MOBILE ? 'touch' : 'mouse'; + } + + private addDocumentEvents() { + const doc = this.trigger.ownerDocument; + doc.addEventListener(this.dndMouse.MOUSEMOVE, this.onMouseMove, true /*useCapture*/); + doc.addEventListener(this.dndMouse.MOUSEUP, this.onMouseUp, true /*useCapture*/); + } + + private removeDocumentEvents() { + const doc = this.trigger.ownerDocument; + doc.removeEventListener(this.dndMouse.MOUSEMOVE, this.onMouseMove, true /*useCapture*/); + doc.removeEventListener(this.dndMouse.MOUSEUP, this.onMouseUp, true /*useCapture*/); + } + + private onMouseDown = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + this.addDocumentEvents(); + [this.initX, this.initY] = this.dndMouse.getPageXY(e as MouseEvent & TouchEvent); + this.initValue = this.handler.onDragStart?.(this.context, e as MouseEvent); + }; + + private onMouseMove = (e: Event) => { + e.preventDefault(); + const [pageX, pageY] = this.dndMouse.getPageXY(e as MouseEvent & TouchEvent); + const deltaX = (pageX - this.initX) / this.zoomScale; + const deltaY = (pageY - this.initY) / this.zoomScale; + if ( + this.initValue && + this.handler.onDragging?.(this.context, e as MouseEvent, this.initValue, deltaX, deltaY) + ) { + this.onSubmit?.(this.context, this.trigger); + } + }; + + private onMouseUp = (e: Event) => { + e.preventDefault(); + this.removeDocumentEvents(); + if (this.handler.onDragEnd?.(this.context, e as MouseEvent, this.initValue)) { + this.onSubmit?.(this.context, this.trigger); + } + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getIntersectedRect.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getIntersectedRect.ts new file mode 100644 index 00000000000..4845fcaef6b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getIntersectedRect.ts @@ -0,0 +1,46 @@ +import normalizeRect from './normalizeRect'; +import type { Rect } from 'roosterjs-content-model-types'; + +/** + * Get the intersected Rect of elements provided + * + * @example + * The result of the following Elements Rects would be: + { + top: Element2.top, + bottom: Element1.bottom, + left: Element2.left, + right: Element2.right + } + +-------------------------+ + | Element 1 | + | +-----------------+ | + | | Element2 | | + | | | | + | | | | + +-------------------------+ + | | + +-----------------+ + * @internal + * @param elements Elements to use. + * @param additionalRects additional rects to use + * @returns If the Rect is valid return the rect, if not, return null. + */ +export default function getIntersectedRect( + elements: HTMLElement[], + additionalRects: Rect[] = [] +): Rect | null { + const rects = elements + .map(element => normalizeRect(element.getBoundingClientRect())) + .concat(additionalRects) + .filter(element => !!element) as Rect[]; + + const result: Rect = { + top: Math.max(...rects.map(r => r.top)), + bottom: Math.min(...rects.map(r => r.bottom)), + left: Math.max(...rects.map(r => r.left)), + right: Math.min(...rects.map(r => r.right)), + }; + + return result.top < result.bottom && result.left < result.right ? result : null; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/normalizeRect.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/normalizeRect.ts new file mode 100644 index 00000000000..dca5dcf9af0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/normalizeRect.ts @@ -0,0 +1,19 @@ +import type { Rect } from 'roosterjs-content-model-types'; + +/** + * @internal + * A ClientRect of all 0 is possible. i.e. chrome returns a ClientRect of 0 when the cursor is on an empty p + * We validate that and only return a rect when the passed in ClientRect is valid + */ +export default function normalizeRect(clientRect: DOMRect): Rect | null { + const { left, right, top, bottom } = + clientRect || { left: 0, right: 0, top: 0, bottom: 0 }; + return left === 0 && right === 0 && top === 0 && bottom === 0 + ? null + : { + left: Math.round(left), + right: Math.round(right), + top: Math.round(top), + bottom: Math.round(bottom), + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 3060811a4b3..1e762489d3c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1,10 +1,9 @@ import * as keyboardTrigger from '../../lib/autoFormat/keyboardListTrigger'; -import { ContentModelAutoFormatPlugin } from '../../lib/autoFormat/ContentModelAutoFormatPlugin'; -import { IContentModelEditor } from 'roosterjs-content-model-editor'; -import { KeyDownEvent } from 'roosterjs-content-model-types'; +import { AutoFormatPlugin } from '../../lib/autoFormat/AutoFormatPlugin'; +import { IStandaloneEditor, KeyDownEvent } from 'roosterjs-content-model-types'; describe('Content Model Auto Format Plugin Test', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; beforeEach(() => { editor = ({ @@ -13,7 +12,7 @@ describe('Content Model Auto Format Plugin Test', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); describe('onPluginEvent', () => { @@ -28,7 +27,7 @@ describe('Content Model Auto Format Plugin Test', () => { shouldCallTrigger: boolean, options?: { autoBullet: boolean; autoNumbering: boolean } ) { - const plugin = new ContentModelAutoFormatPlugin(options); + const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); plugin.onPluginEvent(event); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts index abdbb18ccdc..2f302ae4d9a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -89,7 +89,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -144,7 +143,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -204,7 +202,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -242,7 +239,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -371,7 +367,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -457,4 +452,643 @@ describe('keyboardListTrigger', () => { false ); }); + + it('trigger continued numbering list between lists', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '3)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"A) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"B) "', + }, + }, + ], + format: {}, + }, + + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 3, + marginTop: '0px', + marginBottom: '0px', + direction: undefined, + textAlign: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"A) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"B) "', + }, + }, + ], + format: {}, + }, + true + ); + }); + + it('trigger a new numbering list after a numbering list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + direction: undefined, + textAlign: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + ], + format: {}, + }, + true + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts deleted file mode 100644 index 9a7c0b5f7a6..00000000000 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as keyboardDelete from '../../lib/edit/keyboardDelete'; -import * as keyboardInput from '../../lib/edit/keyboardInput'; -import { ContentModelEditPlugin } from '../../lib/edit/ContentModelEditPlugin'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; - -describe('ContentModelEditPlugin', () => { - let editor: IStandaloneEditor; - - beforeEach(() => { - editor = ({ - getDOMSelection: () => - ({ - type: -1, - } as any), // Force return invalid range to go through content model code - } as any) as IStandaloneEditor; - }); - - describe('onPluginEvent', () => { - let keyboardDeleteSpy: jasmine.Spy; - let keyboardInputSpy: jasmine.Spy; - - beforeEach(() => { - keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); - keyboardInputSpy = spyOn(keyboardInput, 'keyboardInput'); - }); - - it('Backspace', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'Backspace' } as any; - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent, - }); - - expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(keyboardInputSpy).not.toHaveBeenCalled(); - }); - - it('Delete', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'Delete' } as any; - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent, - }); - - expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(keyboardInputSpy).not.toHaveBeenCalled(); - }); - - it('Other key', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: 41, key: 'A' } as any; - const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); - - editor.takeSnapshot = addUndoSnapshotSpy; - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent, - }); - - expect(keyboardDeleteSpy).not.toHaveBeenCalled(); - expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); - }); - - it('Default prevented', () => { - const plugin = new ContentModelEditPlugin(); - const rawEvent = { key: 'Delete', defaultPrevented: true } as any; - - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent, - }); - - expect(keyboardDeleteSpy).not.toHaveBeenCalled(); - expect(keyboardInputSpy).not.toHaveBeenCalled(); - }); - - it('Trigger entity event first', () => { - const plugin = new ContentModelEditPlugin(); - const wrapper = 'WRAPPER' as any; - - plugin.initialize(editor); - - plugin.onPluginEvent({ - eventType: 'entityOperation', - operation: 'overwrite', - rawEvent: { - type: 'keydown', - } as any, - entity: wrapper, - }); - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { key: 'Delete' } as any, - }); - - expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { - key: 'Delete', - } as any); - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { key: 'Delete' } as any, - }); - - expect(keyboardDeleteSpy).toHaveBeenCalledTimes(2); - expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { - key: 'Delete', - } as any); - expect(keyboardInputSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts new file mode 100644 index 00000000000..7c0c2c5ac93 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -0,0 +1,228 @@ +import * as keyboardDelete from '../../lib/edit/keyboardDelete'; +import * as keyboardInput from '../../lib/edit/keyboardInput'; +import * as keyboardTab from '../../lib/edit/keyboardTab'; +import { DOMEventRecord, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { EditPlugin } from '../../lib/edit/EditPlugin'; + +describe('EditPlugin', () => { + let plugin: EditPlugin; + let editor: IStandaloneEditor; + let eventMap: Record; + let attachDOMEventSpy: jasmine.Spy; + let getEnvironmentSpy: jasmine.Spy; + + beforeEach(() => { + attachDOMEventSpy = jasmine + .createSpy('attachDOMEvent') + .and.callFake((handlers: Record) => { + eventMap = handlers; + }); + + getEnvironmentSpy = jasmine.createSpy('getEnvironment').and.returnValue({ + isAndroid: true, + }); + + editor = ({ + attachDomEvent: attachDOMEventSpy, + getEnvironment: getEnvironmentSpy, + getDOMSelection: () => + ({ + type: -1, + } as any), // Force return invalid range to go through content model code + } as any) as IStandaloneEditor; + }); + + afterEach(() => { + plugin.dispose(); + }); + + describe('onPluginEvent', () => { + let keyboardDeleteSpy: jasmine.Spy; + let keyboardInputSpy: jasmine.Spy; + let keyboardTabSpy: jasmine.Spy; + + beforeEach(() => { + keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); + keyboardInputSpy = spyOn(keyboardInput, 'keyboardInput'); + keyboardTabSpy = spyOn(keyboardTab, 'keyboardTab'); + }); + + it('Backspace', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Backspace' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + }); + + it('Delete', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Delete' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + }); + + it('Tab', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Tab' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + }); + + it('Other key', () => { + plugin = new EditPlugin(); + const rawEvent = { which: 41, key: 'A' } as any; + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + editor.takeSnapshot = addUndoSnapshotSpy; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); + }); + + it('Default prevented', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Delete', defaultPrevented: true } as any; + + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + }); + + it('Trigger entity event first', () => { + plugin = new EditPlugin(); + const wrapper = 'WRAPPER' as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'entityOperation', + operation: 'overwrite', + rawEvent: { + type: 'keydown', + } as any, + entity: wrapper, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { key: 'Delete' } as any, + }); + + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { + key: 'Delete', + } as any); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { key: 'Delete' } as any, + }); + + expect(keyboardDeleteSpy).toHaveBeenCalledTimes(2); + expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { + key: 'Delete', + } as any); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onBeforeInputEvent', () => { + let keyboardDeleteSpy: jasmine.Spy; + + beforeEach(() => { + keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); + }); + + it('Handle deleteContentBackward event when key is unidentified', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Unidentified' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + eventMap.beforeinput.beforeDispatch( + new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + }) + ); + + expect(keyboardDeleteSpy).toHaveBeenCalledTimes(1); + expect(keyboardDeleteSpy).toHaveBeenCalledWith( + editor, + new KeyboardEvent('keydown', { + key: 'Backspace', + keyCode: 8, + which: 8, + }) + ); + }); + + it('Handle deleteContentForward event when key is unidentified', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Unidentified' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + eventMap.beforeinput.beforeDispatch( + new InputEvent('beforeinput', { + inputType: 'deleteContentForward', + }) + ); + + expect(keyboardDeleteSpy).toHaveBeenCalledTimes(1); + expect(keyboardDeleteSpy).toHaveBeenCalledWith( + editor, + new KeyboardEvent('keydown', { + key: 'Delete', + keyCode: 46, + which: 46, + }) + ); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts index 1885fef65d4..1ff6f52eb16 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts @@ -1,16 +1,17 @@ import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, IStandaloneEditor, } from 'roosterjs-content-model-types'; export function editingTestCommon( - apiName: string, + apiName: string | undefined, executionCallback: (editor: IStandaloneEditor) => void, model: ContentModelDocument, result: ContentModelDocument, - calledTimes: number + calledTimes: number, + doNotCallDefaultFormat?: boolean ) { const triggerEvent = jasmine.createSpy('triggerEvent'); @@ -18,7 +19,7 @@ export function editingTestCommon( const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { expect(options.apiName).toBe(apiName); formatResult = callback(model, { newEntities: [], @@ -30,6 +31,8 @@ export function editingTestCommon( const editor = ({ triggerEvent, + takeSnapshot: () => {}, + isInIME: () => false, getEnvironment: () => ({}), formatContentModel, } as any) as IStandaloneEditor; @@ -37,6 +40,11 @@ export function editingTestCommon( executionCallback(editor); expect(model).toEqual(result); - expect(formatContentModel).toHaveBeenCalledTimes(1); - expect(formatResult).toBe(calledTimes > 0); + if (doNotCallDefaultFormat) { + expect(formatContentModel).not.toHaveBeenCalled(); + } else { + expect(formatContentModel).toHaveBeenCalledTimes(1); + } + + expect(!!formatResult).toBe(calledTimes > 0); } diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts index ab0f9f9a561..5c7f56a94f0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts @@ -1,5 +1,5 @@ import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { FormatWithContentModelContext, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { FormatContentModelContext, IStandaloneEditor } from 'roosterjs-content-model-types'; import { handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, @@ -39,7 +39,7 @@ describe('handleKeyboardEventResult', () => { const mockedModel = 'MODEL' as any; const which = 'WHICH' as any; (mockedEvent).which = which; - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], @@ -66,7 +66,7 @@ describe('handleKeyboardEventResult', () => { it('notDeleted', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], @@ -91,7 +91,7 @@ describe('handleKeyboardEventResult', () => { it('range', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], @@ -118,7 +118,7 @@ describe('handleKeyboardEventResult', () => { it('nothingToDelete', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts new file mode 100644 index 00000000000..f11408881aa --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -0,0 +1,2081 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { deleteSelection } from 'roosterjs-content-model-core'; +import { editingTestCommon } from '../editingTestCommon'; +import { handleEnterOnList } from '../../../lib/edit/inputSteps/handleEnterOnList'; +import { keyboardInput } from '../../../lib/edit/keyboardInput'; +import { normalizeContentModel } from 'roosterjs-content-model-dom'; + +describe('handleEnterOnList', () => { + function runTest( + model: ContentModelDocument, + expectedModel: ContentModelDocument, + expectedResult: 'notDeleted' | 'range' + ) { + const result = deleteSelection(model, [handleEnterOnList]); + normalizeContentModel(model); + + expect(model).toEqual(expectedModel); + expect(result.deleteResult).toBe(expectedResult); + } + + it('no list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + }; + runTest(model, model, 'notDeleted'); + }); + + it('empty list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); + + it('enter on middle list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); + + it('enter on last list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + runTest(model, expectedModel, 'range'); + }); + + it('enter on last list item of second list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"A) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"B) "', + }, + }, + ], + format: {}, + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"A) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"B) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + runTest(model, expectedModel, 'range'); + }); + + it('enter on list item with selected text', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fdsfsdf', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1. "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fsdfsd', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2. "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fsdf', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"3. "', + }, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fdsfsdf', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1. "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2. "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fsdf', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"3. "', + }, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); + + it('enter on multiple list items with selected text', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"3) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"4) "', + }, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"4) "', + }, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); +}); + +describe('keyboardInput - handleEnterOnList', () => { + function runTest( + input: ContentModelDocument, + isShiftKey: boolean, + expectedResult: ContentModelDocument, + doNotCallDefaultFormat: boolean = false, + calledTimes: number = 1 + ) { + const preventDefault = jasmine.createSpy('preventDefault'); + const mockedEvent = ({ + key: 'Enter', + shiftKey: isShiftKey, + preventDefault, + } as any) as KeyboardEvent; + + let editor: any; + + editingTestCommon( + undefined, + newEditor => { + editor = newEditor; + + editor.getDOMSelection = () => ({ + type: 'range', + range: { + collapsed: true, + }, + }); + + keyboardInput(editor, mockedEvent); + }, + input, + expectedResult, + calledTimes, + doNotCallDefaultFormat + ); + } + + it('Enter on list', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(input, false, expected); + }); + + it('Enter on empty list item', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, false, expected); + }); + + it('Enter + Shift on list item', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(input, true, expected, true, 0); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 06f1edd5e85..be70773518d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -65,7 +65,7 @@ describe('keyboardDelete', () => { const result = keyboardDelete(editor, mockedEvent); - expect(result).toBeTrue(); + expect(result).toBe(expectedDelete == 'range' || expectedDelete == 'singleChar'); }, input, expectedResult, @@ -93,7 +93,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, null!, forwardDeleteCollapsedSelection, null!], + [null!, null!, forwardDeleteCollapsedSelection, deleteList], 'notDeleted', true, 0 @@ -131,7 +131,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, forwardDeleteWordSelection, forwardDeleteCollapsedSelection, null!], + [null!, forwardDeleteWordSelection, forwardDeleteCollapsedSelection, deleteList], 'notDeleted', true, 0 @@ -171,7 +171,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, null!, forwardDeleteCollapsedSelection, null!], + [null!, null!, forwardDeleteCollapsedSelection, deleteList], 'notDeleted', true, 0 @@ -233,7 +233,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, forwardDeleteCollapsedSelection, null!], + [null!, null!, forwardDeleteCollapsedSelection, deleteList], 'notDeleted', true, 0 @@ -327,7 +327,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, forwardDeleteCollapsedSelection, null!], + [null!, null!, forwardDeleteCollapsedSelection, deleteList], 'singleChar', false, 1 @@ -589,9 +589,8 @@ describe('keyboardDelete', () => { getDOMSelection: () => range, } as any; - const result = keyboardDelete(editor, rawEvent); + keyboardDelete(editor, rawEvent); - expect(result).toBeTrue(); expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); }); @@ -613,9 +612,8 @@ describe('keyboardDelete', () => { getDOMSelection: () => range, } as any; - const result = keyboardDelete(editor, rawEvent); + keyboardDelete(editor, rawEvent); - expect(result).toBeTrue(); expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index 89ca280fa1e..7db310df950 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -1,10 +1,11 @@ import * as deleteSelection from 'roosterjs-content-model-core/lib/publicApi/selection/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; +import { handleEnterOnList } from '../../lib/edit/inputSteps/handleEnterOnList'; import { keyboardInput } from '../../lib/edit/keyboardInput'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelContext, + FormatContentModelContext, IStandaloneEditor, } from 'roosterjs-content-model-types'; @@ -17,7 +18,7 @@ describe('keyboardInput', () => { let isInIMESpy: jasmine.Spy; let mockedModel: ContentModelDocument; let normalizeContentModelSpy: jasmine.Spy; - let mockedContext: FormatWithContentModelContext; + let mockedContext: FormatContentModelContext; let formatResult: boolean | undefined; beforeEach(() => { @@ -385,4 +386,47 @@ describe('keyboardInput', () => { }); expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); + + it('Enter key input on collapsed range', () => { + const mockedFormat = 'FORMAT' as any; + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + insertPoint: { + marker: { + format: mockedFormat, + }, + }, + }); + + const rawEvent = { + key: 'Enter', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [handleEnterOnList], + mockedContext + ); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: mockedFormat, + }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); + }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts new file mode 100644 index 00000000000..150d9b5c673 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -0,0 +1,870 @@ +import * as setModelIndentation from '../../../roosterjs-content-model-api/lib/modelApi/block/setModelIndentation'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { keyboardTab } from '../../lib/edit/keyboardTab'; + +describe('keyboardTab', () => { + let takeSnapshotSpy: jasmine.Spy; + let setModelIndentationSpy: jasmine.Spy; + + beforeEach(() => { + takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + setModelIndentationSpy = spyOn(setModelIndentation, 'setModelIndentation'); + }); + + function runTest( + input: ContentModelDocument, + indent: 'outdent' | 'indent' | undefined, + shiftKey: boolean, + expectedResult: boolean + ) { + const formatWithContentModelSpy = jasmine + .createSpy('formatWithContentModel') + .and.callFake((callback, options) => { + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + expect(result).toBe(expectedResult); + }); + + const editor = { + focus: () => {}, + formatContentModel: formatWithContentModelSpy, + takeSnapshot: takeSnapshotSpy, + getDOMSelection: () => { + return { + type: 'range', + }; + }, + }; + + keyboardTab( + editor as any, + { + key: 'Tab', + shiftKey: shiftKey, + preventDefault: () => {}, + } as KeyboardEvent + ); + + expect(formatWithContentModelSpy).toHaveBeenCalled(); + if (indent) { + expect(setModelIndentationSpy).toHaveBeenCalledWith(input as any, indent); + } else { + expect(setModelIndentationSpy).not.toHaveBeenCalled(); + } + } + + it('tab on paragraph', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + runTest(model, undefined, false, false); + }); + + it('tab on empty list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + runTest(model, 'indent', false, true); + }); + + it('tab on the start first item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + runTest(model, 'indent', false, true); + }); + + it('tab on the end first item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, false, false); + }); + + it('tab on the start second item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, 'indent', false, true); + }); + + it('tab on the end second item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, false, false); + }); + + it('shift tab on empty list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, 'outdent', true, true); + }); + + it('shift tab on the start first item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'testdsadas', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'dsadasdasdas', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, 'outdent', true, true); + }); + + it('shift tab on the middle first item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'testd', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'sadas', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'dsadasdasdas', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, true, false); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts index 643adbf380d..5af29820fa0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts @@ -5,7 +5,7 @@ import * as PowerPointFile from '../../lib/paste/PowerPoint/processPastedContent import * as setProcessor from '../../lib/paste/utils/setProcessor'; import * as WacFile from '../../lib/paste/WacComponents/processPastedContentWacComponents'; import { BeforePasteEvent, IStandaloneEditor } from 'roosterjs-content-model-types'; -import { ContentModelPastePlugin } from '../../lib/paste/ContentModelPastePlugin'; +import { PastePlugin } from '../../lib/paste/PastePlugin'; import { PastePropertyNames } from '../../lib/paste/pasteSourceValidations/constants'; const trustedHTMLHandler = (val: string) => val; @@ -25,10 +25,10 @@ describe('Content Model Paste Plugin Test', () => { let event: BeforePasteEvent; describe('onPluginEvent', () => { - let plugin = new ContentModelPastePlugin(); + let plugin = new PastePlugin(); beforeEach(() => { - plugin = new ContentModelPastePlugin(); + plugin = new PastePlugin(); event = { eventType: 'beforePaste', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index 44724e566c8..7cdc51cda32 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -1,5 +1,5 @@ import { cloneModel, StandaloneEditor } from 'roosterjs-content-model-core'; -import { ContentModelPastePlugin } from '../../../lib/paste/ContentModelPastePlugin'; +import { PastePlugin } from '../../../lib/paste/PastePlugin'; import { ContentModelDocument, IStandaloneEditor, @@ -12,7 +12,7 @@ export function initEditor(id: string): IStandaloneEditor { document.body.insertBefore(node, document.body.childNodes[0]); let options: StandaloneEditorOptions = { - plugins: [new ContentModelPastePlugin()], + plugins: [new PastePlugin()], coreApiOverride: { getVisibleViewport: () => { return { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index e6832379ba0..6bfb1a13ecf 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -101,40 +101,34 @@ describe('processPastedContentFromWacTest', () => { it('Single DIV with child LI', () => { runTest( '
                                                      • 1
                                                      • 2
                                                      ', - '
                                                      • 1
                                                      • 2
                                                      ' + '
                                                      • 1
                                                      • 2
                                                      ' ); }); it('Single DIV with deeper child LI', () => { runTest( '
                                                      • 1
                                                      • 2
                                                      ', - '
                                                      • 1
                                                      • 2
                                                      ' + '
                                                      • 1
                                                      • 2
                                                      ' ); }); it('Single DIV with text and LI', () => { runTest( '
                                                      test
                                                      • 1
                                                      ', - 'test
                                                      • 1
                                                      ' + 'test
                                                      • 1
                                                      ' ); }); it('Single LI', () => { - runTest('
                                                      • 1
                                                      ', '
                                                      • 1
                                                      '); + runTest('
                                                      • 1
                                                      ', '
                                                      • 1
                                                      '); }); it('Single LI and text', () => { - runTest( - '
                                                      • 1
                                                      test', - '
                                                      • 1
                                                      test' - ); + runTest('
                                                      • 1
                                                      test', '
                                                      • 1
                                                      test'); }); it('Multiple LI', () => { - runTest( - '
                                                      • 1
                                                      • 2
                                                      ', - '
                                                      • 1
                                                      • 2
                                                      ' - ); + runTest('
                                                      • 1
                                                      • 2
                                                      ', '
                                                      • 1
                                                      • 2
                                                      '); }); }); @@ -203,7 +197,7 @@ describe('wordOnlineHandler', () => { it('has all list items on the same level', () => { runTest( '
                                                      • A
                                                      • B
                                                      • C
                                                      ', - '
                                                      • A
                                                      • B
                                                        • C
                                                      ', + '
                                                      • A
                                                      • B
                                                        • C
                                                      ', { blockGroupType: 'Document', blocks: [ @@ -329,7 +323,7 @@ describe('wordOnlineHandler', () => { it('List items on different level but only going on direction in terms of depth', () => { runTest( '
                                                      • A
                                                      • B
                                                      • C
                                                      ', - '
                                                      • A
                                                        • B
                                                          • C
                                                      ', + '
                                                      • A
                                                        • B
                                                          • C
                                                      ', { blockGroupType: 'Document', blocks: [ @@ -474,7 +468,7 @@ describe('wordOnlineHandler', () => { it('List items on different level but have different branch in each level', () => { runTest( '
                                                      • A
                                                      • B
                                                      • C
                                                      • D
                                                      • E
                                                      ', - '
                                                      • A
                                                        • B
                                                          • C
                                                        • D
                                                          • E
                                                      ', + '
                                                      • A
                                                        • B
                                                          • C
                                                        • D
                                                          • E
                                                      ', { blockGroupType: 'Document', blocks: [ @@ -728,7 +722,7 @@ describe('wordOnlineHandler', () => { it('List items on different level with different branch with a combination of order and unordered list items', () => { runTest( '
                                                      • A
                                                      • B
                                                      1. C1
                                                      1. C2
                                                      • D
                                                      ', - '
                                                      • A
                                                        • B
                                                          1. C1
                                                          2. C2
                                                        • D
                                                      ', + '
                                                      • A
                                                        • B
                                                          1. C1
                                                          2. C2
                                                        • D
                                                      ', { blockGroupType: 'Document', blocks: [ @@ -984,7 +978,7 @@ describe('wordOnlineHandler', () => { it('only has text and list', () => { runTest( '

                                                      asdfasdf

                                                      • A
                                                      • B
                                                      1. C1
                                                      1. C2
                                                      • D

                                                      asdfasdf

                                                      ', - '

                                                      asdfasdf

                                                      • A
                                                        • B
                                                          1. C1
                                                          2. C2
                                                        • D

                                                      asdfasdf

                                                      ' + '

                                                      asdfasdf

                                                      • A
                                                        • B
                                                          1. C1
                                                          2. C2
                                                        • D

                                                      asdfasdf

                                                      ' ); }); @@ -1007,7 +1001,7 @@ describe('wordOnlineHandler', () => { it('fragments contains text, list and table that consist of list 2', () => { runTest( '

                                                      asdfasdf

                                                      • A
                                                      • B
                                                      1. C1
                                                      1. C2
                                                      • D

                                                      asdfasdf

                                                      asdfasdf

                                                      • A
                                                      • B
                                                      • C
                                                      • D

                                                      ', - '

                                                      asdfasdf

                                                      • A
                                                        • B
                                                          1. C1
                                                          1. C2
                                                        • D

                                                      asdfasdf

                                                      asdfasdf

                                                      • A
                                                      • B
                                                      • C
                                                      • D
                                                      ' + '

                                                      asdfasdf

                                                      • A
                                                        • B
                                                          1. C1
                                                          1. C2
                                                        • D

                                                      asdfasdf

                                                      asdfasdf

                                                      • A
                                                      • B
                                                      • C
                                                      • D
                                                      ' ); }); // e.g. @@ -1019,7 +1013,7 @@ describe('wordOnlineHandler', () => { it('fragments contains text, list and table that consist of list', () => { runTest( '

                                                      asdfasdf

                                                      asdfasdf222

                                                      • A
                                                      • A
                                                      ', - '

                                                      asdfasdf

                                                      asdfasdf222

                                                      • A
                                                      • A
                                                      ' + '

                                                      asdfasdf

                                                      asdfasdf222

                                                      • A
                                                      • A
                                                      ' ); }); }); @@ -1027,14 +1021,14 @@ describe('wordOnlineHandler', () => { it('does not have list container', () => { runTest( '
                                                      • A
                                                      • B
                                                      • C
                                                      • D
                                                      • E
                                                      ', - '
                                                      • A
                                                        • B
                                                          • C
                                                        • D
                                                          • E
                                                      ' + '
                                                      • A
                                                        • B
                                                          • C
                                                        • D
                                                          • E
                                                      ' ); }); it('does not have BulletListStyle or NumberListStyle but has ListContainerWrapper', () => { runTest( '
                                                      • A
                                                      • B
                                                      • C
                                                      ', - '
                                                      • A
                                                        • B
                                                          • C
                                                      ' + '
                                                      • A
                                                        • B
                                                          • C
                                                      ' ); }); @@ -1180,7 +1174,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains two UL', () => { runTest( '
                                                      • A
                                                      • B
                                                      • C
                                                      ', - '
                                                      • A
                                                      • B
                                                      • C
                                                      ' + '
                                                      • A
                                                      • B
                                                      • C
                                                      ' ); }); @@ -1222,7 +1216,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains list that is already well formatted', () => { runTest( '
                                                      • A
                                                        • B
                                                          • C
                                                        • D
                                                          • E
                                                      ', - '
                                                      • A
                                                        • B
                                                          • C
                                                        • D
                                                          • E
                                                      ' + '
                                                      • A
                                                        • B
                                                          • C
                                                        • D
                                                          • E
                                                      ' ); }); @@ -1240,7 +1234,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if there are multiple list item in ol (word online has one list item in each ol for ordered list)', () => { runTest( '
                                                      1. A
                                                      2. B
                                                      1. C
                                                      ', - '
                                                      1. A
                                                      2. B
                                                      1. C
                                                      ' + '
                                                      1. A
                                                      2. B
                                                      1. C
                                                      ' ); }); @@ -1273,7 +1267,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains well formated UL and non formated ol', () => { runTest( '
                                                      • A
                                                      1. B
                                                      ', - '
                                                      • A
                                                      1. B
                                                      ' + '
                                                      • A
                                                      1. B
                                                      ' ); }); @@ -1292,7 +1286,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains two OL', () => { runTest( '
                                                      • A
                                                      1. B
                                                      1. C
                                                      ', - '
                                                      • A
                                                      1. B
                                                      2. C
                                                      ' + '
                                                      • A
                                                      1. B
                                                      2. C
                                                      ' ); }); @@ -1309,7 +1303,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains two OL and one UL', () => { runTest( '
                                                      • A
                                                      1. B
                                                      1. C
                                                      ', - '
                                                      • A
                                                      1. B
                                                      1. C
                                                      ' + '
                                                      • A
                                                      1. B
                                                      1. C
                                                      ' ); }); @@ -1324,7 +1318,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if there are list not in the ListContainerWrapper', () => { runTest( '
                                                      1. C
                                                      • A
                                                      ', - '
                                                      1. C
                                                      • A
                                                      ' + '
                                                      1. C
                                                      • A
                                                      ' ); }); @@ -1343,7 +1337,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains two UL', () => { runTest( '
                                                      1. C
                                                      • A
                                                      • A
                                                      • A
                                                      ', - '
                                                      1. C
                                                      • A
                                                      • A
                                                      • A
                                                      ' + '
                                                      1. C
                                                      • A
                                                      • A
                                                      • A
                                                      ' ); }); @@ -1355,7 +1349,7 @@ describe('wordOnlineHandler', () => { it('should retain all text, if ListContainerWrapper contains Elements before li and ul', () => { runTest( '

                                                      paragraph

                                                      1. C
                                                      ', - '

                                                      paragraph

                                                      1. C
                                                      ' + '

                                                      paragraph

                                                      1. C
                                                      ' ); }); @@ -1367,7 +1361,7 @@ describe('wordOnlineHandler', () => { it('should retain all text, if ListContainerWrapper contains Elements after li and ul', () => { runTest( '
                                                      1. C

                                                      paragraph

                                                      ', - '
                                                      1. C

                                                      paragraph

                                                      ' + '
                                                      1. C

                                                      paragraph

                                                      ' ); }); }); @@ -1425,7 +1419,7 @@ describe('wordOnlineHandler', () => { it('List directly under fragment', () => { runTest( '
                                                      • A

                                                      B

                                                      ', - '
                                                      • A

                                                      B

                                                      ' + '
                                                      • A

                                                      B

                                                      ' ); }); @@ -1438,7 +1432,7 @@ describe('wordOnlineHandler', () => { it('should remove the display and margin styles from the element', () => { runTest( '
                                                      • A

                                                      • B

                                                      • C

                                                        1. D

                                                      ', - '
                                                      • A

                                                      • B

                                                      • C

                                                        1. D

                                                      ' + '
                                                      • A

                                                      • B

                                                      • C

                                                        1. D

                                                      ' ); }); }); @@ -1533,7 +1527,7 @@ describe('wordOnlineHandler', () => { it('Text between lists', () => { runTest( '
                                                      • List1

                                                      Text

                                                      • List2
                                                      ', - '
                                                      • List1

                                                      Text

                                                      • List2
                                                      ', + '
                                                      • List1

                                                      Text

                                                      • List2
                                                      ', { blockGroupType: 'Document', blocks: [ @@ -1629,7 +1623,7 @@ describe('wordOnlineHandler', () => { it('Remove temp marker from Word Online', () => { runTest( '

                                                      it went:  

                                                      1. Test

                                                      1. Test. 


                                                      ', - '

                                                      it went:  

                                                      1. Test

                                                      2. Test. 


                                                      ' + '

                                                      it went:  

                                                      1. Test

                                                      2. Test. 


                                                      ' ); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/DragAndDropHelperTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/DragAndDropHelperTest.ts new file mode 100644 index 00000000000..b2222c8374e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/DragAndDropHelperTest.ts @@ -0,0 +1,141 @@ +import DragAndDropHelper from '../../lib/pluginUtils/DragAndDrop/DragAndDropHelper'; + +interface DragAndDropContext { + node: HTMLElement; +} + +interface DragAndDropInitValue { + originalRect: DOMRect; +} + +describe('DragAndDropHelper |', () => { + let id = 'DragAndDropHelperId'; + let dndHelper: DragAndDropHelper; + + beforeEach(() => { + //Empty Div for dragging + let node = document.createElement('div'); + node.id = id; + //Start as black square + node.style.width = '50px'; + node.style.height = '50px'; + node.style.backgroundColor = 'black'; + node.style.position = 'fixed'; + node.style.top = '0px'; + node.style.left = '0px'; + + //Put node on top of body + document.body.insertBefore(node, document.body.childNodes[0]); + }); + + //Creates the DragAndDropHelper for testing + function createDnD(node: HTMLElement, mobile: boolean) { + dndHelper = new DragAndDropHelper( + node, + { node }, + () => {}, + { + onDragEnd(context: DragAndDropContext) { + //Red indicates dragging stopped + context.node.style.backgroundColor = 'red'; + return true; + }, + onDragStart(context: DragAndDropContext) { + //Green indicates dragging started + context.node.style.backgroundColor = 'green'; + return { originalRect: context.node.getBoundingClientRect() }; + }, + onDragging(context: DragAndDropContext, event: MouseEvent) { + //Yellow indicates dragging is happening + context.node.style.backgroundColor = 'yellow'; + context.node.style.left = event.pageX + 'px'; + context.node.style.top = event.pageY + 'px'; + return true; + }, + }, + 1, + mobile + ); + } + + afterEach(() => { + dndHelper.dispose(); + }); + + it('mouse movement', () => { + // Arrange + const target = document.getElementById(id); + createDnD(target, false); + let targetEnd = target; + targetEnd.style.top = 50 + 'px'; + + // Assert + expect(dndHelper.mouseType).toBe('mouse'); + + // Act + simulateMouseEvent('mousedown', target); + + // Assert + expect(target?.style.backgroundColor).toBe('green'); + + // Act + simulateMouseEvent('mousemove', targetEnd); + + // Assert + expect(target?.style.backgroundColor).toBe('yellow'); + + // Act + simulateMouseEvent('mouseup', targetEnd); + + // Assert + expect(target?.style.backgroundColor).toBe('red'); + }); + + it('touch movement', () => { + // Arrange + const target = document.getElementById(id); + createDnD(target, true); + let targetEnd = target; + targetEnd.style.left = 50 + 'px'; + + // Assert + expect(dndHelper.mouseType).toBe('touch'); + + // Act + simulateTouchEvent('touchstart', target); + + // Assert + expect(target?.style.backgroundColor).toBe('green'); + + // Act + simulateTouchEvent('touchmove', targetEnd); + + // Assert + expect(target?.style.backgroundColor).toBe('yellow'); + + // Act + simulateTouchEvent('touchend', targetEnd); + + // Assert + expect(target?.style.backgroundColor).toBe('red'); + }); +}); + +function simulateMouseEvent(type: string, target: HTMLElement, shiftKey: boolean = false) { + const rect = target.getBoundingClientRect(); + var event = new MouseEvent(type, { + view: window, + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.top, + shiftKey, + }); + target.dispatchEvent(event); +} + +function simulateTouchEvent(type: string, target: HTMLElement) { + var event = (new Event(type) as any) as TouchEvent; + + target.dispatchEvent(event); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/createElementTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/createElementTest.ts new file mode 100644 index 00000000000..488770f69d8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/createElementTest.ts @@ -0,0 +1,70 @@ +import createElement from '../../lib/pluginUtils/CreateElement/createElement'; +import CreateElementData from '../../lib/pluginUtils/CreateElement/CreateElementData'; + +describe('createElement', () => { + function runTest(input: CreateElementData, output: string) { + const result = createElement(input, document); + const html = result ? result.outerHTML : null; + expect(html).toBe(output); + } + + it('null', () => { + runTest(null, null); + }); + + it('create by tag', () => { + runTest({ tag: 'div' }, '
                                                      '); + }); + + it('create by tag and namespace', () => { + runTest({ tag: 'svg', namespace: 'http://www.w3.org/2000/svg' }, ''); + }); + it('create by tag and class', () => { + runTest({ tag: 'div', className: 'test' }, '
                                                      '); + }); + it('create by tag and style', () => { + runTest( + { tag: 'div', style: 'position: absolute' }, + '
                                                      ' + ); + }); + it('create by tag and dataset', () => { + runTest({ tag: 'div', dataset: null }, '
                                                      '); + runTest({ tag: 'div', dataset: {} }, '
                                                      '); + runTest({ tag: 'div', dataset: { x: '1', y: '2' } }, '
                                                      '); + }); + it('create by tag and attributes', () => { + runTest({ tag: 'div', attributes: null }, '
                                                      '); + runTest({ tag: 'div', attributes: {} }, '
                                                      '); + runTest( + { tag: 'div', attributes: { contenteditable: 'true', align: 'left' } }, + '
                                                      ' + ); + }); + + it('create by tag and everthing', () => { + runTest( + { + tag: 'div', + className: 'test1', + style: 'position:absolute', + dataset: { x: '1' }, + attributes: { contenteditable: 'true' }, + }, + '
                                                      ' + ); + }); + it('create by tag and children', () => { + runTest({ tag: 'div', children: null }, '
                                                      '); + runTest({ tag: 'div', children: [] }, '
                                                      '); + runTest({ tag: 'div', children: ['text'] }, '
                                                      text
                                                      '); + runTest( + { tag: 'div', children: [{ tag: 'span' }, 'text', { tag: 'span' }] }, + '
                                                      text
                                                      ' + ); + runTest( + { tag: 'div', children: [null, 'text', { tag: 'span' }, ''] }, + '
                                                      text
                                                      ' + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/ContentModelDomIndexer.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomIndexer.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-types/lib/context/ContentModelDomIndexer.ts rename to packages-content-model/roosterjs-content-model-types/lib/context/DomIndexer.ts index c4383e2d3ac..43058b6ac74 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/ContentModelDomIndexer.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomIndexer.ts @@ -8,7 +8,7 @@ import type { DOMSelection } from '../selection/DOMSelection'; * Represents an indexer object which provides methods to help build backward relationship * from DOM node to Content Model */ -export interface ContentModelDomIndexer { +export interface DomIndexer { /** * Invoked when processing a segment * @param segmentNode The new DOM node for this segment diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts index e381cc5b596..48f243ed566 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts @@ -1,3 +1,4 @@ +import type { ValueSanitizer } from '../parameter/ValueSanitizer'; import type { ElementProcessorMap, FormatParsers, @@ -23,3 +24,28 @@ export interface DomToModelOption { */ additionalFormatParsers?: Partial; } + +/** + * Options for DOM to Content Model conversion for paste only + */ +export interface DomToModelOptionForSanitizing extends Required { + /** + * Additional allowed HTML tags in lower case. Element with these tags will be preserved + */ + readonly additionalAllowedTags: Lowercase[]; + + /** + * Additional disallowed HTML tags in lower case. Elements with these tags will be dropped + */ + readonly additionalDisallowedTags: Lowercase[]; + + /** + * Additional sanitizers for CSS styles + */ + readonly styleSanitizers: Record; + + /** + * Additional sanitizers for CSS styles + */ + readonly attributeSanitizers: Record; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts index ac6eecfc00f..4ea984e21d6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts @@ -1,7 +1,7 @@ import type { DarkColorHandler } from './DarkColorHandler'; -import type { ContentModelDomIndexer } from './ContentModelDomIndexer'; +import type { DomIndexer } from './DomIndexer'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; -import type { PendingFormat } from '../pluginState/ContentModelFormatPluginState'; +import type { PendingFormat } from '../pluginState/FormatPluginState'; /** * An editor context interface used by ContentModel PAI @@ -51,5 +51,10 @@ export interface EditorContext { /** * @optional Indexer for content model, to help build backward relationship from DOM node to Content Model */ - domIndexer?: ContentModelDomIndexer; + domIndexer?: DomIndexer; + + /** + * Root Font size in Px. + */ + rootFontSize?: number; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index 6810a474c31..87c657be187 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -12,10 +12,11 @@ import type { DOMSelection } from '../selection/DOMSelection'; import type { EditorEnvironment } from '../parameter/EditorEnvironment'; import type { ContentModelFormatter, - FormatWithContentModelOptions, -} from '../parameter/FormatWithContentModelOptions'; + FormatContentModelOptions, +} from '../parameter/FormatContentModelOptions'; import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; +import type { Rect } from '../parameter/Rect'; /** * An interface of standalone Content Model editor. @@ -60,12 +61,9 @@ export interface IStandaloneEditor { * to do format change. Then according to the return value, write back the modified content model into editor. * If there is cached model, it will be used and updated. * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ - formatContentModel( - formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions - ): void; + formatContentModel(formatter: ContentModelFormatter, options?: FormatContentModelOptions): void; /** * Get pending format of editor if any, or return null @@ -137,12 +135,6 @@ export interface IStandaloneEditor { */ restoreSnapshot(snapshot: Snapshot): void; - /** - * Check if editor is in IME input sequence - * @returns True if editor is in IME input sequence, otherwise false - */ - isInIME(): boolean; - /** * Attach a DOM event to the editor content DIV * @param eventMap A map from event name to its handler @@ -199,4 +191,14 @@ export interface IStandaloneEditor { * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types */ getTrustedHTMLHandler(): TrustedHTMLHandler; + + /** + * Get the scroll container of the editor + */ + getScrollContainer(): HTMLElement; + + /** + * Retrieves the rect of the visible viewport of the editor. + */ + getVisibleViewport(): Rect | null; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 6cdbe0a3115..a8450f864f5 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -6,7 +6,7 @@ import type { ClipboardData } from '../parameter/ClipboardData'; import type { PasteType } from '../enum/PasteType'; import type { DOMEventRecord } from '../parameter/DOMEventRecord'; import type { Snapshot } from '../parameter/Snapshot'; -import type { EntityState } from '../parameter/FormatWithContentModelContext'; +import type { EntityState } from '../parameter/FormatContentModelContext'; import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; @@ -20,8 +20,8 @@ import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; import type { Rect } from '../parameter/Rect'; import type { ContentModelFormatter, - FormatWithContentModelOptions, -} from '../parameter/FormatWithContentModelOptions'; + FormatContentModelOptions, +} from '../parameter/FormatContentModelOptions'; /** * Create a EditorContext object used by ContentModel API @@ -81,12 +81,12 @@ export type SetDOMSelection = ( * If there is cached model, it will be used and updated. * @param core The StandaloneEditorCore object * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ export type FormatContentModel = ( core: StandaloneEditorCore, formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions + options?: FormatContentModelOptions ) => void; /** @@ -218,7 +218,7 @@ export interface StandaloneCoreApiMap { * If there is cached model, it will be used and updated. * @param core The StandaloneEditorCore object * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ formatContentModel: FormatContentModel; diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 3ef59057fd2..9b3394c214f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -6,8 +6,8 @@ import type { SelectionPluginState } from '../pluginState/SelectionPluginState'; import type { EntityPluginState } from '../pluginState/EntityPluginState'; import type { LifecyclePluginState } from '../pluginState/LifecyclePluginState'; import type { DOMEventPluginState } from '../pluginState/DOMEventPluginState'; -import type { ContentModelCachePluginState } from '../pluginState/ContentModelCachePluginState'; -import type { ContentModelFormatPluginState } from '../pluginState/ContentModelFormatPluginState'; +import type { CachePluginState } from '../pluginState/CachePluginState'; +import type { FormatPluginState } from '../pluginState/FormatPluginState'; /** * Core plugins for standalone editor @@ -16,12 +16,12 @@ export interface StandaloneEditorCorePlugins { /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ - readonly cache: PluginWithState; + readonly cache: PluginWithState; /** * ContentModelFormat plugins helps editor to do formatting on top of content model. */ - readonly format: PluginWithState; + readonly format: PluginWithState; /** * Copy and paste plugin for handling onCopy and onPaste event diff --git a/packages-content-model/roosterjs-content-model-types/lib/enum/ExportContentMode.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/ExportContentMode.ts new file mode 100644 index 00000000000..b4ac150929b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/enum/ExportContentMode.ts @@ -0,0 +1,18 @@ +/** + * The mode parameter type for exportContent API + */ +export type ExportContentMode = + /** + * Export to clean HTML in light color mode with dehydrated entities + */ + | 'HTML' + + /** + * Export to plain text + */ + | 'PlainText' + + /** + * Export to plain text via browser's textContent property + */ + | 'PlainTextFast'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts index 58d45aa8a9a..d08e76db901 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts @@ -1,35 +1,9 @@ +import type { DomToModelOptionForSanitizing } from '../context/DomToModelOption'; import type { PasteType } from '../enum/PasteType'; import type { ClipboardData } from '../parameter/ClipboardData'; import type { BasePluginEvent } from './BasePluginEvent'; -import type { DomToModelOption } from '../context/DomToModelOption'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { InsertPoint } from '../selection/InsertPoint'; -import type { ValueSanitizer } from '../parameter/ValueSanitizer'; - -/** - * Options for DOM to Content Model conversion for paste only - */ -export interface DomToModelOptionForPaste extends Required { - /** - * Additional allowed HTML tags in lower case. Element with these tags will be preserved - */ - readonly additionalAllowedTags: Lowercase[]; - - /** - * Additional disallowed HTML tags in lower case. Elements with these tags will be dropped - */ - readonly additionalDisallowedTags: Lowercase[]; - - /** - * Additional sanitizers for CSS styles - */ - readonly styleSanitizers: Record; - - /** - * Additional sanitizers for CSS styles - */ - readonly attributeSanitizers: Record; -} /** * A function type used by merging pasted content into current Content Model @@ -43,7 +17,7 @@ export type MergePastedContentFunc = ( ) => InsertPoint | null; /** - * Data of ContentModelBeforePasteEvent + * Data of BeforePasteEvent */ export interface BeforePasteEvent extends BasePluginEvent<'beforePaste'> { /** @@ -79,7 +53,7 @@ export interface BeforePasteEvent extends BasePluginEvent<'beforePaste'> { /** * domToModel Options to use when creating the content model from the paste fragment */ - readonly domToModelOption: DomToModelOptionForPaste; + readonly domToModelOption: DomToModelOptionForSanitizing; /** * customizedMerge Customized merge function to use when merging the paste fragment into the editor diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts index bac9d637942..45e52c946f6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts @@ -1,6 +1,6 @@ import type { AnnounceData } from '../parameter/AnnounceData'; import type { BasePluginEvent } from './BasePluginEvent'; -import type { EntityState } from '../parameter/FormatWithContentModelContext'; +import type { EntityState } from '../parameter/FormatContentModelContext'; import type { ContentModelEntity } from '../entity/ContentModelEntity'; import type { EntityRemovalOperation } from '../enum/EntityOperation'; import type { ContentModelDocument } from '../group/ContentModelDocument'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelFormatMap.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelFormatMap.ts index 50c8c7518ce..893e4978b79 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelFormatMap.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelFormatMap.ts @@ -141,4 +141,9 @@ export interface ContentModelFormatMap { * Format type for entity */ entity: ContentModelEntityFormat; + + /** + * Format type for general model + */ + general: ContentModelSegmentFormat; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts index 45bdb0514ae..2f9307e1d91 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts @@ -11,4 +11,11 @@ export type ListMetadataFormat = { * Style type for Unordered list. Use value of constant BulletListType as value. */ unorderedStyleType?: number; + + /** + * When set to true, if there is no orderedStyleType (for OL) or unorderedStyleType (for UL) specified, use the list from its level + * For ordered list, the default list styles from levels are: 'decimal', 'lower-alpha', 'lower-roman', then loop + * For unordered list, the default list styles from levels are: 'disc', 'circle', 'square', then loop + */ + applyListStyleFromLevel?: boolean; }; diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 48047b40c35..69dceefa3dd 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -86,6 +86,7 @@ export { PasteType } from './enum/PasteType'; export { BorderOperations } from './enum/BorderOperations'; export { DeleteResult } from './enum/DeleteResult'; export { InsertEntityPosition } from './enum/InsertEntityPosition'; +export { ExportContentMode } from './enum/ExportContentMode'; export { ContentModelBlock } from './block/ContentModelBlock'; export { ContentModelParagraph } from './block/ContentModelParagraph'; @@ -181,9 +182,9 @@ export { ContentModelSegmentHandler, ContentModelBlockHandler, } from './context/ContentModelHandler'; -export { DomToModelOption } from './context/DomToModelOption'; +export { DomToModelOption, DomToModelOptionForSanitizing } from './context/DomToModelOption'; export { ModelToDomOption } from './context/ModelToDomOption'; -export { ContentModelDomIndexer } from './context/ContentModelDomIndexer'; +export { DomIndexer } from './context/DomIndexer'; export { TextMutationObserver } from './context/TextMutationObserver'; export { DefinitionType } from './metadata/DefinitionType'; @@ -227,11 +228,8 @@ export { EditorPlugin } from './editor/EditorPlugin'; export { PluginWithState } from './editor/PluginWithState'; export { ContextMenuProvider } from './editor/ContextMenuProvider'; -export { ContentModelCachePluginState } from './pluginState/ContentModelCachePluginState'; -export { - ContentModelFormatPluginState, - PendingFormat, -} from './pluginState/ContentModelFormatPluginState'; +export { CachePluginState } from './pluginState/CachePluginState'; +export { FormatPluginState, PendingFormat } from './pluginState/FormatPluginState'; export { CopyPastePluginState } from './pluginState/CopyPastePluginState'; export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; @@ -252,12 +250,12 @@ export { EditorEnvironment } from './parameter/EditorEnvironment'; export { EntityState, DeletedEntity, - FormatWithContentModelContext, -} from './parameter/FormatWithContentModelContext'; + FormatContentModelContext, +} from './parameter/FormatContentModelContext'; export { - FormatWithContentModelOptions, + FormatContentModelOptions, ContentModelFormatter, -} from './parameter/FormatWithContentModelOptions'; +} from './parameter/FormatContentModelOptions'; export { ContentModelFormatState } from './parameter/ContentModelFormatState'; export { ImageFormatState } from './parameter/ImageFormatState'; export { Border } from './parameter/Border'; @@ -291,11 +289,7 @@ export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; export { BeforeDisposeEvent } from './event/BeforeDisposeEvent'; export { BeforeKeyboardEditingEvent } from './event/BeforeKeyboardEditingEvent'; -export { - BeforePasteEvent, - DomToModelOptionForPaste, - MergePastedContentFunc, -} from './event/BeforePasteEvent'; +export { BeforePasteEvent, MergePastedContentFunc } from './event/BeforePasteEvent'; export { BeforeSetContentEvent } from './event/BeforeSetContentEvent'; export { ContentChangedEvent, ChangedEntity } from './event/ContentChangedEvent'; export { ContextMenuEvent } from './event/ContextMenuEvent'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 2e8e1e4e2a1..65b2927178f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -26,8 +26,32 @@ export interface DOMHelper { */ queryElements(selector: string): HTMLElement[]; + /** + * Get plain text content of editor using textContent property + */ + getTextContent(): string; + /** * Calculate current zoom scale of editor */ calculateZoomScale(): number; + + /** + * Set DOM attribute of editor content DIV + * @param name Name of the attribute + * @param value Value of the attribute + */ + setDomAttribute(name: string, value: string | null): void; + + /** + * Get DOM attribute of editor content DIV, null if there is no such attribute. + * @param name Name of the attribute + */ + getDomAttribute(name: string): string | null; + + /** + * Get DOM style of editor content DIV + * @param style Name of the style + */ + getDomStyle(style: T): CSSStyleDeclaration[T]; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts index 84a0976f326..b6801a01148 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts @@ -1,6 +1,6 @@ import type { ContentModelParagraph } from '../block/ContentModelParagraph'; import type { DeleteResult } from '../enum/DeleteResult'; -import type { FormatWithContentModelContext } from './FormatWithContentModelContext'; +import type { FormatContentModelContext } from './FormatContentModelContext'; import type { InsertPoint } from '../selection/InsertPoint'; import type { TableSelectionContext } from '../selection/TableSelectionContext'; @@ -36,7 +36,7 @@ export interface DeleteSelectionContext extends DeleteSelectionResult { /** * Format context provided by formatContentModel API */ - formatContext?: FormatWithContentModelContext; + formatContext?: FormatContentModelContext; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts index ec554db2bd8..e5d9966272b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts @@ -16,4 +16,9 @@ export interface EditorEnvironment { * Whether editor is running on Safari browser */ isSafari?: boolean; + + /** + * Whether current browser is on mobile or a tablet + */ + isMobileOrTablet?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts index 9a54666c7ac..3c895186c3c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts @@ -43,7 +43,7 @@ export interface DeletedEntity { /** * Context object for API formatWithContentModel */ -export interface FormatWithContentModelContext { +export interface FormatContentModelContext { /** * New entities added during the format process */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts index cfcb8736a7c..33bf1158b8b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts @@ -1,12 +1,12 @@ import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; -import type { FormatWithContentModelContext } from './FormatWithContentModelContext'; +import type { FormatContentModelContext } from './FormatContentModelContext'; import type { OnNodeCreated } from '../context/ModelToDomSettings'; /** * Options for API formatWithContentModel */ -export interface FormatWithContentModelOptions { +export interface FormatContentModelOptions { /** * Name of the format API */ @@ -48,5 +48,5 @@ export interface FormatWithContentModelOptions { */ export type ContentModelFormatter = ( model: ContentModelDocument, - context: FormatWithContentModelContext + context: FormatContentModelContext ) => boolean; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts index 0c4d3460060..6f8b58d3aab 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts @@ -1,5 +1,5 @@ import type { TableSelectionCoordinates } from '../selection/TableSelectionCoordinates'; -import type { EntityState } from './FormatWithContentModelContext'; +import type { EntityState } from './FormatContentModelContext'; import type { SelectionType } from '../selection/DOMSelection'; /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/CachePluginState.ts similarity index 66% rename from packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/CachePluginState.ts index 82353224c75..5b4d898937c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/CachePluginState.ts @@ -1,12 +1,12 @@ import type { TextMutationObserver } from '../context/TextMutationObserver'; import type { ContentModelDocument } from '../group/ContentModelDocument'; -import type { ContentModelDomIndexer } from '../context/ContentModelDomIndexer'; +import type { DomIndexer } from '../context/DomIndexer'; import type { DOMSelection } from '../selection/DOMSelection'; /** - * Plugin state for ContentModelEditPlugin + * Plugin state for CacheEditPlugin */ -export interface ContentModelCachePluginState { +export interface CachePluginState { /** * Cached selection */ @@ -18,9 +18,9 @@ export interface ContentModelCachePluginState { cachedModel?: ContentModelDocument; /** - * @optional Indexer for content model, to help build backward relationship from DOM node to Content Model + * @optional Indexer for CachePlugin, to help build backward relationship from DOM node to Content Model */ - domIndexer?: ContentModelDomIndexer; + domIndexer?: DomIndexer; /** * @optional A wrapper of MutationObserver to help detect text changes in editor diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts index 13a058373ab..05749a346f6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts @@ -23,7 +23,7 @@ export interface PendingFormat { /** * Plugin state for ContentModelFormatPlugin */ -export interface ContentModelFormatPluginState { +export interface FormatPluginState { /** * Default format of this editor */ diff --git a/packages-content-model/roosterjs-content-model/lib/createEditor.ts b/packages-content-model/roosterjs-content-model/lib/createEditor.ts index dc7c2e1b807..eed17791252 100644 --- a/packages-content-model/roosterjs-content-model/lib/createEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createEditor.ts @@ -1,4 +1,4 @@ -import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; +import { EditPlugin, PastePlugin } from 'roosterjs-content-model-plugins'; import { StandaloneEditor } from 'roosterjs-content-model-core'; import type { ContentModelDocument, @@ -8,23 +8,19 @@ import type { } from 'roosterjs-content-model-types'; /** - * Create a Content Model Editor using the given options + * Create a new Editor instance using the given options * @param contentDiv The html div element needed for creating the editor * @param additionalPlugins The additional user defined plugins. Currently the default plugins that are already included are - * ContentEdit, HyperLink and Paste, user don't need to add those. - * @param initialContent The initial content to show in editor. It can't be removed by undo, user need to manually remove it if needed. - * @returns The ContentModelEditor instance + * PastePlugin, EditPlugin, user don't need to add those. + * @param initialModel The initial content model to show in editor. It can't be removed by undo, user need to manually remove it if needed. + * @returns The Editor instance */ export function createEditor( contentDiv: HTMLDivElement, additionalPlugins?: EditorPlugin[], initialModel?: ContentModelDocument ): IStandaloneEditor { - const plugins = [ - new ContentModelPastePlugin(), - new ContentModelEditPlugin(), - ...(additionalPlugins ?? []), - ]; + const plugins = [new PastePlugin(), new EditPlugin(), ...(additionalPlugins ?? [])]; const options: StandaloneEditorOptions = { plugins: plugins, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts similarity index 57% rename from packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts rename to packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts index 7d38348dba5..a91e0fb2a5f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts @@ -1,44 +1,82 @@ +import { createDarkColorHandler } from '../editor/DarkColorHandlerImpl'; import { createEditPlugin } from './EditPlugin'; -import { createEntityDelimiterPlugin } from './EntityDelimiterPlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; -import { PluginEventType } from 'roosterjs-editor-types'; -import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; -import type { - ContentModelEditorOptions, - IContentModelEditor, -} from '../publicTypes/IContentModelEditor'; import type { EditorPlugin as LegacyEditorPlugin, PluginEvent as LegacyPluginEvent, ContextMenuProvider as LegacyContextMenuProvider, + IEditor as ILegacyEditor, + ExperimentalFeatures, + SizeTransformer, + EditPluginState, + CustomData, + DarkColorHandler, } from 'roosterjs-editor-types'; -import type { ContextMenuProvider, PluginEvent } from 'roosterjs-content-model-types'; +import type { + ContextMenuProvider, + IStandaloneEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; +/** + * @internal + * Represents the core data structure of a editor adapter + */ +export interface EditorAdapterCore { + /** + * Custom data of this editor + */ + readonly customData: Record; + + /** + * Enabled experimental features + */ + readonly experimentalFeatures: ExperimentalFeatures[]; + + /** + * Dark model handler for the editor, used for variable-based solution. + * If keep it null, editor will still use original dataset-based dark mode solution. + */ + readonly darkColorHandler: DarkColorHandler; + + /** + * Plugin state of EditPlugin + */ + readonly edit: EditPluginState; + + /** + * Context Menu providers + */ + readonly contextMenuProviders: LegacyContextMenuProvider[]; + + /** + * @deprecated Use zoomScale instead + */ + readonly sizeTransformer: SizeTransformer; +} + /** * @internal * Act as a bridge between Standalone editor and Content Model editor, translate Standalone editor event type to legacy event type */ export class BridgePlugin implements ContextMenuProvider { private legacyPlugins: LegacyEditorPlugin[]; - private corePluginState: ContentModelCorePluginState; - private outerEditor: IContentModelEditor | null = null; + private edit: EditPluginState; + private contextMenuProviders: LegacyContextMenuProvider[]; private checkExclusivelyHandling: boolean; - constructor(options: ContentModelEditorOptions) { + constructor( + private onInitialize: (core: EditorAdapterCore) => ILegacyEditor, + legacyPlugins: LegacyEditorPlugin[] = [], + private experimentalFeatures: ExperimentalFeatures[] = [] + ) { const editPlugin = createEditPlugin(); - const entityDelimiterPlugin = createEntityDelimiterPlugin(); - - this.legacyPlugins = [ - editPlugin, - ...(options.legacyPlugins ?? []).filter(x => !!x), - entityDelimiterPlugin, - ]; - this.corePluginState = { - edit: editPlugin.getState(), - contextMenuProviders: this.legacyPlugins.filter(isContextMenuProvider), - }; + + this.legacyPlugins = [editPlugin, ...legacyPlugins.filter(x => !!x)]; + this.edit = editPlugin.getState(); + this.contextMenuProviders = this.legacyPlugins.filter(isContextMenuProvider); this.checkExclusivelyHandling = this.legacyPlugins.some( plugin => plugin.willHandleEventExclusively ); @@ -51,36 +89,14 @@ export class BridgePlugin implements ContextMenuProvider { return 'Bridge'; } - /** - * Get core plugin state - */ - getCorePluginState(): ContentModelCorePluginState { - return this.corePluginState; - } - /** * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize() { - if (this.outerEditor) { - const editor = this.outerEditor; - - this.legacyPlugins.forEach(plugin => plugin.initialize(editor)); - - this.legacyPlugins.forEach(plugin => - plugin.onPluginEvent?.({ - eventType: PluginEventType.EditorReady, - }) - ); - } - } + initialize(editor: IStandaloneEditor) { + const outerEditor = this.onInitialize(this.createEditorCore(editor)); - /** - * Initialize all inner plugins with Content Model Editor - */ - setOuterEditor(editor: IContentModelEditor) { - this.outerEditor = editor; + this.legacyPlugins.forEach(plugin => plugin.initialize(outerEditor)); } /** @@ -141,7 +157,7 @@ export class BridgePlugin implements ContextMenuProvider { getContextMenuItems(target: Node): any[] { const allItems: any[] = []; - this.corePluginState.contextMenuProviders.forEach(provider => { + this.contextMenuProviders.forEach(provider => { const items = provider.getContextMenuItems(target) ?? []; if (items?.length > 0) { if (allItems.length > 0) { @@ -154,6 +170,25 @@ export class BridgePlugin implements ContextMenuProvider { return allItems; } + + private createEditorCore(editor: IStandaloneEditor): EditorAdapterCore { + return { + customData: {}, + experimentalFeatures: this.experimentalFeatures ?? [], + sizeTransformer: createSizeTransformer(editor), + darkColorHandler: createDarkColorHandler(editor.getColorManager()), + edit: this.edit, + contextMenuProviders: this.contextMenuProviders, + }; + } +} + +/** + * @internal Export for test only. This function is only used for compatibility from older build + + */ +export function createSizeTransformer(editor: IStandaloneEditor): SizeTransformer { + return size => size / editor.getDOMHelper().calculateZoomScale(); } function isContextMenuProvider( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/EditPlugin.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts rename to packages/roosterjs-editor-adapter/lib/corePlugins/EditPlugin.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts b/packages/roosterjs-editor-adapter/lib/editor/DarkColorHandlerImpl.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts rename to packages/roosterjs-editor-adapter/lib/editor/DarkColorHandlerImpl.ts index 591727ba99d..2d012789a3c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/DarkColorHandlerImpl.ts @@ -6,10 +6,7 @@ const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/ const VARIABLE_PREFIX = 'var('; const COLOR_VAR_PREFIX = 'darkColor'; -/** - * @internal - */ -export class DarkColorHandlerImpl implements DarkColorHandler { +class DarkColorHandlerImpl implements DarkColorHandler { constructor(private innerHandler: StandaloneDarkColorHandler) {} /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts similarity index 85% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts rename to packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 7e6d166d06a..89d4c0edeea 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -1,8 +1,8 @@ import { BridgePlugin } from '../corePlugins/BridgePlugin'; import { buildRangeEx } from './utils/buildRangeEx'; -import { createEditorCore } from './createEditorCore'; import { getObjectKeys } from 'roosterjs-content-model-dom'; -import { getPendableFormatState } from './utils/getPendableFormatState'; +import { insertNode } from './utils/insertNode'; +import type { EditorAdapterCore } from '../corePlugins/BridgePlugin'; import { newEventToOldEvent, oldEventToNewEvent, @@ -10,8 +10,10 @@ import { } from './utils/eventConverter'; import { createModelFromHtml, + exportContent, isBold, redo, + retrieveModelFormatState, StandaloneEditor, transformColor, undo, @@ -52,6 +54,7 @@ import type { TableSelection, DOMEventHandlerObject, DarkColorHandler, + IEditor, } from 'roosterjs-editor-types'; import { convertDomSelectionToRangeEx, @@ -85,27 +88,46 @@ import { toArray, wrap, } from 'roosterjs-editor-dom'; -import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; +import type { EditorAdapterOptions } from '../publicTypes/EditorAdapterOptions'; import type { - ContentModelEditorOptions, - IContentModelEditor, -} from '../publicTypes/IContentModelEditor'; -import type { DOMEventRecord, Rect } from 'roosterjs-content-model-types'; + ContentModelFormatState, + DOMEventRecord, + ExportContentMode, + IStandaloneEditor, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +const GetContentModeMap: Record = { + [GetContentMode.CleanHTML]: 'HTML', + [GetContentMode.PlainText]: 'PlainText', + [GetContentMode.PlainTextFast]: 'PlainTextFast', + [GetContentMode.RawHTMLOnly]: 'HTML', + [GetContentMode.RawHTMLWithSelection]: 'HTML', +}; /** * Editor for Content Model. * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export class ContentModelEditor extends StandaloneEditor implements IContentModelEditor { - private contentModelEditorCore: ContentModelEditorCore | undefined; +export class EditorAdapter extends StandaloneEditor implements IEditor { + private contentModelEditorCore: EditorAdapterCore | undefined; /** * Creates an instance of Editor * @param contentDiv The DIV HTML element which will be the container element of editor * @param options An optional options object to customize the editor */ - constructor(contentDiv: HTMLDivElement, options: ContentModelEditorOptions = {}) { - const bridgePlugin = new BridgePlugin(options); + constructor(contentDiv: HTMLDivElement, options: EditorAdapterOptions = {}) { + const bridgePlugin = new BridgePlugin( + core => { + this.contentModelEditorCore = core; + + return this; + }, + options.legacyPlugins, + options.experimentalFeatures + ); + const plugins = [bridgePlugin, ...(options.plugins ?? [])]; const initContent = options.initialContent ?? contentDiv.innerHTML; const initialModel = @@ -117,49 +139,36 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode options.defaultSegmentFormat ) : options.initialModel; - const standaloneEditorOptions: ContentModelEditorOptions = { + const standaloneEditorOptions: StandaloneEditorOptions = { ...options, plugins, initialModel, }; - const corePluginState = bridgePlugin.getCorePluginState(); - - super(contentDiv, standaloneEditorOptions, () => { - const core = this.getCore(); - const sizeTransformer: SizeTransformer = size => - size / this.getDOMHelper().calculateZoomScale(); - - // Need to create Content Model Editor Core before initialize plugins since some plugins need this object - this.contentModelEditorCore = createEditorCore( - options, - corePluginState, - core.darkColorHandler, - sizeTransformer - ); - bridgePlugin.setOuterEditor(this); - }); + super(contentDiv, standaloneEditorOptions); } /** * Dispose this editor, dispose all plugins and custom data */ dispose(): void { - super.dispose(); + const core = this.contentModelEditorCore; - const core = this.getContentModelEditorCore(); + if (core) { + getObjectKeys(core.customData).forEach(key => { + const data = core.customData[key]; - getObjectKeys(core.customData).forEach(key => { - const data = core.customData[key]; + if (data && data.disposer) { + data.disposer(data.value); + } - if (data && data.disposer) { - data.disposer(data.value); - } + delete core.customData[key]; + }); - delete core.customData[key]; - }); + this.contentModelEditorCore = undefined; + } - this.contentModelEditorCore = undefined; + super.dispose(); } /** @@ -181,10 +190,44 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @returns true if node is inserted. Otherwise false */ insertNode(node: Node, option?: InsertOption): boolean { - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); + if (node) { + option = option || { + position: ContentPosition.SelectionStart, + insertOnNewLine: false, + updateCursor: true, + replaceSelection: true, + insertToRegionRoot: false, + }; + + const { contentDiv } = this.getCore(); + + if (option.updateCursor) { + this.focus(); + } + + if (option.position == ContentPosition.Outside) { + contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); + } else { + if (this.isDarkMode()) { + transformColor( + node, + true /*includeSelf*/, + 'lightToDark', + this.getColorManager() + ); + } + + const selection = insertNode(contentDiv, this.getDOMSelection(), node, option); + + if (selection) { + this.setDOMSelection(selection); + } + } - return node ? core.api.insertNode(core, innerCore, node, option ?? null) : false; + return true; + } else { + return false; + } } /** @@ -300,10 +343,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @returns HTML string representing current editor content */ getContent(mode: GetContentMode | CompatibleGetContentMode = GetContentMode.CleanHTML): string { - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); - - return core.api.getContent(core, innerCore, mode as GetContentMode); + return exportContent(this, GetContentModeMap[mode]); } /** @@ -312,10 +352,39 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true */ setContent(content: string, triggerContentChangedEvent: boolean = true) { - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); + const core = this.getCore(); + const { contentDiv, api, trustedHTMLHandler, lifecycle, darkColorHandler } = core; + + api.triggerEvent( + core, + { + eventType: 'beforeSetContent', + newContent: content, + }, + true /*broadcast*/ + ); - core.api.setContent(core, innerCore, content, triggerContentChangedEvent); + const newModel = createModelFromHtml( + content, + core.domToModelSettings.customized, + trustedHTMLHandler, + core.format.defaultFormat + ); + + api.setContentModel(core, newModel); + + if (triggerContentChangedEvent) { + api.triggerEvent( + core, + { + eventType: 'contentChanged', + source: ChangeSource.SetContent, + }, + false /*broadcast*/ + ); + } else if (lifecycle.isDarkMode) { + transformColor(contentDiv, false /*includeSelf*/, 'lightToDark', darkColorHandler); + } } /** @@ -434,7 +503,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode arg4?: number | PositionType ): boolean { const core = this.getCore(); - const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); + const rangeEx = buildRangeEx(core.contentDiv, arg1, arg2, arg3, arg4); const selection = convertRangeExToDomSelection(rangeEx); this.setDOMSelection(selection); @@ -714,13 +783,6 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode //#region Misc - /** - * Get the scroll container of the editor - */ - getScrollContainer(): HTMLElement { - return this.getCore().domEvent.scrollContainer; - } - /** * Get custom data related to this editor * @param key Key of the custom data @@ -809,7 +871,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param callback The callback function to run * @returns a function to cancel this async run */ - runAsync(callback: (editor: IContentModelEditor) => void) { + runAsync(callback: (editor: IEditor & IStandaloneEditor) => void) { const win = this.getCore().contentDiv.ownerDocument.defaultView || window; const handle = win.requestAnimationFrame(() => { if (!this.isDisposed() && callback) { @@ -828,11 +890,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param value Value of the attribute */ setEditorDomAttribute(name: string, value: string | null) { - if (value === null) { - this.getCore().contentDiv.removeAttribute(name); - } else { - this.getCore().contentDiv.setAttribute(name, value); - } + this.getDOMHelper().setDomAttribute(name, value); } /** @@ -840,7 +898,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param name Name of the attribute */ getEditorDomAttribute(name: string): string | null { - return this.getCore().contentDiv.getAttribute(name); + return this.getDOMHelper().getDomAttribute(name); } /** @@ -906,38 +964,52 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode } /** + * @deprecated * Get style based format state from current selection, including font name/size and colors */ - getStyleBasedFormatState(node?: Node): StyleBasedFormatState { - if (!node) { - const range = this.getSelectionRange(); - node = (range && Position.getStart(range).normalize().node) ?? undefined; - } - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); + getStyleBasedFormatState(): StyleBasedFormatState { + const format = this.retrieveFormatState(); - return core.api.getStyleBasedFormatState(core, innerCore, node ?? null); + return { + backgroundColor: format.backgroundColor, + direction: format.direction, + fontName: format.fontName, + fontSize: format.fontSize, + fontWeight: format.fontWeight, + lineHeight: format.lineHeight, + marginBottom: format.marginBottom, + marginTop: format.marginTop, + textAlign: format.textAlign, + textColor: format.textColor, + }; } /** + * @deprecated * Get the pendable format such as underline and bold - * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. * @returns The pending format state */ - getPendableFormatState(forceGetStateFromDOM: boolean = false): PendableFormatState { - const core = this.getCore(); - return getPendableFormatState(core); + getPendableFormatState(): PendableFormatState { + const format = this.retrieveFormatState(); + + return { + isBold: format.isBold, + isItalic: format.isItalic, + isStrikeThrough: format.isStrikeThrough, + isSubscript: format.isSubscript, + isSuperscript: format.isSubscript, + isUnderline: format.isUnderline, + }; } /** + * @deprecated * Ensure user will type into a container element rather than into the editor content DIV directly * @param position The position that user is about to type to * @param keyboardEvent Optional keyboard event object */ ensureTypeInContainer(position: NodePosition, keyboardEvent?: KeyboardEvent) { - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); - core.api.ensureTypeInContainer(core, innerCore, position, keyboardEvent); + // No OP } //#endregion @@ -1018,15 +1090,6 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode return this.getContentModelEditorCore().sizeTransformer; } - /** - * Retrieves the rect of the visible viewport of the editor. - */ - getVisibleViewport(): Rect | null { - const core = this.getCore(); - - return core.api.getVisibleViewport(core); - } - /** * Get a darkColorHandler object for this editor. */ @@ -1036,10 +1099,28 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode } /** - * @returns the current ContentModelEditorCore object + * Check if editor is in IME input sequence + * @returns True if editor is in IME input sequence, otherwise false + */ + isInIME(): boolean { + return this.getCore().domEvent.isInIME; + } + + private retrieveFormatState(): ContentModelFormatState { + const pendingFormat = this.getPendingFormat(); + const result: ContentModelFormatState = {}; + const model = this.getContentModelCopy('reduced'); + + retrieveModelFormatState(model, pendingFormat, result); + + return result; + } + + /** + * @returns the current EditorAdapterCore object * @throws a standard Error if there's no core object */ - private getContentModelEditorCore(): ContentModelEditorCore { + private getContentModelEditorCore(): EditorAdapterCore { if (!this.contentModelEditorCore) { throw new Error('Editor is already disposed'); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts rename to packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts index 72f6affc879..13d1f0448a8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts @@ -1,6 +1,5 @@ import { createRange, safeInstanceOf } from 'roosterjs-editor-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; import type { NodePosition, PositionType, @@ -13,7 +12,7 @@ import type { * @internal */ export function buildRangeEx( - core: StandaloneEditorCore, + root: HTMLElement, arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, arg2?: NodePosition | number | PositionType | TableSelection | null, arg3?: Node, @@ -44,7 +43,7 @@ export function buildRangeEx( : safeInstanceOf(arg1, 'Range') ? arg1 : isSelectionPath(arg1) - ? createRange(core.contentDiv, arg1.start, arg1.end) + ? createRange(root, arg1.start, arg1.end) : isNodePosition(arg1) || safeInstanceOf(arg1, 'Node') ? createRange( arg1, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts rename to packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts index 3fd1b936649..2ec3011c38d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts @@ -1,6 +1,6 @@ import { convertDomSelectionToRangeEx, convertRangeExToDomSelection } from './selectionConverter'; import { createDefaultHtmlSanitizerOptions } from 'roosterjs-editor-dom'; -import type { ContentModelBeforePasteEvent } from '../../publicTypes/ContentModelBeforePasteEvent'; +import type { BeforePasteAdapterEvent } from '../../publicTypes/BeforePasteAdapterEvent'; import { KnownAnnounceStrings as OldKnownAnnounceStrings, PasteType as OldPasteType, @@ -134,7 +134,7 @@ export function oldEventToNewEvent( case PluginEventType.BeforePaste: const refBeforePasteEvent = refEvent?.eventType == 'beforePaste' ? refEvent : undefined; - const cmBeforePasteEvent = input as ContentModelBeforePasteEvent; + const cmBeforePasteEvent = input as BeforePasteAdapterEvent; return { eventType: 'beforePaste', @@ -352,7 +352,7 @@ export function newEventToOldEvent(input: NewEvent, refEvent?: OldEvent): OldEve const refBeforePasteEvent = refEvent?.eventType == PluginEventType.BeforePaste ? refEvent : undefined; - const oldBeforePasteEvent: ContentModelBeforePasteEvent = { + const oldBeforePasteEvent: BeforePasteAdapterEvent = { eventType: PluginEventType.BeforePaste, clipboardData: input.clipboardData, eventDataCache: input.eventDataCache, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/insertNode.ts similarity index 79% rename from packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts rename to packages/roosterjs-editor-adapter/lib/editor/utils/insertNode.ts index c1f40618293..37e3e48b384 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/insertNode.ts @@ -1,5 +1,4 @@ import { ContentPosition, NodeType, PositionType, RegionType } from 'roosterjs-editor-types'; -import { transformColor } from 'roosterjs-content-model-core'; import type { BlockElement, InsertOption, NodePosition } from 'roosterjs-editor-types'; import { createRange, @@ -16,18 +15,16 @@ import { splitTextNode, splitParentNode, } from 'roosterjs-editor-dom'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { InsertNode } from '../publicTypes/ContentModelEditorCore'; +import type { DOMSelection } from 'roosterjs-content-model-types'; function getInitialRange( - core: StandaloneEditorCore, + selection: DOMSelection | null, option: InsertOption ): { range: Range | null; rangeToRestore: Range | null } { // Selection start replaces based on the current selection. // Range inserts based on a provided range. // Both have the potential to use the current selection to restore cursor position // So in both cases we need to store the selection state. - const selection = core.api.getDOMSelection(core); let range = selection?.type == 'range' ? selection.range : null; let rangeToRestore = null; @@ -44,32 +41,13 @@ function getInitialRange( /** * @internal * Insert a DOM node into editor content - * @param core The ContentModelEditorCore object. No op if null. - * @param option An insert option object to specify how to insert the node */ -export const insertNode: InsertNode = (core, innerCore, node, option) => { - option = option || { - position: ContentPosition.SelectionStart, - insertOnNewLine: false, - updateCursor: true, - replaceSelection: true, - insertToRegionRoot: false, - }; - const { contentDiv, api, lifecycle, darkColorHandler } = innerCore; - - if (option.updateCursor) { - api.focus(innerCore); - } - - if (option.position == ContentPosition.Outside) { - contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); - return true; - } - - if (lifecycle.isDarkMode) { - transformColor(node, true /*includeSelf*/, 'lightToDark', darkColorHandler); - } - +export function insertNode( + contentDiv: HTMLDivElement, + selection: DOMSelection | null, + node: Node, + option: InsertOption +): DOMSelection | undefined { switch (option.position) { case ContentPosition.Begin: case ContentPosition.End: { @@ -132,7 +110,7 @@ export const insertNode: InsertNode = (core, innerCore, node, option) => { break; case ContentPosition.Range: case ContentPosition.SelectionStart: - let { range, rangeToRestore } = getInitialRange(innerCore, option); + let { range, rangeToRestore } = getInitialRange(selection, option); if (!range) { break; } @@ -146,12 +124,12 @@ export const insertNode: InsertNode = (core, innerCore, node, option) => { let blockElement: BlockElement | null; if (option.insertOnNewLine && option.insertToRegionRoot) { - pos = adjustInsertPositionRegionRoot(innerCore, range, pos); + pos = adjustInsertPositionRegionRoot(contentDiv, range, pos); } else if ( option.insertOnNewLine && (blockElement = getBlockElementAtNode(contentDiv, pos.normalize().node)) ) { - pos = adjustInsertPositionNewLine(blockElement, innerCore, pos); + pos = adjustInsertPositionNewLine(blockElement, contentDiv, pos); } else { pos = adjustInsertPosition(contentDiv, node, pos, range); } @@ -168,26 +146,22 @@ export const insertNode: InsertNode = (core, innerCore, node, option) => { ); } - if (rangeToRestore) { - api.setDOMSelection(innerCore, { - type: 'range', - range: rangeToRestore, - isReverted: false, - }); - } - - break; + return rangeToRestore + ? { + type: 'range', + range: rangeToRestore, + isReverted: false, + } + : undefined; } - - return true; -}; +} function adjustInsertPositionRegionRoot( - core: StandaloneEditorCore, + contentDiv: HTMLDivElement, range: Range, position: NodePosition ) { - const region = getRegionsFromRange(core.contentDiv, range, RegionType.Table)[0]; + const region = getRegionsFromRange(contentDiv, range, RegionType.Table)[0]; let node: Node | null = position.node; if (region) { @@ -212,12 +186,12 @@ function adjustInsertPositionRegionRoot( function adjustInsertPositionNewLine( blockElement: BlockElement, - core: StandaloneEditorCore, + contentDiv: HTMLDivElement, pos: Position ) { let tempPos = new Position(blockElement.getEndNode(), PositionType.After); if (safeInstanceOf(tempPos.node, 'HTMLTableRowElement')) { - const div = core.contentDiv.ownerDocument.createElement('div'); + const div = contentDiv.ownerDocument.createElement('div'); const range = createRange(pos); range.insertNode(div); tempPos = new Position(div, PositionType.Begin); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/selectionConverter.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts rename to packages/roosterjs-editor-adapter/lib/editor/utils/selectionConverter.ts diff --git a/packages/roosterjs-editor-adapter/lib/index.ts b/packages/roosterjs-editor-adapter/lib/index.ts new file mode 100644 index 00000000000..89c5b8e99bc --- /dev/null +++ b/packages/roosterjs-editor-adapter/lib/index.ts @@ -0,0 +1,4 @@ +export { EditorAdapterOptions } from './publicTypes/EditorAdapterOptions'; +export { BeforePasteAdapterEvent } from './publicTypes/BeforePasteAdapterEvent'; + +export { EditorAdapter } from './editor/EditorAdapter'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/BeforePasteAdapterEvent.ts similarity index 72% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts rename to packages/roosterjs-editor-adapter/lib/publicTypes/BeforePasteAdapterEvent.ts index d9cb72619f1..c86baee478d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/BeforePasteAdapterEvent.ts @@ -1,17 +1,17 @@ import type { BeforePasteEvent } from 'roosterjs-editor-types'; import type { - DomToModelOptionForPaste, + DomToModelOptionForSanitizing, MergePastedContentFunc, } from 'roosterjs-content-model-types'; /** - * A temporary event type to be compatible with both legacy plugin and content model editor + * A temporary event type to be compatible with both legacy plugin and EditorAdapter */ -export interface ContentModelBeforePasteEvent extends BeforePasteEvent { +export interface BeforePasteAdapterEvent extends BeforePasteEvent { /** * domToModel Options to use when creating the content model from the paste fragment */ - readonly domToModelOption: DomToModelOptionForPaste; + readonly domToModelOption: DomToModelOptionForSanitizing; /** * customizedMerge Customized merge function to use when merging the paste fragment into the editor diff --git a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts new file mode 100644 index 00000000000..b5ed78fe6fe --- /dev/null +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts @@ -0,0 +1,23 @@ +import type { StandaloneEditorOptions } from 'roosterjs-content-model-types'; +import type { EditorPlugin, ExperimentalFeatures } from 'roosterjs-editor-types'; + +/** + * Options for editor adapter + */ +export interface EditorAdapterOptions extends StandaloneEditorOptions { + /** + * Initial HTML content + * Default value is whatever already inside the editor content DIV + */ + initialContent?: string; + + /** + * Specify the enabled experimental features + */ + experimentalFeatures?: ExperimentalFeatures[]; + + /** + * Legacy plugins using IEditor interface + */ + legacyPlugins?: EditorPlugin[]; +} diff --git a/packages-content-model/roosterjs-content-model-editor/package.json b/packages/roosterjs-editor-adapter/package.json similarity index 68% rename from packages-content-model/roosterjs-content-model-editor/package.json rename to packages/roosterjs-editor-adapter/package.json index 91e09718abb..9248d2db571 100644 --- a/packages-content-model/roosterjs-content-model-editor/package.json +++ b/packages/roosterjs-editor-adapter/package.json @@ -1,6 +1,6 @@ { - "name": "roosterjs-content-model-editor", - "description": "Content Model for roosterjs (Under development)", + "name": "roosterjs-editor-adapter", + "description": "An adapter on top of Content Model based editor to work with legacy roosterjs editor", "dependencies": { "tslib": "^2.3.1", "roosterjs-editor-types": "", diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts similarity index 60% rename from packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts rename to packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index 26484b98713..ec3a93d133e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -1,6 +1,7 @@ +import * as BridgePlugin from '../../lib/corePlugins/BridgePlugin'; +import * as DarkColorHandler from '../../lib/editor/DarkColorHandlerImpl'; import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as eventConverter from '../../lib/editor/utils/eventConverter'; -import { BridgePlugin } from '../../lib/corePlugins/BridgePlugin'; import { PluginEventType } from 'roosterjs-editor-types'; describe('BridgePlugin', () => { @@ -35,40 +36,148 @@ describe('BridgePlugin', () => { const mockedEditor = { queryElements: queryElementsSpy, } as any; - - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); expect(initializeSpy).not.toHaveBeenCalled(); expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); expect(disposeSpy).not.toHaveBeenCalled(); + expect(onInitializeSpy).not.toHaveBeenCalled(); + + const mockedZoomScale = 'ZOOM' as any; + const calculateZoomScaleSpy = jasmine + .createSpy('calculateZoomScale') + .and.returnValue(mockedZoomScale); + const mockedColorManager = 'COLOR' as any; + const mockedInnerDarkColorHandler = 'INNERCOLOR' as any; + const mockedInnerEditor = { + getDOMHelper: () => ({ + calculateZoomScale: calculateZoomScaleSpy, + }), + getColorManager: () => mockedInnerDarkColorHandler, + } as any; + + const createDarkColorHandlerSpy = spyOn( + DarkColorHandler, + 'createDarkColorHandler' + ).and.returnValue(mockedColorManager); + + plugin.initialize(mockedInnerEditor); - expect(plugin.getCorePluginState()).toEqual({ + expect(onInitializeSpy).toHaveBeenCalledWith({ + customData: {}, + experimentalFeatures: [], + sizeTransformer: jasmine.anything(), + darkColorHandler: mockedColorManager, edit: 'edit', contextMenuProviders: [], } as any); - - plugin.setOuterEditor(mockedEditor); - - expect(initializeSpy).toHaveBeenCalledTimes(0); - expect(onPluginEventSpy1).toHaveBeenCalledTimes(0); - expect(onPluginEventSpy2).toHaveBeenCalledTimes(0); + expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedInnerDarkColorHandler); + expect(initializeSpy).toHaveBeenCalledTimes(2); expect(disposeSpy).not.toHaveBeenCalled(); + expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); + expect(onPluginEventSpy1).not.toHaveBeenCalled(); + expect(onPluginEventSpy2).not.toHaveBeenCalled(); - plugin.initialize(); + plugin.onPluginEvent({ eventType: 'editorReady' }); - expect(initializeSpy).toHaveBeenCalledTimes(2); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); + expect(onPluginEventSpy1).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + eventDataCache: undefined, + }); + expect(onPluginEventSpy2).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + eventDataCache: undefined, + }); + + plugin.dispose(); + + expect(disposeSpy).toHaveBeenCalledTimes(2); + }); + + it('Ctor and init with more options', () => { + const initializeSpy = jasmine.createSpy('initialize'); + const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); + const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); + const disposeSpy = jasmine.createSpy('dispose'); + const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); + + const mockedPlugin1 = { + initialize: initializeSpy, + onPluginEvent: onPluginEventSpy1, + dispose: disposeSpy, + } as any; + const mockedPlugin2 = { + initialize: initializeSpy, + onPluginEvent: onPluginEventSpy2, + dispose: disposeSpy, + } as any; + const mockedEditor = { + queryElements: queryElementsSpy, + } as any; + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin( + onInitializeSpy, + [mockedPlugin1, mockedPlugin2], + ['c' as any] + ); + expect(initializeSpy).not.toHaveBeenCalled(); + expect(onPluginEventSpy1).not.toHaveBeenCalled(); + expect(onPluginEventSpy2).not.toHaveBeenCalled(); expect(disposeSpy).not.toHaveBeenCalled(); + expect(onInitializeSpy).not.toHaveBeenCalled(); + + const mockedZoomScale = 'ZOOM' as any; + const calculateZoomScaleSpy = jasmine + .createSpy('calculateZoomScale') + .and.returnValue(mockedZoomScale); + const mockedColorManager = 'COLOR' as any; + const mockedInnerDarkColorHandler = 'INNERCOLOR' as any; + const mockedInnerEditor = { + getDOMHelper: () => ({ + calculateZoomScale: calculateZoomScaleSpy, + }), + getColorManager: () => mockedInnerDarkColorHandler, + } as any; + + const createDarkColorHandlerSpy = spyOn( + DarkColorHandler, + 'createDarkColorHandler' + ).and.returnValue(mockedColorManager); + + plugin.initialize(mockedInnerEditor); + expect(onInitializeSpy).toHaveBeenCalledWith({ + customData: {}, + experimentalFeatures: ['c'], + sizeTransformer: jasmine.anything(), + darkColorHandler: mockedColorManager, + edit: 'edit', + contextMenuProviders: [], + } as any); + expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedInnerDarkColorHandler); + expect(initializeSpy).toHaveBeenCalledTimes(2); + expect(disposeSpy).not.toHaveBeenCalled(); expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); + expect(onPluginEventSpy1).not.toHaveBeenCalled(); + expect(onPluginEventSpy2).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); + expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); expect(onPluginEventSpy1).toHaveBeenCalledWith({ eventType: PluginEventType.EditorReady, + eventDataCache: undefined, }); expect(onPluginEventSpy2).toHaveBeenCalledWith({ eventType: PluginEventType.EditorReady, + eventDataCache: undefined, }); plugin.dispose(); @@ -97,16 +206,16 @@ describe('BridgePlugin', () => { dispose: disposeSpy, } as any; const mockedEditor = 'EDITOR' as any; - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); spyOn(eventConverter, 'newEventToOldEvent').and.callFake(newEvent => { return ('NEW_' + newEvent) as any; }); - plugin.setOuterEditor(mockedEditor); - const mockedEvent = {} as any; const result = plugin.willHandleEventExclusively(mockedEvent); @@ -144,9 +253,11 @@ describe('BridgePlugin', () => { } as any; const mockedEditor = 'EDITOR' as any; - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); spyOn(eventConverter, 'newEventToOldEvent').and.callFake(newEvent => { return { @@ -160,8 +271,6 @@ describe('BridgePlugin', () => { } as any; }); - plugin.setOuterEditor(mockedEditor); - const mockedEvent = { eventType: 'newEvent', } as any; @@ -218,9 +327,11 @@ describe('BridgePlugin', () => { } as any; const mockedEditor = 'EDITOR' as any; - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); spyOn(eventConverter, 'newEventToOldEvent').and.callFake(newEvent => { return { @@ -229,8 +340,6 @@ describe('BridgePlugin', () => { } as any; }); - plugin.setOuterEditor(mockedEditor); - const mockedEvent = { eventType: 'newEvent', eventDataCache: { @@ -283,39 +392,62 @@ describe('BridgePlugin', () => { queryElements: queryElementsSpy, } as any; - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); expect(initializeSpy).not.toHaveBeenCalled(); expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); expect(disposeSpy).not.toHaveBeenCalled(); - expect(plugin.getCorePluginState()).toEqual({ + const mockedZoomScale = 'ZOOM' as any; + const calculateZoomScaleSpy = jasmine + .createSpy('calculateZoomScale') + .and.returnValue(mockedZoomScale); + const mockedColorManager = 'COLOR' as any; + const mockedInnerEditor = { + getDOMHelper: () => ({ + calculateZoomScale: calculateZoomScaleSpy, + }), + getColorManager: () => mockedColorManager, + } as any; + const mockedDarkColorHandler = 'COLOR' as any; + const createDarkColorHandlerSpy = spyOn( + DarkColorHandler, + 'createDarkColorHandler' + ).and.returnValue(mockedDarkColorHandler); + + plugin.initialize(mockedInnerEditor); + + expect(onInitializeSpy).toHaveBeenCalledWith({ + customData: {}, + experimentalFeatures: [], + sizeTransformer: jasmine.anything(), + darkColorHandler: mockedDarkColorHandler, edit: 'edit', contextMenuProviders: [mockedPlugin1, mockedPlugin2], } as any); - - plugin.setOuterEditor(mockedEditor); - - expect(initializeSpy).toHaveBeenCalledTimes(0); + expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedColorManager); + expect(initializeSpy).toHaveBeenCalledTimes(2); expect(onPluginEventSpy1).toHaveBeenCalledTimes(0); expect(onPluginEventSpy2).toHaveBeenCalledTimes(0); expect(disposeSpy).not.toHaveBeenCalled(); + expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); - plugin.initialize(); + plugin.onPluginEvent({ eventType: 'editorReady' }); - expect(initializeSpy).toHaveBeenCalledTimes(2); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); expect(disposeSpy).not.toHaveBeenCalled(); - - expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); expect(onPluginEventSpy1).toHaveBeenCalledWith({ eventType: PluginEventType.EditorReady, + eventDataCache: undefined, }); expect(onPluginEventSpy2).toHaveBeenCalledWith({ eventType: PluginEventType.EditorReady, + eventDataCache: undefined, }); const mockedNode = 'NODE' as any; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts b/packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts similarity index 86% rename from packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts rename to packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts index b7f793b8914..a7c02c0072f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts @@ -1,6 +1,6 @@ -import { ColorKeyAndValue } from 'roosterjs-editor-types'; -import { createDarkColorHandler } from 'roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl'; -import { DarkColorHandlerImpl } from '../../lib/editor/DarkColorHandlerImpl'; +import { ColorKeyAndValue, DarkColorHandler } from 'roosterjs-editor-types'; +import { createDarkColorHandler } from '../../lib/editor/DarkColorHandlerImpl'; +import { createDarkColorHandler as createInnderDarkColorHandler } from 'roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl'; function getDarkColor(color: string) { return 'Dark_' + color; @@ -9,16 +9,16 @@ function getDarkColor(color: string) { describe('DarkColorHandlerImpl.ctor', () => { it('No additional param', () => { const div = document.createElement('div'); - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); expect(handler).toBeDefined(); }); it('Calculate color using customized base color', () => { const div = document.createElement('div'); - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); const darkColor = handler.registerColor('red', true); const parsedColor = handler.parseColorValue(darkColor); @@ -34,12 +34,12 @@ describe('DarkColorHandlerImpl.ctor', () => { describe('DarkColorHandlerImpl.parseColorValue', () => { let div: HTMLElement; - let handler: DarkColorHandlerImpl; + let handler: DarkColorHandler; beforeEach(() => { div = document.createElement('div'); - const innerHandler = createDarkColorHandler(div, getDarkColor); - handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + handler = createDarkColorHandler(innerHandler); }); function runTest(input: string, expectedOutput: ColorKeyAndValue) { @@ -133,7 +133,7 @@ describe('DarkColorHandlerImpl.parseColorValue', () => { describe('DarkColorHandlerImpl.registerColor', () => { let setProperty: jasmine.Spy; - let handler: DarkColorHandlerImpl; + let handler: DarkColorHandler; beforeEach(() => { setProperty = jasmine.createSpy('setProperty'); @@ -142,8 +142,8 @@ describe('DarkColorHandlerImpl.registerColor', () => { setProperty, }, } as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + handler = createDarkColorHandler(innerHandler); }); function runTest( @@ -233,8 +233,8 @@ describe('DarkColorHandlerImpl.reset', () => { removeProperty, }, } as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--aa': { @@ -258,8 +258,8 @@ describe('DarkColorHandlerImpl.reset', () => { describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Not found', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); const result = handler.findLightColorFromDarkColor('#010203'); @@ -268,8 +268,8 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: HEX to RGB', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--bb': { @@ -289,8 +289,8 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: HEX to HEX', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--bb': { @@ -310,8 +310,8 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: RGB to HEX', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--bb': { @@ -331,8 +331,8 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: RGB to RGB', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--bb': { @@ -352,13 +352,13 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { }); describe('DarkColorHandlerImpl.transformElementColor', () => { - let handler: DarkColorHandlerImpl; + let handler: DarkColorHandler; let contentDiv: HTMLDivElement; beforeEach(() => { contentDiv = document.createElement('div'); - const innerHandler = createDarkColorHandler(contentDiv, getDarkColor); - handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(contentDiv, getDarkColor); + handler = createDarkColorHandler(innerHandler); }); it('No color, light to dark', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts rename to packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts index 55b31bf529c..55bb8dc7381 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts @@ -1,7 +1,7 @@ import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; -import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; +import { EditorAdapter } from '../../lib/editor/EditorAdapter'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; import { ContentModelDocument, @@ -13,7 +13,7 @@ const editorContext: EditorContext = { isDarkMode: false, }; -describe('ContentModelEditor', () => { +describe('EditorAdapter', () => { it('domToContentModel', () => { const mockedResult = 'Result' as any; const mockedContext = 'MockedContext' as any; @@ -27,7 +27,7 @@ describe('ContentModelEditor', () => { spyOn(findAllEntities, 'findAllEntities'); const div = document.createElement('div'); - const editor = new ContentModelEditor(div, { + const editor = new EditorAdapter(div, { coreApiOverride: { createEditorContext: jasmine .createSpy('createEditorContext') @@ -64,7 +64,7 @@ describe('ContentModelEditor', () => { spyOn(findAllEntities, 'findAllEntities'); const div = document.createElement('div'); - const editor = new ContentModelEditor(div, { + const editor = new EditorAdapter(div, { coreApiOverride: { createEditorContext: jasmine .createSpy('createEditorContext') @@ -107,7 +107,7 @@ describe('ContentModelEditor', () => { } }, }; - const editor = new ContentModelEditor(div, { + const editor = new EditorAdapter(div, { legacyPlugins: [plugin], }); editor.dispose(); @@ -137,7 +137,7 @@ describe('ContentModelEditor', () => { it('get model with cache', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new EditorAdapter(div); const cachedModel = 'MODEL' as any; (editor as any).core.cache.cachedModel = cachedModel; @@ -152,7 +152,7 @@ describe('ContentModelEditor', () => { it('formatContentModel', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new EditorAdapter(div); const core = (editor as any).core; const formatContentModelSpy = spyOn(core.api, 'formatContentModel'); const callback = jasmine.createSpy('callback'); @@ -165,7 +165,7 @@ describe('ContentModelEditor', () => { it('default format', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div, { + const editor = new EditorAdapter(div, { defaultSegmentFormat: { fontWeight: 'bold', italic: true, @@ -192,7 +192,7 @@ describe('ContentModelEditor', () => { it('getPendingFormat', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new EditorAdapter(div); const core: StandaloneEditorCore = (editor as any).core; const mockedFormat = 'FORMAT' as any; @@ -209,7 +209,7 @@ describe('ContentModelEditor', () => { const div = document.createElement('div'); div.style.fontFamily = 'Arial'; - const editor = new ContentModelEditor(div); + const editor = new EditorAdapter(div); expect(div.style.fontFamily).toBe('Arial'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts b/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts rename to packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts index 581971e0e6c..0860987e5bd 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts @@ -8,7 +8,7 @@ import { } from 'roosterjs-editor-types'; import type { ContentChangedEvent, PluginEvent as OldEvent } from 'roosterjs-editor-types'; import type { PluginEvent as NewEvent } from 'roosterjs-content-model-types'; -import type { ContentModelBeforePasteEvent } from '../../../lib/publicTypes/ContentModelBeforePasteEvent'; +import type { BeforePasteAdapterEvent } from '../../../lib/publicTypes/BeforePasteAdapterEvent'; describe('oldEventToNewEvent', () => { function runTest( @@ -746,7 +746,7 @@ describe('newEventToOldEvent', () => { }, customizedMerge: mockedCustomizedMerge, domToModelOption: mockedDomToModelOption, - } as ContentModelBeforePasteEvent + } as BeforePasteAdapterEvent ); }); @@ -796,7 +796,7 @@ describe('newEventToOldEvent', () => { sanitizingOption: mockedSanitizeOption, customizedMerge: mockedCustomizedMerge, domToModelOption: mockedDomToModelOption, - } as ContentModelBeforePasteEvent + } as BeforePasteAdapterEvent ); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts b/packages/roosterjs-editor-adapter/test/editor/utils/selectionConverterTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts rename to packages/roosterjs-editor-adapter/test/editor/utils/selectionConverterTest.ts diff --git a/tools/buildTools/buildDemo.js b/tools/buildTools/buildDemo.js index 445fb90b28f..478c9151d33 100644 --- a/tools/buildTools/buildDemo.js +++ b/tools/buildTools/buildDemo.js @@ -79,7 +79,7 @@ async function buildDemoSite() { [/^roosterjs-react$/, 'roosterjsReact'], [/^roosterjs-content-model((?!-editor).)*\/.*$/, 'roosterjsContentModel'], ], - [] + ['roosterjs-editor-adapter'] ), stats: 'minimal', mode: 'production', diff --git a/tools/tsconfig.doc.json b/tools/tsconfig.doc.json index 6c968646613..b5da40f7c30 100644 --- a/tools/tsconfig.doc.json +++ b/tools/tsconfig.doc.json @@ -36,7 +36,7 @@ "../packages-content-model/roosterjs-content-model-core/lib/index.ts", "../packages-content-model/roosterjs-content-model-api/lib/index.ts", "../packages-content-model/roosterjs-content-model-plugins/lib/index.ts", - "../packages-content-model/roosterjs-content-model-editor/lib/index.ts", + "../packages/roosterjs-editor-adapter/lib/index.ts", "../packages-content-model/roosterjs-content-model/lib/index.ts" ], "plugin": ["typedoc-plugin-remove-references", "typedoc-plugin-external-module-map"], diff --git a/versions.json b/versions.json index a8b82e0c795..98fc7538df4 100644 --- a/versions.json +++ b/versions.json @@ -1,8 +1,9 @@ { "packages": "8.60.0", "packages-ui": "8.55.0", - "packages-content-model": "0.25.0", + "packages-content-model": "0.26.0", "overrides": { - "roosterjs-editor-plugins": "8.60.2" + "roosterjs-editor-plugins": "8.60.2", + "roosterjs-editor-adapter": "0.26.0" } }