diff --git a/demo/scripts/controls/BuildInPluginState.ts b/demo/scripts/controls/BuildInPluginState.ts index fb61f825b38..919c41da93b 100644 --- a/demo/scripts/controls/BuildInPluginState.ts +++ b/demo/scripts/controls/BuildInPluginState.ts @@ -15,7 +15,6 @@ export interface BuildInPluginList { imageEdit: boolean; cutPasteListChain: boolean; tableCellSelection: boolean; - tableResize: boolean; customReplace: boolean; listEditMenu: boolean; imageEditMenu: boolean; diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index c7f0fb2bead..b09021fd353 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -20,7 +20,6 @@ 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'; @@ -80,11 +79,13 @@ 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, IEditor, Snapshots } from 'roosterjs-content-model-types'; import { - ContentModelSegmentFormat, - IStandaloneEditor, - Snapshots, -} from 'roosterjs-content-model-types'; + AutoFormatPlugin, + EditPlugin, + PastePlugin, + TableEditPlugin, +} from 'roosterjs-content-model-plugins'; import { spaceAfterButton, spaceBeforeButton, @@ -155,7 +156,7 @@ const DarkTheme: PartialTheme = { }; interface ContentModelMainPaneState extends MainPaneBaseState { - editorCreator: (div: HTMLDivElement, options: EditorAdapterOptions) => IStandaloneEditor; + editorCreator: (div: HTMLDivElement, options: EditorAdapterOptions) => IEditor; } class ContentModelEditorMainPane extends MainPaneBase { @@ -174,6 +175,7 @@ class ContentModelEditorMainPane extends MainPaneBase private formatPainterPlugin: ContentModelFormatPainterPlugin; private pastePlugin: PastePlugin; private sampleEntityPlugin: SampleEntityPlugin; + private tableEditPlugin: TableEditPlugin; private snapshots: Snapshots; private buttons: ContentModelRibbonButton[] = [ formatPainterButton, @@ -263,6 +265,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); + this.tableEditPlugin = new TableEditPlugin(); this.pastePlugin = new PastePlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); this.state = { @@ -382,6 +385,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelRibbonPlugin, this.formatPainterPlugin, this.pastePlugin, + this.tableEditPlugin, this.contentModelAutoFormatPlugin, this.contentModelEditPlugin, this.contentModelPanePlugin.getInnerRibbonPlugin(), diff --git a/demo/scripts/controls/StandaloneEditorMainPane.tsx b/demo/scripts/controls/StandaloneEditorMainPane.tsx index e2bf0f19e7b..73c420f20f8 100644 --- a/demo/scripts/controls/StandaloneEditorMainPane.tsx +++ b/demo/scripts/controls/StandaloneEditorMainPane.tsx @@ -17,7 +17,6 @@ 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'; @@ -30,6 +29,7 @@ import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentMo import { darkMode } from './ribbonButtons/contentModel/darkMode'; import { decreaseFontSizeButton } from './ribbonButtons/contentModel/decreaseFontSizeButton'; import { decreaseIndentButton } from './ribbonButtons/contentModel/decreaseIndentButton'; +import { Editor } from 'roosterjs-content-model-core'; import { exportContent } from './ribbonButtons/contentModel/export'; import { fontButton } from './ribbonButtons/contentModel/fontButton'; import { fontSizeButton } from './ribbonButtons/contentModel/fontSizeButton'; @@ -63,7 +63,6 @@ import { setTableCellShadeButton } from './ribbonButtons/contentModel/setTableCe import { setTableHeaderButton } from './ribbonButtons/contentModel/setTableHeaderButton'; import { Snapshots } from 'roosterjs-editor-types'; import { spacingButton } from './ribbonButtons/contentModel/spacingButton'; -import { StandaloneEditor } from 'roosterjs-content-model-core'; import { strikethroughButton } from './ribbonButtons/contentModel/strikethroughButton'; import { subscriptButton } from './ribbonButtons/contentModel/subscriptButton'; import { superscriptButton } from './ribbonButtons/contentModel/superscriptButton'; @@ -76,11 +75,17 @@ import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { underlineButton } from './ribbonButtons/contentModel/underlineButton'; import { undoButton } from './ribbonButtons/contentModel/undoButton'; import { zoom } from './ribbonButtons/contentModel/zoom'; +import { + AutoFormatPlugin, + EditPlugin, + ShortcutPlugin, + TableEditPlugin, +} from 'roosterjs-content-model-plugins'; import { ContentModelSegmentFormat, - IStandaloneEditor, + IEditor, Snapshot, - StandaloneEditorOptions, + EditorOptions, } from 'roosterjs-content-model-types'; import { spaceAfterButton, @@ -152,7 +157,7 @@ const DarkTheme: PartialTheme = { }; interface ContentModelMainPaneState extends MainPaneBaseState { - editorCreator: (div: HTMLDivElement, options: StandaloneEditorOptions) => IStandaloneEditor; + editorCreator: (div: HTMLDivElement, options: EditorOptions) => IEditor; } class ContentModelEditorMainPane extends MainPaneBase { @@ -165,7 +170,9 @@ class ContentModelEditorMainPane extends MainPaneBase private contentModelRibbonPlugin: RibbonPlugin; private contentAutoFormatPlugin: AutoFormatPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; + private shortcutPlugin: ShortcutPlugin; private formatPainterPlugin: ContentModelFormatPainterPlugin; + private tableEditPlugin: TableEditPlugin; private snapshots: Snapshots; private buttons: ContentModelRibbonButton[] = [ formatPainterButton, @@ -251,8 +258,10 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelPanePlugin = new ContentModelPanePlugin(); this.contentModelEditPlugin = new EditPlugin(); this.contentAutoFormatPlugin = new AutoFormatPlugin(); + this.shortcutPlugin = new ShortcutPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); + this.tableEditPlugin = new TableEditPlugin(); this.state = { showSidePane: window.location.hash != '', popoutWindow: null, @@ -305,8 +314,8 @@ class ContentModelEditorMainPane extends MainPaneBase resetEditor() { this.setState({ - editorCreator: (div: HTMLDivElement, options: StandaloneEditorOptions) => - new StandaloneEditor(div, { + editorCreator: (div: HTMLDivElement, options: EditorOptions) => + new Editor(div, { ...options, cacheModel: this.state.initState.cacheModel, }), @@ -345,8 +354,10 @@ class ContentModelEditorMainPane extends MainPaneBase plugins={[ this.contentModelRibbonPlugin, this.formatPainterPlugin, + this.tableEditPlugin, this.contentModelEditPlugin, this.contentAutoFormatPlugin, + this.shortcutPlugin, ]} defaultSegmentFormat={defaultFormat} inDarkMode={this.state.isDarkMode} diff --git a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx index a0d5f6cb501..5e76e16ef95 100644 --- a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx +++ b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx @@ -2,12 +2,8 @@ import * as React from 'react'; import { createUIUtilities, ReactEditorPlugin, UIUtilities } from 'roosterjs-react'; import { divProperties, getNativeProps } from '@fluentui/react/lib/Utilities'; import { EditorAdapter, EditorAdapterOptions } from 'roosterjs-editor-adapter'; +import { EditorOptions, EditorPlugin, IEditor } from 'roosterjs-content-model-types'; import { useTheme } from '@fluentui/react/lib/Theme'; -import { - EditorPlugin, - IStandaloneEditor, - StandaloneEditorOptions, -} from 'roosterjs-content-model-types'; import type { EditorPlugin as LegacyEditorPlugin } from 'roosterjs-editor-types'; /** @@ -20,7 +16,7 @@ export interface ContentModelRoosterProps * Creator function used for creating the instance of roosterjs editor. * Use this callback when you have your own sub class of roosterjs Editor or force trigging a reset of editor */ - editorCreator?: (div: HTMLDivElement, options: StandaloneEditorOptions) => IStandaloneEditor; + editorCreator?: (div: HTMLDivElement, options: EditorOptions) => IEditor; /** * Whether editor should get focus once it is created @@ -36,7 +32,7 @@ export interface ContentModelRoosterProps */ export default function ContentModelRooster(props: ContentModelRoosterProps) { const editorDiv = React.useRef(null); - const editor = React.useRef(null); + const editor = React.useRef(null); const theme = useTheme(); const { focusOnInit, editorCreator, inDarkMode, plugins, legacyPlugins } = props; diff --git a/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts b/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts index a5ad17cfb36..fe54d377874 100644 --- a/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts +++ b/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts @@ -3,7 +3,7 @@ import { applySegmentFormat, getFormatState } from 'roosterjs-content-model-api' import { ContentModelSegmentFormat, EditorPlugin, - IStandaloneEditor, + IEditor, PluginEvent, } from 'roosterjs-content-model-types'; @@ -11,7 +11,7 @@ const FORMATPAINTERCURSOR_SVG = require('./formatpaintercursor.svg'); const FORMATPAINTERCURSOR_STYLE = `cursor: url("${FORMATPAINTERCURSOR_SVG}") 8.5 16, auto`; export default class ContentModelFormatPainterPlugin implements EditorPlugin { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private styleNode: HTMLStyleElement | null = null; private painterFormat: ContentModelSegmentFormat | null = null; private static instance: ContentModelFormatPainterPlugin | undefined; @@ -24,7 +24,7 @@ export default class ContentModelFormatPainterPlugin implements EditorPlugin { return 'FormatPainter'; } - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; const doc = this.editor.getDocument(); @@ -77,7 +77,7 @@ export default class ContentModelFormatPainterPlugin implements EditorPlugin { } } -function getSegmentFormat(editor: IStandaloneEditor): ContentModelSegmentFormat { +function getSegmentFormat(editor: IEditor): ContentModelSegmentFormat { const formatState = getFormatState(editor); return { diff --git a/demo/scripts/controls/getToggleablePlugins.ts b/demo/scripts/controls/getToggleablePlugins.ts index 9a3512a312c..27ad93ed877 100644 --- a/demo/scripts/controls/getToggleablePlugins.ts +++ b/demo/scripts/controls/getToggleablePlugins.ts @@ -9,7 +9,6 @@ import { HyperLink } from 'roosterjs-editor-plugins/lib/HyperLink'; import { ImageEdit } from 'roosterjs-editor-plugins/lib/ImageEdit'; import { Paste } from 'roosterjs-editor-plugins/lib/Paste'; import { TableCellSelection } from 'roosterjs-editor-plugins/lib/TableCellSelection'; -import { TableResize } from 'roosterjs-editor-plugins/lib/TableResize'; import { Watermark } from 'roosterjs-editor-plugins/lib/Watermark'; import { createContextMenuPlugin, @@ -43,9 +42,6 @@ export default function getToggleablePlugins(initState: BuildInPluginState) { imageEdit, cutPasteListChain: pluginList.cutPasteListChain ? new CutPasteListChain() : null, tableCellSelection: pluginList.tableCellSelection ? new TableCellSelection() : null, - tableResize: pluginList.tableResize - ? new TableResize(undefined, initState.tableFeaturesContainerSelector) - : null, customReplace: pluginList.customReplace ? new CustomReplacePlugin() : null, autoFormat: pluginList.autoFormat ? new AutoFormat() : null, listEditMenu: diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonButton.ts index a31a7845057..042aeace457 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonButton.ts @@ -1,5 +1,5 @@ import { LocalizedStrings, RibbonButtonDropDown, UIUtilities } from 'roosterjs-react'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; import type { FormatState } from 'roosterjs-editor-types'; import type { ICommandBarItemProps } from '@fluentui/react/lib/CommandBar'; @@ -35,7 +35,7 @@ export default interface ContentModelRibbonButton { * @param uiUtilities a utilities object to help render addition UI elements */ onClick: ( - editor: IStandaloneEditor, + editor: IEditor, key: T, strings: LocalizedStrings | undefined, uiUtilities: UIUtilities diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts index 6e27346c751..c8d7c339657 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts @@ -4,14 +4,10 @@ import { FormatState } from 'roosterjs-editor-types'; import { getFormatState } from 'roosterjs-content-model-api'; import { getObjectKeys } from 'roosterjs-editor-dom'; import { LocalizedStrings, UIUtilities } from 'roosterjs-react'; -import { - ContentModelFormatState, - IStandaloneEditor, - PluginEvent, -} from 'roosterjs-content-model-types'; +import { ContentModelFormatState, IEditor, PluginEvent } from 'roosterjs-content-model-types'; export class ContentModelRibbonPlugin implements RibbonPlugin { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private onFormatChanged: ((formatState: FormatState) => void) | null = null; private timer = 0; private formatState: ContentModelFormatState | null = null; @@ -34,7 +30,7 @@ export class ContentModelRibbonPlugin implements RibbonPlugin { * Initialize this plugin * @param editor The editor instance */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; } diff --git a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts index b392e4c38cd..1ceebff6902 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 { IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor as ILegacyEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { setCurrentContentModel } from './currentModel'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -17,10 +17,10 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< this.contentModelRibbon = new ContentModelRibbonPlugin(); } - initialize(editor: IEditor): void { + initialize(editor: ILegacyEditor): void { super.initialize(editor); - this.contentModelRibbon.initialize(editor as IEditor & IStandaloneEditor); // TODO: Port side pane to use IStandaloneEditor + this.contentModelRibbon.initialize(editor as ILegacyEditor & IEditor); // TODO: Port side pane to use IStandaloneEditor editor.getDocument().addEventListener('selectionchange', this.onModelChangeFromSelection); } @@ -36,8 +36,7 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< onPluginEvent(e: PluginEvent) { 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 IEditor & IStandaloneEditor).getContentModelCopy( + const model = (this.editor as ILegacyEditor & IEditor).getContentModelCopy( 'connected' ); component.setContentModel(model); @@ -74,9 +73,7 @@ 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 IEditor & IStandaloneEditor).getContentModelCopy( - 'connected' - ); + const model = (this.editor as ILegacyEditor & IEditor).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 e380bafbb40..6595fb0ae64 100644 --- a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import ApiPaneProps from '../ApiPaneProps'; -import { Entity, IEditor } from 'roosterjs-editor-types'; +import { Entity, IEditor as ILegacyEditor } from 'roosterjs-editor-types'; import { getEntityFromElement, getEntitySelector } from 'roosterjs-editor-dom'; +import { IEditor, InsertEntityOptions } from 'roosterjs-content-model-types'; import { insertEntity } from 'roosterjs-content-model-api'; -import { InsertEntityOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; import { trustedHTMLHandler } from '../../../../utils/trustedHTMLHandler'; const styles = require('./InsertEntityPane.scss'); @@ -114,7 +114,7 @@ export default class InsertEntityPane extends React.Component (state.applyChangesOnMouseUp = value) ) )} - {this.renderPluginItem('tableResize', 'Table Resize Plugin')} {this.renderPluginItem('customReplace', 'Custom Replace Plugin (autocomplete)')} {this.renderPluginItem( 'contextMenu', diff --git a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts index 01c06bc44d0..47528ae642d 100644 --- a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -13,7 +13,6 @@ const initialState: BuildInPluginState = { imageEdit: true, cutPasteListChain: true, tableCellSelection: true, - tableResize: true, customReplace: true, listEditMenu: true, imageEditMenu: true, diff --git a/demo/scripts/controls/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controls/sidePane/editorOptions/Plugins.tsx index 952f890ce85..8135a4c0638 100644 --- a/demo/scripts/controls/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controls/sidePane/editorOptions/Plugins.tsx @@ -54,7 +54,6 @@ export default class Plugins extends React.Component { ) )} {this.renderPluginItem('cutPasteListChain', 'CutPasteListChainPlugin')} - {this.renderPluginItem('tableResize', 'Table Resize Plugin')} {this.renderPluginItem('customReplace', 'Custom Replace Plugin (autocomplete)')} {this.renderPluginItem( 'contextMenu', diff --git a/demo/scripts/controls/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controls/sidePane/editorOptions/codes/PluginsCode.ts index 0148327ffff..10fa248b3b0 100644 --- a/demo/scripts/controls/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controls/sidePane/editorOptions/codes/PluginsCode.ts @@ -9,7 +9,6 @@ import { CutPasteListChainCode, ImageEditCode, ContentModelPasteCode, - TableResizeCode, } from './SimplePluginCode'; export default class PluginsCode extends CodeElement { @@ -26,7 +25,6 @@ export default class PluginsCode extends CodeElement { pluginList.watermark && new WatermarkCode(this.state.watermarkText), pluginList.imageEdit && new ImageEditCode(), pluginList.cutPasteListChain && new CutPasteListChainCode(), - pluginList.tableResize && new TableResizeCode(), pluginList.customReplace && new CustomReplaceCode(), pluginList.tableCellSelection && new TableCellSelectionCode(), ].filter(plugin => !!plugin); diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 0118762dd9e..fe8280631e1 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -10,22 +10,5 @@ export default function getDefaultContentEditFeatureSettings(): ContentEditFeatu settings[key] = !allFeatures[key].defaultDisabled; return settings; }, {}), - ...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/formatState/ContentModelFormatStatePlugin.ts b/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts index 523031c369d..158ffd1f439 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, IEditor } from 'roosterjs-editor-types'; +import { FormatState, IEditor as ILegacyEditor } from 'roosterjs-editor-types'; import { getFormatState } from 'roosterjs-content-model-api'; import { getPositionRect } from 'roosterjs-editor-dom'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; export default class ContentModelFormatStatePlugin extends FormatStatePlugin { protected getFormatState() { @@ -11,7 +11,7 @@ export default class ContentModelFormatStatePlugin extends FormatStatePlugin { } const format = (getFormatState( - this.editor as IStandaloneEditor & IEditor + this.editor as IEditor & ILegacyEditor ) as any) as FormatState; const position = this.editor && this.editor.getFocusedPosition(); const rect = position && getPositionRect(position); diff --git a/demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPlugin.tsx b/demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPlugin.tsx index 610489b1969..cbf5e866e82 100644 --- a/demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPlugin.tsx +++ b/demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPlugin.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import ContentModelSnapshotPane from './ContentModelSnapshotPane'; import SidePanePlugin from '../../SidePanePlugin'; -import { IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; -import { IStandaloneEditor, Snapshot, Snapshots } from 'roosterjs-content-model-types'; +import { IEditor as ILegacyEDitor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { IEditor, Snapshot, Snapshots } from 'roosterjs-content-model-types'; export default class ContentModelSnapshotPlugin implements SidePanePlugin { - private editorInstance: IEditor & IStandaloneEditor; + private editorInstance: ILegacyEDitor & IEditor; private component: ContentModelSnapshotPane; constructor(private snapshots: Snapshots) { @@ -16,8 +16,8 @@ export default class ContentModelSnapshotPlugin implements SidePanePlugin { return 'Snapshot'; } - initialize(editor: IEditor) { - this.editorInstance = editor as IEditor & IStandaloneEditor; + initialize(editor: ILegacyEDitor) { + this.editorInstance = editor as ILegacyEDitor & IEditor; } dispose() { 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 c824a5a1bbe..5f748f276fc 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 @@ -15,8 +15,13 @@ import type { * Set a list type to content model * @param model the model document * @param listType the list type OL | UL + * @param removeMargins true to remove margins, false to keep margins @default false */ -export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') { +export function setListType( + model: ContentModelDocument, + listType: 'OL' | 'UL', + removeMargins: boolean = false +) { const paragraphOrListItems = getOperationalBlocks( model, ['ListItem'], @@ -76,8 +81,8 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') : 1, direction: block.format.direction, textAlign: block.format.textAlign, - marginTop: '0px', - marginBottom: '0px', + marginBottom: removeMargins ? '0px' : undefined, + marginTop: removeMargins ? '0px' : undefined, }), ], // For list bullet, we only want to carry over these formats from segments: @@ -98,6 +103,7 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') newListItem.format.marginRight = block.format.marginRight; block.format.marginRight = undefined; } + if (block.format.marginLeft) { newListItem.format.marginLeft = block.format.marginLeft; block.format.marginLeft = undefined; diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts index 9a9392477ed..8eff386e60c 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts @@ -1,5 +1,5 @@ import { setModelAlignment } from '../../modelApi/block/setModelAlignment'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Set text alignment of selected paragraphs @@ -7,7 +7,7 @@ import type { IStandaloneEditor } from 'roosterjs-content-model-types'; * @param alignment Alignment value: left, center or right */ export default function setAlignment( - editor: IStandaloneEditor, + editor: IEditor, alignment: 'left' | 'center' | 'right' | 'justify' ) { editor.focus(); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setDirection.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setDirection.ts index 2ec3396557a..520ec5cd98f 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setDirection.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setDirection.ts @@ -1,12 +1,12 @@ import { setModelDirection } from '../../modelApi/block/setModelDirection'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Set text direction of selected paragraphs (Left to right or Right to left) * @param editor The editor to set alignment * @param direction Direction value: ltr (Left to right) or rtl (Right to left) */ -export default function setDirection(editor: IStandaloneEditor, direction: 'ltr' | 'rtl') { +export default function setDirection(editor: IEditor, direction: 'ltr' | 'rtl') { editor.focus(); editor.formatContentModel(model => setModelDirection(model, direction), { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setHeadingLevel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setHeadingLevel.ts index d77e53f82d6..53e3536ad41 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setHeadingLevel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setHeadingLevel.ts @@ -1,8 +1,5 @@ import { formatParagraphWithContentModel } from '../utils/formatParagraphWithContentModel'; -import type { - ContentModelParagraphDecorator, - IStandaloneEditor, -} from 'roosterjs-content-model-types'; +import type { ContentModelParagraphDecorator, IEditor } from 'roosterjs-content-model-types'; type HeadingLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; @@ -20,10 +17,7 @@ const HeaderFontSizes: Record = { * @param editor The editor to set heading level to * @param headingLevel Level of heading, from 1 to 6. Set to 0 means set it back to a regular paragraph */ -export default function setHeadingLevel( - editor: IStandaloneEditor, - headingLevel: 0 | 1 | 2 | 3 | 4 | 5 | 6 -) { +export default function setHeadingLevel(editor: IEditor, headingLevel: 0 | 1 | 2 | 3 | 4 | 5 | 6) { editor.focus(); formatParagraphWithContentModel(editor, 'setHeadingLevel', para => { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts index 24266a90f78..8e82effe88d 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts @@ -1,6 +1,6 @@ import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setModelIndentation } from '../../modelApi/block/setModelIndentation'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Indent or outdent to selected paragraphs @@ -9,7 +9,7 @@ import type { IStandaloneEditor } from 'roosterjs-content-model-types'; * @param length The length of pixel to indent/outdent @default 40 */ export default function setIndentation( - editor: IStandaloneEditor, + editor: IEditor, indentation: 'indent' | 'outdent', length?: number ) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setParagraphMargin.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setParagraphMargin.ts index a2f85af4d5f..3f0b05c0aff 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setParagraphMargin.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setParagraphMargin.ts @@ -1,6 +1,6 @@ import { createParagraphDecorator } from 'roosterjs-content-model-dom'; import { formatParagraphWithContentModel } from '../utils/formatParagraphWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggles the current block(s) margin properties. @@ -10,7 +10,7 @@ import type { IStandaloneEditor } from 'roosterjs-content-model-types'; * @param marginBottom value for bottom margin */ export default function setParagraphMargin( - editor: IStandaloneEditor, + editor: IEditor, marginTop?: string | null, marginBottom?: string | null ) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setSpacing.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setSpacing.ts index dd204e6d253..befed4e3fe0 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setSpacing.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setSpacing.ts @@ -1,12 +1,12 @@ import { formatParagraphWithContentModel } from '../utils/formatParagraphWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Sets current selected block(s) line-height property and wipes such property from child segments * @param editor The editor to operate on * @param spacing Unitless/px value to set line height */ -export default function setSpacing(editor: IStandaloneEditor, spacing: number | string) { +export default function setSpacing(editor: IEditor, spacing: number | string) { editor.focus(); formatParagraphWithContentModel(editor, 'setSpacing', paragraph => { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts index 87fa8055e0b..f1a4bdddeed 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts @@ -1,8 +1,5 @@ import { toggleModelBlockQuote } from '../../modelApi/block/toggleModelBlockQuote'; -import type { - ContentModelFormatContainerFormat, - IStandaloneEditor, -} from 'roosterjs-content-model-types'; +import type { ContentModelFormatContainerFormat, IEditor } from 'roosterjs-content-model-types'; const DefaultQuoteFormatLtr: ContentModelFormatContainerFormat = { borderLeft: '3px solid rgb(200, 200, 200)', @@ -27,7 +24,7 @@ const BuildInQuoteFormat: ContentModelFormatContainerFormat = { * @param quoteFormat @optional Block format for the new quote object */ export default function toggleBlockQuote( - editor: IStandaloneEditor, + editor: IEditor, quoteFormat?: ContentModelFormatContainerFormat, quoteFormatRtl?: ContentModelFormatContainerFormat ) { 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 8282c0fcc9f..c6615a0089c 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 @@ -1,12 +1,17 @@ import { ChangeSource } from 'roosterjs-content-model-core'; -import { createEntity, normalizeContentModel } from 'roosterjs-content-model-dom'; import { insertEntityModel } from '../../modelApi/entity/insertEntityModel'; +import { + createEntity, + normalizeContentModel, + parseEntityFormat, +} from 'roosterjs-content-model-dom'; import type { ContentModelEntity, DOMSelection, InsertEntityPosition, InsertEntityOptions, - IStandaloneEditor, + IEditor, + EntityState, } from 'roosterjs-content-model-types'; const BlockEntityTag = 'div'; @@ -14,7 +19,7 @@ const InlineEntityTag = 'span'; /** * Insert an entity into editor - * @param editor The Content Model editor + * @param editor The editor object * @param type Type of entity * @param isBlock True to insert a block entity, false to insert an inline entity * @param position Position of the entity to insert. It can be @@ -24,7 +29,7 @@ const InlineEntityTag = 'span'; * @param options Move options to insert. See InsertEntityOptions */ export default function insertEntity( - editor: IStandaloneEditor, + editor: IEditor, type: string, isBlock: boolean, position: 'focus' | 'begin' | 'end' | DOMSelection, @@ -33,7 +38,7 @@ export default function insertEntity( /** * Insert a block entity into editor - * @param editor The Content Model editor + * @param editor The editor object * @param type Type of entity * @param isBlock Must be true for a block entity * @param position Position of the entity to insert. It can be @@ -43,7 +48,7 @@ export default function insertEntity( * @param options Move options to insert. See InsertEntityOptions */ export default function insertEntity( - editor: IStandaloneEditor, + editor: IEditor, type: string, isBlock: true, position: InsertEntityPosition | DOMSelection, @@ -51,13 +56,14 @@ export default function insertEntity( ): ContentModelEntity | null; export default function insertEntity( - editor: IStandaloneEditor, + editor: IEditor, type: string, isBlock: boolean, position?: InsertEntityPosition | DOMSelection, options?: InsertEntityOptions ): ContentModelEntity | null { - const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot } = options || {}; + const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot, initialEntityState } = + options || {}; const document = editor.getDocument(); const wrapper = document.createElement(isBlock ? BlockEntityTag : InlineEntityTag); const display = wrapperDisplay ?? (isBlock ? undefined : 'inline-block'); @@ -75,6 +81,10 @@ export default function insertEntity( const entityModel = createEntity(wrapper, true /* isReadonly */, undefined /*format*/, type); + if (!skipUndoSnapshot) { + editor.takeSnapshot(); + } + editor.formatContentModel( (model, context) => { insertEntityModel( @@ -88,7 +98,7 @@ export default function insertEntity( normalizeContentModel(model); - context.skipUndoSnapshot = skipUndoSnapshot; + context.skipUndoSnapshot = true; context.newEntities.push(entityModel); return true; @@ -106,5 +116,25 @@ export default function insertEntity( } ); + if (!skipUndoSnapshot) { + let entityState: EntityState | undefined; + + if (initialEntityState) { + const format = parseEntityFormat(wrapper); + const { id, entityType } = format; + + entityState = + id && entityType + ? { + id: id, + type: entityType, + state: initialEntityState, + } + : undefined; + } + + editor.takeSnapshot(entityState); + } + return entityModel; } diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts index a890b26dc60..ed1a172d678 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts @@ -1,7 +1,7 @@ import { clearModelFormat } from '../../modelApi/common/clearModelFormat'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { - IStandaloneEditor, + IEditor, ContentModelBlock, ContentModelBlockGroup, ContentModelSegment, @@ -12,7 +12,7 @@ import type { * Clear format of selection * @param editor The editor to clear format from */ -export default function clearFormat(editor: IStandaloneEditor) { +export default function clearFormat(editor: IEditor) { editor.focus(); editor.formatContentModel( 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 e97cc925430..9c3af3ddcc8 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,11 +1,11 @@ import { retrieveModelFormatState } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; +import type { IEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; /** * Get current format state * @param editor The editor to get format from */ -export default function getFormatState(editor: IStandaloneEditor): ContentModelFormatState { +export default function getFormatState(editor: IEditor): ContentModelFormatState { const pendingFormat = editor.getPendingFormat(); const model = editor.getContentModelCopy('reduced'); const manager = editor.getSnapshotsManager(); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/adjustImageSelection.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/adjustImageSelection.ts index 83e7e76c6a1..622ca208ce1 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/adjustImageSelection.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/adjustImageSelection.ts @@ -1,11 +1,11 @@ import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; -import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelImage, IEditor } from 'roosterjs-content-model-types'; /** * Adjust selection to make sure select an image if any * @return Content Model Image object if an image is select, or null */ -export default function adjustImageSelection(editor: IStandaloneEditor): ContentModelImage | null { +export default function adjustImageSelection(editor: IEditor): ContentModelImage | null { let image: ContentModelImage | null = null; editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts index b2bc15d94f1..08872385a14 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts @@ -1,13 +1,13 @@ import formatImageWithContentModel from '../utils/formatImageWithContentModel'; import { readFile, updateImageMetadata } from 'roosterjs-content-model-core'; -import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelImage, IEditor } from 'roosterjs-content-model-types'; /** * Change the selected image src * @param editor The editor instance * @param file The image file */ -export default function changeImage(editor: IStandaloneEditor, file: File) { +export default function changeImage(editor: IEditor, file: File) { editor.focus(); const selection = editor.getDOMSelection(); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts index 2cc4ee92ae1..b63614347c0 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts @@ -1,13 +1,13 @@ import { addSegment, createContentModelDocument, createImage } from 'roosterjs-content-model-dom'; import { mergeModel, readFile } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Insert an image into current selected position * @param editor The editor to operate on * @param file Image Blob file or source string */ -export default function insertImage(editor: IStandaloneEditor, imageFileOrSrc: File | string) { +export default function insertImage(editor: IEditor, imageFileOrSrc: File | string) { editor.focus(); if (typeof imageFileOrSrc == 'string') { @@ -21,7 +21,7 @@ export default function insertImage(editor: IStandaloneEditor, imageFileOrSrc: F } } -function insertImageWithSrc(editor: IStandaloneEditor, src: string) { +function insertImageWithSrc(editor: IEditor, src: string) { editor.formatContentModel( (model, context) => { const image = createImage(src, { backgroundColor: '' }); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageAltText.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageAltText.ts index c21995e2d3f..aa3779db680 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageAltText.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageAltText.ts @@ -1,5 +1,5 @@ import formatImageWithContentModel from '../utils/formatImageWithContentModel'; -import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelImage, IEditor } from 'roosterjs-content-model-types'; /** * Set image alt text for all selected images at selection. If no images is contained @@ -7,7 +7,7 @@ import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-mod * @param editor The editor instance * @param altText The image alt text */ -export default function setImageAltText(editor: IStandaloneEditor, altText: string) { +export default function setImageAltText(editor: IEditor, altText: string) { editor.focus(); formatImageWithContentModel(editor, 'setImageAltText', (image: ContentModelImage) => { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBorder.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBorder.ts index 71b2d5a0306..8fea90f6f86 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBorder.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBorder.ts @@ -1,6 +1,6 @@ import applyImageBorderFormat from '../../modelApi/image/applyImageBorderFormat'; import formatImageWithContentModel from '../utils/formatImageWithContentModel'; -import type { Border, ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { Border, ContentModelImage, IEditor } from 'roosterjs-content-model-types'; /** * Set image border style for all selected images at selection. @@ -10,7 +10,7 @@ import type { Border, ContentModelImage, IStandaloneEditor } from 'roosterjs-con * @param borderRadius the border radius value, if undefined, the border radius will keep the actual value */ export default function setImageBorder( - editor: IStandaloneEditor, + editor: IEditor, border: Border | null, borderRadius?: string ) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBoxShadow.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBoxShadow.ts index 2f50789079b..4da659632fa 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBoxShadow.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBoxShadow.ts @@ -1,5 +1,5 @@ import formatImageWithContentModel from '../utils/formatImageWithContentModel'; -import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelImage, IEditor } from 'roosterjs-content-model-types'; /** * Set image box shadow for all selected images at selection. @@ -8,7 +8,7 @@ import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-mod * @param margin The image margin for all sides (eg. "4px"), null to remove margin */ export default function setImageBoxShadow( - editor: IStandaloneEditor, + editor: IEditor, boxShadow: string, margin?: string | null ) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/adjustLinkSelection.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/adjustLinkSelection.ts index c0e1dc63610..c2b192dbc06 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/adjustLinkSelection.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/adjustLinkSelection.ts @@ -1,13 +1,13 @@ import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; import { getSelectedSegments, setSelection } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Adjust selection to make sure select a hyperlink if any, or a word if original selection is collapsed * @return A combination of existing link display text and url if any. If there is no existing link, return selected text and null */ -export default function adjustLinkSelection(editor: IStandaloneEditor): [string, string | null] { +export default function adjustLinkSelection(editor: IEditor): [string, string | null] { let text = ''; let url: string | null = null; diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts index 90b16943afe..edf36977355 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts @@ -1,7 +1,7 @@ import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; import { ChangeSource, getSelectedSegments, mergeModel } from 'roosterjs-content-model-core'; import { matchLink } from '../../modelApi/link/matchLink'; -import type { ContentModelLink, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelLink, IEditor } from 'roosterjs-content-model-types'; import { addLink, addSegment, @@ -30,7 +30,7 @@ const FTP_REGEX = /^ftp\./i; * If not specified and there wasn't a link, the link url will be used as display text. */ export default function insertLink( - editor: IStandaloneEditor, + editor: IEditor, link: string, anchorTitle?: string, displayText?: string, diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/removeLink.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/removeLink.ts index b6c7f38abb6..8b90fa1d68b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/removeLink.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/removeLink.ts @@ -1,6 +1,6 @@ import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; import { getSelectedSegments } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Remove link at selection. If no links at selection, do nothing. @@ -8,7 +8,7 @@ import type { IStandaloneEditor } from 'roosterjs-content-model-types'; * If only part of a link is selected, the whole link style will be removed. * @param editor The editor instance */ -export default function removeLink(editor: IStandaloneEditor) { +export default function removeLink(editor: IEditor) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStartNumber.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStartNumber.ts index 1ce023a263a..37e1091894a 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStartNumber.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStartNumber.ts @@ -1,12 +1,12 @@ import { getFirstSelectedListItem } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Set start number of a list item * @param editor The editor to operate on * @param value The number to set to, must be equal or greater than 1 */ -export default function setListStartNumber(editor: IStandaloneEditor, value: number) { +export default function setListStartNumber(editor: IEditor, value: number) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStyle.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStyle.ts index 1a4c075e19f..98b4507d841 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStyle.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStyle.ts @@ -1,13 +1,13 @@ import { findListItemsInSameThread } from '../../modelApi/list/findListItemsInSameThread'; import { getFirstSelectedListItem, updateListMetadata } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor, ListMetadataFormat } from 'roosterjs-content-model-types'; +import type { IEditor, ListMetadataFormat } from 'roosterjs-content-model-types'; /** * Set style of list items with in same thread of current item * @param editor The editor to operate on * @param style The target list item style to set */ -export default function setListStyle(editor: IStandaloneEditor, style: ListMetadataFormat) { +export default function setListStyle(editor: IEditor, style: ListMetadataFormat) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleBullet.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleBullet.ts index 7729eade0c6..cbf8a36ca65 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleBullet.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleBullet.ts @@ -1,20 +1,21 @@ import { setListType } from '../../modelApi/list/setListType'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggle bullet list type * - When there are some blocks not in bullet list, set all blocks to the given type * - When all blocks are already in bullet list, turn off / outdent there list type * @param editor The editor to operate on + * @param removeMargins true to remove margins, false to keep margins @default false */ -export default function toggleBullet(editor: IStandaloneEditor) { +export default function toggleBullet(editor: IEditor, removeMargins: boolean = false) { editor.focus(); editor.formatContentModel( (model, context) => { context.newPendingFormat = 'preserve'; - return setListType(model, 'UL'); + return setListType(model, 'UL', removeMargins); }, { apiName: 'toggleBullet', diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleNumbering.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleNumbering.ts index b98c6df7b2f..2e754b6fad6 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleNumbering.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleNumbering.ts @@ -1,20 +1,21 @@ import { setListType } from '../../modelApi/list/setListType'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggle numbering list type * - When there are some blocks not in numbering list, set all blocks to the given type * - When all blocks are already in numbering list, turn off / outdent there list type * @param editor The editor to operate on + * @param removeMargins true to remove margins, false to keep margins @default false */ -export default function toggleNumbering(editor: IStandaloneEditor) { +export default function toggleNumbering(editor: IEditor, removeMargins: boolean = false) { editor.focus(); editor.formatContentModel( (model, context) => { context.newPendingFormat = 'preserve'; - return setListType(model, 'OL'); + return setListType(model, 'OL', removeMargins); }, { apiName: 'toggleNumbering', diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/applySegmentFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/applySegmentFormat.ts index a3cf335538a..65aaec07a00 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/applySegmentFormat.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/applySegmentFormat.ts @@ -1,15 +1,12 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; /** * Bulk apply segment format to all selected content. This is usually used for format painter. * @param editor The editor to operate on * @param newFormat The segment format to apply */ -export default function applySegmentFormat( - editor: IStandaloneEditor, - newFormat: ContentModelSegmentFormat -) { +export default function applySegmentFormat(editor: IEditor, newFormat: ContentModelSegmentFormat) { formatSegmentWithContentModel( editor, 'applySegmentFormat', diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts index e5e3e30f96e..4bbe56f8fd8 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts @@ -1,5 +1,5 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Change the capitalization of text in the selection @@ -10,7 +10,7 @@ import type { IStandaloneEditor } from 'roosterjs-content-model-types'; * Default is the host environment’s current locale. */ export default function changeCapitalization( - editor: IStandaloneEditor, + editor: IEditor, capitalization: 'sentence' | 'lowerCase' | 'upperCase' | 'capitalize', language?: string ) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeFontSize.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeFontSize.ts index fcfba224fd0..b1b23c401be 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeFontSize.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeFontSize.ts @@ -4,7 +4,7 @@ import { setFontSizeInternal } from './setFontSize'; import type { ContentModelParagraph, ContentModelSegmentFormat, - IStandaloneEditor, + IEditor, } from 'roosterjs-content-model-types'; /** @@ -21,7 +21,7 @@ const MAX_FONT_SIZE = 1000; * @param change Whether increase or decrease font size * @param fontSizes A sorted font size array, in pt. Default value is FONT_SIZES */ -export default function changeFontSize(editor: IStandaloneEditor, change: 'increase' | 'decrease') { +export default function changeFontSize(editor: IEditor, change: 'increase' | 'decrease') { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setBackgroundColor.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setBackgroundColor.ts index 099c2d82ac6..13ba5da55db 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setBackgroundColor.ts @@ -1,17 +1,14 @@ import { createSelectionMarker } from 'roosterjs-content-model-dom'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; import { setSelection } from 'roosterjs-content-model-core'; -import type { ContentModelParagraph, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelParagraph, IEditor } from 'roosterjs-content-model-types'; /** * Set background color * @param editor The editor to operate on * @param backgroundColor The color to set. Pass null to remove existing color. */ -export default function setBackgroundColor( - editor: IStandaloneEditor, - backgroundColor: string | null -) { +export default function setBackgroundColor(editor: IEditor, backgroundColor: string | null) { editor.focus(); let lastParagraph: ContentModelParagraph | null = null; diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontName.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontName.ts index bef80eaed7e..3acaeeaa37c 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontName.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontName.ts @@ -1,12 +1,12 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Set font name * @param editor The editor to operate on * @param fontName The font name to set */ -export default function setFontName(editor: IStandaloneEditor, fontName: string) { +export default function setFontName(editor: IEditor, fontName: string) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontSize.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontSize.ts index f39104ecd1a..ccfa9194f32 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontSize.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontSize.ts @@ -2,7 +2,7 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContent import type { ContentModelParagraph, ContentModelSegmentFormat, - IStandaloneEditor, + IEditor, } from 'roosterjs-content-model-types'; /** @@ -10,7 +10,7 @@ import type { * @param editor The editor to operate on * @param fontSize The font size to set */ -export default function setFontSize(editor: IStandaloneEditor, fontSize: string) { +export default function setFontSize(editor: IEditor, fontSize: string) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setTextColor.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setTextColor.ts index edf70181d89..0c6c4c03e0f 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setTextColor.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setTextColor.ts @@ -1,12 +1,12 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Set text color * @param editor The editor to operate on * @param textColor The text color to set. Pass null to remove existing color. */ -export default function setTextColor(editor: IStandaloneEditor, textColor: string | null) { +export default function setTextColor(editor: IEditor, textColor: string | null) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleBold.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleBold.ts index 94440c2c7d0..5fc881ce9d1 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleBold.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleBold.ts @@ -1,12 +1,12 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; import { isBold } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggle bold style * @param editor The editor to operate on */ -export default function toggleBold(editor: IStandaloneEditor) { +export default function toggleBold(editor: IEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleCode.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleCode.ts index 4269bcd762a..06526e8aee2 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleCode.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleCode.ts @@ -1,6 +1,6 @@ import { addCode } from 'roosterjs-content-model-dom'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { ContentModelCode, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelCode, IEditor } from 'roosterjs-content-model-types'; const DefaultCode: ContentModelCode = { format: { @@ -12,7 +12,7 @@ const DefaultCode: ContentModelCode = { * Toggle italic style * @param editor The editor to operate on */ -export default function toggleCode(editor: IStandaloneEditor) { +export default function toggleCode(editor: IEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleItalic.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleItalic.ts index b3c25de6524..2be06d9b899 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleItalic.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleItalic.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggle italic style * @param editor The editor to operate on */ -export default function toggleItalic(editor: IStandaloneEditor) { +export default function toggleItalic(editor: IEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleStrikethrough.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleStrikethrough.ts index 93e2df84d2c..3794ea99d27 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleStrikethrough.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleStrikethrough.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggle strikethrough style * @param editor The editor to operate on */ -export default function toggleStrikethrough(editor: IStandaloneEditor) { +export default function toggleStrikethrough(editor: IEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSubscript.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSubscript.ts index 28c9b6da37b..4396b9e1d47 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSubscript.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSubscript.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggle subscript style * @param editor The editor to operate on */ -export default function toggleSubscript(editor: IStandaloneEditor) { +export default function toggleSubscript(editor: IEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSuperscript.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSuperscript.ts index 2e84d4d5059..74d37665778 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSuperscript.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSuperscript.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggle superscript style * @param editor The editor to operate on */ -export default function toggleSuperscript(editor: IStandaloneEditor) { +export default function toggleSuperscript(editor: IEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts index b58ef453783..f1aa57e86c9 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts @@ -1,12 +1,12 @@ import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Toggle underline style * @param editor The editor to operate on */ -export default function toggleUnderline(editor: IStandaloneEditor) { +export default function toggleUnderline(editor: IEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts index f7c92cc7069..ffc0d4026a4 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts @@ -6,7 +6,7 @@ import { updateTableCellMetadata, } from 'roosterjs-content-model-core'; import type { - IStandaloneEditor, + IEditor, Border, ContentModelTable, ContentModelTableCell, @@ -39,7 +39,7 @@ type Perimeter = { * @param operation The operation to apply */ export default function applyTableBorderFormat( - editor: IStandaloneEditor, + editor: IEditor, border: Border, operation: BorderOperations ) { 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 5f63f760b54..543110c135f 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 @@ -10,7 +10,7 @@ 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 type { TableOperation, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { TableOperation, IEditor } from 'roosterjs-content-model-types'; import { alignTableCellHorizontally, alignTableCellVertically, @@ -21,7 +21,7 @@ import { * @param editor The editor instance * @param operation The table operation to apply */ -export default function editTable(editor: IStandaloneEditor, operation: TableOperation) { +export default function editTable(editor: IEditor, operation: TableOperation) { editor.focus(); formatTableWithContentModel(editor, 'editTable', tableModel => { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/formatTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/formatTable.ts index 411f229bedb..8fd4d6235a4 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/formatTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/formatTable.ts @@ -3,7 +3,7 @@ import { getFirstSelectedTable, updateTableCellMetadata, } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor, TableMetadataFormat } from 'roosterjs-content-model-types'; +import type { IEditor, TableMetadataFormat } from 'roosterjs-content-model-types'; /** * Format current focused table with the given format @@ -12,7 +12,7 @@ import type { IStandaloneEditor, TableMetadataFormat } from 'roosterjs-content-m * @param keepCellShade Whether keep existing shade color when apply format if there is a manually set shade color */ export default function formatTable( - editor: IStandaloneEditor, + editor: IEditor, format: TableMetadataFormat, keepCellShade?: boolean ) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts index 0128b7913a2..8c1072c94ae 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts @@ -7,7 +7,7 @@ import { normalizeTable, setSelection, } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor, TableMetadataFormat } from 'roosterjs-content-model-types'; +import type { IEditor, TableMetadataFormat } from 'roosterjs-content-model-types'; /** * Insert table into editor at current selection @@ -19,7 +19,7 @@ import type { IStandaloneEditor, TableMetadataFormat } from 'roosterjs-content-m * background color: #FFF; border color: #ABABAB */ export default function insertTable( - editor: IStandaloneEditor, + editor: IEditor, columns: number, rows: number, format?: Partial diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts index fee572ec8bf..8c54f2c595e 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts @@ -4,14 +4,14 @@ import { normalizeTable, setTableCellBackgroundColor, } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Set table cell shade color * @param editor The editor instance * @param color The color to set. Pass null to remove existing shade color */ -export default function setTableCellShade(editor: IStandaloneEditor, color: string | null) { +export default function setTableCellShade(editor: IEditor, color: string | null) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatImageWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatImageWithContentModel.ts index df35d9b8e74..1bda429eeed 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatImageWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatImageWithContentModel.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from './formatSegmentWithContentModel'; -import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelImage, IEditor } from 'roosterjs-content-model-types'; /** * @internal */ export default function formatImageWithContentModel( - editor: IStandaloneEditor, + editor: IEditor, apiName: string, callback: (segment: ContentModelImage) => void ) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatParagraphWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatParagraphWithContentModel.ts index a54e2e8deca..951e26f7748 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatParagraphWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatParagraphWithContentModel.ts @@ -1,11 +1,11 @@ import { getSelectedParagraphs } from 'roosterjs-content-model-core'; -import type { ContentModelParagraph, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelParagraph, IEditor } from 'roosterjs-content-model-types'; /** * @internal */ export function formatParagraphWithContentModel( - editor: IStandaloneEditor, + editor: IEditor, apiName: string, setStyleCallback: (paragraph: ContentModelParagraph) => void ) { 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 45f9653674e..727615732e5 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 @@ -5,14 +5,14 @@ import type { ContentModelParagraph, ContentModelSegment, ContentModelSegmentFormat, - IStandaloneEditor, + IEditor, } from 'roosterjs-content-model-types'; /** * @internal */ export function formatSegmentWithContentModel( - editor: IStandaloneEditor, + editor: IEditor, apiName: string, toggleStyleCallback: ( format: ContentModelSegmentFormat, 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 index fa33f6ed282..b9f26ac9c35 100644 --- 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 @@ -11,11 +11,7 @@ import { normalizeTable, setSelection, } from 'roosterjs-content-model-core'; -import type { - ContentModelTable, - IStandaloneEditor, - TableSelection, -} from 'roosterjs-content-model-types'; +import type { ContentModelTable, IEditor, TableSelection } from 'roosterjs-content-model-types'; /** * Invoke a callback to format the selected table using Content Model @@ -25,7 +21,7 @@ import type { * @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, + editor: IEditor, apiName: string, callback: (tableModel: ContentModelTable) => void, selectionOverride?: TableSelection 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 b457c35abff..aa2084fd99a 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 @@ -74,9 +74,9 @@ describe('indent', () => { format: { startNumberOverride: 1, direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, - marginTop: '0px', - marginBottom: '0px', }, dataset: {}, }, @@ -110,6 +110,65 @@ describe('indent', () => { }); }); + it('Group with single paragraph selection remove margins', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const text = createText('test'); + + para.segments.push(text); + group.blocks.push(para); + + text.isSelected = true; + + const result = setListType(group, 'OL', true /** remove margins */); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + direction: undefined, + marginBottom: '0px', + marginTop: '0px', + textAlign: undefined, + }, + dataset: {}, + }, + ], + blocks: [para], + formatHolder: { + segmentType: 'SelectionMarker', + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + isSelected: true, + }, + format: {}, + }, + ], + }); + expect(result).toBeTrue(); + expect(para).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + }); + }); it('Group with single list item selection in a different type', () => { const group = createContentModelDocument(); const para = createParagraph(); @@ -301,9 +360,9 @@ describe('indent', () => { format: { startNumberOverride: undefined, direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, - marginTop: '0px', - marginBottom: '0px', }, dataset: {}, }, @@ -366,8 +425,8 @@ describe('indent', () => { startNumberOverride: 1, direction: 'rtl', textAlign: 'start', - marginTop: '0px', - marginBottom: '0px', + marginBottom: undefined, + marginTop: undefined, }, }, ], @@ -445,9 +504,9 @@ describe('indent', () => { format: { startNumberOverride: 1, direction: undefined, + marginTop: undefined, textAlign: undefined, marginBottom: '0px', - marginTop: '0px', }, }, ], @@ -473,10 +532,10 @@ describe('indent', () => { dataset: {}, format: { direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, startNumberOverride: undefined, - marginTop: '0px', - marginBottom: '0px', }, }, ], @@ -529,9 +588,9 @@ describe('indent', () => { format: { startNumberOverride: 1, direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, - marginTop: '0px', - marginBottom: '0px', }, }, ], @@ -586,9 +645,9 @@ describe('indent', () => { format: { startNumberOverride: 1, direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, - marginTop: '0px', - marginBottom: '0px', }, }, ], @@ -614,9 +673,9 @@ describe('indent', () => { format: { startNumberOverride: undefined, direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, - marginTop: '0px', - marginBottom: '0px', }, }, ], @@ -641,10 +700,10 @@ describe('indent', () => { dataset: {}, format: { direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, startNumberOverride: undefined, - marginTop: '0px', - marginBottom: '0px', }, }, ], @@ -716,9 +775,9 @@ describe('indent', () => { format: { startNumberOverride: 1, direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, - marginTop: '0px', - marginBottom: '0px', }, dataset: {}, }, @@ -746,9 +805,9 @@ describe('indent', () => { format: { startNumberOverride: undefined, direction: undefined, + marginBottom: undefined, + marginTop: undefined, textAlign: undefined, - marginTop: '0px', - marginBottom: '0px', }, dataset: {}, }, @@ -799,10 +858,7 @@ describe('indent', () => { levels: [ { listType: 'UL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: {}, }, ], @@ -840,10 +896,7 @@ describe('indent', () => { levels: [ { listType: 'UL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: {}, }, ], 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 fff6d7b07d2..87f94541614 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 @@ -1,4 +1,4 @@ -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -7,7 +7,7 @@ import { export function paragraphTestCommon( apiName: string, - executionCallback: (editor: IStandaloneEditor) => void, + executionCallback: (editor: IEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number @@ -26,7 +26,7 @@ export function paragraphTestCommon( focus: jasmine.createSpy(), getFocusedPosition: () => ({}), formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; executionCallback(editor); 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 a2bc884ffc4..011584e2754 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 @@ -1,7 +1,7 @@ import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; import setAlignment from '../../../lib/publicApi/block/setAlignment'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { paragraphTestCommon } from './paragraphTestCommon'; import { ContentModelDocument, @@ -415,7 +415,7 @@ describe('setAlignment', () => { }); describe('setAlignment in table', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let triggerEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; @@ -431,7 +431,7 @@ describe('setAlignment in table', () => { isDarkMode: () => false, triggerEvent, getVisibleViewport, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); function runTest( @@ -813,7 +813,7 @@ describe('setAlignment in table', () => { }); describe('setAlignment in list', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let triggerEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; @@ -827,7 +827,7 @@ describe('setAlignment in list', () => { isDarkMode: () => false, triggerEvent, getVisibleViewport, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); function runTest( 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 a016d66f7db..a72c0e02f21 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,11 +1,11 @@ import * as setModelIndentation from '../../../lib/modelApi/block/setModelIndentation'; import setIndentation from '../../../lib/publicApi/block/setIndentation'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelFormatter, FormatContentModelContext } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; describe('setIndentation', () => { const fakeModel: any = { a: 'b' }; - let editor: IStandaloneEditor; + let editor: IEditor; let formatContentModelSpy: jasmine.Spy; let context: FormatContentModelContext; @@ -26,7 +26,7 @@ describe('setIndentation', () => { formatContentModel: formatContentModelSpy, focus: jasmine.createSpy('focus'), getPendingFormat: () => null as any, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); it('indent', () => { 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 5479f7988f0..76ebbef70e5 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,11 +1,11 @@ import * as toggleModelBlockQuote from '../../../lib/modelApi/block/toggleModelBlockQuote'; import toggleBlockQuote from '../../../lib/publicApi/block/toggleBlockQuote'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelFormatter, FormatContentModelContext } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; describe('toggleBlockQuote', () => { const fakeModel: any = { a: 'b' }; - let editor: IStandaloneEditor; + let editor: IEditor; let formatContentModelSpy: jasmine.Spy; let context: FormatContentModelContext; @@ -26,7 +26,7 @@ describe('toggleBlockQuote', () => { editor = ({ focus: jasmine.createSpy('focus'), formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); it('toggleBlockQuote', () => { 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 2753aaaeb2f..475163b52e5 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 @@ -3,14 +3,14 @@ import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityMod 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 { IEditor } from 'roosterjs-content-model-types'; import { FormatContentModelContext, FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('insertEntity', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let context: FormatContentModelContext; let wrapper: HTMLElement; const model = 'MockedModel' as any; @@ -24,6 +24,7 @@ describe('insertEntity', () => { let insertEntityModelSpy: jasmine.Spy; let isDarkModeSpy: jasmine.Spy; let normalizeContentModelSpy: jasmine.Spy; + let takeSnapshotSpy: jasmine.Spy; const type = 'Entity'; const apiName = 'insertEntity'; @@ -39,6 +40,7 @@ describe('insertEntity', () => { appendChildSpy = jasmine.createSpy('appendChildSpy'); insertEntityModelSpy = spyOn(insertEntityModel, 'insertEntityModel'); isDarkModeSpy = jasmine.createSpy('isDarkMode'); + takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); wrapper = { style: { @@ -68,6 +70,7 @@ describe('insertEntity', () => { getDocument: getDocumentSpy, isDarkMode: isDarkModeSpy, formatContentModel: formatWithContentModelSpy, + takeSnapshot: takeSnapshotSpy, } as any; spyOn(entityUtils, 'addDelimiters').and.returnValue([]); @@ -76,6 +79,9 @@ describe('insertEntity', () => { it('insert inline entity to top', () => { const entity = insertEntity(editor, type, false, 'begin'); + expect(takeSnapshotSpy).toHaveBeenCalledTimes(2); + expect(takeSnapshotSpy).toHaveBeenCalledWith(); + expect(takeSnapshotSpy).toHaveBeenCalledWith(undefined); expect(createElementSpy).toHaveBeenCalledWith('span'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); expect(appendChildSpy).not.toHaveBeenCalled(); @@ -120,6 +126,9 @@ describe('insertEntity', () => { it('block inline entity to root', () => { const entity = insertEntity(editor, type, true, 'root'); + expect(takeSnapshotSpy).toHaveBeenCalledTimes(2); + expect(takeSnapshotSpy).toHaveBeenCalledWith(); + expect(takeSnapshotSpy).toHaveBeenCalledWith(undefined); expect(createElementSpy).toHaveBeenCalledWith('div'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); expect(setPropertySpy).toHaveBeenCalledWith('width', '100%'); @@ -172,6 +181,7 @@ describe('insertEntity', () => { wrapperDisplay: 'none', }); + expect(takeSnapshotSpy).toHaveBeenCalledTimes(0); expect(createElementSpy).toHaveBeenCalledWith('div'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'none'); expect(setPropertySpy).not.toHaveBeenCalledWith('display', 'inline-block'); @@ -221,6 +231,81 @@ describe('insertEntity', () => { const entity = insertEntity(editor, type, false, 'begin'); + expect(takeSnapshotSpy).toHaveBeenCalledTimes(2); + expect(takeSnapshotSpy).toHaveBeenCalledWith(); + expect(takeSnapshotSpy).toHaveBeenCalledWith(undefined); + expect(createElementSpy).toHaveBeenCalledWith('span'); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toBe( + ChangeSource.InsertEntity + ); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + id: undefined, + entityType: type, + isReadonly: true, + }, + wrapper: wrapper, + }, + 'begin', + false, + undefined, + context + ); + expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); + expect(normalizeContentModelSpy).toHaveBeenCalled(); + + expect(context.newEntities).toEqual([ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + id: undefined, + entityType: 'Entity', + isReadonly: true, + }, + wrapper, + }, + ]); + + expect(entity).toEqual({ + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + id: undefined, + entityType: type, + isReadonly: true, + }, + wrapper: wrapper, + }); + }); + + it('Insert with initial state', () => { + spyOn(entityUtils, 'parseEntityFormat').and.returnValue({ + id: 'A', + entityType: 'B', + }); + + const entity = insertEntity(editor, type, false, 'begin', { + initialEntityState: 'test', + }); + + expect(takeSnapshotSpy).toHaveBeenCalledTimes(2); + expect(takeSnapshotSpy).toHaveBeenCalledWith(); + expect(takeSnapshotSpy).toHaveBeenCalledWith({ + id: 'A', + type: 'B', + state: 'test', + }); expect(createElementSpy).toHaveBeenCalledWith('span'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); expect(appendChildSpy).not.toHaveBeenCalled(); 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 2ab4ed941fe..f98777a04e8 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 @@ -1,7 +1,7 @@ import * as clearModelFormat from '../../../lib/modelApi/common/clearModelFormat'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import clearFormat from '../../../lib/publicApi/format/clearFormat'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -21,7 +21,7 @@ describe('clearFormat', () => { const editor = ({ focus: () => {}, formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(clearModelFormat, 'clearModelFormat'); spyOn(normalizeContentModel, 'normalizeContentModel'); 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 5ba4c29395c..a9e90949ed8 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 @@ -2,7 +2,7 @@ import * as retrieveModelFormatState from 'roosterjs-content-model-core/lib/publ import getFormatState from '../../../lib/publicApi/format/getFormatState'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { ContentModelFormatState } from 'roosterjs-content-model-types'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { reducedModelChildProcessor } from 'roosterjs-content-model-core/lib/override/reducedModelChildProcessor'; import { createContentModelDocument, @@ -60,7 +60,7 @@ describe('getFormatState', () => { return model; }, - } as any) as IStandaloneEditor; + } as any) as IEditor; const result = getFormatState(editor); 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 65aa6f3d944..4b4593d7513 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 @@ -1,6 +1,6 @@ import * as readFile from 'roosterjs-content-model-core/lib/publicApi/domUtils/readFile'; import changeImage from '../../../lib/publicApi/image/changeImage'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -21,7 +21,7 @@ describe('changeImage', () => { function runTest( model: ContentModelDocument, - executionCallback: (editor: IStandaloneEditor) => void, + executionCallback: (editor: IEditor) => void, result: ContentModelDocument, calledTimes: number ) { @@ -49,7 +49,7 @@ describe('changeImage', () => { getDOMSelection, triggerEvent, formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; executionCallback(editor); 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 fb061333a62..ab9cbd3212b 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 @@ -1,6 +1,6 @@ import * as readFile from 'roosterjs-content-model-core/lib/publicApi/domUtils/readFile'; import insertImage from '../../../lib/publicApi/image/insertImage'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -17,7 +17,7 @@ describe('insertImage', () => { function runTest( apiName: string, - executionCallback: (editor: IStandaloneEditor) => void, + executionCallback: (editor: IEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number @@ -37,7 +37,7 @@ describe('insertImage', () => { focus: jasmine.createSpy(), isDisposed: () => false, formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; executionCallback(editor); @@ -198,6 +198,7 @@ describe('insertImage', () => { isSelected: true, }, ], + segmentFormat: { fontFamily: 'Test', fontSize: '20px' }, }, ], format: { 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 ea34577e818..ae1d7127f80 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 @@ -1,5 +1,5 @@ import adjustLinkSelection from '../../../lib/publicApi/link/adjustLinkSelection'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelLink, @@ -17,7 +17,7 @@ import { } from 'roosterjs-content-model-dom'; describe('adjustLinkSelection', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; let mockedModel: ContentModelDocument; @@ -38,7 +38,7 @@ describe('adjustLinkSelection', () => { editor = ({ formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); function runTest( 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 7d12c8d9c99..18924cb5ae9 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 @@ -1,6 +1,6 @@ import insertLink from '../../../lib/publicApi/link/insertLink'; -import { ChangeSource, StandaloneEditor } from 'roosterjs-content-model-core'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ChangeSource, Editor } from 'roosterjs-content-model-core'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelLink, @@ -16,13 +16,13 @@ import { } from 'roosterjs-content-model-dom'; describe('insertLink', () => { - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { editor = ({ focus: () => {}, getPendingFormat: () => null as any, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); function runTest( @@ -326,7 +326,7 @@ describe('insertLink', () => { getName: () => 'mock', onPluginEvent: onPluginEvent, }; - const editor = new StandaloneEditor(div, { + const editor = new Editor(div, { plugins: [mockedPlugin], }); 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 7ee4421aa21..3495cdb9f05 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 @@ -1,5 +1,5 @@ import removeLink from '../../../lib/publicApi/link/removeLink'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelLink, @@ -15,12 +15,12 @@ import { } from 'roosterjs-content-model-dom'; describe('removeLink', () => { - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { editor = ({ focus: () => {}, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); function runTest(model: ContentModelDocument, expectedModel: ContentModelDocument | null) { 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 aff7fbd3260..00f4866eb81 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 @@ -1,6 +1,6 @@ import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleBullet from '../../../lib/publicApi/list/toggleBullet'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -9,7 +9,7 @@ import { } from 'roosterjs-content-model-types'; describe('toggleBullet', () => { - let editor = ({} as any) as IStandaloneEditor; + let editor = ({} as any) as IEditor; let formatContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; @@ -36,7 +36,7 @@ describe('toggleBullet', () => { focus, formatContentModel, getFocusedPosition: () => ({}), - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(setListType, 'setListType').and.returnValue(true); }); @@ -45,7 +45,11 @@ describe('toggleBullet', () => { toggleBullet(editor); expect(setListType.setListType).toHaveBeenCalledTimes(1); - expect(setListType.setListType).toHaveBeenCalledWith(mockedModel, 'UL'); + expect(setListType.setListType).toHaveBeenCalledWith( + mockedModel, + 'UL', + false /** remove margins */ + ); expect(context).toEqual({ newEntities: [], deletedEntities: [], 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 131a7d011cc..bbde934f83f 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 @@ -1,6 +1,6 @@ import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleNumbering from '../../../lib/publicApi/list/toggleNumbering'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -9,7 +9,7 @@ import { } from 'roosterjs-content-model-types'; describe('toggleNumbering', () => { - let editor = ({} as any) as IStandaloneEditor; + let editor = ({} as any) as IEditor; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; let context: FormatContentModelContext; @@ -35,7 +35,7 @@ describe('toggleNumbering', () => { editor = ({ focus, formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(setListType, 'setListType').and.returnValue(true); }); @@ -44,7 +44,11 @@ describe('toggleNumbering', () => { toggleNumbering(editor); expect(setListType.setListType).toHaveBeenCalledTimes(1); - expect(setListType.setListType).toHaveBeenCalledWith(mockedModel, 'OL'); + expect(setListType.setListType).toHaveBeenCalledWith( + mockedModel, + 'OL', + false /** remove margins */ + ); expect(context).toEqual({ newEntities: [], deletedEntities: [], 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 647b6db1e06..268d8cfc6b1 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 @@ -1,7 +1,7 @@ import changeFontSize from '../../../lib/publicApi/segment/changeFontSize'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { segmentTestCommon } from './segmentTestCommon'; import { ContentModelDocument, @@ -361,7 +361,7 @@ describe('changeFontSize', () => { formatContentModel, focus: jasmine.createSpy(), getPendingFormat: () => null as ContentModelSegmentFormat, - } as any) as IStandaloneEditor; + } as any) as IEditor; changeFontSize(editor, 'increase'); 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 fcb001b9300..f0c17cd84cb 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 @@ -1,4 +1,4 @@ -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -7,7 +7,7 @@ import { export function segmentTestCommon( apiName: string, - executionCallback: (editor: IStandaloneEditor) => void, + executionCallback: (editor: IEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number @@ -27,7 +27,7 @@ export function segmentTestCommon( focus: jasmine.createSpy(), getPendingFormat: () => null as any, formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; executionCallback(editor); 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 17208920825..2e69d1b483e 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 @@ -2,7 +2,7 @@ import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/tabl import applyTableBorderFormat from '../../../lib/publicApi/table/applyTableBorderFormat'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { Border, BorderOperations, @@ -13,7 +13,7 @@ import { } from 'roosterjs-content-model-types'; describe('applyTableBorderFormat', () => { - let editor: IStandaloneEditor; + let editor: IEditor; const width = '3px'; const style = 'double'; const color = '#AABBCC'; @@ -43,7 +43,7 @@ describe('applyTableBorderFormat', () => { beforeEach(() => { spyOn(normalizeTable, 'normalizeTable'); - editor = ({} as any) as IStandaloneEditor; + editor = ({} as any) as IEditor; }); function runTest( 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 index 7593be0a2ed..841b50cc468 100644 --- 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 @@ -12,10 +12,10 @@ 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'; +import { IEditor, TableOperation } from 'roosterjs-content-model-types'; describe('editTable', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let focusSpy: jasmine.Spy; let formatTableWithContentModelSpy: jasmine.Spy; const mockedTable = 'TABLE' as any; 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 c3ae1bbbaf6..9869155565a 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 @@ -1,7 +1,7 @@ import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; import setTableCellShade from '../../../lib/publicApi/table/setTableCellShade'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelTable, ContentModelFormatter, @@ -9,14 +9,14 @@ import { } from 'roosterjs-content-model-types'; describe('setTableCellShade', () => { - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { spyOn(normalizeTable, 'normalizeTable'); editor = ({ focus: () => {}, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); function runTest( 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 987776d7e06..f15a296691b 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 @@ -1,5 +1,5 @@ import formatImageWithContentModel from '../../../lib/publicApi/utils/formatImageWithContentModel'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelImage, @@ -196,7 +196,7 @@ describe('formatImageWithContentModel', () => { function segmentTestForPluginEvent( apiName: string, - executionCallback: (editor: IStandaloneEditor) => void, + executionCallback: (editor: IEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number @@ -215,7 +215,7 @@ function segmentTestForPluginEvent( const editor = ({ formatContentModel, getPendingFormat: () => null as any, - } as any) as IStandaloneEditor; + } as any) as IEditor; executionCallback(editor); 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 341e6d3a259..1d9f532be78 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 @@ -1,5 +1,5 @@ import { formatParagraphWithContentModel } from '../../../lib/publicApi/utils/formatParagraphWithContentModel'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelParagraph, @@ -14,7 +14,7 @@ import { } from 'roosterjs-content-model-dom'; describe('formatParagraphWithContentModel', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let model: ContentModelDocument; let context: FormatContentModelContext; @@ -42,7 +42,7 @@ describe('formatParagraphWithContentModel', () => { editor = ({ getFocusedPosition: () => ({ node: mockedContainer, offset: mockedOffset }), formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); it('empty doc', () => { 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 3b87fecf7b5..e6be395e9d3 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 @@ -1,5 +1,5 @@ import { formatSegmentWithContentModel } from '../../../lib/publicApi/utils/formatSegmentWithContentModel'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelSegmentFormat, @@ -15,7 +15,7 @@ import { } from 'roosterjs-content-model-dom'; describe('formatSegment', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let focus: jasmine.Spy; let model: ContentModelDocument; let formatContentModel: jasmine.Spy; @@ -43,7 +43,7 @@ describe('formatSegment', () => { editor = ({ focus, formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); it('empty doc', () => { 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 index 377317b6d43..ef3b022d22c 100644 --- 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 @@ -2,7 +2,7 @@ import * as applyTableFormat from 'roosterjs-content-model-core/lib/publicApi/ta 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 { ContentModelDocument, IEditor } from 'roosterjs-content-model-types'; import { formatTableWithContentModel } from '../../../lib/publicApi/utils/formatTableWithContentModel'; import { createContentModelDocument, @@ -11,7 +11,7 @@ import { } from 'roosterjs-content-model-dom'; describe('formatTableWithContentModel', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let formatContentModelSpy: jasmine.Spy; let model: ContentModelDocument; let formatResult: boolean | undefined; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts index c2bcdcf9eb2..a4d44d45983 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts @@ -4,20 +4,20 @@ import type { AddUndoSnapshot, Snapshot } from 'roosterjs-content-model-types'; /** * @internal * Add an undo snapshot to current undo snapshot stack - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). * @param entityStates @optional Entity states related to this snapshot. * Each entity state will cause an EntityOperation event with operation = EntityOperation.UpdateEntityState * when undo/redo to this snapshot */ export const addUndoSnapshot: AddUndoSnapshot = (core, canUndoByBackspace, entityStates) => { - const { lifecycle, contentDiv, undo } = core; + const { lifecycle, physicalRoot, undo } = core; let snapshot: Snapshot | null = null; if (!lifecycle.shadowEditFragment) { // Need to create snapshot selection before retrieve innerHTML since HTML can be changed during creating selection when normalize table const selection = createSnapshotSelection(core); - const html = contentDiv.innerHTML; + const html = physicalRoot.innerHTML; snapshot = { html, diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/attachDomEvent.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/attachDomEvent.ts index 833989ebee0..b32a19877e8 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/attachDomEvent.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/attachDomEvent.ts @@ -4,7 +4,7 @@ import type { AttachDomEvent, PluginEvent } from 'roosterjs-content-model-types' /** * @internal * Attach a DOM event to the editor content DIV - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param eventName The DOM event name * @param pluginEventType Optional event type. When specified, editor will trigger a plugin event with this name when the DOM event is triggered * @param beforeDispatch Optional callback function to be invoked when the DOM event is triggered before trigger plugin event @@ -30,10 +30,10 @@ export const attachDomEvent: AttachDomEvent = (core, eventMap) => { } }; - core.contentDiv.addEventListener(eventName, onEvent); + core.logicalRoot.addEventListener(eventName, onEvent); return () => { - core.contentDiv.removeEventListener(eventName, onEvent); + core.logicalRoot.removeEventListener(eventName, onEvent); }; }); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts index 42f09475ec6..64640a328c4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts @@ -39,7 +39,7 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv ) : createDomToModelContextWithConfig(core.domToModelSettings.calculated, editorContext); - const model = domToContentModel(core.contentDiv, domToModelContext, selection); + const model = domToContentModel(core.logicalRoot, domToModelContext, selection); if (saveIndex) { core.cache.cachedModel = model; 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 0895824658a..7e96ba42119 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 @@ -6,7 +6,7 @@ import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model * Create a EditorContext object used by ContentModel API */ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { - const { lifecycle, format, darkColorHandler, contentDiv, cache, domHelper } = core; + const { lifecycle, format, darkColorHandler, logicalRoot, cache, domHelper } = core; const context: EditorContext = { isDarkMode: lifecycle.isDarkMode, @@ -17,10 +17,10 @@ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { allowCacheElement: true, domIndexer: saveIndex ? cache.domIndexer : undefined, zoomScale: domHelper.calculateZoomScale(), - ...getRootComputedStyleForContext(contentDiv.ownerDocument), + ...getRootComputedStyleForContext(logicalRoot.ownerDocument), }; - checkRootRtl(contentDiv, context); + checkRootRtl(logicalRoot, context); return context; }; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts index b5b5c8e69f5..6d51b4a71f7 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts @@ -3,7 +3,7 @@ import type { Focus } from 'roosterjs-content-model-types'; /** * @internal * Focus to editor. If there is a cached selection range, use it as current selection - * @param core The StandaloneEditorCore object + * @param core The EditorCore object */ export const focus: Focus = core => { if (!core.lifecycle.shadowEditFragment) { @@ -15,7 +15,7 @@ export const focus: Focus = core => { // fallback, in case editor still have no focus if (!core.api.hasFocus(core)) { - core.contentDiv.focus(); + core.logicalRoot.focus(); } } }; 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 a75c9ab498c..3e01ee49079 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 @@ -5,7 +5,7 @@ import type { DOMSelection, FormatContentModel, FormatContentModelContext, - StandaloneEditorCore, + EditorCore, } from 'roosterjs-content-model-types'; /** @@ -14,7 +14,7 @@ import type { * It will grab a Content Model for current editor content, and invoke a callback function * 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 core The StandaloneEditorCore object + * @param core The EditorCore object * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatContentModelOptions */ @@ -42,9 +42,7 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) if (shouldAddSnapshot) { core.undo.isNested = true; - if (core.undo.snapshotsManager.hasNewContent || entityStates) { - core.api.addUndoSnapshot(core, !!canUndoByBackspace); - } + core.api.addUndoSnapshot(core, !!canUndoByBackspace, entityStates); } try { @@ -97,7 +95,7 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) } }; -function handleImages(core: StandaloneEditorCore, context: FormatContentModelContext) { +function handleImages(core: EditorCore, context: FormatContentModelContext) { if (context.newImages.length > 0) { const viewport = core.api.getVisibleViewport(core); @@ -113,7 +111,7 @@ function handleImages(core: StandaloneEditorCore, context: FormatContentModelCon } function handlePendingFormat( - core: StandaloneEditorCore, + core: EditorCore, context: FormatContentModelContext, selection?: DOMSelection | null ) { diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts index ee4c7f8e26a..11f64f26b23 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts @@ -1,8 +1,4 @@ -import type { - DOMSelection, - GetDOMSelection, - StandaloneEditorCore, -} from 'roosterjs-content-model-types'; +import type { DOMSelection, GetDOMSelection, EditorCore } from 'roosterjs-content-model-types'; /** * @internal @@ -19,11 +15,11 @@ export const getDOMSelection: GetDOMSelection = core => { } }; -function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { - const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); +function getNewSelection(core: EditorCore): DOMSelection | null { + const selection = core.logicalRoot.ownerDocument.defaultView?.getSelection(); const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; - return range && core.contentDiv.contains(range.commonAncestorContainer) + return range && core.logicalRoot.contains(range.commonAncestorContainer) ? { type: 'range', range, diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts index deadbe89f1a..74609933caa 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts @@ -3,13 +3,15 @@ import type { GetVisibleViewport, Rect } from 'roosterjs-content-model-types'; /** * @internal * Retrieves the rect of the visible viewport of the editor. - * @param core The StandaloneEditorCore object + * @param core The EditorCore object */ export const getVisibleViewport: GetVisibleViewport = core => { const scrollContainer = core.domEvent.scrollContainer; return getIntersectedRect( - scrollContainer == core.contentDiv ? [scrollContainer] : [scrollContainer, core.contentDiv] + scrollContainer == core.physicalRoot + ? [scrollContainer] + : [scrollContainer, core.physicalRoot] ); }; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/hasFocus.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/hasFocus.ts index 5026e5f17eb..ba41f871a56 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/hasFocus.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/hasFocus.ts @@ -3,10 +3,10 @@ import type { HasFocus } from 'roosterjs-content-model-types'; /** * @internal * Check if the editor has focus now - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @returns True if the editor has focus, otherwise false */ export const hasFocus: HasFocus = core => { - const activeElement = core.contentDiv.ownerDocument.activeElement; - return !!(activeElement && core.contentDiv.contains(activeElement)); + const activeElement = core.logicalRoot.ownerDocument.activeElement; + return !!(activeElement && core.logicalRoot.contains(activeElement)); }; 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 d229c830a10..ea1654ecdc0 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 @@ -9,7 +9,7 @@ import type { PasteType, ClipboardData, Paste, - StandaloneEditorCore, + EditorCore, TrustedHTMLHandler, } from 'roosterjs-content-model-types'; @@ -20,12 +20,12 @@ const CloneOption: CloneModelOptions = { /** * @internal * Paste into editor using a clipboardData object - * @param core The StandaloneEditorCore object. + * @param core The EditorCore object. * @param clipboardData Clipboard data retrieved from clipboard * @param pasteType Type of content to paste. @default normal */ export const paste: Paste = ( - core: StandaloneEditorCore, + core: EditorCore, clipboardData: ClipboardData, pasteType: PasteType = 'normal' ) => { @@ -45,7 +45,7 @@ export const paste: Paste = ( // 3. Create target fragment const sourceFragment = createPasteFragment( - core.contentDiv.ownerDocument, + core.physicalRoot.ownerDocument, clipboardData, pasteType, (clipboardData.rawHtml == clipboardData.html diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts index bf776ab6b70..39409e3e7df 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts @@ -26,8 +26,8 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea modelToDomContext.onNodeCreated = onNodeCreated; const selection = contentModelToDom( - core.contentDiv.ownerDocument, - core.contentDiv, + core.logicalRoot.ownerDocument, + core.logicalRoot, model, modelToDomContext ); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts index 9f40ab68127..5c9b1fb63b6 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts @@ -23,14 +23,14 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC // Set skipReselectOnFocus to skip this behavior const skipReselectOnFocus = core.selection.skipReselectOnFocus; - const doc = core.contentDiv.ownerDocument; + const doc = core.physicalRoot.ownerDocument; const sheet = core.selection.selectionStyleNode?.sheet; core.selection.skipReselectOnFocus = true; try { let selectionRules: string[] | undefined; - const rootSelector = '#' + addUniqueId(core.contentDiv, CONTENT_DIV_ID); + const rootSelector = '#' + addUniqueId(core.physicalRoot, CONTENT_DIV_ID); switch (selection?.type) { case 'image': diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts index c2053bd4351..c703a641ee0 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts @@ -5,7 +5,7 @@ import type { SwitchShadowEdit } from 'roosterjs-content-model-types'; /** * @internal * Switch the Shadow Edit mode of editor On/Off - * @param editorCore The StandaloneEditorCore object + * @param editorCore The EditorCore object * @param isOn True to switch On, False to switch Off */ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { @@ -14,8 +14,8 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { const model = !core.cache.cachedModel ? core.api.createContentModel(core) : null; - const fragment = core.contentDiv.ownerDocument.createDocumentFragment(); - const clonedRoot = core.contentDiv.cloneNode(true /*deep*/); + const fragment = core.logicalRoot.ownerDocument.createDocumentFragment(); + const clonedRoot = core.logicalRoot.cloneNode(true /*deep*/); moveChildNodes(fragment, clonedRoot); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/triggerEvent.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/triggerEvent.ts index a4dc7587f09..c2553fec493 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/triggerEvent.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/triggerEvent.ts @@ -15,7 +15,7 @@ const allowedEventsInShadowEdit: PluginEventType[] = [ /** * @internal * Trigger a plugin event - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param pluginEvent The event object to trigger * @param broadcast Set to true to skip the shouldHandleEventExclusively check */ diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts index 90c2c28980b..8b9cf407e7d 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts @@ -3,17 +3,17 @@ import { createTextMutationObserver } from './utils/textMutationObserver'; import { domIndexerImpl } from './utils/domIndexerImpl'; import type { CachePluginState, - IStandaloneEditor, + IEditor, PluginEvent, PluginWithState, - StandaloneEditorOptions, + EditorOptions, } from 'roosterjs-content-model-types'; /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ class CachePlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private state: CachePluginState; /** @@ -21,7 +21,7 @@ class CachePlugin implements PluginWithState { * @param option The editor option * @param contentDiv The editor content DIV */ - constructor(option: StandaloneEditorOptions, contentDiv: HTMLDivElement) { + constructor(option: EditorOptions, contentDiv: HTMLDivElement) { this.state = option.cacheModel ? { domIndexer: domIndexerImpl, @@ -43,7 +43,7 @@ class CachePlugin implements PluginWithState { * editor reference so that it can call to any editor method or format API later. * @param editor The editor object */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; this.editor.getDocument().addEventListener('selectionchange', this.onNativeSelectionChange); @@ -134,7 +134,7 @@ class CachePlugin implements PluginWithState { } } - private updateCachedModel(editor: IStandaloneEditor, forceUpdate?: boolean) { + private updateCachedModel(editor: IEditor, forceUpdate?: boolean) { const cachedSelection = this.state.cachedSelection; this.state.cachedSelection = undefined; // Clear it to force getDOMSelection() retrieve the latest selection range @@ -169,7 +169,7 @@ class CachePlugin implements PluginWithState { * @param contentDiv The editor content DIV */ export function createCachePlugin( - option: StandaloneEditorOptions, + option: EditorOptions, contentDiv: HTMLDivElement ): PluginWithState { return new CachePlugin(option, contentDiv); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts index ed433c9d890..e1d584ebd05 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts @@ -2,9 +2,9 @@ import { getSelectionRootNode } from '../publicApi/selection/getSelectionRootNod import type { ContextMenuPluginState, ContextMenuProvider, - IStandaloneEditor, + IEditor, PluginWithState, - StandaloneEditorOptions, + EditorOptions, } from 'roosterjs-content-model-types'; const ContextMenuButton = 2; @@ -13,7 +13,7 @@ const ContextMenuButton = 2; * Edit Component helps handle Content edit features */ class ContextMenuPlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private state: ContextMenuPluginState; private disposer: (() => void) | null = null; @@ -21,7 +21,7 @@ class ContextMenuPlugin implements PluginWithState { * Construct a new instance of EditPlugin * @param options The editor options */ - constructor(options: StandaloneEditorOptions) { + constructor(options: EditorOptions) { this.state = { contextMenuProviders: options.plugins?.filter>(isContextMenuProvider) || [], @@ -39,7 +39,7 @@ class ContextMenuPlugin implements PluginWithState { * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; this.disposer = this.editor.attachDomEvent({ contextmenu: { @@ -97,7 +97,7 @@ class ContextMenuPlugin implements PluginWithState { } }; - private getFocusedNode(editor: IStandaloneEditor) { + private getFocusedNode(editor: IEditor) { const selection = editor.getDOMSelection(); if (selection) { @@ -121,7 +121,7 @@ function isContextMenuProvider(source: unknown): source is ContextMenuProvider { return new ContextMenuPlugin(options); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts index 45834288230..ef98518470a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts @@ -22,9 +22,9 @@ import type { CopyPastePluginState, ContentModelTable, DOMSelection, - IStandaloneEditor, + IEditor, OnNodeCreated, - StandaloneEditorOptions, + EditorOptions, PluginWithState, ContentModelDocument, ContentModelParagraph, @@ -36,7 +36,7 @@ import type { * Copy and paste plugin for handling onCopy and onPaste event */ class CopyPastePlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private disposer: (() => void) | null = null; private state: CopyPastePluginState; @@ -44,10 +44,11 @@ class CopyPastePlugin implements PluginWithState { * Construct a new instance of CopyPastePlugin * @param option The editor option */ - constructor(option: StandaloneEditorOptions) { + constructor(option: EditorOptions) { this.state = { allowedCustomPasteType: option.allowedCustomPasteType || [], tempDiv: null, + defaultPasteType: option.defaultPasteType, }; } @@ -62,7 +63,7 @@ class CopyPastePlugin implements PluginWithState { * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; this.disposer = this.editor.attachDomEvent({ paste: { @@ -199,7 +200,7 @@ class CopyPastePlugin implements PluginWithState { this.state.allowedCustomPasteType ).then((clipboardData: ClipboardData) => { if (!editor.isDisposed()) { - editor.pasteFromClipboard(clipboardData); + editor.pasteFromClipboard(clipboardData, this.state.defaultPasteType); } }); } @@ -338,7 +339,7 @@ export function preprocessTable(table: ContentModelTable) { * @param option The editor option */ export function createCopyPastePlugin( - option: StandaloneEditorOptions + option: EditorOptions ): PluginWithState { 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 f3f08524a5f..2cf35b040c7 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 @@ -3,9 +3,9 @@ import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/event import { isNodeOfType } from 'roosterjs-content-model-dom'; import type { DOMEventPluginState, - IStandaloneEditor, + IEditor, DOMEventRecord, - StandaloneEditorOptions, + EditorOptions, PluginWithState, } from 'roosterjs-content-model-types'; @@ -27,7 +27,7 @@ const EventTypeMap: Record = { * It contains special handling for Safari since Safari cannot get correct selection when onBlur event is triggered in editor. */ class DOMEventPlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private disposer: (() => void) | null = null; private state: DOMEventPluginState; @@ -36,7 +36,7 @@ class DOMEventPlugin implements PluginWithState { * @param options The editor options * @param contentDiv The editor content DIV */ - constructor(options: StandaloneEditorOptions, contentDiv: HTMLDivElement) { + constructor(options: EditorOptions, contentDiv: HTMLDivElement) { this.state = { isInIME: false, scrollContainer: options.scrollContainer || contentDiv, @@ -57,7 +57,7 @@ class DOMEventPlugin implements PluginWithState { * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; const document = this.editor.getDocument(); @@ -228,7 +228,7 @@ class DOMEventPlugin implements PluginWithState { * @param contentDiv The editor content DIV element */ export function createDOMEventPlugin( - option: StandaloneEditorOptions, + option: EditorOptions, contentDiv: HTMLDivElement ): PluginWithState { return new DOMEventPlugin(option, contentDiv); 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 b49687c2f07..460ef490da6 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 @@ -11,15 +11,14 @@ import { getAllEntityWrappers, getObjectKeys, isEntityElement, - parseEntityClassName, + parseEntityFormat, } from 'roosterjs-content-model-dom'; import type { ChangedEntity, ContentChangedEvent, - ContentModelEntityFormat, EntityOperation, EntityPluginState, - IStandaloneEditor, + IEditor, MouseUpEvent, PluginEvent, PluginWithState, @@ -31,7 +30,7 @@ const ENTITY_ID_REGEX = /_(\d{1,8})$/; * Entity Plugin helps handle all operations related to an entity and generate entity specified events */ class EntityPlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private state: EntityPluginState; /** @@ -54,7 +53,7 @@ class EntityPlugin implements PluginWithState { * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; } @@ -102,7 +101,7 @@ class EntityPlugin implements PluginWithState { } } - private handleMouseUpEvent(editor: IStandaloneEditor, event: MouseUpEvent) { + private handleMouseUpEvent(editor: IEditor, event: MouseUpEvent) { const { rawEvent, isClicking } = event; let node: Node | null = rawEvent.target as Node; @@ -118,7 +117,7 @@ class EntityPlugin implements PluginWithState { } } - private handleContentChangedEvent(editor: IStandaloneEditor, event?: ContentChangedEvent) { + private handleContentChangedEvent(editor: IEditor, event?: ContentChangedEvent) { const modifiedEntities: ChangedEntity[] = event?.changedEntities ?? this.getChangedEntities(editor); const entityStates = event?.entityStates; @@ -184,7 +183,7 @@ class EntityPlugin implements PluginWithState { handleDelimiterContentChangedEvent(editor); } - private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] { + private getChangedEntities(editor: IEditor): ChangedEntity[] { const result: ChangedEntity[] = []; editor.formatContentModel(model => { @@ -209,16 +208,19 @@ class EntityPlugin implements PluginWithState { result.splice(index, 1); } else { // Entity is not in editor, which means it is deleted, use a temporary entity here to represent this entity - const tempEntity = createEntity(entry.element); - let isEntity = false; - - entry.element.classList.forEach(name => { - isEntity = parseEntityClassName(name, tempEntity.entityFormat) || isEntity; - }); + const format = parseEntityFormat(entry.element); + + if (!format.isFakeEntity) { + const entity = createEntity( + entry.element, + format.isReadonly, + {}, + format.entityType, + format.id + ); - if (isEntity) { result.push({ - entity: tempEntity, + entity: entity, operation: 'overwrite', }); } @@ -229,7 +231,7 @@ class EntityPlugin implements PluginWithState { return result; } - private handleExtractContentWithDomEvent(editor: IStandaloneEditor, root: HTMLElement) { + private handleExtractContentWithDomEvent(editor: IEditor, root: HTMLElement) { getAllEntityWrappers(root).forEach(element => { element.removeAttribute('contentEditable'); @@ -238,16 +240,13 @@ class EntityPlugin implements PluginWithState { } private triggerEvent( - editor: IStandaloneEditor, + editor: IEditor, wrapper: HTMLElement, operation: EntityOperation, rawEvent?: Event, state?: string ) { - const format: ContentModelEntityFormat = {}; - wrapper.classList.forEach(name => { - parseEntityClassName(name, format); - }); + const format = parseEntityFormat(wrapper); return format.id && format.entityType && !format.isFakeEntity ? editor.triggerEvent('entityOperation', { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts index 1fd6c7164a1..812a1ab5fd7 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts @@ -7,10 +7,10 @@ import type { FontFamilyFormat, FontSizeFormat, FormatPluginState, - IStandaloneEditor, + IEditor, PluginEvent, PluginWithState, - StandaloneEditorOptions, + EditorOptions, TextColorFormat, } from 'roosterjs-content-model-types'; @@ -32,7 +32,7 @@ const DefaultStyleKeyMap: Record< * 1. Handle pending format changes when selection is collapsed */ class FormatPlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private defaultFormatKeys: Set; private state: FormatPluginState; private lastCheckedNode: Node | null = null; @@ -41,7 +41,7 @@ class FormatPlugin implements PluginWithState { * Construct a new instance of FormatPlugin class * @param option The editor option */ - constructor(option: StandaloneEditorOptions) { + constructor(option: EditorOptions) { this.state = { defaultFormat: { ...option.defaultSegmentFormat }, pendingFormat: null, @@ -69,7 +69,7 @@ class FormatPlugin implements PluginWithState { * editor reference so that it can call to any editor method or format API later. * @param editor The editor object */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; } @@ -168,7 +168,7 @@ class FormatPlugin implements PluginWithState { return result; } - private shouldApplyDefaultFormat(editor: IStandaloneEditor): boolean { + private shouldApplyDefaultFormat(editor: IEditor): boolean { const selection = editor.getDOMSelection(); const range = selection?.type == 'range' ? selection.range : null; const posContainer = range?.startContainer ?? null; @@ -216,8 +216,6 @@ class FormatPlugin implements PluginWithState { * Create a new instance of FormatPlugin. * @param option The editor option */ -export function createFormatPlugin( - option: StandaloneEditorOptions -): PluginWithState { +export function createFormatPlugin(option: EditorOptions): PluginWithState { return new FormatPlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts index 77875155d38..e98d81313d4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts @@ -1,11 +1,11 @@ import { ChangeSource } from '../constants/ChangeSource'; import { setColor } from 'roosterjs-content-model-dom'; import type { - IStandaloneEditor, + IEditor, LifecyclePluginState, PluginEvent, PluginWithState, - StandaloneEditorOptions, + EditorOptions, } from 'roosterjs-content-model-types'; const ContentEditableAttributeName = 'contenteditable'; @@ -16,7 +16,7 @@ const DefaultBackColor = '#ffffff'; * Lifecycle plugin handles editor initialization and disposing */ class LifecyclePlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private state: LifecyclePluginState; private initializer: (() => void) | null = null; private disposer: (() => void) | null = null; @@ -27,7 +27,7 @@ class LifecyclePlugin implements PluginWithState { * @param options The editor options * @param contentDiv The editor content DIV */ - constructor(options: StandaloneEditorOptions, contentDiv: HTMLDivElement) { + constructor(options: EditorOptions, contentDiv: HTMLDivElement) { // Make the container editable and set its selection styles if (contentDiv.getAttribute(ContentEditableAttributeName) === null) { this.initializer = () => { @@ -62,7 +62,7 @@ class LifecyclePlugin implements PluginWithState { * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; // Set content DIV to be editable @@ -141,7 +141,7 @@ class LifecyclePlugin implements PluginWithState { * @param contentDiv The editor content DIV element */ export function createLifecyclePlugin( - option: StandaloneEditorOptions, + option: EditorOptions, contentDiv: HTMLDivElement ): PluginWithState { return new LifecyclePlugin(option, contentDiv); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index 286ef0b57e6..535c686a52f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -2,24 +2,24 @@ import { isElementOfType, isNodeOfType, toArray } from 'roosterjs-content-model- import { isModifierKey } from '../publicApi/domUtils/eventUtils'; import type { DOMSelection, - IStandaloneEditor, + IEditor, PluginEvent, PluginWithState, SelectionPluginState, - StandaloneEditorOptions, + EditorOptions, } from 'roosterjs-content-model-types'; const MouseMiddleButton = 1; const MouseRightButton = 2; class SelectionPlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private state: SelectionPluginState; private disposer: (() => void) | null = null; private isSafari = false; private isMac = false; - constructor(options: StandaloneEditorOptions) { + constructor(options: EditorOptions) { this.state = { selection: null, selectionStyleNode: null, @@ -31,7 +31,7 @@ class SelectionPlugin implements PluginWithState { return 'Selection'; } - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; const doc = this.editor.getDocument(); @@ -141,14 +141,14 @@ class SelectionPlugin implements PluginWithState { } } - private selectImage(editor: IStandaloneEditor, image: HTMLImageElement) { + private selectImage(editor: IEditor, image: HTMLImageElement) { editor.setDOMSelection({ type: 'image', image: image, }); } - private selectBeforeImage(editor: IStandaloneEditor, image: HTMLImageElement) { + private selectBeforeImage(editor: IEditor, image: HTMLImageElement) { const doc = editor.getDocument(); const parent = image.parentNode; const index = parent && toArray(parent.childNodes).indexOf(image); @@ -231,7 +231,7 @@ class SelectionPlugin implements PluginWithState { * @param option The editor option */ export function createSelectionPlugin( - options: StandaloneEditorOptions + options: EditorOptions ): PluginWithState { return new SelectionPlugin(options); } 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 7e35ff5c0f1..600ed50a63b 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 @@ -4,10 +4,10 @@ import { isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; import { undo } from '../publicApi/undo/undo'; import type { ContentChangedEvent, - IStandaloneEditor, + IEditor, PluginEvent, PluginWithState, - StandaloneEditorOptions, + EditorOptions, UndoPluginState, } from 'roosterjs-content-model-types'; @@ -19,14 +19,14 @@ const Enter = 'Enter'; * Provides snapshot based undo service for Editor */ class UndoPlugin implements PluginWithState { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private state: UndoPluginState; /** * Construct a new instance of UndoPlugin * @param options The wrapper of the state object */ - constructor(options: StandaloneEditorOptions) { + constructor(options: EditorOptions) { this.state = { snapshotsManager: createSnapshotsManager(options.snapshots), isRestoring: false, @@ -48,7 +48,7 @@ class UndoPlugin implements PluginWithState { * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize(editor: IStandaloneEditor): void { + initialize(editor: IEditor): void { this.editor = editor; } @@ -120,7 +120,7 @@ class UndoPlugin implements PluginWithState { } } - private onKeyDown(editor: IStandaloneEditor, evt: KeyboardEvent): void { + private onKeyDown(editor: IEditor, evt: KeyboardEvent): void { const { snapshotsManager } = this.state; // Handle backspace/delete when there is a selection to take a snapshot @@ -167,7 +167,7 @@ class UndoPlugin implements PluginWithState { } } - private onKeyPress(editor: IStandaloneEditor, evt: KeyboardEvent): void { + private onKeyPress(editor: IEditor, evt: KeyboardEvent): void { if (evt.metaKey) { // if metaKey is pressed, simply return since no actual effect will be taken on the editor. // this is to prevent changing hasNewContent to true when meta + v to paste on Safari. @@ -226,7 +226,7 @@ class UndoPlugin implements PluginWithState { this.state.snapshotsManager.hasNewContent = true; } - private canUndoAutoComplete(editor: IStandaloneEditor) { + private canUndoAutoComplete(editor: IEditor) { const selection = editor.getDOMSelection(); return ( @@ -244,7 +244,7 @@ class UndoPlugin implements PluginWithState { this.state.posOffset = null; } - private isCtrlOrMetaPressed(editor: IStandaloneEditor, event: KeyboardEvent) { + private isCtrlOrMetaPressed(editor: IEditor, event: KeyboardEvent) { const env = editor.getEnvironment(); return env.isMac ? event.metaKey : event.ctrlKey; @@ -256,8 +256,6 @@ class UndoPlugin implements PluginWithState { * Create a new instance of UndoPlugin. * @param option The editor option */ -export function createUndoPlugin( - option: StandaloneEditorOptions -): PluginWithState { +export function createUndoPlugin(option: EditorOptions): PluginWithState { return new UndoPlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createEditorCorePlugins.ts similarity index 79% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/createEditorCorePlugins.ts index eb8692305e5..df38c0e2572 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createEditorCorePlugins.ts @@ -7,20 +7,17 @@ import { createFormatPlugin } from './FormatPlugin'; import { createLifecyclePlugin } from './LifecyclePlugin'; import { createSelectionPlugin } from './SelectionPlugin'; import { createUndoPlugin } from './UndoPlugin'; -import type { - StandaloneEditorCorePlugins, - StandaloneEditorOptions, -} from 'roosterjs-content-model-types'; +import type { EditorCorePlugins, EditorOptions } from 'roosterjs-content-model-types'; /** * @internal - * Create core plugins for standalone editor + * Create core plugins for editor * @param options Options of editor */ -export function createStandaloneEditorCorePlugins( - options: StandaloneEditorOptions, +export function createEditorCorePlugins( + options: EditorOptions, contentDiv: HTMLDivElement -): StandaloneEditorCorePlugins { +): EditorCorePlugins { return { cache: createCachePlugin(options, contentDiv), format: createFormatPlugin(options), 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 263848843b7..145a9dc2325 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,17 +1,14 @@ import { deleteSelection } from '../../publicApi/selection/deleteSelection'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; -import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; /** * @internal * When necessary, set default format as current pending format so it will be applied when Input event is fired - * @param editor The Content Model Editor + * @param editor The editor object * @param defaultFormat The default segment format to apply */ -export function applyDefaultFormat( - editor: IStandaloneEditor, - defaultFormat: ContentModelSegmentFormat -) { +export function applyDefaultFormat(editor: IEditor, defaultFormat: ContentModelSegmentFormat) { editor.formatContentModel((model, context) => { const result = deleteSelection(model, [], context); @@ -64,7 +61,7 @@ export function applyDefaultFormat( } function getNewPendingFormat( - editor: IStandaloneEditor, + editor: IEditor, defaultFormat: ContentModelSegmentFormat, markerFormat: ContentModelSegmentFormat ): ContentModelSegmentFormat { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts index d16e0c501f8..8113912ad09 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts @@ -1,5 +1,5 @@ import { iterateSelections } from '../../publicApi/selection/iterateSelections'; -import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; import { createText, normalizeContentModel, @@ -16,7 +16,7 @@ const NON_BREAK_SPACE = '\u00A0'; * @param data The text user just input */ export function applyPendingFormat( - editor: IStandaloneEditor, + editor: IEditor, data: string, format: ContentModelSegmentFormat ) { 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 index e2f8f1953c1..53d84e232b2 100644 --- 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 @@ -6,7 +6,7 @@ import type { ContentModelFormatter, ContentModelParagraph, ContentModelSegmentFormat, - IStandaloneEditor, + IEditor, KeyDownEvent, RangeSelection, } from 'roosterjs-content-model-types'; @@ -32,7 +32,7 @@ const BlockEntityContainerSelector = '.' + BlockEntityContainer; /** * @internal exported only for unit test */ -export function preventTypeInDelimiter(node: HTMLElement, editor: IStandaloneEditor) { +export function preventTypeInDelimiter(node: HTMLElement, editor: IEditor) { const isAfter = node.classList.contains(DelimiterAfter); const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; if (entitySibling && isEntityElement(entitySibling)) { @@ -164,7 +164,7 @@ function getFocusedElement( /** * @internal */ -export function handleDelimiterContentChangedEvent(editor: IStandaloneEditor) { +export function handleDelimiterContentChangedEvent(editor: IEditor) { const helper = editor.getDOMHelper(); removeInvalidDelimiters(helper.queryElements(DelimiterSelector)); addDelimitersIfNeeded(helper.queryElements(InlineEntitySelector), editor.getPendingFormat()); @@ -173,7 +173,7 @@ export function handleDelimiterContentChangedEvent(editor: IStandaloneEditor) { /** * @internal */ -export function handleCompositionEndEvent(editor: IStandaloneEditor, event: CompositionEndEvent) { +export function handleCompositionEndEvent(editor: IEditor, event: CompositionEndEvent) { const selection = editor.getDOMSelection(); if (selection?.type == 'range' && selection.range.collapsed) { @@ -193,7 +193,7 @@ export function handleCompositionEndEvent(editor: IStandaloneEditor, event: Comp /** * @internal */ -export function handleDelimiterKeyDownEvent(editor: IStandaloneEditor, event: KeyDownEvent) { +export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent) { const selection = editor.getDOMSelection(); const { rawEvent } = event; diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/Editor.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts rename to packages-content-model/roosterjs-content-model-core/lib/editor/Editor.ts index 37b6ba45a90..5477ab1dcdf 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/Editor.ts @@ -1,7 +1,7 @@ import { ChangeSource } from '../constants/ChangeSource'; import { cloneModel } from '../publicApi/model/cloneModel'; +import { createEditorCore } from './createEditorCore'; import { createEmptyModel, tableProcessor } from 'roosterjs-content-model-dom'; -import { createStandaloneEditorCore } from './createStandaloneEditorCore'; import { reducedModelChildProcessor } from '../override/reducedModelChildProcessor'; import { transformColor } from '../publicApi/color/transformColor'; import type { CachedElementHandler } from '../publicApi/model/cloneModel'; @@ -16,32 +16,33 @@ import type { DOMSelection, EditorEnvironment, FormatContentModelOptions, - IStandaloneEditor, + IEditor, PasteType, PluginEventData, PluginEventFromType, PluginEventType, Snapshot, SnapshotsManager, - StandaloneEditorCore, - StandaloneEditorOptions, + EditorCore, + EditorOptions, TrustedHTMLHandler, Rect, + EntityState, } from 'roosterjs-content-model-types'; /** - * The standalone editor class based on Content Model + * The main editor class based on Content Model */ -export class StandaloneEditor implements IStandaloneEditor { - private core: StandaloneEditorCore | null = null; +export class Editor implements IEditor { + private core: EditorCore | null = null; /** * 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: StandaloneEditorOptions = {}) { - this.core = createStandaloneEditorCore(contentDiv, options); + constructor(contentDiv: HTMLDivElement, options: EditorOptions = {}) { + this.core = createEditorCore(contentDiv, options); const initialModel = options.initialModel ?? createEmptyModel(options.defaultSegmentFormat); @@ -174,11 +175,16 @@ export class StandaloneEditor implements IStandaloneEditor { /** * Add a single undo snapshot to undo stack + * @param entityState @optional State for entity if we want to add entity state for this snapshot */ - takeSnapshot(): Snapshot | null { + takeSnapshot(entityState?: EntityState): Snapshot | null { const core = this.getCore(); - return core.api.addUndoSnapshot(core, false /*canUndoByBackspace*/); + return core.api.addUndoSnapshot( + core, + false /*canUndoByBackspace*/, + entityState ? [entityState] : undefined + ); } /** @@ -196,7 +202,7 @@ export class StandaloneEditor implements IStandaloneEditor { * @returns The HTML document which contains this editor */ getDocument(): Document { - return this.getCore().contentDiv.ownerDocument; + return this.getCore().physicalRoot.ownerDocument; } /** @@ -275,7 +281,7 @@ export class StandaloneEditor implements IStandaloneEditor { if (!!isDarkMode != core.lifecycle.isDarkMode) { transformColor( - core.contentDiv, + core.physicalRoot, false /*includeSelf*/, isDarkMode ? 'lightToDark' : 'darkToLight', core.darkColorHandler @@ -367,10 +373,10 @@ export class StandaloneEditor implements IStandaloneEditor { } /** - * @returns the current StandaloneEditorCore object + * @returns the current EditorCore object * @throws a standard Error if there's no core object */ - protected getCore(): StandaloneEditorCore { + protected getCore(): EditorCore { if (!this.core) { throw new Error('Editor is already disposed'); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts index cfe4d25f59e..338d076295a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts @@ -52,8 +52,9 @@ class SnapshotsManagerImpl implements SnapshotsManager { currentSnapshot.html == snapshot.html && !currentSnapshot.entityStates && !snapshot.entityStates; + const addSnapshot = !currentSnapshot || shouldAddSnapshot(currentSnapshot, snapshot); - if (this.snapshots.currentIndex < 0 || !currentSnapshot || !isSameSnapshot) { + if (this.snapshots.currentIndex < 0 || addSnapshot) { this.clearRedo(); this.snapshots.snapshots.push(snapshot); this.snapshots.currentIndex++; @@ -129,3 +130,13 @@ class SnapshotsManagerImpl implements SnapshotsManager { export function createSnapshotsManager(snapshots?: Snapshots): SnapshotsManager { return new SnapshotsManagerImpl(snapshots); } + +function shouldAddSnapshot(currentSnapshot: Snapshot, snapshot: Snapshot) { + return ( + currentSnapshot.html !== snapshot.html || + (currentSnapshot.entityStates && + snapshot.entityStates && + currentSnapshot.entityStates !== snapshot.entityStates) || + (!currentSnapshot.entityStates && snapshot.entityStates) + ); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/coreApiMap.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts rename to packages-content-model/roosterjs-content-model-core/lib/editor/coreApiMap.ts index fadf02808ff..8626ea508eb 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/coreApiMap.ts @@ -13,13 +13,13 @@ import { setContentModel } from '../coreApi/setContentModel'; import { setDOMSelection } from '../coreApi/setDOMSelection'; import { switchShadowEdit } from '../coreApi/switchShadowEdit'; import { triggerEvent } from '../coreApi/triggerEvent'; -import type { StandaloneCoreApiMap } from 'roosterjs-content-model-types'; +import type { CoreApiMap } from 'roosterjs-content-model-types'; /** * @internal - * Core API map for Standalone Content Model Editor + * Core API map for Editor */ -export const standaloneCoreApiMap: StandaloneCoreApiMap = { +export const coreApiMap: CoreApiMap = { createContentModel: createContentModel, createEditorContext: createEditorContext, formatContentModel: formatContentModel, diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createEditorCore.ts similarity index 85% rename from packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts rename to packages-content-model/roosterjs-content-model-core/lib/editor/createEditorCore.ts index 53e629732dd..9fefb9a34d2 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createEditorCore.ts @@ -1,35 +1,29 @@ +import { coreApiMap } from './coreApiMap'; import { createDarkColorHandler } from './DarkColorHandlerImpl'; import { createDOMHelper } from './DOMHelperImpl'; -import { createStandaloneEditorCorePlugins } from '../corePlugin/createStandaloneEditorCorePlugins'; -import { standaloneCoreApiMap } from './standaloneCoreApiMap'; -import { - createDomToModelSettings, - createModelToDomSettings, -} from './createStandaloneEditorDefaultSettings'; +import { createEditorCorePlugins } from '../corePlugin/createEditorCorePlugins'; +import { createDomToModelSettings, createModelToDomSettings } from './createEditorDefaultSettings'; import type { EditorEnvironment, PluginState, - StandaloneEditorCore, - StandaloneEditorCorePlugins, - StandaloneEditorOptions, + EditorCore, + EditorCorePlugins, + EditorOptions, } from 'roosterjs-content-model-types'; /** - * @internal - * A temporary function to create Standalone Editor core + * @internal Create core object for editor * @param contentDiv Editor content DIV * @param options Editor options */ -export function createStandaloneEditorCore( - contentDiv: HTMLDivElement, - options: StandaloneEditorOptions -): StandaloneEditorCore { - const corePlugins = createStandaloneEditorCorePlugins(options, contentDiv); +export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOptions): EditorCore { + const corePlugins = createEditorCorePlugins(options, contentDiv); return { - contentDiv, - api: { ...standaloneCoreApiMap, ...options.coreApiOverride }, - originalApi: { ...standaloneCoreApiMap }, + physicalRoot: contentDiv, + logicalRoot: contentDiv, + api: { ...coreApiMap, ...options.coreApiOverride }, + originalApi: { ...coreApiMap }, plugins: [ corePlugins.cache, corePlugins.format, @@ -97,7 +91,7 @@ export function defaultTrustHtmlHandler(html: string) { return html; } -function getPluginState(corePlugins: StandaloneEditorCorePlugins): PluginState { +function getPluginState(corePlugins: EditorCorePlugins): PluginState { return { domEvent: corePlugins.domEvent.getState(), copyPaste: corePlugins.copyPaste.getState(), diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createEditorDefaultSettings.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts rename to packages-content-model/roosterjs-content-model-core/lib/editor/createEditorDefaultSettings.ts index bbba2c45b6a..1340cb9d058 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createEditorDefaultSettings.ts @@ -7,16 +7,16 @@ import type { DomToModelSettings, ModelToDomOption, ModelToDomSettings, - StandaloneEditorOptions, + EditorOptions, } from 'roosterjs-content-model-types'; /** * @internal - * Create default DOM to Content Model conversion settings for a standalone editor + * Create default DOM to Content Model conversion settings for an editor * @param options The editor options */ export function createDomToModelSettings( - options: StandaloneEditorOptions + options: EditorOptions ): ContentModelSettings { const builtIn: DomToModelOption = { processorOverride: { @@ -34,11 +34,11 @@ export function createDomToModelSettings( /** * @internal - * Create default Content Model to DOM conversion settings for a standalone editor + * Create default Content Model to DOM conversion settings for an editor * @param options The editor options */ export function createModelToDomSettings( - options: StandaloneEditorOptions + options: EditorOptions ): ContentModelSettings { const builtIn: ModelToDomOption = { metadataAppliers: { 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 743e4314697..6407e084e5f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -33,7 +33,7 @@ export { export { setSelection } from './publicApi/selection/setSelection'; export { applyTableFormat } from './publicApi/table/applyTableFormat'; -export { normalizeTable } from './publicApi/table/normalizeTable'; +export { normalizeTable, MIN_ALLOWED_TABLE_CELL_WIDTH } from './publicApi/table/normalizeTable'; export { setTableCellBackgroundColor } from './publicApi/table/setTableCellBackgroundColor'; export { getSelectedCells } from './publicApi/table/getSelectedCells'; @@ -43,6 +43,7 @@ export { isPunctuation, isSpace, normalizeText } from './publicApi/domUtils/stri export { parseTableCells, createTableRanges } from './publicApi/domUtils/tableCellUtils'; export { getSegmentTextFormat } from './publicApi/domUtils/getSegmentTextFormat'; export { readFile } from './publicApi/domUtils/readFile'; +export { cacheGetEventData } from './publicApi/domUtils/cacheGetEventData'; export { undo } from './publicApi/undo/undo'; export { redo } from './publicApi/undo/redo'; @@ -60,4 +61,4 @@ export { BulletListType } from './constants/BulletListType'; export { NumberingListType } from './constants/NumberingListType'; export { TableBorderFormat } from './constants/TableBorderFormat'; -export { StandaloneEditor } from './editor/StandaloneEditor'; +export { Editor } from './editor/Editor'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/cacheGetEventData.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/cacheGetEventData.ts new file mode 100644 index 00000000000..2e99ef02add --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/cacheGetEventData.ts @@ -0,0 +1,23 @@ +import type { PluginEvent } from 'roosterjs-content-model-types'; + +/** + * Gets the cached event data by cache key from event object if there is already one. + * Otherwise, call getter function to create one, and cache it. + * @param event The event object + * @param key Cache key string, need to be unique + * @param getter Getter function to get the object when it is not in cache yet + */ +export function cacheGetEventData( + event: E, + key: string, + getter: (event: E) => T +): T { + const result = + event.eventDataCache && event.eventDataCache.hasOwnProperty(key) + ? event.eventDataCache[key] + : getter(event); + event.eventDataCache = event.eventDataCache || {}; + event.eventDataCache[key] = result; + + return result; +} 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 index 38df067ba2b..fc3096adbde 100644 --- 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 @@ -3,11 +3,7 @@ import { contentModelToText, createModelToDomContext, } from 'roosterjs-content-model-dom'; -import type { - ExportContentMode, - IStandaloneEditor, - ModelToDomOption, -} from 'roosterjs-content-model-types'; +import type { ExportContentMode, IEditor, ModelToDomOption } from 'roosterjs-content-model-types'; /** * Export string content of editor @@ -19,7 +15,7 @@ import type { * @param options @optional Options for Model to DOM conversion */ export function exportContent( - editor: IStandaloneEditor, + editor: IEditor, mode: ExportContentMode = 'HTML', options?: ModelToDomOption ): string { 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 2a6621fa30f..bb499451ad1 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 @@ -343,6 +343,7 @@ function applyDefaultFormat( applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat' ) { group.blocks.forEach(block => { + mergeBlockFormat(applyDefaultFormatOption, block); switch (block.blockType) { case 'BlockGroup': if (block.blockGroupType == 'ListItem') { @@ -384,6 +385,12 @@ function applyDefaultFormat( }); } +function mergeBlockFormat(applyDefaultFormatOption: string, block: ContentModelBlock) { + if (applyDefaultFormatOption == 'keepSourceEmphasisFormat' && block.format.backgroundColor) { + delete block.format.backgroundColor; + } +} + function mergeSegmentFormat( applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat', targetformat: ContentModelSegmentFormat, 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 0b48a50f51e..6b58dc828e9 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 @@ -38,9 +38,6 @@ export function deleteBlock( : undefined; if (operation !== undefined) { - const wrapper = blockToDelete.wrapper; - - wrapper.parentNode?.removeChild(wrapper); replacement ? blocks.splice(index, 1, replacement) : blocks.splice(index, 1); context?.deletedEntities.push({ entity: 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 64bccd128ff..1f35bb9bdcd 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 @@ -48,9 +48,6 @@ export function deleteSegment( ? 'removeFromEnd' : undefined; if (operation !== undefined) { - const wrapper = segmentToDelete.wrapper; - - wrapper.parentNode?.removeChild(wrapper); segments.splice(index, 1); context?.deletedEntities.push({ entity: segmentToDelete, diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts index 3a43078f9cd..d4052e6c64c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts @@ -6,6 +6,10 @@ import type { ContentModelTableCell, } from 'roosterjs-content-model-types'; +/** + * Minimum width for a table cell + */ +export const MIN_ALLOWED_TABLE_CELL_WIDTH: number = 30; const MIN_HEIGHT = 22; /** @@ -75,6 +79,8 @@ export function normalizeTable( for (let i = 0; i < columns; i++) { if (table.widths[i] === undefined) { table.widths[i] = getTableCellWidth(columns); + } else if (table.widths[i] < MIN_ALLOWED_TABLE_CELL_WIDTH) { + table.widths[i] = MIN_ALLOWED_TABLE_CELL_WIDTH; } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/redo.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/redo.ts index 1ae09d3b1a0..6255b1aed07 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/redo.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/redo.ts @@ -1,10 +1,10 @@ -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Redo to next undo snapshot * @param editor The editor to undo with */ -export function redo(editor: IStandaloneEditor): void { +export function redo(editor: IEditor): void { editor.focus(); const manager = editor.getSnapshotsManager(); diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/undo.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/undo.ts index c67714a07fd..bac557a39ac 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/undo.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/undo.ts @@ -1,10 +1,10 @@ -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * Undo to last undo snapshot * @param editor The editor to undo with */ -export function undo(editor: IStandaloneEditor): void { +export function undo(editor: IEditor): void { editor.focus(); const manager = editor.getSnapshotsManager(); diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts index b15da47dd43..e18b8de138e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts @@ -1,24 +1,24 @@ import { isElementOfType, isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom'; -import type { SnapshotSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import type { SnapshotSelection, EditorCore } from 'roosterjs-content-model-types'; /** * @internal */ -export function createSnapshotSelection(core: StandaloneEditorCore): SnapshotSelection { - const { contentDiv, api } = core; +export function createSnapshotSelection(core: EditorCore): SnapshotSelection { + const { physicalRoot, api } = core; const selection = api.getDOMSelection(core); // Normalize tables to ensure they have TBODY element between TABLE and TR so that the selection path will include correct values if (selection?.type == 'range') { const { startContainer, startOffset, endContainer, endOffset } = selection.range; - let isDOMChanged = normalizeTableTree(startContainer, contentDiv); + let isDOMChanged = normalizeTableTree(startContainer, physicalRoot); if (endContainer != startContainer) { - isDOMChanged = normalizeTableTree(endContainer, contentDiv) || isDOMChanged; + isDOMChanged = normalizeTableTree(endContainer, physicalRoot) || isDOMChanged; } if (isDOMChanged) { - const newRange = contentDiv.ownerDocument.createRange(); + const newRange = physicalRoot.ownerDocument.createRange(); newRange.setStart(startContainer, startOffset); newRange.setEnd(endContainer, endOffset); @@ -56,8 +56,8 @@ export function createSnapshotSelection(core: StandaloneEditorCore): SnapshotSel return { type: 'range', - start: getPath(range.startContainer, range.startOffset, contentDiv), - end: getPath(range.endContainer, range.endOffset, contentDiv), + start: getPath(range.startContainer, range.startOffset, physicalRoot), + end: getPath(range.endContainer, range.endOffset, physicalRoot), isReverted: !!selection.isReverted, }; 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 0d73aace66e..2d85729aac1 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 @@ -4,14 +4,14 @@ import type { ClipboardData, DomToModelOptionForSanitizing, PasteType, - StandaloneEditorCore, + EditorCore, } from 'roosterjs-content-model-types'; /** * @internal */ export function generatePasteOptionFromPlugins( - core: StandaloneEditorCore, + core: EditorCore, clipboardData: ClipboardData, fragment: DocumentFragment, htmlFromClipboard: HtmlFromClipboard, 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 418571ef16c..87b8bf9af71 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 @@ -11,7 +11,7 @@ import type { ClipboardData, ContentModelDocument, ContentModelSegmentFormat, - StandaloneEditorCore, + EditorCore, } from 'roosterjs-content-model-types'; const EmptySegmentFormat: Required = { @@ -32,7 +32,7 @@ const EmptySegmentFormat: Required = { * @internal */ export function mergePasteContent( - core: StandaloneEditorCore, + core: EditorCore, eventResult: BeforePasteEvent, clipboardData: ClipboardData ) { @@ -43,7 +43,7 @@ export function mergePasteContent( (model, context) => { const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; const domToModelContext = createDomToModelContextForSanitizing( - core.contentDiv.ownerDocument, + core.physicalRoot.ownerDocument, undefined /*defaultFormat*/, core.domToModelSettings.customized, domToModelOption diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotColors.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotColors.ts index 2bae196159d..733b7601757 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotColors.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotColors.ts @@ -1,17 +1,17 @@ import { transformColor } from '../publicApi/color/transformColor'; -import type { StandaloneEditorCore, Snapshot } from 'roosterjs-content-model-types'; +import type { EditorCore, Snapshot } from 'roosterjs-content-model-types'; /** * @internal */ -export function restoreSnapshotColors(core: StandaloneEditorCore, snapshot: Snapshot) { +export function restoreSnapshotColors(core: EditorCore, snapshot: Snapshot) { const isDarkMode = core.lifecycle.isDarkMode; core.darkColorHandler.updateKnownColor(isDarkMode); // Pass no parameter to force update all colors if (!!snapshot.isDarkMode != !!isDarkMode) { transformColor( - core.contentDiv, + core.physicalRoot, false /*includeSelf*/, isDarkMode ? 'lightToDark' : 'darkToLight', core.darkColorHandler 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 3b05a30be00..ad4a2e1a5f7 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 @@ -2,27 +2,22 @@ import { getAllEntityWrappers, isEntityElement, isNodeOfType, - parseEntityClassName, + parseEntityFormat, reuseCachedElement, } from 'roosterjs-content-model-dom'; -import type { - Snapshot, - StandaloneEditorCore, - KnownEntityItem, - ContentModelEntityFormat, -} from 'roosterjs-content-model-types'; +import type { Snapshot, EditorCore, KnownEntityItem } from 'roosterjs-content-model-types'; const BlockEntityContainer = '_E_EBlockEntityContainer'; /** * @internal */ -export function restoreSnapshotHTML(core: StandaloneEditorCore, snapshot: Snapshot) { +export function restoreSnapshotHTML(core: EditorCore, snapshot: Snapshot) { const { - contentDiv, + physicalRoot, entity: { entityMap }, } = core; - let refNode: Node | null = contentDiv.firstChild; + let refNode: Node | null = physicalRoot.firstChild; const body = new DOMParser().parseFromString( core.trustedHTMLHandler?.(snapshot.html) ?? snapshot.html, @@ -34,9 +29,9 @@ export function restoreSnapshotHTML(core: StandaloneEditorCore, snapshot: Snapsh const originalEntityElement = tryGetEntityElement(entityMap, currentNode); if (originalEntityElement) { - refNode = reuseCachedElement(contentDiv, originalEntityElement, refNode); + refNode = reuseCachedElement(physicalRoot, originalEntityElement, refNode); } else { - contentDiv.insertBefore(currentNode, refNode); + physicalRoot.insertBefore(currentNode, refNode); if (isNodeOfType(currentNode, 'ELEMENT_NODE')) { const childEntities = getAllEntityWrappers(currentNode); @@ -51,7 +46,7 @@ export function restoreSnapshotHTML(core: StandaloneEditorCore, snapshot: Snapsh // Then after replaceChild(), the original refNode will be moved away const markerNode = wrapper.cloneNode(); - contentDiv.insertBefore(markerNode, refNode); + physicalRoot.insertBefore(markerNode, refNode); refNode = markerNode; } @@ -79,11 +74,7 @@ function tryGetEntityElement( if (isNodeOfType(node, 'ELEMENT_NODE')) { if (isEntityElement(node)) { - const format: ContentModelEntityFormat = {}; - - node.classList.forEach(name => { - parseEntityClassName(name, format); - }); + const format = parseEntityFormat(node); result = (format.id && entityMap[format.id]?.element) || null; } else if (isBlockEntityContainer(node)) { @@ -93,6 +84,7 @@ function tryGetEntityElement( return result; } + function isBlockEntityContainer(node: HTMLElement) { return node.classList.contains(BlockEntityContainer); } @@ -101,14 +93,16 @@ function tryGetEntityFromContainer( element: HTMLElement, entityMap: Record ): HTMLElement | null { - const format: ContentModelEntityFormat = {}; - element.childNodes.forEach(node => { + for (let node = element.firstChild; node; node = node.nextSibling) { if (isEntityElement(node) && isNodeOfType(node, 'ELEMENT_NODE')) { - node.classList.forEach(name => parseEntityClassName(name, format)); - } - }); + const format = parseEntityFormat(node); + const parent = format.id ? entityMap[format.id]?.element.parentElement : null; - const parent = format.id ? entityMap[format.id]?.element.parentElement : null; + return isNodeOfType(parent, 'ELEMENT_NODE') && isBlockEntityContainer(parent) + ? parent + : null; + } + } - return isNodeOfType(parent, 'ELEMENT_NODE') && isBlockEntityContainer(parent) ? parent : null; + return null; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts index 79e2e4e2b70..505d056a5ca 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts @@ -1,20 +1,20 @@ import { isNodeOfType } from 'roosterjs-content-model-dom'; -import type { DOMSelection, StandaloneEditorCore, Snapshot } from 'roosterjs-content-model-types'; +import type { DOMSelection, EditorCore, Snapshot } from 'roosterjs-content-model-types'; /** * @internal */ -export function restoreSnapshotSelection(core: StandaloneEditorCore, snapshot: Snapshot) { +export function restoreSnapshotSelection(core: EditorCore, snapshot: Snapshot) { const snapshotSelection = snapshot.selection; - const { contentDiv } = core; + const { physicalRoot } = core; let domSelection: DOMSelection | null = null; if (snapshotSelection) { switch (snapshotSelection.type) { case 'range': - const startPos = getPositionFromPath(contentDiv, snapshotSelection.start); - const endPos = getPositionFromPath(contentDiv, snapshotSelection.end); - const range = contentDiv.ownerDocument.createRange(); + const startPos = getPositionFromPath(physicalRoot, snapshotSelection.start); + const endPos = getPositionFromPath(physicalRoot, snapshotSelection.end); + const range = physicalRoot.ownerDocument.createRange(); range.setStart(startPos.node, startPos.offset); range.setEnd(endPos.node, endPos.offset); @@ -26,7 +26,7 @@ export function restoreSnapshotSelection(core: StandaloneEditorCore, snapshot: S }; break; case 'table': - const table = contentDiv.querySelector( + const table = physicalRoot.querySelector( '#' + snapshotSelection.tableId ) as HTMLTableElement; @@ -42,7 +42,7 @@ export function restoreSnapshotSelection(core: StandaloneEditorCore, snapshot: S } break; case 'image': - const image = contentDiv.querySelector( + const image = physicalRoot.querySelector( '#' + snapshotSelection.imageId ) as HTMLImageElement; diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts index 20f75114fa3..80402409eae 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts @@ -288,7 +288,13 @@ export function sanitizeElement( if (sanitizedElement) { for (let child = element.firstChild; child; child = child.nextSibling) { const newChild = isNodeOfType(child, 'ELEMENT_NODE') - ? sanitizeElement(child, allowedTags, disallowedTags, styleSanitizers) + ? sanitizeElement( + child, + allowedTags, + disallowedTags, + styleSanitizers, + attributeSanitizers + ) : isNodeOfType(child, 'TEXT_NODE') ? child.cloneNode() : null; diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts index f1a579518e5..04133f4d1c0 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts @@ -1,9 +1,9 @@ import * as createSnapshotSelection from '../../lib/utils/createSnapshotSelection'; import { addUndoSnapshot } from '../../lib/coreApi/addUndoSnapshot'; -import { SnapshotsManager, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { EditorCore, SnapshotsManager } from 'roosterjs-content-model-types'; describe('addUndoSnapshot', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let contentDiv: HTMLDivElement; let addSnapshotSpy: jasmine.Spy; let getKnownColorsCopySpy: jasmine.Spy; @@ -21,7 +21,8 @@ describe('addUndoSnapshot', () => { } as any; core = { - contentDiv, + physicalRoot: contentDiv, + logicalRoot: contentDiv, darkColorHandler: { getKnownColorsCopy: getKnownColorsCopySpy, }, diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/attachDomEventTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/attachDomEventTest.ts index 92163c4f6ac..0f8126e28ab 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/attachDomEventTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/attachDomEventTest.ts @@ -1,15 +1,16 @@ import { attachDomEvent } from '../../lib/coreApi/attachDomEvent'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { EditorCore } from 'roosterjs-content-model-types'; describe('attachDomEvent', () => { let div: HTMLDivElement; - let core: StandaloneEditorCore; + let core: EditorCore; beforeEach(() => { div = document.createElement('div'); document.body.appendChild(div); core = { - contentDiv: div, + physicalRoot: div, + logicalRoot: div, api: {}, } as any; }); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts index 526e3991776..5d738afd2b2 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts @@ -2,7 +2,7 @@ import * as cloneModel from '../../lib/publicApi/model/cloneModel'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import { createContentModel } from '../../lib/coreApi/createContentModel'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { EditorCore } from 'roosterjs-content-model-types'; const mockedEditorContext = 'EDITORCONTEXT' as any; const mockedContext = 'CONTEXT' as any; @@ -12,7 +12,7 @@ const mockedCachedMode = 'CACHEDMODEL' as any; const mockedClonedModel = 'CLONEDMODEL' as any; describe('createContentModel', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let createEditorContext: jasmine.Spy; let getDOMSelection: jasmine.Spy; let domToContentModelSpy: jasmine.Spy; @@ -34,7 +34,8 @@ describe('createContentModel', () => { ); core = ({ - contentDiv: mockedDiv, + physicalRoot: mockedDiv, + logicalRoot: mockedDiv, api: { createEditorContext, getDOMSelection, @@ -44,7 +45,7 @@ describe('createContentModel', () => { }, lifecycle: {}, domToModelSettings: {}, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; }); it('Reuse model, no cache, no shadow edit', () => { @@ -99,7 +100,8 @@ describe('createContentModel with selection', () => { ); core = { - contentDiv: MockedDiv, + physicalRoot: MockedDiv, + logicalRoot: MockedDiv, api: { getDOMSelection: getDOMSelectionSpy, createEditorContext: createEditorContextSpy, 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 bdf2f92ba06..3d9104ce3ce 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 @@ -1,5 +1,5 @@ import { createEditorContext } from '../../lib/coreApi/createEditorContext'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { EditorCore } from 'roosterjs-content-model-types'; describe('createEditorContext', () => { it('create a normal context', () => { @@ -19,7 +19,8 @@ describe('createEditorContext', () => { }; const core = ({ - contentDiv: div, + physicalRoot: div, + logicalRoot: div, lifecycle: { isDarkMode, }, @@ -33,7 +34,7 @@ describe('createEditorContext', () => { domHelper: { calculateZoomScale: calculateZoomScaleSpy, }, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; const context = createEditorContext(core, false); @@ -67,7 +68,8 @@ describe('createEditorContext', () => { }; const core = ({ - contentDiv: div, + physicalRoot: div, + logicalRoot: div, lifecycle: { isDarkMode, }, @@ -81,7 +83,7 @@ describe('createEditorContext', () => { domHelper: { calculateZoomScale: calculateZoomScaleSpy, }, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; const context = createEditorContext(core, true); @@ -115,7 +117,8 @@ describe('createEditorContext', () => { }; const core = ({ - contentDiv: div, + physicalRoot: div, + logicalRoot: div, lifecycle: { isDarkMode, }, @@ -128,7 +131,7 @@ describe('createEditorContext', () => { domHelper: { calculateZoomScale: calculateZoomScaleSpy, }, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; const context = createEditorContext(core, false); @@ -147,7 +150,7 @@ describe('createEditorContext', () => { }); describe('createEditorContext - checkZoomScale', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let div: any; let getComputedStyleSpy: jasmine.Spy; let calculateZoomScaleSpy: jasmine.Spy; @@ -167,7 +170,8 @@ describe('createEditorContext - checkZoomScale', () => { }, }; core = ({ - contentDiv: div, + physicalRoot: div, + logicalRoot: div, lifecycle: { isDarkMode, }, @@ -179,7 +183,7 @@ describe('createEditorContext - checkZoomScale', () => { domHelper: { calculateZoomScale: calculateZoomScaleSpy, }, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; }); it('Zoom scale = 2', () => { @@ -202,7 +206,7 @@ describe('createEditorContext - checkZoomScale', () => { }); describe('createEditorContext - checkRootDir', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let div: any; let getComputedStyleSpy: jasmine.Spy; let calculateZoomScaleSpy: jasmine.Spy; @@ -221,7 +225,8 @@ describe('createEditorContext - checkRootDir', () => { }, }; core = ({ - contentDiv: div, + physicalRoot: div, + logicalRoot: div, lifecycle: { isDarkMode, }, @@ -233,7 +238,7 @@ describe('createEditorContext - checkRootDir', () => { domHelper: { calculateZoomScale: calculateZoomScaleSpy, }, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; }); it('LTR CSS', () => { diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/focusTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/focusTest.ts index af96e16dd14..ac792875dcd 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/focusTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/focusTest.ts @@ -1,9 +1,9 @@ +import { EditorCore } from 'roosterjs-content-model-types'; import { focus } from '../../lib/coreApi/focus'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('focus', () => { let div: HTMLDivElement; - let core: StandaloneEditorCore; + let core: EditorCore; let hasFocusSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; let nativeFocusSpy: jasmine.Spy; @@ -24,13 +24,14 @@ describe('focus', () => { } as any; core = { + physicalRoot: div, + logicalRoot: div, lifecycle: {}, api: { hasFocus: hasFocusSpy, setDOMSelection: setDOMSelectionSpy, }, selection: {}, - contentDiv: div, } as any; }); 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 96aaf2b74c3..884709a8055 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 @@ -6,11 +6,11 @@ import { ContentModelDocument, ContentModelSegmentFormat, FormatContentModelContext, - StandaloneEditorCore, + EditorCore, } from 'roosterjs-content-model-types'; describe('formatContentModel', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let addUndoSnapshot: jasmine.Spy; let createContentModel: jasmine.Spy; let setContentModel: jasmine.Spy; @@ -56,7 +56,7 @@ describe('formatContentModel', () => { undo: { snapshotsManager: {}, }, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; }); describe('Editor has focus', () => { @@ -93,7 +93,7 @@ describe('formatContentModel', () => { newImages: [], }); expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -725,7 +725,7 @@ describe('formatContentModel', () => { expect(callback).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(2); - expect(addUndoSnapshot).toHaveBeenCalledWith(core, false); + expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -750,7 +750,7 @@ describe('formatContentModel', () => { expect(callback).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(2); - expect(addUndoSnapshot).toHaveBeenCalledWith(core, false); + expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, mockedEntityState); expect(addUndoSnapshot).toHaveBeenCalledWith(core, false, mockedEntityState); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -771,7 +771,7 @@ describe('formatContentModel', () => { formatContentModel(core, callback); expect(callback).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, true, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -800,7 +800,7 @@ describe('formatContentModel', () => { formatContentModel(core, callback); expect(callback).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(2); expect(addUndoSnapshot).toHaveBeenCalledWith(core, true, undefined); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts index 952a2af9fa2..db3fccce6c2 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts @@ -1,8 +1,8 @@ +import { EditorCore } from 'roosterjs-content-model-types'; import { getDOMSelection } from '../../lib/coreApi/getDOMSelection'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('getDOMSelection', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let getSelectionSpy: jasmine.Spy; let hasFocusSpy: jasmine.Spy; let containsSpy: jasmine.Spy; @@ -12,17 +12,20 @@ describe('getDOMSelection', () => { containsSpy = jasmine.createSpy('contains'); hasFocusSpy = jasmine.createSpy('hasFocus'); + const contentDiv = { + ownerDocument: { + defaultView: { + getSelection: getSelectionSpy, + }, + }, + contains: containsSpy, + }; + core = { + physicalRoot: contentDiv, + logicalRoot: contentDiv, lifecycle: {}, selection: {}, - contentDiv: { - ownerDocument: { - defaultView: { - getSelection: getSelectionSpy, - }, - }, - contains: containsSpy, - }, api: { hasFocus: hasFocusSpy, }, diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts index 785895374bb..bc4ebd2644b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts @@ -6,7 +6,8 @@ describe('getVisibleViewport', () => { getBoundingClientRect: () => ({ left: 100, right: 200, top: 300, bottom: 400 }), }; const core = { - contentDiv: div, + physicalRoot: div, + logicalRoot: div, domEvent: { scrollContainer: div, }, @@ -25,7 +26,8 @@ describe('getVisibleViewport', () => { getBoundingClientRect: () => ({ left: 150, right: 250, top: 350, bottom: 450 }), }; const core = { - contentDiv: div1, + physicalRoot: div1, + logicalRoot: div1, domEvent: { scrollContainer: div2, }, diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/hasFocusTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/hasFocusTest.ts index 3eff77e4d38..a1e5c7d9a35 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/hasFocusTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/hasFocusTest.ts @@ -1,18 +1,22 @@ +import { EditorCore } from 'roosterjs-content-model-types'; import { hasFocus } from '../../lib/coreApi/hasFocus'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('hasFocus', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let containsSpy: jasmine.Spy; let mockedElement = 'ELEMENT' as any; beforeEach(() => { containsSpy = jasmine.createSpy('contains'); + + const mockedRoot = { + ownerDocument: {}, + contains: containsSpy, + }; + core = { - contentDiv: { - ownerDocument: {}, - contains: containsSpy, - }, + physicalRoot: mockedRoot, + logicalRoot: mockedRoot, } as any; }); @@ -21,7 +25,7 @@ describe('hasFocus', () => { }); it('Has active element inside editor', () => { - (core.contentDiv.ownerDocument as any).activeElement = mockedElement; + (core.physicalRoot.ownerDocument as any).activeElement = mockedElement; containsSpy.and.returnValue(true); let result = hasFocus(core); @@ -30,7 +34,7 @@ describe('hasFocus', () => { }); it('Has active element outside editor', () => { - (core.contentDiv.ownerDocument as any).activeElement = mockedElement; + (core.physicalRoot.ownerDocument as any).activeElement = mockedElement; containsSpy.and.returnValue(false); let result = hasFocus(core); 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 3592b897491..fcb8f1e13d4 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,13 +9,13 @@ 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 { Editor } from '../../lib/editor/Editor'; 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, ContentModelDocument, - IStandaloneEditor, + IEditor, BeforePasteEvent, PluginEvent, } from 'roosterjs-content-model-types'; @@ -25,7 +25,7 @@ let clipboardData: ClipboardData; const DEFAULT_TIMES_ADD_PARSER_CALLED = 4; describe('Paste ', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let createContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; @@ -72,7 +72,7 @@ describe('Paste ', () => { } as any, ]); - editor = new StandaloneEditor(div, { + editor = new Editor(div, { plugins: [new PastePlugin()], coreApiOverride: { focus, @@ -105,13 +105,13 @@ describe('Paste ', () => { }); describe('paste with content model & paste plugin', () => { - let editor: StandaloneEditor | undefined; + let editor: Editor | undefined; let div: HTMLDivElement | undefined; beforeEach(() => { div = document.createElement('div'); document.body.appendChild(div); - editor = new StandaloneEditor(div, { + editor = new Editor(div, { plugins: [new PastePlugin()], }); spyOn(addParserF, 'default').and.callThrough(); @@ -258,7 +258,7 @@ describe('paste with content model & paste plugin', () => { }; let eventChecker: BeforePasteEvent = {}; - editor = new StandaloneEditor(div!, { + editor = new Editor(div!, { plugins: [ { initialize: () => {}, @@ -283,7 +283,7 @@ describe('paste with content model & paste plugin', () => { }); describe('Paste with clipboardData', () => { - let editor: IStandaloneEditor = undefined!; + let editor: IEditor = undefined!; const ID = 'EDITOR_ID'; beforeEach(() => { diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshotTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshotTest.ts index 81707e39596..b1c2db93602 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshotTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshotTest.ts @@ -2,11 +2,11 @@ import * as restoreSnapshotColors from '../../lib/utils/restoreSnapshotColors'; import * as restoreSnapshotHTML from '../../lib/utils/restoreSnapshotHTML'; import * as restoreSnapshotSelection from '../../lib/utils/restoreSnapshotSelection'; import { ChangeSource } from '../../lib/constants/ChangeSource'; +import { EditorCore, Snapshot } from 'roosterjs-content-model-types'; import { restoreUndoSnapshot } from '../../lib/coreApi/restoreUndoSnapshot'; -import { Snapshot, StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('restoreUndoSnapshot', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let triggerEventSpy: jasmine.Spy; let restoreSnapshotColorsSpy: jasmine.Spy; let restoreSnapshotHTMLSpy: jasmine.Spy; diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts index 31a0cefe234..cb3a22b2456 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts @@ -1,7 +1,7 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; +import { EditorCore } from 'roosterjs-content-model-types'; import { setContentModel } from '../../lib/coreApi/setContentModel'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; const mockedDoc = 'DOCUMENT' as any; const mockedModel = 'MODEL' as any; @@ -11,7 +11,7 @@ const mockedDiv = { ownerDocument: mockedDoc } as any; const mockedConfig = 'CONFIG' as any; describe('setContentModel', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let contentModelToDomSpy: jasmine.Spy; let createEditorContext: jasmine.Spy; let createModelToDomContextSpy: jasmine.Spy; @@ -36,7 +36,8 @@ describe('setContentModel', () => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); core = ({ - contentDiv: mockedDiv, + physicalRoot: mockedDiv, + logicalRoot: mockedDiv, api: { createEditorContext, setDOMSelection: setDOMSelectionSpy, @@ -47,7 +48,7 @@ describe('setContentModel', () => { modelToDomSettings: { calculated: mockedConfig, }, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; }); it('no default option, no shadow edit', () => { diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts index d97267cd9a6..65a4ba02f49 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts @@ -1,9 +1,9 @@ import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; -import { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; import { setDOMSelection } from '../../lib/coreApi/setDOMSelection'; describe('setDOMSelection', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let querySelectorAllSpy: jasmine.Spy; let hasFocusSpy: jasmine.Spy; let triggerEventSpy: jasmine.Spy; @@ -46,10 +46,11 @@ describe('setDOMSelection', () => { } as any; core = { + physicalRoot: contentDiv, + logicalRoot: contentDiv, selection: { selectionStyleNode: mockedStyleNode, }, - contentDiv, api: { hasFocus: hasFocusSpy, triggerEvent: triggerEventSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts index 40c40ba5006..ea5469f628b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts @@ -1,12 +1,12 @@ import * as iterateSelections from '../../lib/publicApi/selection/iterateSelections'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { EditorCore } from 'roosterjs-content-model-types'; import { switchShadowEdit } from '../../lib/coreApi/switchShadowEdit'; const mockedModel = 'MODEL' as any; const mockedCachedModel = 'CACHEMODEL' as any; describe('switchShadowEdit', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let createContentModel: jasmine.Spy; let setContentModel: jasmine.Spy; let getSelectionRange: jasmine.Spy; @@ -18,7 +18,11 @@ describe('switchShadowEdit', () => { getSelectionRange = jasmine.createSpy('getSelectionRange'); triggerEvent = jasmine.createSpy('triggerEvent'); + const contentDiv = document.createElement('div'); + core = ({ + physicalRoot: contentDiv, + logicalRoot: contentDiv, api: { createContentModel, setContentModel, @@ -26,9 +30,8 @@ describe('switchShadowEdit', () => { triggerEvent, }, lifecycle: {}, - contentDiv: document.createElement('div'), cache: {}, - } as any) as StandaloneEditorCore; + } as any) as EditorCore; }); describe('was off', () => { diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/triggerEventTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/triggerEventTest.ts index ed9197ee6c7..053410fbd1d 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/triggerEventTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/triggerEventTest.ts @@ -1,16 +1,17 @@ -import { EditorPlugin, PluginEvent, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { EditorCore, EditorPlugin, PluginEvent } from 'roosterjs-content-model-types'; import { triggerEvent } from '../../lib/coreApi/triggerEvent'; describe('triggerEvent', () => { let div: HTMLDivElement; - let core: StandaloneEditorCore; + let core: EditorCore; beforeEach(() => { div = document.createElement('div'); document.body.appendChild(div); core = { - contentDiv: div, + physicalRoot: div, + logicalRoot: div, api: {}, plugins: [], lifecycle: {}, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts index 29463824edc..f5f76c1aab5 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts @@ -4,14 +4,14 @@ import { domIndexerImpl } from '../../lib/corePlugin/utils/domIndexerImpl'; import { CachePluginState, DomIndexer, - IStandaloneEditor, + IEditor, PluginWithState, - StandaloneEditorOptions, + EditorOptions, } from 'roosterjs-content-model-types'; describe('CachePlugin', () => { let plugin: PluginWithState; - let editor: IStandaloneEditor; + let editor: IEditor; let addEventListenerSpy: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; @@ -21,7 +21,7 @@ describe('CachePlugin', () => { let domIndexer: DomIndexer; let contentDiv: HTMLDivElement; - function init(option: StandaloneEditorOptions) { + function init(option: EditorOptions) { addEventListenerSpy = jasmine.createSpy('addEventListenerSpy'); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); @@ -43,7 +43,7 @@ describe('CachePlugin', () => { removeEventListener: removeEventListenerSpy, }; }, - } as any) as IStandaloneEditor; + } as any) as IEditor; plugin = createCachePlugin(option, contentDiv); plugin.initialize(editor); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts index 92fac47a729..42e3282e5ef 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts @@ -3,7 +3,7 @@ import { createContextMenuPlugin } from '../../lib/corePlugin/ContextMenuPlugin' import { ContextMenuPluginState, DOMEventRecord, - IStandaloneEditor, + IEditor, PluginWithState, } from 'roosterjs-content-model-types'; @@ -14,7 +14,7 @@ describe('ContextMenu handle other event', () => { let getDOMSelectionSpy: jasmine.Spy; let attachDOMEventSpy: jasmine.Spy; let getSelectionRootNodeSpy: jasmine.Spy; - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { triggerEventSpy = jasmine.createSpy('triggerEvent'); @@ -26,7 +26,7 @@ describe('ContextMenu handle other event', () => { eventMap = handlers; }); - editor = ({ + editor = ({ getDOMSelection: getDOMSelectionSpy, attachDomEvent: attachDOMEventSpy, triggerEvent: triggerEventSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts index cf529029dd4..66eb559940a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts @@ -13,12 +13,13 @@ import { DOMSelection, ContentModelFormatter, FormatContentModelOptions, - IStandaloneEditor, + IEditor, DOMEventRecord, ClipboardData, CopyPastePluginState, PluginWithState, DarkColorHandler, + PasteType, } from 'roosterjs-content-model-types'; import { adjustSelectionForCopyCut, @@ -42,24 +43,27 @@ describe('CopyPastePlugin.Ctor', () => { expect(state).toEqual({ allowedCustomPasteType: [], tempDiv: null, + defaultPasteType: undefined, }); }); it('Ctor with options', () => { const plugin = createCopyPastePlugin({ allowedCustomPasteType, + defaultPasteType: 'mergeFormat', }); const state = plugin.getState(); expect(state).toEqual({ allowedCustomPasteType: allowedCustomPasteType, tempDiv: null, + defaultPasteType: 'mergeFormat', }); }); }); describe('CopyPastePlugin |', () => { - let editor: IStandaloneEditor = null!; + let editor: IEditor = null!; let plugin: PluginWithState; let domEvents: Record = {}; let div: HTMLDivElement; @@ -115,7 +119,7 @@ describe('CopyPastePlugin |', () => { allowedCustomPasteType, }); plugin.getState().tempDiv = div; - editor = ({ + editor = ({ attachDomEvent: (eventMap: Record) => { domEvents = eventMap; }, @@ -145,8 +149,8 @@ describe('CopyPastePlugin |', () => { isDarkMode: () => { return false; }, - pasteFromClipboard: (ar1: any) => { - pasteSpy(ar1); + pasteFromClipboard: (ar1: any, pasteType?: PasteType) => { + pasteSpy(ar1, pasteType); }, getColorManager: () => mockedDarkColorHandler, isDisposed, @@ -552,7 +556,7 @@ describe('CopyPastePlugin |', () => { domEvents.paste.beforeDispatch?.(clipboardEvent); - expect(pasteSpy).toHaveBeenCalledWith(clipboardData); + expect(pasteSpy).toHaveBeenCalledWith(clipboardData, undefined); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), allowedCustomPasteType @@ -581,7 +585,127 @@ describe('CopyPastePlugin |', () => { domEvents.paste.beforeDispatch?.(clipboardEvent); - expect(pasteSpy).toHaveBeenCalledWith(clipboardData); + expect(pasteSpy).toHaveBeenCalledWith(clipboardData, undefined); + expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( + Array.from(clipboardEvent.clipboardData!.items), + allowedCustomPasteType + ); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('Handle with defaultPasteType mergePaste', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); + plugin.getState().defaultPasteType = 'mergeFormat'; + let clipboardEvent = { + clipboardData: ({ + items: [{}], + }), + preventDefault() { + preventDefaultSpy(); + }, + }; + spyOn(extractClipboardItemsFile, 'extractClipboardItems').and.returnValue(< + Promise + >{ + then: (cb: (value: ClipboardData) => void | PromiseLike) => { + cb(clipboardData); + }, + }); + isDisposed.and.returnValue(false); + + domEvents.paste.beforeDispatch?.(clipboardEvent); + + expect(pasteSpy).toHaveBeenCalledWith(clipboardData, 'mergeFormat'); + expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( + Array.from(clipboardEvent.clipboardData!.items), + allowedCustomPasteType + ); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('Handle with defaultPasteType asImage', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); + plugin.getState().defaultPasteType = 'asImage'; + let clipboardEvent = { + clipboardData: ({ + items: [{}], + }), + preventDefault() { + preventDefaultSpy(); + }, + }; + spyOn(extractClipboardItemsFile, 'extractClipboardItems').and.returnValue(< + Promise + >{ + then: (cb: (value: ClipboardData) => void | PromiseLike) => { + cb(clipboardData); + }, + }); + isDisposed.and.returnValue(false); + + domEvents.paste.beforeDispatch?.(clipboardEvent); + + expect(pasteSpy).toHaveBeenCalledWith(clipboardData, 'asImage'); + expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( + Array.from(clipboardEvent.clipboardData!.items), + allowedCustomPasteType + ); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('Handle with defaultPasteType asPlainText', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); + plugin.getState().defaultPasteType = 'asPlainText'; + let clipboardEvent = { + clipboardData: ({ + items: [{}], + }), + preventDefault() { + preventDefaultSpy(); + }, + }; + spyOn(extractClipboardItemsFile, 'extractClipboardItems').and.returnValue(< + Promise + >{ + then: (cb: (value: ClipboardData) => void | PromiseLike) => { + cb(clipboardData); + }, + }); + isDisposed.and.returnValue(false); + + domEvents.paste.beforeDispatch?.(clipboardEvent); + + expect(pasteSpy).toHaveBeenCalledWith(clipboardData, 'asPlainText'); + expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( + Array.from(clipboardEvent.clipboardData!.items), + allowedCustomPasteType + ); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('Handle with defaultPasteType asPlainText', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); + plugin.getState().defaultPasteType = 'normal'; + let clipboardEvent = { + clipboardData: ({ + items: [{}], + }), + preventDefault() { + preventDefaultSpy(); + }, + }; + spyOn(extractClipboardItemsFile, 'extractClipboardItems').and.returnValue(< + Promise + >{ + then: (cb: (value: ClipboardData) => void | PromiseLike) => { + cb(clipboardData); + }, + }); + isDisposed.and.returnValue(false); + + domEvents.paste.beforeDispatch?.(clipboardEvent); + + expect(pasteSpy).toHaveBeenCalledWith(clipboardData, 'normal'); expect(extractClipboardItemsFile.extractClipboardItems).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), allowedCustomPasteType 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 e65bbddcf5c..7f4890e0fe8 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 @@ -1,11 +1,7 @@ import * as eventUtils from '../../lib/publicApi/domUtils/eventUtils'; import { ChangeSource } from '../../lib/constants/ChangeSource'; import { createDOMEventPlugin } from '../../lib/corePlugin/DOMEventPlugin'; -import { - DOMEventPluginState, - IStandaloneEditor, - PluginWithState, -} from 'roosterjs-content-model-types'; +import { DOMEventPluginState, IEditor, PluginWithState } from 'roosterjs-content-model-types'; const getDocument = () => document; @@ -25,7 +21,7 @@ describe('DOMEventPlugin', () => { getDocument, attachDomEvent, getEnvironment: () => ({}), - } as any) as IStandaloneEditor; + } as any) as IEditor; plugin.initialize(editor); @@ -70,7 +66,7 @@ describe('DOMEventPlugin', () => { const attachDomEvent = jasmine .createSpy('attachDomEvent') .and.returnValue(jasmine.createSpy('disposer')); - plugin.initialize(({ + plugin.initialize(({ getDocument, attachDomEvent, getEnvironment: () => ({}), @@ -108,7 +104,7 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro triggerEventSpy = jasmine.createSpy('triggerEvent'); plugin = createDOMEventPlugin({}, div); - plugin.initialize(({ + plugin.initialize(({ getDocument, attachDomEvent: (map: Record) => { eventMap = map; @@ -278,7 +274,7 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { }, null! ); - plugin.initialize(({ + plugin.initialize(({ getDocument: () => ({ addEventListener, removeEventListener, @@ -395,7 +391,7 @@ describe('DOMEventPlugin handle other event', () => { let eventMap: Record; let scrollContainer: HTMLElement; let addEventListenerSpy: jasmine.Spy; - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { addEventListener = jasmine.createSpy('addEventListener'); @@ -414,7 +410,7 @@ describe('DOMEventPlugin handle other event', () => { null! ); - editor = ({ + editor = ({ getDocument: () => ({ addEventListener, removeEventListener, 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 bb723cfc8d2..0f9bf77b654 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 @@ -7,12 +7,12 @@ import { ContentModelDocument, DarkColorHandler, EntityPluginState, - IStandaloneEditor, + IEditor, PluginWithState, } from 'roosterjs-content-model-types'; describe('EntityPlugin', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let plugin: PluginWithState; let formatContentModelSpy: jasmine.Spy; let triggerPluginEventSpy: jasmine.Spy; @@ -599,7 +599,7 @@ describe('EntityPlugin', () => { it('Click on entity', () => { const mockedNode = { parentNode: null as any, - classList: ['_ENtity', '_EType_A', '_EId_A'], + classList: ['_Entity', '_EType_A', '_EId_A'], } as any; const mockedEvent = { target: mockedNode, @@ -631,7 +631,7 @@ describe('EntityPlugin', () => { it('Click on child of entity', () => { const mockedNode1 = { parentNode: null as any, - classList: ['_ENtity', '_EType_A', '_EId_A'], + classList: ['_Entity', '_EType_A', '_EId_A'], } as any; const mockedNode2 = { @@ -667,7 +667,7 @@ describe('EntityPlugin', () => { it('Not clicking', () => { const mockedNode = { parentNode: null as any, - classList: ['_ENtity', '_EType_A', '_EId_A'], + classList: ['_Entity', '_EType_A', '_EId_A'], } as any; const mockedEvent = { target: mockedNode, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts index dd5a92fc80b..9fd485fdc12 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts @@ -2,7 +2,7 @@ import * as applyDefaultFormat from '../../lib/corePlugin/utils/applyDefaultForm import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { createFormatPlugin } from '../../lib/corePlugin/FormatPlugin'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { IEditor } from 'roosterjs-content-model-types'; describe('FormatPlugin', () => { const mockedFormat = { @@ -18,7 +18,7 @@ describe('FormatPlugin', () => { const editor = ({ cacheContentModel: () => {}, isDarkMode: () => false, - } as any) as IStandaloneEditor; + } as any) as IEditor; const plugin = createFormatPlugin({}); plugin.initialize(editor); @@ -40,7 +40,7 @@ describe('FormatPlugin', () => { isInIME: () => false, cacheContentModel: () => {}, getEnvironment: () => ({}), - } as any) as IStandaloneEditor; + } as any) as IEditor; const plugin = createFormatPlugin({}); const model = createContentModelDocument(); @@ -76,7 +76,7 @@ describe('FormatPlugin', () => { isDarkMode: () => false, triggerEvent, getVisibleViewport, - } as any) as IStandaloneEditor; + } as any) as IEditor; const plugin = createFormatPlugin({}); const state = plugin.getState(); @@ -101,7 +101,7 @@ describe('FormatPlugin', () => { const editor = ({ createContentModel: () => model, cacheContentModel: () => {}, - } as any) as IStandaloneEditor; + } as any) as IEditor; const plugin = createFormatPlugin({}); plugin.initialize(editor); @@ -133,7 +133,7 @@ describe('FormatPlugin', () => { callback(); }, cacheContentModel: () => {}, - } as any) as IStandaloneEditor; + } as any) as IEditor; const plugin = createFormatPlugin({}); const state = plugin.getState(); @@ -162,7 +162,7 @@ describe('FormatPlugin', () => { const editor = ({ createContentModel: () => model, cacheContentModel: () => {}, - } as any) as IStandaloneEditor; + } as any) as IEditor; const plugin = createFormatPlugin({}); const state = plugin.getState(); @@ -192,7 +192,7 @@ describe('FormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, getEnvironment: () => ({}), - } as any) as IStandaloneEditor; + } as any) as IEditor; const plugin = createFormatPlugin({}); const state = plugin.getState(); @@ -218,7 +218,7 @@ describe('FormatPlugin', () => { }); describe('FormatPlugin for default format', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let contentDiv: HTMLDivElement; let getDOMSelection: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; @@ -243,7 +243,7 @@ describe('FormatPlugin for default format', () => { cacheContentModel: cacheContentModelSpy, takeSnapshot: takeSnapshotSpy, formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); it('Collapsed range, text input, under editor directly', () => { diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts index d4a3e3bb258..7e76078b19b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts @@ -1,7 +1,7 @@ import * as color from 'roosterjs-content-model-dom/lib/formatHandlers/utils/color'; import { ChangeSource } from '../../lib/constants/ChangeSource'; import { createLifecyclePlugin } from '../../lib/corePlugin/LifecyclePlugin'; -import { DarkColorHandler, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { DarkColorHandler, IEditor } from 'roosterjs-content-model-types'; describe('LifecyclePlugin', () => { it('init', () => { @@ -10,7 +10,7 @@ describe('LifecyclePlugin', () => { const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - plugin.initialize(({ + plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, @@ -47,7 +47,7 @@ describe('LifecyclePlugin', () => { const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - plugin.initialize(({ + plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, @@ -74,7 +74,7 @@ describe('LifecyclePlugin', () => { const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); - plugin.initialize(({ + plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, @@ -96,7 +96,7 @@ describe('LifecyclePlugin', () => { const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); - plugin.initialize(({ + plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, @@ -121,7 +121,7 @@ describe('LifecyclePlugin', () => { const setColorSpy = spyOn(color, 'setColor'); - plugin.initialize(({ + plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, })); @@ -151,7 +151,7 @@ describe('LifecyclePlugin', () => { const setColorSpy = spyOn(color, 'setColor'); - plugin.initialize(({ + plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, })); @@ -203,7 +203,7 @@ describe('LifecyclePlugin', () => { const setColorSpy = spyOn(color, 'setColor'); - plugin.initialize(({ + plugin.initialize(({ triggerEvent, getDarkColorHandler: () => mockedDarkColorHandler, })); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts index e73cac4e98b..e5b624c4027 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -1,7 +1,7 @@ import { createSelectionPlugin } from '../../lib/corePlugin/SelectionPlugin'; import { EditorPlugin, - IStandaloneEditor, + IEditor, PluginWithState, SelectionPluginState, } from 'roosterjs-content-model-types'; @@ -30,7 +30,7 @@ describe('SelectionPlugin', () => { getDocument: getDocumentSpy, attachDomEvent, getEnvironment: () => ({}), - } as any) as IStandaloneEditor; + } as any) as IEditor; plugin.initialize(editor); @@ -71,7 +71,7 @@ describe('SelectionPlugin', () => { removeEventListener: removeEventListenerSpy, }); - plugin.initialize(({ + plugin.initialize(({ getDocument: getDocumentSpy, attachDomEvent, getEnvironment: () => ({}), @@ -100,7 +100,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { let setDOMSelectionSpy: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { triggerEvent = jasmine.createSpy('triggerEvent'); @@ -119,7 +119,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { plugin = createSelectionPlugin({}); - editor = ({ + editor = ({ getDocument: getDocumentSpy, triggerEvent, getEnvironment: () => ({}), @@ -172,7 +172,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { describe('SelectionPlugin handle image selection', () => { let plugin: EditorPlugin; - let editor: IStandaloneEditor; + let editor: IEditor; let getDOMSelectionSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; @@ -584,7 +584,7 @@ describe('SelectionPlugin on Safari', () => { let hasFocusSpy: jasmine.Spy; let isInShadowEditSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { disposer = jasmine.createSpy('disposer'); @@ -614,7 +614,7 @@ describe('SelectionPlugin on Safari', () => { hasFocus: hasFocusSpy, isInShadowEdit: isInShadowEditSpy, getDOMSelection: getDOMSelectionSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; }); it('init and dispose', () => { diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts index ca29db4c828..b487e57e37e 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts @@ -3,14 +3,14 @@ import * as undo from '../../lib/publicApi/undo/undo'; import { ChangeSource } from '../../lib/constants/ChangeSource'; import { createUndoPlugin } from '../../lib/corePlugin/UndoPlugin'; import { - IStandaloneEditor, + IEditor, PluginWithState, SnapshotsManager, UndoPluginState, } from 'roosterjs-content-model-types'; describe('UndoPlugin', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let createSnapshotsManagerSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; let canUndoAutoCompleteSpy: jasmine.Spy; 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 c6f5823f1e1..fcc9944b030 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 @@ -7,7 +7,7 @@ import { ContentModelSegmentFormat, FormatContentModelContext, FormatContentModelOptions, - IStandaloneEditor, + IEditor, InsertPoint, } from 'roosterjs-content-model-types'; import { @@ -20,7 +20,7 @@ import { } from 'roosterjs-content-model-dom'; describe('applyDefaultFormat', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let getDOMSelectionSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let deleteSelectionSpy: jasmine.Spy; 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 78bbab041f0..6eabe6aa0c3 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 @@ -8,7 +8,7 @@ import { ContentModelText, ContentModelFormatter, FormatContentModelOptions, - IStandaloneEditor, + IEditor, } from 'roosterjs-content-model-types'; import { createContentModelDocument, @@ -52,7 +52,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -124,7 +124,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -183,7 +183,7 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine.createSpy('formatContentModel'); const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -241,7 +241,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [text]); @@ -290,7 +290,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); 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 index 8b2a2fb17da..203c28c4e4f 100644 --- 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 @@ -1,5 +1,6 @@ import * as DelimiterFile from '../../../lib/corePlugin/utils/entityDelimiterUtils'; import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import { ContentModelDocument, DOMSelection, IEditor } from 'roosterjs-content-model-types'; import { handleDelimiterContentChangedEvent, handleDelimiterKeyDownEvent, @@ -9,11 +10,6 @@ import { createEntity, createModelToDomContext, } from 'roosterjs-content-model-dom'; -import { - ContentModelDocument, - DOMSelection, - IStandaloneEditor, -} from 'roosterjs-content-model-types'; const ZeroWidthSpace = '\u200B'; const BlockEntityContainer = '_E_EBlockEntityContainer'; @@ -29,7 +25,7 @@ describe('EntityDelimiterUtils |', () => { isNodeInEditor: () => true, }), getPendingFormat: ((): any => null), - }) as Partial; + }) as Partial; }); describe('contentChanged |', () => { @@ -182,7 +178,7 @@ describe('EntityDelimiterUtils |', () => { isNodeInEditor: () => true, }), takeSnapshot: takeSnapshotSpy, - }) as Partial; + }) as Partial; spyOn(DelimiterFile, 'preventTypeInDelimiter').and.callThrough(); }); @@ -576,7 +572,7 @@ describe('preventTypeInDelimiter', () => { formatContentModel: formatter => { formatter(mockedModel, context); }, - } as Partial; + } as Partial; }); it('handle delimiter after entity', () => { diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/EditorTest.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts rename to packages-content-model/roosterjs-content-model-core/test/editor/EditorTest.ts index 21f333d1427..591c3df519b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -1,14 +1,14 @@ import * as cloneModel from '../../lib/publicApi/model/cloneModel'; +import * as createEditorCore from '../../lib/editor/createEditorCore'; import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; -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 { Editor } from '../../lib/editor/Editor'; +import { EditorCore, Rect } from 'roosterjs-content-model-types'; import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; -import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; import { tableProcessor } from 'roosterjs-content-model-dom'; -describe('StandaloneEditor', () => { +describe('Editor', () => { let createEditorCoreSpy: jasmine.Spy; let updateKnownColorSpy: jasmine.Spy; let setContentModelSpy: jasmine.Spy; @@ -16,10 +16,7 @@ describe('StandaloneEditor', () => { beforeEach(() => { updateKnownColorSpy = jasmine.createSpy('updateKnownColor'); - createEditorCoreSpy = spyOn( - createStandaloneEditorCore, - 'createStandaloneEditorCore' - ).and.callThrough(); + createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.callThrough(); setContentModelSpy = jasmine.createSpy('setContentModel'); createEmptyModelSpy = spyOn(createEmptyModel, 'createEmptyModel'); }); @@ -29,7 +26,7 @@ describe('StandaloneEditor', () => { createEmptyModelSpy.and.callThrough(); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); expect(createEditorCoreSpy).toHaveBeenCalledWith(div, {}); expect(editor.isDisposed()).toBeFalse(); @@ -75,7 +72,7 @@ describe('StandaloneEditor', () => { createEmptyModelSpy.and.callThrough(); - const editor = new StandaloneEditor(div, options); + const editor = new Editor(div, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(div, options); expect(editor.isDisposed()).toBeFalse(); @@ -129,7 +126,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const model1 = editor.getContentModelCopy('connected'); @@ -180,7 +177,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.returnValue(mockedClonedModel); @@ -256,7 +253,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result = editor.getEnvironment(); @@ -288,7 +285,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result = editor.getDOMSelection(); @@ -319,7 +316,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); editor.setDOMSelection(null); @@ -354,7 +351,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); editor.formatContentModel(mockedFormatter); @@ -391,7 +388,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result1 = editor.getPendingFormat(); @@ -430,11 +427,11 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const snapshot = editor.takeSnapshot(); - expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false); + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false, undefined); expect(snapshot).toBe(mockedSnapshot); editor.dispose(); @@ -458,7 +455,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const domHelper = editor.getDOMHelper(); expect(domHelper).toBe(mockedDOMHelper); @@ -468,6 +465,38 @@ describe('StandaloneEditor', () => { expect(() => editor.takeSnapshot()).toThrow(); }); + it('takeSnapshot', () => { + const div = document.createElement('div'); + const mockedSnapshot = 'SNAPSHOT' as any; + const resetSpy = jasmine.createSpy('reset'); + const addUndoSnapshotSpy = jasmine + .createSpy('addUndoSnapshot') + .and.returnValue(mockedSnapshot); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { addUndoSnapshot: addUndoSnapshotSpy, setContentModel: setContentModelSpy }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new Editor(div); + const snapshot = editor.takeSnapshot(); + + expect(snapshot).toEqual(mockedSnapshot); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false, undefined); + + const mockedState = 'STATE' as any; + + editor.takeSnapshot(mockedState); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(2); + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(mockedCore, false, [mockedState]); + }); + it('restoreSnapshot', () => { const div = document.createElement('div'); const mockedSnapshot = 'SNAPSHOT' as any; @@ -487,7 +516,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); editor.restoreSnapshot(mockedSnapshot); @@ -516,7 +545,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); editor.focus(); expect(focusSpy).toHaveBeenCalledWith(mockedCore); @@ -545,7 +574,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result = editor.hasFocus(); @@ -580,7 +609,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const mockedEventType = 'EVENTTYPE' as any; const result = editor.triggerEvent(mockedEventType, mockedEventData, true); @@ -627,7 +656,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); const mockedEventMap = 'EVENTMAP' as any; - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result = editor.attachDomEvent(mockedEventMap); @@ -658,7 +687,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result = editor.getSnapshotsManager(); @@ -692,7 +721,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); expect(editor.isInShadowEdit()).toBeFalse(); @@ -736,7 +765,7 @@ describe('StandaloneEditor', () => { const mockedClipboardData = 'ClipboardData' as any; const mockedPasteType = 'PASTETYPE' as any; - const editor = new StandaloneEditor(div); + const editor = new Editor(div); editor.pasteFromClipboard(mockedClipboardData); @@ -766,7 +795,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result = editor.getColorManager(); @@ -793,7 +822,8 @@ describe('StandaloneEditor', () => { const mockedCore = { plugins: [], darkColorHandler: mockedColorHandler, - contentDiv: div, + physicalRoot: div, + logicalRoot: div, lifecycle: { isDarkMode: false, }, @@ -805,7 +835,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); expect(editor.isDarkMode()).toBeFalse(); @@ -879,7 +909,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result = editor.getScrollContainer(); @@ -902,7 +932,7 @@ describe('StandaloneEditor', () => { }, api: { setContentModel: setContentModelSpy, - getVisibleViewport: (core: StandaloneEditorCore) => { + getVisibleViewport: (core: EditorCore) => { return mockedScrollContainer; }, }, @@ -911,7 +941,7 @@ describe('StandaloneEditor', () => { createEditorCoreSpy.and.returnValue(mockedCore); - const editor = new StandaloneEditor(div); + const editor = new Editor(div); const result = editor.getVisibleViewport(); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts index 70d0d6ec426..636f850d0b4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts @@ -300,6 +300,171 @@ describe('SnapshotsManagerImpl.addSnapshot', () => { ]); }); + it('Add snapshot with entity state with equal entity states', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + }); + + it('Add snapshot with entity state with different entity states', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + const mockedEntityStates2 = 'ENTITYSTATES2' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates2, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates2, + }, + ]); + }); + + it('Add snapshot without entity state after a snapshot with empty state', () => { + const mockedEntityStates = 'ENTITYSTATES' as any; + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + + service.addSnapshot( + { + html: 'test', + isDarkMode: false, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + isDarkMode: false, + entityStates: mockedEntityStates, + }, + ]); + }); + it('Has onChanged', () => { const onChanged = jasmine.createSpy('onChanged'); snapshots.onChanged = onChanged; diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createEditorCoreTest.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts rename to packages-content-model/roosterjs-content-model-core/test/editor/createEditorCoreTest.ts index 3d6a523dd40..b14ba8ffc01 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createEditorCoreTest.ts @@ -1,14 +1,14 @@ -import * as createDefaultSettings from '../../lib/editor/createStandaloneEditorDefaultSettings'; -import * as createStandaloneEditorCorePlugins from '../../lib/corePlugin/createStandaloneEditorCorePlugins'; +import * as createDefaultSettings from '../../lib/editor/createEditorDefaultSettings'; +import * as createEditorCorePlugins from '../../lib/corePlugin/createEditorCorePlugins'; import * as DarkColorHandlerImpl from '../../lib/editor/DarkColorHandlerImpl'; import * as DOMHelperImpl from '../../lib/editor/DOMHelperImpl'; -import { standaloneCoreApiMap } from '../../lib/editor/standaloneCoreApiMap'; -import { StandaloneEditorCore, StandaloneEditorOptions } from 'roosterjs-content-model-types'; +import { coreApiMap } from '../../lib/editor/coreApiMap'; +import { EditorCore, EditorOptions } from 'roosterjs-content-model-types'; import { - createStandaloneEditorCore, + createEditorCore, defaultTrustHtmlHandler, getDarkColorFallback, -} from '../../lib/editor/createStandaloneEditorCore'; +} from '../../lib/editor/createEditorCore'; describe('createEditorCore', () => { function createMockedPlugin(stateName: string): any { @@ -43,10 +43,7 @@ describe('createEditorCore', () => { const mockedDOMHelper = 'DOMHELPER' as any; beforeEach(() => { - spyOn( - createStandaloneEditorCorePlugins, - 'createStandaloneEditorCorePlugins' - ).and.returnValue(mockedPlugins); + spyOn(createEditorCorePlugins, 'createEditorCorePlugins').and.returnValue(mockedPlugins); spyOn(DarkColorHandlerImpl, 'createDarkColorHandler').and.returnValue( mockedDarkColorHandler ); @@ -61,15 +58,16 @@ describe('createEditorCore', () => { function runTest( contentDiv: HTMLDivElement, - options: StandaloneEditorOptions, - additionalResult: Partial + options: EditorOptions, + additionalResult: Partial ) { - const core = createStandaloneEditorCore(contentDiv, options); + const core = createEditorCore(contentDiv, options); expect(core).toEqual({ - contentDiv: contentDiv, - api: standaloneCoreApiMap, - originalApi: standaloneCoreApiMap, + physicalRoot: contentDiv, + logicalRoot: contentDiv, + api: coreApiMap, + originalApi: coreApiMap, plugins: [ mockedCachePlugin, mockedFormatPlugin, @@ -105,9 +103,10 @@ describe('createEditorCore', () => { ...additionalResult, }); - expect( - createStandaloneEditorCorePlugins.createStandaloneEditorCorePlugins - ).toHaveBeenCalledWith(options, contentDiv); + expect(createEditorCorePlugins.createEditorCorePlugins).toHaveBeenCalledWith( + options, + contentDiv + ); expect(createDefaultSettings.createDomToModelSettings).toHaveBeenCalledWith(options); expect(createDefaultSettings.createModelToDomSettings).toHaveBeenCalledWith(options); } @@ -157,8 +156,9 @@ describe('createEditorCore', () => { } as any; runTest(mockedDiv, mockedOptions, { - contentDiv: mockedDiv, - api: { ...standaloneCoreApiMap, a: 'b' } as any, + physicalRoot: mockedDiv, + logicalRoot: mockedDiv, + api: { ...coreApiMap, a: 'b' } as any, plugins: [ mockedCachePlugin, mockedFormatPlugin, diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorDefaultSettingsTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createEditorDefaultSettingsTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorDefaultSettingsTest.ts rename to packages-content-model/roosterjs-content-model-core/test/editor/createEditorDefaultSettingsTest.ts index fad2b2eb099..fee3355d279 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorDefaultSettingsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createEditorDefaultSettingsTest.ts @@ -8,7 +8,7 @@ import { import { createDomToModelSettings, createModelToDomSettings, -} from '../../lib/editor/createStandaloneEditorDefaultSettings'; +} from '../../lib/editor/createEditorDefaultSettings'; describe('createDomToModelSettings', () => { const mockedCalculatedConfig = 'CONFIG' as any; diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/cacheGetEventDataTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/cacheGetEventDataTest.ts new file mode 100644 index 00000000000..aa9c28f8732 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/cacheGetEventDataTest.ts @@ -0,0 +1,31 @@ +import { cacheGetEventData } from '../../../lib/publicApi/domUtils/cacheGetEventData'; +import { EditorReadyEvent } from 'roosterjs-content-model-types'; + +describe('cacheGetEventData', () => { + const cacheKey = '__Key'; + it('get cached data', () => { + const event: EditorReadyEvent = { + eventType: 'editorReady', + }; + + const mockedData = 'DATA'; + const mockedGetter = jasmine.createSpy('getter').and.returnValue(mockedData); + + const data = cacheGetEventData(event, cacheKey, mockedGetter); + + expect(data).toBe(mockedData); + expect(mockedGetter).toHaveBeenCalledTimes(1); + expect(mockedGetter).toHaveBeenCalledWith(event); + expect(event).toEqual({ + eventType: 'editorReady', + eventDataCache: { + [cacheKey]: mockedData, + }, + }); + + const data2 = cacheGetEventData(event, cacheKey, mockedGetter); + + expect(data2).toBe(mockedData); + expect(mockedGetter).toHaveBeenCalledTimes(1); + }); +}); 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 index 6b5fa650314..68efec22da1 100644 --- 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 @@ -72,7 +72,7 @@ describe('createModelFromHtml', () => { format: { fontSize: '20px', textColor: 'red', fontFamily: 'Arial' }, }, ], - segmentFormat: { fontSize: '20px', fontFamily: 'Arial' }, + segmentFormat: { fontSize: '20px', fontFamily: 'Arial', textColor: 'red' }, format: {}, }, ], 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 index 431d7da523b..494f2c12033 100644 --- 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 @@ -2,7 +2,7 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/c 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'; +import { IEditor } from 'roosterjs-content-model-types'; describe('exportContent', () => { it('PlainTextFast', () => { @@ -10,7 +10,7 @@ describe('exportContent', () => { const getTextContentSpy = jasmine .createSpy('getTextContent') .and.returnValue(mockedTextContent); - const editor: IStandaloneEditor = { + const editor: IEditor = { getDOMHelper: () => ({ getTextContent: getTextContentSpy, }), @@ -27,7 +27,7 @@ describe('exportContent', () => { const getContentModelCopySpy = jasmine .createSpy('getContentModelCopy') .and.returnValue(mockedModel); - const editor: IStandaloneEditor = { + const editor: IEditor = { getContentModelCopy: getContentModelCopySpy, } as any; const mockedText = 'TEXT'; @@ -56,7 +56,7 @@ describe('exportContent', () => { createElement: () => mockedDiv, } as any; const triggerEventSpy = jasmine.createSpy('triggerEvent'); - const editor: IStandaloneEditor = { + const editor: IEditor = { getContentModelCopy: getContentModelCopySpy, getDocument: () => mockedDoc, triggerEvent: triggerEventSpy, @@ -99,7 +99,7 @@ describe('exportContent', () => { createElement: () => mockedDiv, } as any; const triggerEventSpy = jasmine.createSpy('triggerEvent'); - const editor: IStandaloneEditor = { + const editor: IEditor = { getContentModelCopy: getContentModelCopySpy, getDocument: () => mockedDoc, triggerEvent: triggerEventSpy, 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 83e0c4112d6..4cde086cbb4 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 @@ -1784,6 +1784,80 @@ describe('mergeModel', () => { }); }); + it('Merge with keepSourceEmphasisFormat and remove background color of model', () => { + const MockedFormat = { + formatName: 'mocked', + } as any; + const majorModel = createContentModelDocument(MockedFormat); + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + formatName: 'ToBeRemoved', + fontWeight: 'sourceFontWeight', + italic: true, + underline: true, + } as any, + }, + ], + format: { + backgroundColor: 'ToBeRemoved', + }, + }, + ], + }; + const para1 = createParagraph(); + const marker = createSelectionMarker(); + + para1.segments.push(marker); + majorModel.blocks.push(para1); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + formatName: 'mocked', + fontWeight: 'sourceFontWeight', + italic: true, + underline: true, + } as any, + }, + marker, + ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + format: MockedFormat, + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); + }); + it('Merge model with List Item with default format, keep the source bold, italic and underline', () => { const MockedFormat = { formatName: 'mocked', @@ -2333,6 +2407,7 @@ describe('mergeModel', () => { resultMarker, ], format: {}, + segmentFormat: { fontFamily: 'sourceFontFamily' }, }; expect(majorModel).toEqual({ @@ -2510,6 +2585,7 @@ describe('mergeModel', () => { marker, ], format: {}, + segmentFormat: { fontFamily: 'Calibri', fontSize: '11pt', textColor: 'black' }, }; expect(majorModel).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/undo/redoTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/undo/redoTest.ts index 90976936d2d..c1b24e82b77 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/undo/redoTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/undo/redoTest.ts @@ -1,8 +1,8 @@ -import { IStandaloneEditor, SnapshotsManager } from 'roosterjs-content-model-types'; +import { IEditor, SnapshotsManager } from 'roosterjs-content-model-types'; import { redo } from '../../../lib/publicApi/undo/redo'; describe('redo', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let getSnapshotsManagerSpy: jasmine.Spy; let takeSnapshotSpy: jasmine.Spy; let restoreSnapshotSpy: jasmine.Spy; diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/undo/undoTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/undo/undoTest.ts index 251f8a63788..86dee0b8980 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/undo/undoTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/undo/undoTest.ts @@ -1,8 +1,8 @@ -import { IStandaloneEditor, SnapshotsManager } from 'roosterjs-content-model-types'; +import { IEditor, SnapshotsManager } from 'roosterjs-content-model-types'; import { undo } from '../../../lib/publicApi/undo/undo'; describe('undo', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let getSnapshotsManagerSpy: jasmine.Spy; let takeSnapshotSpy: jasmine.Spy; let restoreSnapshotSpy: jasmine.Spy; diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts index d6261cf5be8..866d65f06f2 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts @@ -1,16 +1,17 @@ import { createSnapshotSelection } from '../../lib/utils/createSnapshotSelection'; -import { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; describe('createSnapshotSelection', () => { let div: HTMLDivElement; - let core: StandaloneEditorCore; + let core: EditorCore; let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { div = document.createElement('div'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); core = { - contentDiv: div, + physicalRoot: div, + logicalRoot: div, api: { getDOMSelection: getDOMSelectionSpy, }, @@ -66,14 +67,15 @@ describe('createSnapshotSelection', () => { describe('createSnapshotSelection - Range selection', () => { let div: HTMLDivElement; - let core: StandaloneEditorCore; + let core: EditorCore; let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { div = document.createElement('div'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); core = { - contentDiv: div, + physicalRoot: div, + logicalRoot: div, api: { getDOMSelection: getDOMSelectionSpy, }, @@ -227,7 +229,7 @@ describe('createSnapshotSelection - Normalize Table', () => { const TABLE_ID1 = 't1'; const TABLE_ID2 = 't2'; let div: HTMLDivElement; - let core: StandaloneEditorCore; + let core: EditorCore; let getDOMSelectionSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; @@ -236,7 +238,8 @@ describe('createSnapshotSelection - Normalize Table', () => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); core = { - contentDiv: div, + physicalRoot: div, + logicalRoot: div, api: { getDOMSelection: getDOMSelectionSpy, setDOMSelection: setDOMSelectionSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts index 5ad2a7b4304..0ab1e87e81d 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts @@ -1,8 +1,8 @@ +import { EditorCore } from 'roosterjs-content-model-types'; import { generatePasteOptionFromPlugins } from '../../../lib/utils/paste/generatePasteOptionFromPlugins'; -import { StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('generatePasteOptionFromPlugins', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let triggerPluginEventSpy: jasmine.Spy; const mockedClipboardData = 'CLIPBOARDDATA' as any; 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 e8885658051..7a28b5aea9b 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 @@ -10,7 +10,7 @@ import { FormatContentModelContext, FormatContentModelOptions, InsertPoint, - StandaloneEditorCore, + EditorCore, } from 'roosterjs-content-model-types'; describe('mergePasteContent', () => { @@ -18,7 +18,7 @@ describe('mergePasteContent', () => { let context: FormatContentModelContext | undefined; let formatContentModel: jasmine.Spy; let sourceModel: ContentModelDocument; - let core: StandaloneEditorCore; + let core: EditorCore; const mockedClipboard = 'CLIPBOARD' as any; beforeEach(() => { @@ -51,7 +51,7 @@ describe('mergePasteContent', () => { formatContentModel, }, domToModelSettings: {}, - contentDiv: { + physicalRoot: { ownerDocument: document, }, } as any; diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotColorsTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotColorsTest.ts index f5c7f5537d9..bec0dcb1ff6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotColorsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotColorsTest.ts @@ -1,9 +1,9 @@ import * as transformColor from '../../lib/publicApi/color/transformColor'; -import { DarkColorHandler, Snapshot, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { DarkColorHandler, EditorCore, Snapshot } from 'roosterjs-content-model-types'; import { restoreSnapshotColors } from '../../lib/utils/restoreSnapshotColors'; describe('restoreSnapshotColors', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let updateKnownColorSpy: jasmine.Spy; let transformColorSpy: jasmine.Spy; let darkColorHandler: DarkColorHandler; @@ -17,10 +17,11 @@ describe('restoreSnapshotColors', () => { } as any; core = { + physicalRoot: mockedDiv, + logicalRoot: mockedDiv, lifecycle: { isDarkMode: false, }, - contentDiv: mockedDiv, darkColorHandler, } as any; }); 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 0fba26ea47e..37404206593 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,16 +1,17 @@ +import { EditorCore, Snapshot } from 'roosterjs-content-model-types'; 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; + let core: EditorCore; let div: HTMLDivElement; beforeEach(() => { div = document.createElement('div'); core = { - contentDiv: div, + physicalRoot: div, + logicalRoot: div, entity: { entityMap: {}, }, diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts index db6196e2e1c..aaad9729ce7 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts @@ -1,8 +1,8 @@ +import { EditorCore, Snapshot, SnapshotSelection } from 'roosterjs-content-model-types'; import { restoreSnapshotSelection } from '../../lib/utils/restoreSnapshotSelection'; -import { Snapshot, SnapshotSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('restoreSnapshotSelection', () => { - let core: StandaloneEditorCore; + let core: EditorCore; let div: HTMLDivElement; let setDOMSelectionSpy: jasmine.Spy; @@ -12,7 +12,8 @@ describe('restoreSnapshotSelection', () => { setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); core = { - contentDiv: div, + physicalRoot: div, + logicalRoot: div, api: { setDOMSelection: setDOMSelectionSpy, }, diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts index c24c35e6749..526f03f84c6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts @@ -143,6 +143,7 @@ describe('sanitizeElement', () => { }); expect(result!.outerHTML).toBe('
'); + expect(sanitizerSpy).toHaveBeenCalledTimes(1); expect(sanitizerSpy).toHaveBeenCalledWith('a', 'div'); }); @@ -157,6 +158,27 @@ describe('sanitizeElement', () => { expect(result!.outerHTML).toBe('
'); }); + + it('attributeCallbacks with child element', () => { + const element = document.createElement('div'); + const child = document.createElement('span'); + const sanitizerSpy = jasmine + .createSpy('sanitizer') + .and.callFake((value: string) => value + value); + + element.id = 'a'; + child.id = 'b'; + element.appendChild(child); + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, undefined, { + id: sanitizerSpy, + }); + + expect(result!.outerHTML).toBe('
'); + expect(sanitizerSpy).toHaveBeenCalledTimes(2); + expect(sanitizerSpy).toHaveBeenCalledWith('a', 'div'); + expect(sanitizerSpy).toHaveBeenCalledWith('b', 'span'); + }); }); describe('sanitizeHtml', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts index 68956b4eb53..9c23e18e7ca 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts @@ -1,7 +1,11 @@ import { addDecorators } from '../../modelApi/common/addDecorators'; import { addSegment } from '../../modelApi/common/addSegment'; import { createSelectionMarker } from '../../modelApi/creators/createSelectionMarker'; -import type { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; +import type { + ContentModelBlockGroup, + ContentModelSegmentFormat, + DomToModelContext, +} from 'roosterjs-content-model-types'; /** * @internal @@ -12,6 +16,22 @@ export function addSelectionMarker( container?: Node, offset?: number ) { + const lastPara = group.blocks[group.blocks.length - 1]; + const formatFromParagraph: ContentModelSegmentFormat = + !lastPara || lastPara.blockType != 'Paragraph' + ? {} + : lastPara.decorator + ? { + fontFamily: lastPara.decorator.format.fontFamily, + fontSize: lastPara.decorator.format.fontSize, + } + : lastPara.segmentFormat + ? { + fontFamily: lastPara.segmentFormat.fontFamily, + fontSize: lastPara.segmentFormat.fontSize, + } + : {}; + const pendingFormat = context.pendingFormat && context.pendingFormat.posContainer === container && @@ -20,6 +40,7 @@ export function addSelectionMarker( : undefined; const segmentFormat = { ...context.defaultFormat, + ...formatFromParagraph, ...context.segmentFormat, ...pendingFormat, }; 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 a95979139dd..2579f769c37 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 @@ -32,12 +32,33 @@ export function getAllEntityWrappers(root: HTMLElement): HTMLElement[] { return toArray(root.querySelectorAll('.' + ENTITY_INFO_NAME)) as HTMLElement[]; } +/** + * Parse entity format from entity wrapper element + * @param wrapper The wrapper element to parse entity format from + * @returns Entity format + */ +export function parseEntityFormat(wrapper: HTMLElement): ContentModelEntityFormat { + let isEntity = false; + const format: ContentModelEntityFormat = {}; + + wrapper.classList.forEach(name => { + isEntity = parseEntityClassName(name, format) || isEntity; + }); + + if (!isEntity) { + format.isFakeEntity = true; + format.isReadonly = !wrapper.isContentEditable; + } + + return format; +} + /** * Parse entity class names from entity wrapper element * @param className Class names of entity * @param format The output entity format object */ -export function parseEntityClassName( +function parseEntityClassName( className: string, format: ContentModelEntityFormat ): boolean | undefined { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts index 282bad51333..471c94ece4a 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/reuseCachedElement.ts @@ -11,10 +11,12 @@ import { isEntityElement } from './entityUtils'; */ export function reuseCachedElement(parent: Node, element: Node, refNode: Node | null): Node | null { if (element.parentNode == parent) { + const isEntity = isEntityElement(element); + // Remove nodes before the one we are hitting since they don't appear in Content Model at this position. // But we don't want to touch entity since it would better to keep entity at its place unless it is removed // In that case we will remove it after we have handled all other nodes - while (refNode && refNode != element && !isEntityElement(refNode)) { + while (refNode && refNode != element && (isEntity || !isEntityElement(refNode))) { const next = refNode.nextSibling; refNode.parentNode?.removeChild(refNode); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/entity/entityFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/entity/entityFormatHandler.ts index 2c360c3f243..d1192fda95b 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/entity/entityFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/entity/entityFormatHandler.ts @@ -1,4 +1,4 @@ -import { generateEntityClassNames, parseEntityClassName } from '../../domUtils/entityUtils'; +import { generateEntityClassNames, parseEntityFormat } from '../../domUtils/entityUtils'; import type { EntityInfoFormat, IdFormat } from 'roosterjs-content-model-types'; import type { FormatHandler } from '../FormatHandler'; @@ -7,16 +7,7 @@ import type { FormatHandler } from '../FormatHandler'; */ export const entityFormatHandler: FormatHandler = { parse: (format, element) => { - let isEntity = false; - - element.classList.forEach(name => { - isEntity = parseEntityClassName(name, format) || isEntity; - }); - - if (!isEntity) { - format.isFakeEntity = true; - format.isReadonly = !element.isContentEditable; - } + Object.assign(format, parseEntityFormat(element)); }, apply: (format, element) => { 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 0ffe238f6e4..b8f909736da 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -24,7 +24,7 @@ export { wrap } from './domUtils/wrap'; export { isEntityElement, getAllEntityWrappers, - parseEntityClassName, + parseEntityFormat, generateEntityClassNames, addDelimiters, isEntityDelimiter, 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 8f9a9d41849..14616124d47 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 @@ -3,7 +3,11 @@ import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import { normalizeAllSegments } from './normalizeSegment'; -import type { ContentModelParagraph } from 'roosterjs-content-model-types'; +import type { + ContentModelParagraph, + ContentModelSegment, + ContentModelSegmentFormat, +} from 'roosterjs-content-model-types'; /** * @param paragraph The paragraph to normalize @@ -42,6 +46,8 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { removeEmptyLinks(paragraph); removeEmptySegments(paragraph); + + moveUpSegmentFormat(paragraph); } function removeEmptySegments(block: ContentModelParagraph) { @@ -74,3 +80,41 @@ function removeEmptyLinks(paragraph: ContentModelParagraph) { } } } + +type FormatsToMoveUp = 'fontFamily' | 'fontSize' | 'textColor'; +const formatsToMoveUp: FormatsToMoveUp[] = ['fontFamily', 'fontSize', 'textColor']; + +// When all segments are sharing the same segment format (font name, size and color), we can move its format to paragraph +function moveUpSegmentFormat(paragraph: ContentModelParagraph) { + if (!paragraph.decorator) { + const segments = paragraph.segments.filter(x => x.segmentType != 'SelectionMarker'); + const target = paragraph.segmentFormat || {}; + let changed = false; + + formatsToMoveUp.forEach(key => { + changed = internalMoveUpSegmentFormat(segments, target, key) || changed; + }); + + if (changed) { + paragraph.segmentFormat = target; + } + } +} + +function internalMoveUpSegmentFormat( + segments: ContentModelSegment[], + target: ContentModelSegmentFormat, + formatKey: FormatsToMoveUp +): boolean { + const firstFormat = segments[0]?.format; + + if ( + firstFormat?.[formatKey] && + segments.every(segment => segment.format[formatKey] == firstFormat[formatKey]) + ) { + target[formatKey] = firstFormat[formatKey]; + return true; + } else { + return false; + } +} diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts index 162e4fd0bc9..9b83269e9ff 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts @@ -88,6 +88,90 @@ describe('addSelectionMarker', () => { }); }); + it('add marker with block format from existing block', () => { + const doc = createContentModelDocument(); + const context = createDomToModelContext(); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + }, + }); + + addSelectionMarker(doc, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { + fontFamily: 'Arial', + }, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontFamily: 'Arial', fontSize: undefined }, + }, + ], + }, + ], + }); + }); + + it('add marker with block format from existing block decorator', () => { + const doc = createContentModelDocument(); + const context = createDomToModelContext(); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + }, + decorator: { + tagName: 'h1', + format: { + fontFamily: 'Tahoma', + }, + }, + }); + + addSelectionMarker(doc, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { + fontFamily: 'Arial', + }, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontFamily: 'Tahoma', fontSize: undefined }, + }, + ], + decorator: { + tagName: 'h1', + format: { + fontFamily: 'Tahoma', + }, + }, + }, + ], + }); + }); + it('add marker with link format', () => { const doc = createContentModelDocument(); const context = createDomToModelContext(); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts index 74d896e4da0..686a73ed507 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts @@ -5,7 +5,7 @@ import { getAllEntityWrappers, isEntityDelimiter, isEntityElement, - parseEntityClassName, + parseEntityFormat, } from '../../lib/domUtils/entityUtils'; export function setEntityElementClasses( @@ -43,69 +43,61 @@ describe('isEntityElement', () => { }); }); -describe('parseEntityClassName', () => { +describe('parseEntityFormat', () => { it('No entity class', () => { - const format: ContentModelEntityFormat = {}; + const div = document.createElement('div'); + + div.className = 'test'; - const result = parseEntityClassName('test', format); + const format = parseEntityFormat(div); - expect(result).toBeFalsy(); - expect(format).toEqual({}); + expect(format).toEqual({ + isFakeEntity: true, + isReadonly: true, + }); }); it('Entity class', () => { - const format: ContentModelEntityFormat = {}; - - const result = parseEntityClassName('_Entity', format); - - expect(result).toBeTrue(); - expect(format).toEqual({}); - }); + const div = document.createElement('div'); - it('EntityId class', () => { - const format: ContentModelEntityFormat = {}; + div.className = '_Entity _EId_A _EType_B _EReadonly_1'; - const result = parseEntityClassName('_EId_A', format); + const format = parseEntityFormat(div); - expect(result).toBeFalsy(); expect(format).toEqual({ id: 'A', + entityType: 'B', + isReadonly: true, }); }); - it('EntityType class', () => { - const format: ContentModelEntityFormat = {}; + it('Fake entity', () => { + const div = document.createElement('div'); - const result = parseEntityClassName('_EType_B', format); + div.contentEditable = 'true'; - expect(result).toBeFalsy(); - expect(format).toEqual({ - entityType: 'B', - }); - }); - - it('Entity readonly class', () => { - const format: ContentModelEntityFormat = {}; + div.className = '_EId_A _EType_B _EReadonly_1'; - const result = parseEntityClassName('_EReadonly_1', format); + const format = parseEntityFormat(div); - expect(result).toBeFalsy(); expect(format).toEqual({ - isReadonly: true, + isFakeEntity: true, + isReadonly: false, + id: 'A', + entityType: 'B', }); }); - it('Parse class on existing format', () => { - const format: ContentModelEntityFormat = { - id: 'A', - }; + it('Fake entity, readonly', () => { + const div = document.createElement('div'); + + div.contentEditable = 'false'; - const result = parseEntityClassName('_EType_B', format); + const format = parseEntityFormat(div); - expect(result).toBeFalsy(); expect(format).toEqual({ - id: 'A', - entityType: 'B', + isFakeEntity: true, + isReadonly: true, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts index 7a834034f9f..1f612a8c6cb 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domUtils/reuseCachedElementTest.ts @@ -66,6 +66,7 @@ describe('reuseCachedElement', () => { const refNode = document.createElement('div'); const element = document.createElement('span'); const nextNode = document.createElement('br'); + const removeChildSpy = spyOn(Node.prototype, 'removeChild').and.callThrough(); parent.appendChild(refNode); parent.appendChild(element); @@ -75,6 +76,7 @@ describe('reuseCachedElement', () => { const result = reuseCachedElement(parent, element, refNode); + expect(removeChildSpy).not.toHaveBeenCalled(); expect(parent.outerHTML).toBe( '

' ); @@ -82,4 +84,31 @@ describe('reuseCachedElement', () => { expect(parent.firstChild?.nextSibling).toBe(refNode); expect(result).toBe(refNode); }); + + it('RefNode is entity, current element is entity', () => { + const parent = document.createElement('div'); + const refNode = document.createElement('div'); + const element = document.createElement('span'); + const nextNode = document.createElement('br'); + const removeChildSpy = spyOn(Node.prototype, 'removeChild').and.callThrough(); + + parent.appendChild(refNode); + parent.appendChild(element); + parent.appendChild(nextNode); + + setEntityElementClasses(refNode, 'TestEntity', true); + setEntityElementClasses(element, 'TestEntity2', true); + + const result = reuseCachedElement(parent, element, refNode); + + expect(removeChildSpy).toHaveBeenCalledTimes(1); + expect(removeChildSpy).toHaveBeenCalledWith(refNode); + + expect(parent.outerHTML).toBe( + '

' + ); + expect(parent.firstChild).toBe(element); + expect(parent.firstChild?.nextSibling).toBe(nextNode); + expect(result).toBe(nextNode); + }); }); 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 37c555d2953..f3fd9d32d89 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -57,6 +57,11 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], isImplicit: true, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], levels: [ @@ -99,6 +104,11 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], isImplicit: true, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], levels: [ @@ -262,6 +272,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { ], format: { whiteSpace: 'pre' }, isImplicit: true, + segmentFormat: { fontFamily: 'monospace' }, }, ], format: { @@ -329,6 +340,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], isImplicit: true, + segmentFormat: { fontFamily: 'monospace' }, }, ], }, @@ -353,6 +365,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], isImplicit: true, + segmentFormat: { fontFamily: 'monospace' }, }, ], }, @@ -905,6 +918,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { whiteSpace: 'pre', }, isImplicit: true, + segmentFormat: { fontFamily: 'monospace' }, }, ], format: { @@ -934,6 +948,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { whiteSpace: 'pre', }, isImplicit: true, + segmentFormat: { fontFamily: 'monospace', fontSize: '20px' }, }, ], format: { @@ -945,7 +960,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { ], }, 'aaa\nbbb\r\naaa\nbb', - '
aaa\nbbb
aaa\nbb
' + '
aaa\nbbb
aaa\nbb
' ); }); @@ -1034,6 +1049,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], format: {}, + segmentFormat: { textColor: 'red' }, }, { blockType: 'BlockGroup', @@ -1054,6 +1070,11 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], format: {}, + segmentFormat: { + fontFamily: 'Calibri, Arial, Helvetica, sans-serif', + fontSize: '12pt', + textColor: 'rgb(102, 102, 102)', + }, }, ], format: { @@ -1077,6 +1098,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], format: {}, + segmentFormat: { textColor: 'red' }, }, { blockType: 'Paragraph', @@ -1090,6 +1112,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], format: {}, + segmentFormat: { textColor: 'red' }, }, { blockType: 'Paragraph', @@ -1108,6 +1131,11 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { marginRight: '40px', marginLeft: '40px', }, + segmentFormat: { + fontFamily: 'Calibri, Arial, Helvetica, sans-serif', + fontSize: '12pt', + textColor: 'rgb(102, 102, 102)', + }, }, { blockType: 'Paragraph', @@ -1121,11 +1149,14 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, ], format: {}, + segmentFormat: { + textColor: 'red', + }, }, ], }, 'aaaa\r\nbbbbbb\r\ncccc\r\naaaa\r\nbbbbbb\r\ncccc', - '
aaaa
bbbbbb
cccc
aaaa
bbbbbb
cccc
' + '
aaaa
bbbbbb
cccc
aaaa
bbbbbb
cccc
' ); }); @@ -1167,6 +1198,9 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { format: { whiteSpace: 'pre', }, + segmentFormat: { + fontFamily: 'monospace', + }, }, ], format: { @@ -1570,6 +1604,7 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { }, }, ], + segmentFormat: { textColor: 'red' }, }, ], }, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index a74793a4d16..95f6f6175f5 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -5,6 +5,7 @@ import { createParagraph } from '../../../lib/modelApi/creators/createParagraph' import { createSelectionMarker } from '../../../lib/modelApi/creators/createSelectionMarker'; import { createText } from '../../../lib/modelApi/creators/createText'; import { normalizeContentModel } from '../../../lib/modelApi/common/normalizeContentModel'; +import { normalizeParagraph } from '../../../lib/modelApi/common/normalizeParagraph'; describe('Normalize text that contains space', () => { function runTest(texts: string[], expected: string[], whiteSpace?: string) { @@ -354,3 +355,156 @@ describe('Normalize text that contains space', () => { }); }); }); + +describe('Normalize paragraph with segmentFormat', () => { + it('Empty paragraph', () => { + const paragraph = createParagraph(); + + normalizeParagraph(paragraph); + + expect(paragraph).toEqual({ + blockType: 'Paragraph', + segments: [], + format: {}, + }); + }); + + it('Single text segment', () => { + const paragraph = createParagraph(); + const text = createText('test', { + fontFamily: 'Arial', + }); + + paragraph.segments.push(text); + + normalizeParagraph(paragraph); + + expect(paragraph).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: { fontFamily: 'Arial' }, + text: 'test', + }, + ], + format: {}, + segmentFormat: { fontFamily: 'Arial' }, + }); + }); + + it('text + selection marker + text', () => { + const paragraph = createParagraph(); + const text1 = createText('test1', { + fontFamily: 'Arial', + }); + const text2 = createText('test2', { + fontFamily: 'Arial', + }); + const marker = createSelectionMarker(); + + paragraph.segments.push(text1, marker, text2); + + normalizeParagraph(paragraph); + + expect(paragraph).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: { fontFamily: 'Arial' }, + text: 'test1', + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + format: { fontFamily: 'Arial' }, + text: 'test2', + }, + ], + format: {}, + segmentFormat: { fontFamily: 'Arial' }, + }); + }); + + it('text + selection marker + text, formats are different', () => { + const paragraph = createParagraph(); + const text1 = createText('test1', { + fontFamily: 'Arial', + }); + const text2 = createText('test2', { + fontFamily: 'Tahoma', + }); + const marker = createSelectionMarker(); + + paragraph.segments.push(text1, marker, text2); + + normalizeParagraph(paragraph); + + expect(paragraph).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: { fontFamily: 'Arial' }, + text: 'test1', + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + format: { fontFamily: 'Tahoma' }, + text: 'test2', + }, + ], + format: {}, + }); + }); + + it('text + selection marker + text, formats are partially different', () => { + const paragraph = createParagraph(); + const text1 = createText('test1', { + fontFamily: 'Arial', + fontSize: '10px', + }); + const text2 = createText('test2', { + fontFamily: 'Tahoma', + fontSize: '10px', + }); + const marker = createSelectionMarker(); + + paragraph.segments.push(text1, marker, text2); + + normalizeParagraph(paragraph); + + expect(paragraph).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: { fontFamily: 'Arial', fontSize: '10px' }, + text: 'test1', + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + format: { fontFamily: 'Tahoma', fontSize: '10px' }, + text: 'test2', + }, + ], + format: {}, + segmentFormat: { fontSize: '10px' }, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index b4f1a4bd98b..5308866f429 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -1,7 +1,7 @@ import { keyboardListTrigger } from './keyboardListTrigger'; import type { EditorPlugin, - IStandaloneEditor, + IEditor, KeyDownEvent, PluginEvent, } from 'roosterjs-content-model-types'; @@ -34,7 +34,7 @@ const DefaultOptions: Required = { * It can be customized with options to enable or disable auto list features. */ export class AutoFormatPlugin implements EditorPlugin { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; /** * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: @@ -56,7 +56,7 @@ export class AutoFormatPlugin implements EditorPlugin { * editor reference so that it can call to any editor method or format API later. * @param editor The editor object */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; } @@ -85,7 +85,7 @@ export class AutoFormatPlugin implements EditorPlugin { } } - private handleKeyDownEvent(editor: IStandaloneEditor, event: KeyDownEvent) { + private handleKeyDownEvent(editor: IEditor, event: KeyDownEvent) { const rawEvent = event.rawEvent; if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { switch (rawEvent.key) { 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 900c0c10900..78fea6df5db 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,14 +2,13 @@ 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'; +import type { ContentModelDocument, IEditor } from 'roosterjs-content-model-types'; /** * @internal */ export function keyboardListTrigger( - editor: IStandaloneEditor, + editor: IEditor, rawEvent: KeyboardEvent, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true @@ -37,7 +36,7 @@ export function keyboardListTrigger( } const triggerList = ( - editor: IStandaloneEditor, + editor: IEditor, model: ContentModelDocument, listType: 'OL' | 'UL', styleType: number, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 43b5c939612..73a994c473b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -3,7 +3,7 @@ import { keyboardInput } from './keyboardInput'; import { keyboardTab } from './keyboardTab'; import type { EditorPlugin, - IStandaloneEditor, + IEditor, KeyDownEvent, PluginEvent, } from 'roosterjs-content-model-types'; @@ -12,14 +12,14 @@ const BACKSPACE_KEY = 8; const DELETE_KEY = 46; /** - * ContentModel edit plugins helps editor to do editing operation on top of content model. + * 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 EditPlugin implements EditorPlugin { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; private disposer: (() => void) | null = null; private shouldHandleNextInputEvent = false; @@ -36,7 +36,7 @@ export class EditPlugin implements EditorPlugin { * editor reference so that it can call to any editor method or format API later. * @param editor The editor object */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; if (editor.getEnvironment().isAndroid) { this.disposer = this.editor.attachDomEvent({ @@ -74,7 +74,7 @@ export class EditPlugin implements EditorPlugin { } } - private handleKeyDownEvent(editor: IStandaloneEditor, event: KeyDownEvent) { + private handleKeyDownEvent(editor: IEditor, event: KeyDownEvent) { const rawEvent = event.rawEvent; if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { @@ -103,7 +103,7 @@ export class EditPlugin implements EditorPlugin { } } - private handleBeforeInputEvent(editor: IStandaloneEditor, rawEvent: Event) { + private handleBeforeInputEvent(editor: IEditor, 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 ( diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts index 5274e7a465a..08cacd3e998 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts @@ -1,8 +1,19 @@ -import { deleteBlock, deleteSegment } from 'roosterjs-content-model-core'; import { getLeafSiblingBlock } from '../utils/getLeafSiblingBlock'; +import { setModelIndentation } from 'roosterjs-content-model-api'; import { setParagraphNotImplicit } from 'roosterjs-content-model-dom'; +import { + deleteBlock, + deleteSegment, + getClosestAncestorBlockGroupIndex, +} from 'roosterjs-content-model-core'; import type { BlockAndPath } from '../utils/getLeafSiblingBlock'; -import type { ContentModelSegment, DeleteSelectionStep } from 'roosterjs-content-model-types'; +import type { + ContentModelBlockGroup, + ContentModelDocument, + ContentModelParagraph, + ContentModelSegment, + DeleteSelectionStep, +} from 'roosterjs-content-model-types'; function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteSelectionStep { return context => { @@ -19,6 +30,7 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS const index = segments.indexOf(marker) + (isForward ? 1 : -1); const segmentToDelete = segments[index]; let blockToDelete: BlockAndPath | null; + let root: ContentModelDocument | null; if (segmentToDelete) { if (deleteSegment(paragraph, segmentToDelete, context.formatContext, direction)) { @@ -28,6 +40,12 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS // to avoid losing its format. See https://github.com/microsoft/roosterjs/issues/1953 setParagraphNotImplicit(paragraph); } + } else if ( + shouldOutdentParagraph(isForward, segments, paragraph, path) && + (root = getRoot(path)) + ) { + setModelIndentation(root, 'outdent'); + context.deleteResult = 'range'; } else if ((blockToDelete = getLeafSiblingBlock(path, paragraph, isForward))) { const { block, path, siblingSegment } = blockToDelete; @@ -82,6 +100,27 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS }; } +function getRoot(path: ContentModelBlockGroup[]): ContentModelDocument | null { + const lastInPath = path[path.length - 1]; + return lastInPath.blockGroupType == 'Document' ? lastInPath : null; +} + +function shouldOutdentParagraph( + isForward: boolean, + segments: ContentModelSegment[], + paragraph: ContentModelParagraph, + path: ContentModelBlockGroup[] +) { + return ( + !isForward && + segments.length == 1 && + segments[0].segmentType == 'SelectionMarker' && + paragraph.format.marginLeft && + parseInt(paragraph.format.marginLeft) && + getClosestAncestorBlockGroupIndex(path, ['Document', 'TableCell'], ['ListItem']) > -1 + ); +} + /** * If the last segment is BR, remove it for now. We may add it back later when normalize model. * So that if this is an empty paragraph, it will start to delete next block 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 5520d6f566a..a1b6e73c04e 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 @@ -3,7 +3,7 @@ import type { ContentModelDocument, DeleteResult, FormatContentModelContext, - IStandaloneEditor, + IEditor, } from 'roosterjs-content-model-types'; /** @@ -11,7 +11,7 @@ import type { * @return True means content is changed, so need to rewrite content model to editor. Otherwise false */ export function handleKeyboardEventResult( - editor: IStandaloneEditor, + editor: IEditor, model: ContentModelDocument, rawEvent: KeyboardEvent, result: DeleteResult, 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 index 3e5cca47e50..0223b50a9de 100644 --- 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 @@ -1,9 +1,14 @@ -import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; +import { + getClosestAncestorBlockGroupIndex, + isBlockGroupOfType, +} from 'roosterjs-content-model-core'; import { createBr, createListItem, createListLevel, createParagraph, + createSelectionMarker, + normalizeContentModel, normalizeParagraph, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; @@ -19,10 +24,11 @@ import type { * @internal */ export const handleEnterOnList: DeleteSelectionStep = context => { + const { deleteResult } = context; if ( - context.deleteResult == 'nothingToDelete' || - context.deleteResult == 'notDeleted' || - context.deleteResult == 'range' + deleteResult == 'nothingToDelete' || + deleteResult == 'notDeleted' || + deleteResult == 'range' ) { const { insertPoint, formatContext } = context; const { path } = insertPoint; @@ -30,13 +36,46 @@ export const handleEnterOnList: DeleteSelectionStep = context => { const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); const listItem = path[index]; + const listParent = path[index + 1]; if (listItem && listItem.blockGroupType === 'ListItem') { - const listParent = path[index + 1]; - if (isEmptyListItem(listItem)) { - listItem.levels.pop(); - } else { - createNewListItem(context, listItem, listParent); + const listIndex = listParent.blocks.indexOf(listItem); + const nextBlock = listParent.blocks[listIndex + 1]; + if (deleteResult == 'range' && nextBlock) { + normalizeContentModel(listParent); + const nextListItem = listParent.blocks[listIndex + 1]; + if ( + isBlockGroupOfType(nextListItem, 'ListItem') && + nextListItem.levels[0] + ) { + nextListItem.levels.forEach((level, index) => { + level.format.startNumberOverride = undefined; + level.dataset = listItem.levels[index] + ? listItem.levels[index].dataset + : {}; + }); + const lastParagraph = listItem.blocks[listItem.blocks.length - 1]; + const nextParagraph = nextListItem.blocks[0]; + if ( + nextParagraph.blockType === 'Paragraph' && + lastParagraph.blockType === 'Paragraph' && + lastParagraph.segments[lastParagraph.segments.length - 1].segmentType === + 'SelectionMarker' + ) { + lastParagraph.segments.pop(); + + nextParagraph.segments.unshift( + createSelectionMarker(insertPoint.marker.format) + ); + } + context.lastParagraph = undefined; + } + } else if (deleteResult !== 'range') { + if (isEmptyListItem(listItem)) { + listItem.levels.pop(); + } else { + createNewListItem(context, listItem, listParent); + } } rawEvent?.preventDefault(); context.deleteResult = 'range'; @@ -62,9 +101,12 @@ const createNewListItem = ( 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); + insertPoint.paragraph = newParagraph; + context.lastParagraph = newParagraph; listParent.blocks.splice(listIndex + 1, 0, newListItem); }; @@ -104,5 +146,6 @@ const createNewParagraph = (insertPoint: InsertPoint) => { } 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 59f27617d77..0fd7522eba3 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 @@ -15,20 +15,16 @@ import { backwardDeleteCollapsedSelection, forwardDeleteCollapsedSelection, } from './deleteSteps/deleteCollapsedSelection'; -import type { - DOMSelection, - DeleteSelectionStep, - IStandaloneEditor, -} from 'roosterjs-content-model-types'; +import type { DOMSelection, DeleteSelectionStep, IEditor } from 'roosterjs-content-model-types'; /** * @internal * Do keyboard event handling for DELETE/BACKSPACE key - * @param editor The Content Model Editor + * @param editor The editor object * @param rawEvent DOM keyboard event * @returns True if the event is handled by content model, otherwise false */ -export function keyboardDelete(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { +export function keyboardDelete(editor: IEditor, rawEvent: KeyboardEvent) { let handled = false; const selection = editor.getDOMSelection(); 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 b4404bc8db1..f8c3219c12e 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,12 +1,12 @@ 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'; +import type { DOMSelection, IEditor } from 'roosterjs-content-model-types'; /** * @internal */ -export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { +export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); if (shouldInputWithContentModel(selection, rawEvent)) { 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 index 733dc7ef582..04f4e25e0dc 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -1,46 +1,48 @@ import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; +import { handleTabOnList } from './tabUtils/handleTabOnList'; +import { handleTabOnParagraph } from './tabUtils/handleTabOnParagraph'; import { setModelIndentation } from 'roosterjs-content-model-api'; import type { ContentModelDocument, ContentModelListItem, - IStandaloneEditor, + IEditor, } from 'roosterjs-content-model-types'; /** * @internal */ -export function keyboardTab(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { +export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); if (selection?.type == 'range') { - editor.takeSnapshot(); - - editor.formatContentModel((model, _context) => { - return handleTabOnList(model, rawEvent); - }); + editor.formatContentModel( + model => { + return handleTab(model, rawEvent); + }, + { + apiName: 'handleTabKey', + } + ); 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) { +/** + * If multiple blocks are selected, indent or outdent the selected blocks with setModelIndentation. + * If only one block is selected, call handleTabOnParagraph or handleTabOnList to handle the tab key. + */ +function handleTab(model: ContentModelDocument, rawEvent: KeyboardEvent) { const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); - const listItem = blocks[0].block; - - if ( - isBlockGroupOfType(listItem, 'ListItem') && - isMarkerAtStartOfBlock(listItem) - ) { + const block = blocks.length > 0 ? blocks[0].block : undefined; + if (blocks.length > 1) { setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); rawEvent.preventDefault(); return true; + } else if (block?.blockType === 'Paragraph') { + return handleTabOnParagraph(model, block, rawEvent); + } else if (isBlockGroupOfType(block, 'ListItem')) { + return handleTabOnList(model, block, rawEvent); } return false; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts new file mode 100644 index 00000000000..1f2cbf130ff --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts @@ -0,0 +1,41 @@ +import { handleTabOnParagraph } from './handleTabOnParagraph'; +import { setModelIndentation } from 'roosterjs-content-model-api'; +import type { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; + +/** + * 1. When the selection is collapsed and the cursor is at start of a list item, call setModelIndentation. + * 2. Otherwise call handleTabOnParagraph. + * @internal + */ +export function handleTabOnList( + model: ContentModelDocument, + listItem: ContentModelListItem, + rawEvent: KeyboardEvent +) { + const selectedParagraph = findSelectedParagraph(listItem); + if ( + !isMarkerAtStartOfBlock(listItem) && + selectedParagraph.length == 1 && + selectedParagraph[0].blockType === 'Paragraph' + ) { + return handleTabOnParagraph(model, selectedParagraph[0], rawEvent); + } else { + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + rawEvent.preventDefault(); + return true; + } +} + +function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { + return ( + listItem.blocks[0].blockType == 'Paragraph' && + listItem.blocks[0].segments[0].segmentType == 'SelectionMarker' + ); +} + +function findSelectedParagraph(listItem: ContentModelListItem) { + return listItem.blocks.filter( + block => + block.blockType == 'Paragraph' && block.segments.some(segment => segment.isSelected) + ); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts new file mode 100644 index 00000000000..684ef48e70e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts @@ -0,0 +1,90 @@ +import { createSelectionMarker, createText } from 'roosterjs-content-model-dom'; +import { setModelIndentation } from 'roosterjs-content-model-api'; +import type { ContentModelDocument, ContentModelParagraph } from 'roosterjs-content-model-types'; + +const tabSpaces = '    '; +const space = ' '; + +/** + * @internal + The handleTabOnParagraph function will handle the tab key in following scenarios: + * 1. When the selection is collapsed and the cursor is at the end of a paragraph, add 4 spaces. + * 2. When the selection is collapsed and the cursor is at the start of a paragraph, call setModelIndention function to indent the whole paragraph + * 3. When the selection is collapsed and the cursor is at the middle of a paragraph, add 4 spaces. + * 4. When the selection is not collapsed, replace the selected range with a single space. + * 5. When the selection is not collapsed, but all segments are selected, call setModelIndention function to indent the whole paragraph + The handleTabOnParagraph function will handle the shift + tab key in a indented paragraph in following scenarios: + * 1. When the selection is collapsed and the cursor is at the end of a paragraph, remove 4 spaces. + * 2. When the selection is collapsed and the cursor is at the start of a paragraph, call setModelIndention function to outdent the whole paragraph + * 3. When the selection is collapsed and the cursor is at the middle of a paragraph, remove 4 spaces. + * 4. When the selection is not collapsed, replace the selected range with a 4 space. + * 5. When the selection is not collapsed, but all segments are selected, call setModelIndention function to outdent the whole paragraph + */ +export function handleTabOnParagraph( + model: ContentModelDocument, + paragraph: ContentModelParagraph, + rawEvent: KeyboardEvent +) { + const selectedSegments = paragraph.segments.filter(segment => segment.isSelected); + const isCollapsed = + selectedSegments.length === 1 && selectedSegments[0].segmentType === 'SelectionMarker'; + const isAllSelected = paragraph.segments.every(segment => segment.isSelected); + if ((paragraph.segments[0].segmentType === 'SelectionMarker' && isCollapsed) || isAllSelected) { + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + } else { + if (!isCollapsed) { + let firstSelectedSegmentIndex: number | undefined = undefined; + let lastSelectedSegmentIndex: number | undefined = undefined; + + paragraph.segments.forEach((segment, index) => { + if (segment.isSelected) { + if (!firstSelectedSegmentIndex) { + firstSelectedSegmentIndex = index; + } + lastSelectedSegmentIndex = index; + } + }); + if (firstSelectedSegmentIndex && lastSelectedSegmentIndex) { + const firstSelectedSegment = paragraph.segments[firstSelectedSegmentIndex]; + const spaceText = createText( + rawEvent.shiftKey ? tabSpaces : space, + firstSelectedSegment.format + ); + const marker = createSelectionMarker(firstSelectedSegment.format); + paragraph.segments.splice( + firstSelectedSegmentIndex, + lastSelectedSegmentIndex - firstSelectedSegmentIndex + 1, + spaceText, + marker + ); + } else { + return false; + } + } else { + const markerIndex = paragraph.segments.findIndex( + segment => segment.segmentType === 'SelectionMarker' + ); + if (!rawEvent.shiftKey) { + const markerFormat = paragraph.segments[markerIndex].format; + const tabText = createText(tabSpaces, markerFormat); + paragraph.segments.splice(markerIndex, 0, tabText); + } else { + const tabText = paragraph.segments[markerIndex - 1]; + const tabSpacesLength = tabSpaces.length; + if (tabText.segmentType == 'Text') { + const tabSpaceTextLength = tabText.text.length - tabSpacesLength; + if (tabText.text === tabSpaces) { + paragraph.segments.splice(markerIndex - 1, 1); + } else if (tabText.text.substring(tabSpaceTextLength) === tabSpaces) { + tabText.text = tabText.text.substring(0, tabSpaceTextLength); + } else { + return false; + } + } + } + } + } + + rawEvent.preventDefault(); + return true; +} 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 fe5a21770a7..0f75ee9b442 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,3 +1,21 @@ +export { TableEditPlugin } from './tableEdit/TableEditPlugin'; export { PastePlugin } from './paste/PastePlugin'; export { EditPlugin } from './edit/EditPlugin'; export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; + +export { + ShortcutBold, + ShortcutItalic, + ShortcutUnderline, + ShortcutClearFormat, + ShortcutUndo, + ShortcutUndo2, + ShortcutRedo, + ShortcutRedoMacOS, + ShortcutBullet, + ShortcutNumbering, + ShortcutIncreaseFont, + ShortcutDecreaseFont, +} from './shortcut/shortcuts'; +export { ShortcutPlugin } from './shortcut/ShortcutPlugin'; +export { ShortcutKeyDefinition, ShortcutCommand } from './shortcut/ShortcutCommand'; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts index dac08441684..4da8cf902b4 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts @@ -14,7 +14,7 @@ import type { ContentModelTableCellFormat, EditorPlugin, FormatParser, - IStandaloneEditor, + IEditor, PluginEvent, } from 'roosterjs-content-model-types'; @@ -24,10 +24,9 @@ import type { * 2. Content copied from Excel * 3. Content copied from Word Online or OneNote Online * 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 PastePlugin implements EditorPlugin { - private editor: IStandaloneEditor | null = null; + private editor: IEditor | null = null; /** * Construct a new instance of Paste class @@ -49,7 +48,7 @@ export class PastePlugin implements EditorPlugin { * editor reference so that it can call to any editor method or format API later. * @param editor The editor object */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { this.editor = editor; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/ShortcutCommand.ts b/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/ShortcutCommand.ts new file mode 100644 index 00000000000..2c2e4416712 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/ShortcutCommand.ts @@ -0,0 +1,50 @@ +import type { IEditor } from 'roosterjs-content-model-types'; + +/** + * Definition of the shortcut key + */ +export interface ShortcutKeyDefinition { + /** + * Modifier key for this shortcut, allowed values are: + * ctrl: Ctrl key (or Meta key on MacOS) + * alt: Alt key + */ + modifierKey: 'ctrl' | 'alt'; + + /** + * Whether ALT key is required for this shortcut + */ + shiftKey: boolean; + + /** + * Key code for this shortcut. The value should be the value of KeyboardEvent.which + * We are still using key code here rather than key name (event.key) although event.which is deprecated because of globalization. + * For example, on US keyboard, Shift+Comma="<" but on Spanish keyboard it is ":" + * And we still want the shortcut key to be registered on the same key, in that case key name is different but key code keeps the same. + */ + which: number; +} + +/** + * Represents a command for shortcut + */ +export interface ShortcutCommand { + /** + * Definition of the shortcut key + */ + shortcutKey: ShortcutKeyDefinition; + + /** + * @optional Required environment for this command + * all: (Default) This feature is available for all environments + * mac: This feature is available on MacOS only + * nonMac: This feature is available on OS other than MacOS + */ + environment?: 'all' | 'mac' | 'nonMac'; + + /** + * The callback function to invoke when this shortcut is triggered + * @param editor The editor object + */ + onClick: (editor: IEditor) => void; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts new file mode 100644 index 00000000000..9b7fe7a3f60 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts @@ -0,0 +1,150 @@ +import { cacheGetEventData } from 'roosterjs-content-model-core'; +import type { ShortcutCommand, ShortcutKeyDefinition } from './ShortcutCommand'; +import { + ShortcutBold, + ShortcutBullet, + ShortcutClearFormat, + ShortcutDecreaseFont, + ShortcutIncreaseFont, + ShortcutItalic, + ShortcutNumbering, + ShortcutRedo, + ShortcutRedoMacOS, + ShortcutUnderline, + ShortcutUndo, + ShortcutUndo2, +} from './shortcuts'; +import type { + EditorPlugin, + IEditor, + KeyDownEvent, + PluginEvent, +} from 'roosterjs-content-model-types'; + +const defaultShortcuts: ShortcutCommand[] = [ + ShortcutBold, + ShortcutItalic, + ShortcutUnderline, + ShortcutClearFormat, + ShortcutUndo, + ShortcutUndo2, + ShortcutRedo, + ShortcutRedoMacOS, + ShortcutBullet, + ShortcutNumbering, + ShortcutIncreaseFont, + ShortcutDecreaseFont, +]; +const CommandCacheKey = '__ShortcutCommandCache'; + +/** + * Shortcut plugin hook on the specified shortcut keys and trigger related format API + */ +export class ShortcutPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private isMac = false; + + /** + * Create a new instance of ShortcutPlugin + * @param [shortcuts=defaultShortcuts] Allowed commands + */ + constructor(private shortcuts: ShortcutCommand[] = defaultShortcuts) {} + + /** + * Get name of this plugin + */ + getName() { + return 'Shortcut'; + } + + /** + * 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; + this.isMac = !!this.editor.getEnvironment().isMac; + } + + /** + * 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; + } + + /** + * Check if the plugin should handle the given event exclusively. + * Handle an event exclusively means other plugin will not receive this event in + * onPluginEvent method. + * If two plugins will return true in willHandleEventExclusively() for the same event, + * the final result depends on the order of the plugins are added into editor + * @param event The event to check: + */ + willHandleEventExclusively(event: PluginEvent) { + return ( + event.eventType == 'keyDown' && + (event.rawEvent.ctrlKey || event.rawEvent.altKey || event.rawEvent.metaKey) && + !!this.cacheGetCommand(event) + ); + } + + /** + * 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 && event.eventType == 'keyDown') { + const command = this.cacheGetCommand(event); + + if (command) { + command.onClick(this.editor); + event.rawEvent.preventDefault(); + } + } + } + + private cacheGetCommand(event: KeyDownEvent) { + return cacheGetEventData(event, CommandCacheKey, event => { + const editor = this.editor; + + return ( + editor && + this.shortcuts.filter( + command => + this.matchOS(command.environment) && + this.matchShortcut(command.shortcutKey, event.rawEvent) + )[0] + ); + }); + } + + private matchOS(environment?: 'all' | 'mac' | 'nonMac') { + switch (environment) { + case 'mac': + return this.isMac; + + case 'nonMac': + return !this.isMac; + + default: + return true; + } + } + + private matchShortcut(shortcutKey: ShortcutKeyDefinition, event: KeyboardEvent) { + const { ctrlKey, altKey, shiftKey, which, metaKey } = event; + const ctrlOrMeta = this.isMac ? metaKey : ctrlKey; + const matchModifier = + (shortcutKey.modifierKey == 'ctrl' && ctrlOrMeta && !altKey) || + (shortcutKey.modifierKey == 'alt' && altKey && !ctrlOrMeta); + + return matchModifier && shiftKey == shortcutKey.shiftKey && shortcutKey.which == which; + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts b/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts new file mode 100644 index 00000000000..8c2520485c4 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts @@ -0,0 +1,195 @@ +import { redo, undo } from 'roosterjs-content-model-core'; +import { + changeFontSize, + clearFormat, + toggleBold, + toggleBullet, + toggleItalic, + toggleNumbering, + toggleUnderline, +} from 'roosterjs-content-model-api'; +import type { ShortcutCommand } from './ShortcutCommand'; + +const enum Keys { + BACKSPACE = 8, + SPACE = 32, + B = 66, + I = 73, + U = 85, + Y = 89, + Z = 90, + COMMA = 188, + PERIOD = 190, + FORWARD_SLASH = 191, +} + +/** + * Shortcut command for Bold + * Windows: Ctrl + B + * MacOS: Meta + B + */ +export const ShortcutBold: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: false, + which: Keys.B, + }, + onClick: editor => toggleBold(editor), +}; + +/** + * Shortcut command for Italic + * Windows: Ctrl + I + * MacOS: Meta + I + */ +export const ShortcutItalic: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: false, + which: Keys.I, + }, + onClick: editor => toggleItalic(editor), +}; + +/** + * Shortcut command for Underline + * Windows: Ctrl + U + * MacOS: Meta + U + */ +export const ShortcutUnderline: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: false, + which: Keys.U, + }, + onClick: editor => toggleUnderline(editor), +}; + +/** + * Shortcut command for Clear Format + * Windows: Ctrl + Space + * MacOS: Meta + Space + */ +export const ShortcutClearFormat: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: false, + which: Keys.SPACE, + }, + onClick: editor => clearFormat(editor), +}; + +/** + * Shortcut command for Undo 1 + * Windows: Ctrl + Z + * MacOS: Meta + Z + */ +export const ShortcutUndo: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: false, + which: Keys.Z, + }, + onClick: editor => undo(editor), +}; + +/** + * Shortcut command for Undo 2 + * Windows: Alt + Backspace + * MacOS: N/A + */ +export const ShortcutUndo2: ShortcutCommand = { + shortcutKey: { + modifierKey: 'alt', + shiftKey: false, + which: Keys.BACKSPACE, + }, + onClick: editor => undo(editor), + environment: 'nonMac', +}; + +/** + * Shortcut command for Redo 1 + * Windows: Ctrl + Y + * MacOS: N/A + */ +export const ShortcutRedo: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: false, + which: Keys.Y, + }, + onClick: editor => redo(editor), + environment: 'nonMac', +}; + +/** + * Shortcut command for Redo 2 + * Windows: N/A + * MacOS: Meta + Shift + Z + */ +export const ShortcutRedoMacOS: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: true, + which: Keys.Z, + }, + onClick: editor => redo(editor), + environment: 'mac', +}; + +/** + * Shortcut command for Bullet List + * Windows: Ctrl + . (Period) + * MacOS: Meta + . (Period) + */ +export const ShortcutBullet: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: false, + which: Keys.PERIOD, + }, + onClick: editor => toggleBullet(editor), +}; + +/** + * Shortcut command for Numbering List + * Windows: Ctrl + / (Forward slash) + * MacOS: Meta + / (Forward slash) + */ +export const ShortcutNumbering: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: false, + which: Keys.FORWARD_SLASH, + }, + onClick: editor => toggleNumbering(editor), +}; + +/** + * Shortcut command for Increase Font + * Windows: Ctrl + Shift + . (Period) + * MacOS: Meta + Shift + . (Period) + */ +export const ShortcutIncreaseFont: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: true, + which: Keys.PERIOD, + }, + onClick: editor => changeFontSize(editor, 'increase'), +}; + +/** + * Shortcut command for Decrease Font + * Windows: Ctrl + Shift + , (Comma) + * MacOS: Meta + Shift + , (Comma) + */ +export const ShortcutDecreaseFont: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: true, + which: Keys.COMMA, + }, + onClick: editor => changeFontSize(editor, 'decrease'), +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts new file mode 100644 index 00000000000..f2b0c1b9649 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts @@ -0,0 +1,175 @@ +import normalizeRect from '../pluginUtils/Rect/normalizeRect'; +import TableEditor from './editors/TableEditor'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type { EditorPlugin, IEditor, PluginEvent, Rect } from 'roosterjs-content-model-types'; + +const TABLE_RESIZER_LENGTH = 12; + +/** + * TableEdit plugin, provides the ability to resize a table by drag-and-drop + */ +export class TableEditPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private onMouseMoveDisposer: (() => void) | null = null; + private tableRectMap: { table: HTMLTableElement; rect: Rect }[] | null = null; + private tableEditor: TableEditor | null = null; + + /** + * Construct a new instance of TableResize plugin + * @param anchorContainerSelector An optional selector string to specify the container to host the plugin. + * The container must not be affected by transform: scale(), otherwise the position calculation will be wrong. + * If not specified, the plugin will be inserted in document.body + */ + constructor(private anchorContainerSelector?: string) {} + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'TableEdit'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + this.onMouseMoveDisposer = this.editor.attachDomEvent({ + mousemove: { beforeDispatch: this.onMouseMove }, + }); + const scrollContainer = this.editor.getScrollContainer(); + scrollContainer.addEventListener('mouseout', this.onMouseOut); + } + + private onMouseOut = ({ relatedTarget, currentTarget }: MouseEvent) => { + const relatedTargetNode = relatedTarget as Node; + const currentTargetNode = currentTarget as Node; + if ( + isNodeOfType(relatedTargetNode, 'ELEMENT_NODE') && + isNodeOfType(currentTargetNode, 'ELEMENT_NODE') && + this.tableEditor && + !this.tableEditor.isOwnedElement(relatedTargetNode) && + !currentTargetNode.contains(relatedTargetNode) + ) { + this.setTableEditor(null); + } + }; + + /** + * Dispose this plugin + */ + dispose() { + const scrollContainer = this.editor?.getScrollContainer(); + scrollContainer?.removeEventListener('mouseout', this.onMouseOut); + this.onMouseMoveDisposer?.(); + this.invalidateTableRects(); + this.disposeTableEditor(); + this.editor = null; + this.onMouseMoveDisposer = null; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(e: PluginEvent) { + switch (e.eventType) { + case 'input': + case 'contentChanged': + case 'scroll': + case 'zoomChanged': + this.setTableEditor(null); + this.invalidateTableRects(); + break; + } + } + + private onMouseMove = (event: Event) => { + const e = event as MouseEvent; + + if (e.buttons > 0 || !this.editor) { + return; + } + + this.ensureTableRects(); + + const editorWindow = this.editor.getDocument().defaultView || window; + const x = e.pageX - editorWindow.scrollX; + const y = e.pageY - editorWindow.scrollY; + let currentTable: HTMLTableElement | null = null; + + //Find table in range of mouse + if (this.tableRectMap) { + for (let i = this.tableRectMap.length - 1; i >= 0; i--) { + const { table, rect } = this.tableRectMap[i]; + + if ( + x >= rect.left - TABLE_RESIZER_LENGTH && + x <= rect.right + TABLE_RESIZER_LENGTH && + y >= rect.top - TABLE_RESIZER_LENGTH && + y <= rect.bottom + TABLE_RESIZER_LENGTH + ) { + currentTable = table; + break; + } + } + } + + this.setTableEditor(currentTable, e); + this.tableEditor?.onMouseMove(x, y); + }; + + /** + * @internal Public only for unit test + * @param table Table to use when setting the Editors + * @param event (Optional) Mouse event + */ + public setTableEditor(table: HTMLTableElement | null, event?: MouseEvent) { + if (this.tableEditor && !this.tableEditor.isEditing() && table != this.tableEditor.table) { + this.disposeTableEditor(); + } + + if (!this.tableEditor && table && this.editor && table.rows.length > 0) { + const container = this.anchorContainerSelector + ? this.editor.getDOMHelper().queryElements(this.anchorContainerSelector)[0] + : undefined; + + this.tableEditor = new TableEditor( + this.editor, + table, + this.invalidateTableRects, + isNodeOfType(container as Node, 'ELEMENT_NODE') ? container : undefined, + event?.currentTarget + ); + } + } + + private invalidateTableRects = () => { + this.tableRectMap = null; + }; + + private disposeTableEditor() { + this.tableEditor?.dispose(); + this.tableEditor = null; + } + + private ensureTableRects() { + if (!this.tableRectMap && this.editor) { + this.tableRectMap = []; + + const tables = this.editor.getDOMHelper().queryElements('table'); + tables.forEach(table => { + if (table.isContentEditable) { + const rect = normalizeRect(table.getBoundingClientRect()); + if (rect && this.tableRectMap) { + this.tableRectMap.push({ + table, + rect, + }); + } + } + }); + } + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts new file mode 100644 index 00000000000..96cedfe6d22 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts @@ -0,0 +1,385 @@ +import createCellResizer from './features/CellResizer'; +import createTableInserter from './features/TableInserter'; +import createTableMover from './features/TableMover'; +import createTableResizer from './features/TableResizer'; +import normalizeRect from '../../pluginUtils/Rect/normalizeRect'; +import { disposeTableEditFeature } from './features/TableEditorFeature'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type TableEditFeature from './features/TableEditorFeature'; +import type { IEditor, TableSelection } from 'roosterjs-content-model-types'; + +const INSERTER_HOVER_OFFSET = 6; +const enum TOP_OR_SIDE { + top = 0, + side = 1, +} +/** + * @internal + * + * A table has 6 hot areas to be resized/edited (take LTR example): + * + * [6] [ ] + * +[ 1 ]+--------------------+ + * |[ ]| | + * [ ] [ ] | + * [ ] [ ] | + * [2] [3] | + * [ ] [ ] | + * [ ][ 4 ]| | + * +------------------+--------------------+ + * | | | + * | | | + * | | | + * +------------------+--------------------+ + * [5] + * + * 1 - Hover area to show insert column button + * 2 - Hover area to show insert row button + * 3 - Hover area to show vertical resizing bar + * 4 - Hover area to show horizontal resizing bar + * 5 - Hover area to show whole table resize handle + * 6 - Hover area to show whole table mover handle + * + * When set a different current table or change current TD, we need to update these areas + */ +export default class TableEditor { + // 1, 2 - Insert a column or a row + private horizontalInserter: TableEditFeature | null = null; + private verticalInserter: TableEditFeature | null = null; + + // 3, 4 - Resize a column or a row from a cell + private horizontalResizer: TableEditFeature | null = null; + private verticalResizer: TableEditFeature | null = null; + + // 5 - Resize whole table + private tableResizer: TableEditFeature | null = null; + + // 6 - Move as well as select whole table + private tableMover: TableEditFeature | null = null; + + private isRTL: boolean; + private range: Range | null = null; + private isCurrentlyEditing: boolean; + + constructor( + private editor: IEditor, + public readonly table: HTMLTableElement, + private onChanged: () => void, + private anchorContainer?: HTMLElement, + private contentDiv?: EventTarget | null + ) { + this.isRTL = editor.getDocument().defaultView?.getComputedStyle(table).direction == 'rtl'; + this.setEditorFeatures(); + this.isCurrentlyEditing = false; + } + + dispose() { + this.disposeTableResizer(); + this.disposeCellResizers(); + this.disposeTableInserter(); + this.disposeTableMover(); + } + + isEditing(): boolean { + return this.isCurrentlyEditing; + } + + isOwnedElement(node: Node) { + return [ + this.tableResizer, + this.tableMover, + this.horizontalInserter, + this.verticalInserter, + this.horizontalResizer, + this.verticalResizer, + ] + .filter(feature => !!feature?.div) + .some(feature => feature?.div == node); + } + + onMouseMove(x: number, y: number) { + // Get whole table rect + const tableRect = normalizeRect(this.table.getBoundingClientRect()); + + //console.log('>>>tableRect', tableRect); + if (!tableRect) { + return; + } + + // Determine if cursor is on top or side + const topOrSide = + y <= tableRect.top + INSERTER_HOVER_OFFSET + ? TOP_OR_SIDE.top + : this.isRTL + ? x >= tableRect.right - INSERTER_HOVER_OFFSET + ? TOP_OR_SIDE.side + : undefined + : x <= tableRect.left + INSERTER_HOVER_OFFSET + ? TOP_OR_SIDE.side + : undefined; + const topOrSideBinary = topOrSide ? 1 : 0; + + // i is row index, j is column index + for (let i = 0; i < this.table.rows.length; i++) { + const tr = this.table.rows[i]; + let j = 0; + for (; j < tr.cells.length; j++) { + const td = tr.cells[j]; + const tdRect = normalizeRect(td.getBoundingClientRect()); + + if (!tdRect || !tableRect) { + continue; + } + + // Determine the cell the cursor is in range of + // Offset is only used for first row and column + const lessThanBottom = y <= tdRect.bottom; + const lessThanRight = this.isRTL + ? x <= tdRect.right + INSERTER_HOVER_OFFSET * topOrSideBinary + : x <= tdRect.right; + const moreThanLeft = this.isRTL + ? x >= tdRect.left + : x >= tdRect.left - INSERTER_HOVER_OFFSET * topOrSideBinary; + + if (lessThanBottom && lessThanRight && moreThanLeft) { + if (i === 0 && topOrSide == TOP_OR_SIDE.top) { + const center = (tdRect.left + tdRect.right) / 2; + const isOnRightHalf = this.isRTL ? x < center : x > center; + this.setInserterTd( + isOnRightHalf ? td : tr.cells[j - 1], + false /*isHorizontal*/ + ); + } else if (j === 0 && topOrSide == TOP_OR_SIDE.side) { + const tdAbove = this.table.rows[i - 1]?.cells[0]; + const tdAboveRect = tdAbove + ? normalizeRect(tdAbove.getBoundingClientRect()) + : null; + + const isTdNotAboveMerged = !tdAboveRect + ? null + : this.isRTL + ? tdAboveRect.right === tdRect.right + : tdAboveRect.left === tdRect.left; + + this.setInserterTd( + y < (tdRect.top + tdRect.bottom) / 2 && isTdNotAboveMerged + ? tdAbove + : td, + true /*isHorizontal*/ + ); + } else { + this.setInserterTd(null); + } + + this.setResizingTd(td); + + //Cell found + break; + } + } + + if (j < tr.cells.length) { + break; + } + } + + // Create Mover and Resizer + this.setEditorFeatures(); + } + + private setEditorFeatures() { + if (!this.tableMover) { + this.tableMover = createTableMover( + this.table, + this.editor, + this.isRTL, + this.onSelect, + this.getOnMouseOut, + this.contentDiv, + this.anchorContainer + ); + } + + if (!this.tableResizer) { + this.tableResizer = createTableResizer( + this.table, + this.editor, + this.isRTL, + this.onStartTableResize, + this.onFinishEditing, + this.contentDiv, + this.anchorContainer + ); + } + } + + private setResizingTd(td: HTMLTableCellElement) { + if (this.horizontalResizer && this.horizontalResizer.node != td) { + this.disposeCellResizers(); + } + + if (!this.horizontalResizer && td) { + this.horizontalResizer = createCellResizer( + this.editor, + td, + this.table, + this.isRTL, + true /*isHorizontal*/, + this.onStartCellResize, + this.onFinishEditing, + this.anchorContainer + ); + this.verticalResizer = createCellResizer( + this.editor, + td, + this.table, + this.isRTL, + false /*isHorizontal*/, + this.onStartCellResize, + this.onFinishEditing, + this.anchorContainer + ); + } + } + + /** + * create or remove TableInserter + * @param td td to attach to, set this to null to remove inserters (both horizontal and vertical) + */ + private setInserterTd(td: HTMLTableCellElement | null, isHorizontal?: boolean) { + const inserter = isHorizontal ? this.horizontalInserter : this.verticalInserter; + if (td === null || (inserter && inserter.node != td)) { + this.disposeTableInserter(); + } + + if (!this.horizontalInserter && !this.verticalInserter && td) { + const newInserter = createTableInserter( + this.editor, + td, + this.table, + this.isRTL, + !!isHorizontal, + this.onInserted, + this.getOnMouseOut, + this.anchorContainer + ); + if (isHorizontal) { + this.horizontalInserter = newInserter; + } else { + this.verticalInserter = newInserter; + } + } + } + + private disposeTableResizer() { + if (this.tableResizer) { + disposeTableEditFeature(this.tableResizer); + this.tableResizer = null; + } + } + + private disposeTableInserter() { + if (this.horizontalInserter) { + disposeTableEditFeature(this.horizontalInserter); + this.horizontalInserter = null; + } + if (this.verticalInserter) { + disposeTableEditFeature(this.verticalInserter); + this.verticalInserter = null; + } + } + + private disposeCellResizers() { + if (this.horizontalResizer) { + disposeTableEditFeature(this.horizontalResizer); + this.horizontalResizer = null; + } + if (this.verticalResizer) { + disposeTableEditFeature(this.verticalResizer); + this.verticalResizer = null; + } + } + + private disposeTableMover() { + if (this.tableMover) { + disposeTableEditFeature(this.tableMover); + this.tableMover = null; + } + } + + private onFinishEditing = (): false => { + this.editor.focus(); + + if (this.range) { + this.editor.setDOMSelection({ type: 'range', range: this.range, isReverted: false }); + } + + this.editor.takeSnapshot(); // Pass in an empty callback to make sure ContentChangedEvent is triggered + this.onChanged(); + this.isCurrentlyEditing = false; + + return false; + }; + + private onStartTableResize = () => { + this.isCurrentlyEditing = true; + this.onStartResize(); + }; + + private onStartCellResize = () => { + this.isCurrentlyEditing = true; + this.disposeTableResizer(); + this.onStartResize(); + }; + + private onStartResize() { + this.isCurrentlyEditing = true; + const range = this.editor.getDOMSelection(); + + if (range && range.type == 'range') { + this.range = range.range; + } + + this.editor.takeSnapshot(); + } + + private onInserted = () => { + this.disposeTableResizer(); + this.onFinishEditing(); + }; + + /** + * Public only for testing purposes + * @param table the table to select + */ + public onSelect = (table: HTMLTableElement) => { + this.editor.focus(); + + if (table) { + const selection: TableSelection = { + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: table.rows.length - 1, + lastColumn: table.rows[table.rows.length - 1].cells.length - 1, + type: 'table', + }; + + this.editor.setDOMSelection(selection); + } + }; + + private getOnMouseOut = (feature: HTMLElement) => { + return (ev: MouseEvent) => { + if ( + feature && + ev.relatedTarget != feature && + isNodeOfType(this.contentDiv as Node, 'ELEMENT_NODE') && + isNodeOfType(ev.relatedTarget as Node, 'ELEMENT_NODE') && + !(this.contentDiv == ev.relatedTarget) + ) { + this.dispose(); + } + }; + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts new file mode 100644 index 00000000000..799879858be --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts @@ -0,0 +1,244 @@ +import createElement from '../../../pluginUtils/CreateElement/createElement'; +import DragAndDropHelper from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import normalizeRect from '../../../pluginUtils/Rect/normalizeRect'; +import { isElementOfType } from 'roosterjs-content-model-dom'; +import { + getFirstSelectedTable, + MIN_ALLOWED_TABLE_CELL_WIDTH, + normalizeTable, +} from 'roosterjs-content-model-core'; +import type DragAndDropHandler from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; +import type { ContentModelTable, IEditor } from 'roosterjs-content-model-types'; +import type TableEditFeature from './TableEditorFeature'; + +const CELL_RESIZER_WIDTH = 4; + +/** + * @internal + */ +export default function createCellResizer( + editor: IEditor, + td: HTMLTableCellElement, + table: HTMLTableElement, + isRTL: boolean, + isHorizontal: boolean, + onStart: () => void, + onEnd: () => false, + anchorContainer?: HTMLElement +): TableEditFeature | null { + const document = td.ownerDocument; + const createElementData = { + tag: 'div', + style: `position: fixed; cursor: ${isHorizontal ? 'row' : 'col'}-resize; user-select: none`, + }; + const zoomScale = editor.getDOMHelper().calculateZoomScale(); + + const div = createElement(createElementData, document) as HTMLDivElement; + + (anchorContainer || document.body).appendChild(div); + + const context: DragAndDropContext = { editor, td, table, isRTL, zoomScale, onStart }; + const setPosition = isHorizontal ? setHorizontalPosition : setVerticalPosition; + setPosition(context, div); + + const handler: DragAndDropHandler = { + onDragStart, + // Horizontal modifies row height, vertical modifies column width + onDragging: isHorizontal ? onDraggingHorizontal : onDraggingVertical, + onDragEnd: onEnd, + }; + + const featureHandler = new DragAndDropHelper( + div, + context, + setPosition, + handler, + zoomScale, + editor.getEnvironment().isMobileOrTablet + ); + + return { node: td, div, featureHandler }; +} + +interface DragAndDropContext { + editor: IEditor; + td: HTMLTableCellElement; + table: HTMLTableElement; + isRTL: boolean; + zoomScale: number; + onStart: () => void; +} + +interface DragAndDropInitValue { + cmTable: ContentModelTable | undefined; + anchorColumn: number | undefined; + anchorRow: number | undefined; + anchorRowHeight: number; + allWidths: number[]; +} + +function onDragStart(context: DragAndDropContext, event: MouseEvent): DragAndDropInitValue { + const { td, onStart } = context; + const rect = normalizeRect(td.getBoundingClientRect()); + + // Get cell coordinates + const columnIndex = td.cellIndex; + const row = + td.parentElement && isElementOfType(td.parentElement, 'tr') ? td.parentElement : undefined; + const rowIndex = row?.rowIndex; + + if (rowIndex == undefined) { + return { + cmTable: undefined, + anchorColumn: undefined, + anchorRow: undefined, + anchorRowHeight: -1, + allWidths: [], + }; // Just a fallback + } + + const { editor, table } = context; + + // Get current selection + const selection = editor.getDOMSelection(); + + // Select first cell of the table + editor.setDOMSelection({ + type: 'table', + firstColumn: 0, + firstRow: 0, + lastColumn: 0, + lastRow: 0, + table: table, + }); + + // Get the table content model + const cmTable = getFirstSelectedTable(editor.getContentModelCopy('disconnected'))[0]; + + // Restore selection + editor.setDOMSelection(selection); + + if (rect && cmTable) { + onStart(); + + return { + cmTable, + anchorColumn: columnIndex, + anchorRow: rowIndex, + anchorRowHeight: cmTable.rows[rowIndex].height, + allWidths: [...cmTable.widths], + }; + } else { + return { + cmTable, + anchorColumn: undefined, + anchorRow: undefined, + anchorRowHeight: -1, + allWidths: [], + }; // Just a fallback + } +} + +function onDraggingHorizontal( + context: DragAndDropContext, + event: MouseEvent, + initValue: DragAndDropInitValue, + deltaX: number, + deltaY: number +) { + const { table } = context; + const { cmTable, anchorRow, anchorRowHeight } = initValue; + + // Assign new widths and heights to the CM table + if (cmTable && anchorRow != undefined) { + // Modify the CM Table size + cmTable.rows[anchorRow].height = (anchorRowHeight ?? 0) + deltaY; + + // Normalize the table + normalizeTable(cmTable); + + // Writeback CM Table size changes to DOM Table + const tableRow = table.rows[anchorRow]; + for (let col = 0; col < tableRow.cells.length; col++) { + const td = tableRow.cells[col]; + td.style.height = cmTable.rows[anchorRow].height + 'px'; + } + + return true; + } else { + return false; + } +} + +function onDraggingVertical( + context: DragAndDropContext, + event: MouseEvent, + initValue: DragAndDropInitValue, + deltaX: number +) { + const { table, isRTL } = context; + const { cmTable, anchorColumn, allWidths } = initValue; + + // Assign new widths and heights to the CM table + if (cmTable && anchorColumn != undefined) { + // Modify the CM Table size + const lastColumn = anchorColumn == cmTable.widths.length - 1; + const change = deltaX * (isRTL ? -1 : 1); + // This is the last column + if (lastColumn) { + // Only the last column changes + cmTable.widths[anchorColumn] = allWidths[anchorColumn] + change; + } else { + // Any other two columns + const anchorChange = allWidths[anchorColumn] + change; + const nextAnchorChange = allWidths[anchorColumn + 1] - change; + if ( + anchorChange < MIN_ALLOWED_TABLE_CELL_WIDTH || + nextAnchorChange < MIN_ALLOWED_TABLE_CELL_WIDTH + ) { + return false; + } + cmTable.widths[anchorColumn] = anchorChange; + cmTable.widths[anchorColumn + 1] = nextAnchorChange; + } + + // Normalize the table + normalizeTable(cmTable); + + // Writeback CM Table size changes to DOM Table + for (let row = 0; row < table.rows.length; row++) { + const tableRow = table.rows[row]; + for (let col = 0; col < tableRow.cells.length; col++) { + tableRow.cells[col].style.width = cmTable.widths[col] + 'px'; + } + } + + return true; + } else { + return false; + } +} + +function setHorizontalPosition(context: DragAndDropContext, trigger: HTMLElement) { + const { td } = context; + const rect = normalizeRect(td.getBoundingClientRect()); + if (rect) { + trigger.id = 'horizontalResizer'; + trigger.style.top = rect.bottom - CELL_RESIZER_WIDTH + 'px'; + trigger.style.left = rect.left + 'px'; + trigger.style.width = rect.right - rect.left + 'px'; + trigger.style.height = CELL_RESIZER_WIDTH + 'px'; + } +} + +function setVerticalPosition(context: DragAndDropContext, trigger: HTMLElement) { + const { td, isRTL } = context; + const rect = normalizeRect(td.getBoundingClientRect()); + if (rect) { + trigger.id = 'verticalResizer'; + trigger.style.top = rect.top + 'px'; + trigger.style.left = (isRTL ? rect.left : rect.right) - CELL_RESIZER_WIDTH + 1 + 'px'; + trigger.style.width = CELL_RESIZER_WIDTH + 'px'; + trigger.style.height = rect.bottom - rect.top + 'px'; + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditorFeature.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditorFeature.ts new file mode 100644 index 00000000000..f244e2bd39d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditorFeature.ts @@ -0,0 +1,22 @@ +import type Disposable from '../../../pluginUtils/Disposable'; + +/** + * @internal + */ +export default interface TableEditFeature { + node: Node; + div: HTMLDivElement | null; + featureHandler: Disposable | null; +} + +/** + * @internal + */ +export function disposeTableEditFeature(resizer: TableEditFeature | null) { + if (resizer) { + resizer.div?.parentNode?.removeChild(resizer.div); + resizer.div = null; + resizer.featureHandler?.dispose(); + resizer.featureHandler = null; + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts new file mode 100644 index 00000000000..9a6d45eec5d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts @@ -0,0 +1,173 @@ +import createElement from '../../../pluginUtils/CreateElement/createElement'; +import getIntersectedRect from '../../../pluginUtils/Rect/getIntersectedRect'; +import normalizeRect from '../../../pluginUtils/Rect/normalizeRect'; +import { isElementOfType } from 'roosterjs-content-model-dom'; +import { + formatTableWithContentModel, + insertTableColumn, + insertTableRow, +} from 'roosterjs-content-model-api'; +import type CreateElementData from '../../../pluginUtils/CreateElement/CreateElementData'; +import type Disposable from '../../../pluginUtils/Disposable'; +import type TableEditFeature from './TableEditorFeature'; +import type { IEditor } from 'roosterjs-content-model-types'; + +const INSERTER_COLOR = '#4A4A4A'; +const INSERTER_COLOR_DARK_MODE = 'white'; +const INSERTER_SIDE_LENGTH = 12; +const INSERTER_BORDER_SIZE = 1; + +/** + * @internal + */ +export default function createTableInserter( + editor: IEditor, + td: HTMLTableCellElement, + table: HTMLTableElement, + isRTL: boolean, + isHorizontal: boolean, + onInsert: () => void, + getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, + anchorContainer?: HTMLElement +): TableEditFeature | null { + const tdRect = normalizeRect(td.getBoundingClientRect()); + const viewPort = editor.getVisibleViewport(); + const tableRect = table && viewPort ? getIntersectedRect([table], [viewPort]) : null; + + // set inserter position + if (tdRect && tableRect) { + const document = td.ownerDocument; + const createElementData = getInsertElementData( + isHorizontal, + editor.isDarkMode(), + isRTL, + editor.getDOMHelper().getDomStyle('backgroundColor') || 'white' + ); + + const div = createElement(createElementData, document) as HTMLDivElement; + + if (isHorizontal) { + // tableRect.left/right is used because the Inserter is always intended to be on the side + div.id = 'horizontalInserter'; + div.style.left = `${ + isRTL + ? tableRect.right + : tableRect.left - (INSERTER_SIDE_LENGTH - 1 + 2 * INSERTER_BORDER_SIZE) + }px`; + div.style.top = `${tdRect.bottom - 8}px`; + (div.firstChild as HTMLElement).style.width = `${tableRect.right - tableRect.left}px`; + } else { + div.id = 'verticalInserter'; + div.style.left = `${isRTL ? tdRect.left - 8 : tdRect.right - 8}px`; + // tableRect.top is used because the Inserter is always intended to be on top + div.style.top = `${ + tableRect.top - (INSERTER_SIDE_LENGTH - 1 + 2 * INSERTER_BORDER_SIZE) + }px`; + (div.firstChild as HTMLElement).style.height = `${tableRect.bottom - tableRect.top}px`; + } + + (anchorContainer || document.body).appendChild(div); + + const handler = new TableInsertHandler( + div, + td, + table, + isHorizontal, + editor, + onInsert, + getOnMouseOut + ); + + return { div, featureHandler: handler, node: td }; + } + + return null; +} + +class TableInsertHandler implements Disposable { + private onMouseOutEvent: null | ((ev: MouseEvent) => void); + constructor( + private div: HTMLDivElement, + private td: HTMLTableCellElement, + private table: HTMLTableElement, + private isHorizontal: boolean, + private editor: IEditor, + private onInsert: () => void, + getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void + ) { + this.div.addEventListener('click', this.insertTd); + this.onMouseOutEvent = getOnMouseOut(div); + this.div.addEventListener('mouseout', this.onMouseOutEvent); + } + + dispose() { + this.div.removeEventListener('click', this.insertTd); + + if (this.onMouseOutEvent) { + this.div.removeEventListener('mouseout', this.onMouseOutEvent); + } + + this.onMouseOutEvent = null; + } + + private insertTd = () => { + // Get cell coordinates + const columnIndex = this.td.cellIndex; + const row = + this.td.parentElement && isElementOfType(this.td.parentElement, 'tr') + ? this.td.parentElement + : undefined; + const rowIndex = row && row.rowIndex; + + if (row?.cells == undefined || rowIndex == undefined) { + return; + } + + // Insert row or column + formatTableWithContentModel( + this.editor, + 'editTablePlugin', + tableModel => { + this.isHorizontal + ? insertTableRow(tableModel, 'insertBelow') + : insertTableColumn(tableModel, 'insertRight'); + }, // Select cell to make insertion + { + type: 'table', + firstColumn: columnIndex, + firstRow: rowIndex, + lastColumn: columnIndex, + lastRow: rowIndex, + table: this.table, + } + ); + + this.onInsert(); + }; +} + +function getInsertElementData( + isHorizontal: boolean, + isDark: boolean, + isRTL: boolean, + backgroundColor: string +): CreateElementData { + const inserterColor = isDark ? INSERTER_COLOR_DARK_MODE : INSERTER_COLOR; + const outerDivStyle = `position: fixed; width: ${INSERTER_SIDE_LENGTH}px; height: ${INSERTER_SIDE_LENGTH}px; font-size: 16px; color: black; line-height: 8px; vertical-align: middle; text-align: center; cursor: pointer; border: solid ${INSERTER_BORDER_SIZE}px ${inserterColor}; border-radius: 50%; background-color: ${backgroundColor}`; + const leftOrRight = isRTL ? 'right' : 'left'; + const childBaseStyles = `position: absolute; box-sizing: border-box; background-color: ${backgroundColor};`; + const childInfo: CreateElementData = { + tag: 'div', + style: + childBaseStyles + + (isHorizontal + ? `${leftOrRight}: 12px; top: 5px; height: 3px; border-top: 1px solid ${inserterColor}; border-bottom: 1px solid ${inserterColor}; border-right: 1px solid ${inserterColor}; border-left: 0px;` + : `left: 5px; top: 12px; width: 3px; border-left: 1px solid ${inserterColor}; border-right: 1px solid ${inserterColor}; border-bottom: 1px solid ${inserterColor}; border-top: 0px;`), + }; + + return { + tag: 'div', + style: outerDivStyle, + children: [childInfo, '+'], + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts new file mode 100644 index 00000000000..d4dd46a94c0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts @@ -0,0 +1,136 @@ +import createElement from '../../../pluginUtils/CreateElement/createElement'; +import DragAndDropHelper from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import normalizeRect from '../../../pluginUtils/Rect/normalizeRect'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type DragAndDropHandler from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; +import type { IEditor, Rect } from 'roosterjs-content-model-types'; +import type TableEditorFeature from './TableEditorFeature'; + +const TABLE_MOVER_LENGTH = 12; +const TABLE_MOVER_ID = '_Table_Mover'; + +/** + * @internal + * Contains the function to select whole table + * Moving behavior not implemented yet + */ +export default function createTableMover( + table: HTMLTableElement, + editor: IEditor, + isRTL: boolean, + onFinishDragging: (table: HTMLTableElement) => void, + getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, + contentDiv?: EventTarget | null, + anchorContainer?: HTMLElement +): TableEditorFeature | null { + const rect = normalizeRect(table.getBoundingClientRect()); + + if (!isTableTopVisible(editor, rect, contentDiv as Node)) { + return null; + } + + const zoomScale = editor.getDOMHelper().calculateZoomScale(); + const document = table.ownerDocument; + const createElementData = { + tag: 'div', + style: 'position: fixed; cursor: all-scroll; user-select: none; border: 1px solid #808080', + }; + + const div = createElement(createElementData, document) as HTMLDivElement; + + div.id = TABLE_MOVER_ID; + div.style.width = `${TABLE_MOVER_LENGTH}px`; + div.style.height = `${TABLE_MOVER_LENGTH}px`; + + (anchorContainer || document.body).appendChild(div); + + const context: TableMoverContext = { + table, + zoomScale, + rect, + isRTL, + }; + + setDivPosition(context, div); + + const onDragEnd = (context: TableMoverContext, event: MouseEvent): false => { + if (event.target == div) { + onFinishDragging(context.table); + } + return false; + }; + + const featureHandler = new TableMoverFeature( + div, + context, + setDivPosition, + { + onDragEnd, + }, + context.zoomScale, + getOnMouseOut + ); + + return { div, featureHandler, node: table }; +} + +interface TableMoverContext { + table: HTMLTableElement; + zoomScale: number; + rect: Rect | null; + isRTL: boolean; +} + +interface TableMoverInitValue { + event: MouseEvent; +} + +class TableMoverFeature extends DragAndDropHelper { + private onMouseOut: ((ev: MouseEvent) => void) | null; + + constructor( + private div: HTMLElement, + context: TableMoverContext, + onSubmit: ( + context: TableMoverContext, + trigger: HTMLElement, + container?: HTMLElement + ) => void, + handler: DragAndDropHandler, + zoomScale: number, + getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, + forceMobile?: boolean | undefined, + container?: HTMLElement + ) { + super(div, context, onSubmit, handler, zoomScale, forceMobile); + this.onMouseOut = getOnMouseOut(div); + div.addEventListener('mouseout', this.onMouseOut); + } + + dispose(): void { + super.dispose(); + if (this.onMouseOut) { + this.div.removeEventListener('mouseout', this.onMouseOut); + } + this.onMouseOut = null; + } +} + +function setDivPosition(context: TableMoverContext, trigger: HTMLElement) { + const { rect } = context; + if (rect) { + trigger.style.top = `${rect.top - TABLE_MOVER_LENGTH}px`; + trigger.style.left = `${rect.left - TABLE_MOVER_LENGTH - 2}px`; + } +} + +function isTableTopVisible(editor: IEditor, rect: Rect | null, contentDiv?: Node | null): boolean { + const visibleViewport = editor.getVisibleViewport(); + if (isNodeOfType(contentDiv, 'ELEMENT_NODE') && visibleViewport && rect) { + const containerRect = normalizeRect(contentDiv.getBoundingClientRect()); + + return !!containerRect && containerRect.top <= rect.top && visibleViewport.top <= rect.top; + } + + return true; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts new file mode 100644 index 00000000000..f455c9f83a3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts @@ -0,0 +1,249 @@ +import createElement from '../../../pluginUtils/CreateElement/createElement'; +import DragAndDropHelper from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import normalizeRect from '../../../pluginUtils/Rect/normalizeRect'; +import { getFirstSelectedTable, normalizeTable } from 'roosterjs-content-model-core'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type { ContentModelTable, IEditor, Rect } from 'roosterjs-content-model-types'; +import type TableEditFeature from './TableEditorFeature'; + +const TABLE_RESIZER_LENGTH = 12; +const TABLE_RESIZER_ID = '_Table_Resizer'; + +/** + * @internal + */ +export default function createTableResizer( + table: HTMLTableElement, + editor: IEditor, + isRTL: boolean, + onStart: () => void, + onEnd: () => false, + contentDiv?: EventTarget | null, + anchorContainer?: HTMLElement +): TableEditFeature | null { + const rect = normalizeRect(table.getBoundingClientRect()); + + if (!isTableBottomVisible(editor, rect, contentDiv as Node)) { + return null; + } + + const document = table.ownerDocument; + const zoomScale = editor.getDOMHelper().calculateZoomScale(); + const createElementData = { + tag: 'div', + style: `position: fixed; cursor: ${ + isRTL ? 'ne' : 'nw' + }-resize; user-select: none; border: 1px solid #808080`, + }; + + const div = createElement(createElementData, document) as HTMLDivElement; + + div.id = TABLE_RESIZER_ID; + div.style.width = `${TABLE_RESIZER_LENGTH}px`; + div.style.height = `${TABLE_RESIZER_LENGTH}px`; + + (anchorContainer || document.body).appendChild(div); + + const context: DragAndDropContext = { + isRTL, + table, + zoomScale, + onStart, + onEnd, + div, + editor, + contentDiv, + }; + + setDivPosition(context, div); + + const featureHandler = new DragAndDropHelper( + div, + context, + hideResizer, // Resizer is hidden while dragging only + { + onDragStart, + onDragging, + onDragEnd, + }, + zoomScale, + editor.getEnvironment().isMobileOrTablet + ); + + return { node: table, div, featureHandler }; +} + +interface DragAndDropContext { + table: HTMLTableElement; + isRTL: boolean; + zoomScale: number; + onStart: () => void; + onEnd: () => false; + div: HTMLDivElement; + editor: IEditor; + contentDiv?: EventTarget | null; +} + +interface DragAndDropInitValue { + originalRect: DOMRect; + originalHeights: number[]; + originalWidths: number[]; + cmTable: ContentModelTable | undefined; +} + +function onDragStart(context: DragAndDropContext, event: MouseEvent) { + context.onStart(); + + const { editor, table } = context; + + // Get current selection + const selection = editor.getDOMSelection(); + + // Select first cell of the table + editor.setDOMSelection({ + type: 'table', + firstColumn: 0, + firstRow: 0, + lastColumn: 0, + lastRow: 0, + table: table, + }); + + // Get the table content model + const cmTable = getFirstSelectedTable(editor.getContentModelCopy('disconnected'))[0]; + + // Restore selection + editor.setDOMSelection(selection); + + // Save original widths and heights + const heights: number[] = []; + cmTable?.rows.forEach(row => { + heights.push(row.height); + }); + const widths: number[] = []; + cmTable?.widths.forEach(width => { + widths.push(width); + }); + + return { + originalRect: table.getBoundingClientRect(), + cmTable, + originalHeights: heights ?? [], + originalWidths: widths ?? [], + }; +} + +function onDragging( + context: DragAndDropContext, + event: MouseEvent, + initValue: DragAndDropInitValue, + deltaX: number, + deltaY: number +) { + const { isRTL, zoomScale, table } = context; + const { originalRect, originalHeights, originalWidths, cmTable } = initValue; + + const ratioX = 1.0 + (deltaX / originalRect.width) * zoomScale * (isRTL ? -1 : 1); + const ratioY = 1.0 + (deltaY / originalRect.height) * zoomScale; + const shouldResizeX = Math.abs(ratioX - 1.0) > 1e-3; + const shouldResizeY = Math.abs(ratioY - 1.0) > 1e-3; + + // If the width of some external table is fixed, we need to make it resizable + table.style.setProperty('width', null); + // If the height of some external table is fixed, we need to make it resizable + table.style.setProperty('height', null); + + // Assign new widths and heights to the CM table + if (cmTable && cmTable.rows && (shouldResizeX || shouldResizeY)) { + // Modify the CM Table size + for (let i = 0; i < cmTable.rows.length; i++) { + for (let j = 0; j < cmTable.rows[i].cells.length; j++) { + const cell = cmTable.rows[i].cells[j]; + if (cell) { + if (shouldResizeX && i == 0) { + cmTable.widths[j] = (originalWidths[j] ?? 0) * ratioX; + } + if (shouldResizeY && j == 0) { + cmTable.rows[i].height = (originalHeights[i] ?? 0) * ratioY; + } + } + } + } + + // Normalize the table + normalizeTable(cmTable); + + // Writeback CM Table size changes to DOM Table + for (let row = 0; row < table.rows.length; row++) { + const tableRow = table.rows[row]; + + if (tableRow.cells.length == 0) { + // Skip empty row + continue; + } + + for (let col = 0; col < tableRow.cells.length; col++) { + const td = tableRow.cells[col]; + td.style.width = cmTable.widths[col] + 'px'; + td.style.height = cmTable.rows[row].height + 'px'; + } + } + return true; + } else { + return false; + } +} + +function onDragEnd( + context: DragAndDropContext, + event: MouseEvent, + initValue: DragAndDropInitValue | undefined +) { + if ( + isTableBottomVisible( + context.editor, + normalizeRect(context.table.getBoundingClientRect()), + context.contentDiv as Node + ) + ) { + context.div.style.visibility = 'visible'; + setDivPosition(context, context.div); + } + context.onEnd(); + return false; +} + +function setDivPosition(context: DragAndDropContext, trigger: HTMLElement) { + const { table, isRTL } = context; + const rect = normalizeRect(table.getBoundingClientRect()); + + if (rect) { + trigger.style.top = `${rect.bottom}px`; + trigger.style.left = isRTL + ? `${rect.left - TABLE_RESIZER_LENGTH - 2}px` + : `${rect.right}px`; + } +} + +function hideResizer(context: DragAndDropContext, trigger: HTMLElement) { + trigger.style.visibility = 'hidden'; +} + +function isTableBottomVisible( + editor: IEditor, + rect: Rect | null, + contentDiv?: Node | null +): boolean { + const visibleViewport = editor.getVisibleViewport(); + if (isNodeOfType(contentDiv, 'ELEMENT_NODE') && visibleViewport && rect) { + const containerRect = normalizeRect(contentDiv.getBoundingClientRect()); + + return ( + !!containerRect && + containerRect.bottom >= rect.bottom && + visibleViewport.bottom >= rect.bottom + ); + } + + return true; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/TestHelper.ts b/packages-content-model/roosterjs-content-model-plugins/test/TestHelper.ts new file mode 100644 index 00000000000..b9260f1468c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/TestHelper.ts @@ -0,0 +1,27 @@ +import { ContentModelDocument, CoreApiMap, EditorPlugin } from 'roosterjs-content-model-types'; +import { Editor } from 'roosterjs-content-model-core'; + +export function initEditor( + id: string, + plugins?: EditorPlugin[], + initialModel?: ContentModelDocument, + coreApiOverride?: Partial +) { + let node = document.createElement('div'); + node.id = id; + document.body.insertBefore(node, document.body.childNodes[0]); + + return new Editor(node, { + plugins, + initialModel, + coreApiOverride, + }); +} + +// Remove the element with id from the DOM +export function removeElement(id: string) { + let node = document.getElementById(id); + if (node && node.parentNode) { + node.parentNode.removeChild(node); + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 1e762489d3c..573b3411804 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1,9 +1,9 @@ import * as keyboardTrigger from '../../lib/autoFormat/keyboardListTrigger'; import { AutoFormatPlugin } from '../../lib/autoFormat/AutoFormatPlugin'; -import { IStandaloneEditor, KeyDownEvent } from 'roosterjs-content-model-types'; +import { IEditor, KeyDownEvent } from 'roosterjs-content-model-types'; describe('Content Model Auto Format Plugin Test', () => { - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { editor = ({ @@ -12,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 IStandaloneEditor; + } as any) as IEditor; }); describe('onPluginEvent', () => { 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 2f302ae4d9a..a9fe10ec934 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 @@ -95,11 +95,11 @@ describe('keyboardListTrigger', () => { { listType: 'OL', format: { - marginTop: '0px', - marginBottom: '0px', startNumberOverride: 1, direction: undefined, textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -148,10 +148,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', }, @@ -207,10 +204,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', }, @@ -248,8 +242,8 @@ describe('keyboardListTrigger', () => { startNumberOverride: 2, direction: undefined, textAlign: undefined, - marginBottom: '0px', - marginTop: '0px', + marginBottom: undefined, + marginTop: undefined, }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -373,11 +367,11 @@ describe('keyboardListTrigger', () => { { listType: 'UL', format: { - marginTop: '0px', - marginBottom: '0px', startNumberOverride: 1, direction: undefined, textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, }, dataset: { editingInfo: '{"unorderedStyleType":1}', @@ -477,10 +471,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3}', }, @@ -514,10 +505,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3}', }, @@ -579,8 +567,6 @@ describe('keyboardListTrigger', () => { listType: 'OL', format: { startNumberOverride: 1, - marginTop: '0px', - marginBottom: '0px', }, dataset: { editingInfo: '{"orderedStyleType":10}', @@ -615,10 +601,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":10}', }, @@ -659,10 +642,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3}', }, @@ -696,10 +676,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3}', }, @@ -735,10 +712,10 @@ describe('keyboardListTrigger', () => { listType: 'OL', format: { startNumberOverride: 3, - marginTop: '0px', - marginBottom: '0px', direction: undefined, textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -787,8 +764,6 @@ describe('keyboardListTrigger', () => { listType: 'OL', format: { startNumberOverride: 1, - marginTop: '0px', - marginBottom: '0px', }, dataset: { editingInfo: '{"orderedStyleType":10}', @@ -823,10 +798,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":10}', }, @@ -872,10 +844,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3}', }, @@ -909,10 +878,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3}', }, @@ -978,10 +944,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3}', }, @@ -1015,10 +978,7 @@ describe('keyboardListTrigger', () => { levels: [ { listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, + format: {}, dataset: { editingInfo: '{"orderedStyleType":3}', }, @@ -1064,10 +1024,10 @@ describe('keyboardListTrigger', () => { listType: 'OL', format: { startNumberOverride: 1, - marginTop: '0px', - marginBottom: '0px', direction: undefined, textAlign: undefined, + marginBottom: undefined, + marginTop: undefined, }, dataset: { editingInfo: '{"orderedStyleType":10}', 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 index 7c0c2c5ac93..0ccc3ad7e6a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -1,12 +1,12 @@ 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 { DOMEventRecord, IEditor } from 'roosterjs-content-model-types'; import { EditPlugin } from '../../lib/edit/EditPlugin'; describe('EditPlugin', () => { let plugin: EditPlugin; - let editor: IStandaloneEditor; + let editor: IEditor; let eventMap: Record; let attachDOMEventSpy: jasmine.Spy; let getEnvironmentSpy: jasmine.Spy; @@ -29,7 +29,7 @@ describe('EditPlugin', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code - } as any) as IStandaloneEditor; + } as any) as IEditor; }); afterEach(() => { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts index 9a8dcd7d7fd..eeadaf4b008 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts @@ -3232,4 +3232,275 @@ describe('deleteSelection - backward', () => { ], }); }); + + it('Outdent from empty paragraph', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.format.marginLeft = '40px'; + + para.segments.push(marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + marginLeft: '0px', + }, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Dont outdent from empty paragraph nested in list', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const list = createListItem([]); + + para.format.marginLeft = '40px'; + + para.segments.push(marker); + model.blocks.push(list); + list.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('nothingToDelete'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [list, model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + levels: [], + formatHolder: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: { + marginLeft: '40px', + }, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + ], + }); + }); + + it('Dont outdent empty para with no margins', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.format.marginLeft = '0px'; + + para.segments.push(marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('nothingToDelete'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + marginLeft: '0px', + }, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Dont outdent empty para with no margins and delete', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const para0 = createParagraph(); + const marker = createSelectionMarker(); + + para.format.marginLeft = '0px'; + para.segments.push(createBr()); + para.segments.push(marker); + model.blocks.push(para0, para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + { + blockType: 'Paragraph', + format: { marginLeft: '0px' }, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Outdent paragraph inside table nested in a list', () => { + const model = createContentModelDocument(); + const list = createListItem([]); + const table = createTable(1); + const cell = createTableCell(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + cell.blocks.push(para); + table.rows[0].cells.push(cell); + list.blocks.push(table); + para.format.marginLeft = '40px'; + para.segments.push(marker); + model.blocks.push(list); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [cell, list, model], + tableContext: { + table, + colIndex: 0, + rowIndex: 0, + isWholeTableSelected: false, + }, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + levels: [], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }); + }); }); 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 1ff6f52eb16..7aad9ce2154 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 @@ -2,12 +2,12 @@ import { ContentModelDocument, ContentModelFormatter, FormatContentModelOptions, - IStandaloneEditor, + IEditor, } from 'roosterjs-content-model-types'; export function editingTestCommon( apiName: string | undefined, - executionCallback: (editor: IStandaloneEditor) => void, + executionCallback: (editor: IEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number, @@ -35,7 +35,7 @@ export function editingTestCommon( isInIME: () => false, getEnvironment: () => ({}), formatContentModel, - } as any) as IStandaloneEditor; + } as any) as IEditor; executionCallback(editor); 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 5c7f56a94f0..b3c10b86672 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 { FormatContentModelContext, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { FormatContentModelContext, IEditor } from 'roosterjs-content-model-types'; import { handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, @@ -7,7 +7,7 @@ import { } from '../../lib/edit/handleKeyboardEventCommon'; describe('handleKeyboardEventResult', () => { - let mockedEditor: IStandaloneEditor; + let mockedEditor: IEditor; let mockedEvent: KeyboardEvent; let cacheContentModel: jasmine.Spy; let preventDefault: jasmine.Spy; @@ -27,7 +27,7 @@ describe('handleKeyboardEventResult', () => { triggerContentChangedEvent, triggerEvent, addUndoSnapshot, - } as any) as IStandaloneEditor; + } as any) as IEditor; mockedEvent = ({ preventDefault, } as any) as KeyboardEvent; 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 index f11408881aa..a5a8234a6a1 100644 --- 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 @@ -851,8 +851,9 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'fdsfsdf', + text: 'test', format: {}, + isSelected: true, }, ], format: {}, @@ -867,7 +868,7 @@ describe('handleEnterOnList', () => { listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -876,9 +877,7 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"1. "', - }, + format: {}, }, { blockType: 'BlockGroup', @@ -889,9 +888,8 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'fsdfsd', + text: 'test', format: {}, - isSelected: true, }, ], format: {}, @@ -906,7 +904,7 @@ describe('handleEnterOnList', () => { listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -915,9 +913,53 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"2. "', + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + 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: {}, }, { blockType: 'BlockGroup', @@ -928,7 +970,7 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'fsdf', + text: 'test', format: {}, }, ], @@ -942,9 +984,10 @@ describe('handleEnterOnList', () => { marginTop: '0px', marginBottom: '0px', listStyleType: 'decimal', + startNumberOverride: undefined, }, dataset: { - editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -953,14 +996,16 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"3. "', - }, + format: {}, }, ], format: {}, }; - const expectedModel: ContentModelDocument = { + runTest(model, expectedModel, 'range'); + }); + + it('enter on multiple list items with selected text', () => { + const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { @@ -972,7 +1017,7 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'fdsfsdf', + text: 'test', format: {}, }, ], @@ -988,7 +1033,7 @@ describe('handleEnterOnList', () => { listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -997,9 +1042,7 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"1. "', - }, + format: {}, }, { blockType: 'BlockGroup', @@ -1009,9 +1052,16 @@ describe('handleEnterOnList', () => { blockType: 'Paragraph', segments: [ { - segmentType: 'Br', + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, ], format: {}, }, @@ -1025,7 +1075,7 @@ describe('handleEnterOnList', () => { listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -1034,9 +1084,7 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"2. "', - }, + format: {}, }, { blockType: 'BlockGroup', @@ -1046,12 +1094,14 @@ describe('handleEnterOnList', () => { blockType: 'Paragraph', segments: [ { - segmentType: 'SelectionMarker', - isSelected: true, + segmentType: 'Text', + text: 'test', format: {}, + isSelected: true, }, { - segmentType: 'Br', + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, ], @@ -1065,10 +1115,9 @@ describe('handleEnterOnList', () => { marginTop: '0px', marginBottom: '0px', listStyleType: 'decimal', - startNumberOverride: undefined, }, dataset: { - editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -1088,7 +1137,7 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'fsdf', + text: 'test', format: {}, }, ], @@ -1104,7 +1153,7 @@ describe('handleEnterOnList', () => { listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -1113,18 +1162,12 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"3. "', - }, + format: {}, }, ], format: {}, }; - runTest(model, expectedModel, 'range'); - }); - - it('enter on multiple list items with selected text', () => { - const model: ContentModelDocument = { + const expectedModel: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { @@ -1149,9 +1192,10 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', + listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -1160,9 +1204,7 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"1) "', - }, + format: {}, }, { blockType: 'BlockGroup', @@ -1176,11 +1218,45 @@ describe('handleEnterOnList', () => { 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: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ { segmentType: 'Text', text: 'test', format: {}, - isSelected: true, }, ], format: {}, @@ -1192,9 +1268,11 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, }, dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -1203,10 +1281,18 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"2) "', - }, + format: {}, }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); + + it('expanded range mixed list with paragraph', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1216,14 +1302,14 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'test', + text: 'te', format: {}, - isSelected: true, }, { - segmentType: 'SelectionMarker', - isSelected: true, + segmentType: 'Text', + text: 'st', format: {}, + isSelected: true, }, ], format: {}, @@ -1235,9 +1321,10 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', + listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -1246,9 +1333,19 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"3) "', - }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, }, { blockType: 'BlockGroup', @@ -1259,7 +1356,13 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'test', + text: 'te', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'st', format: {}, }, ], @@ -1270,11 +1373,13 @@ describe('handleEnterOnList', () => { { listType: 'OL', format: { + startNumberOverride: 1, marginTop: '0px', marginBottom: '0px', + listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -1283,9 +1388,7 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"4) "', - }, + format: {}, }, ], format: {}, @@ -1302,7 +1405,7 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'test', + text: 'te', format: {}, }, ], @@ -1315,9 +1418,10 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', + listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":1}', }, }, ], @@ -1326,10 +1430,60 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"1) "', + 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('expanded range with mixed list with paragraph | different styles', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1338,9 +1492,16 @@ describe('handleEnterOnList', () => { blockType: 'Paragraph', segments: [ { - segmentType: 'Br', + segmentType: 'Text', + text: 'te', format: {}, }, + { + segmentType: 'Text', + text: 'st', + format: {}, + isSelected: true, + }, ], format: {}, }, @@ -1353,7 +1514,7 @@ describe('handleEnterOnList', () => { marginBottom: '0px', }, dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":3}', }, }, ], @@ -1363,9 +1524,21 @@ describe('handleEnterOnList', () => { format: {}, }, format: { - listStyleType: '"2) "', + listStyleType: '"1) "', }, }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1374,12 +1547,14 @@ describe('handleEnterOnList', () => { blockType: 'Paragraph', segments: [ { - segmentType: 'SelectionMarker', - isSelected: true, + segmentType: 'Text', + text: 'te', format: {}, + isSelected: true, }, { - segmentType: 'Br', + segmentType: 'Text', + text: 'st ', format: {}, }, ], @@ -1390,12 +1565,13 @@ describe('handleEnterOnList', () => { { listType: 'OL', format: { + startNumberOverride: 1, marginTop: '0px', marginBottom: '0px', - startNumberOverride: undefined, + listStyleType: 'lower-alpha', }, dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":5}', }, }, ], @@ -1406,6 +1582,12 @@ describe('handleEnterOnList', () => { }, format: {}, }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1415,7 +1597,7 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'test', + text: 'te', format: {}, }, ], @@ -1430,7 +1612,7 @@ describe('handleEnterOnList', () => { marginBottom: '0px', }, dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + editingInfo: '{"orderedStyleType":3}', }, }, ], @@ -1440,9 +1622,51 @@ describe('handleEnterOnList', () => { format: {}, }, format: { - listStyleType: '"4) "', + listStyleType: '"1) "', }, }, + { + 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', + startNumberOverride: undefined, + listStyleType: 'lower-alpha', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, ], format: {}, }; 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 be70773518d..6da4cb1964d 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 @@ -6,11 +6,7 @@ import { deleteList } from '../../lib/edit/deleteSteps/deleteList'; import { DeleteResult, DeleteSelectionStep } from 'roosterjs-content-model-types'; import { editingTestCommon } from './editingTestCommon'; import { keyboardDelete } from '../../lib/edit/keyboardDelete'; -import { - ContentModelDocument, - DOMSelection, - IStandaloneEditor, -} from 'roosterjs-content-model-types'; +import { ContentModelDocument, DOMSelection, IEditor } from 'roosterjs-content-model-types'; import { backwardDeleteWordSelection, forwardDeleteWordSelection, @@ -489,7 +485,7 @@ describe('keyboardDelete', () => { type: 'range', range: { collapsed: false }, }), - } as any) as IStandaloneEditor; + } as any) as IEditor; const event = { which: Delete, key: 'Delete', 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 7db310df950..ae2a67ce817 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 @@ -6,11 +6,11 @@ import { ContentModelDocument, ContentModelFormatter, FormatContentModelContext, - IStandaloneEditor, + IEditor, } from 'roosterjs-content-model-types'; describe('keyboardInput', () => { - let editor: IStandaloneEditor; + let editor: IEditor; let takeSnapshotSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; 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 index 150d9b5c673..bfc2cf68bd4 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -1,5 +1,6 @@ import * as setModelIndentation from '../../../roosterjs-content-model-api/lib/modelApi/block/setModelIndentation'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { editingTestCommon } from './editingTestCommon'; import { keyboardTab } from '../../lib/edit/keyboardTab'; describe('keyboardTab', () => { @@ -35,6 +36,9 @@ describe('keyboardTab', () => { getDOMSelection: () => { return { type: 'range', + range: { + collapsed: true, + }, }; }, }; @@ -56,7 +60,7 @@ describe('keyboardTab', () => { } } - it('tab on paragraph', () => { + it('tab on the end of paragraph', () => { const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -80,7 +84,34 @@ describe('keyboardTab', () => { format: {}, }; - runTest(model, undefined, false, false); + runTest(model, undefined, false, true); + }); + + it('tab on the start of paragraph', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + runTest(model, 'indent', false, true); }); it('tab on empty list', () => { @@ -382,7 +413,7 @@ describe('keyboardTab', () => { ], format: {}, }; - runTest(model, undefined, false, false); + runTest(model, undefined, false, true); }); it('tab on the start second item on the list', () => { @@ -634,7 +665,7 @@ describe('keyboardTab', () => { ], format: {}, }; - runTest(model, undefined, false, false); + runTest(model, undefined, false, true); }); it('shift tab on empty list item', () => { @@ -868,3 +899,965 @@ describe('keyboardTab', () => { runTest(model, undefined, true, false); }); }); + +describe('keyboardTab - handleTabOnParagraph -', () => { + function runTest( + input: ContentModelDocument, + key: string, + collapsed: boolean, + shiftKey: boolean, + expectedResult: ContentModelDocument, + calledTimes: number = 1 + ) { + const preventDefault = jasmine.createSpy('preventDefault'); + const mockedEvent = ({ + key, + shiftKey: shiftKey, + preventDefault, + } as any) as KeyboardEvent; + + let editor: any; + + editingTestCommon( + 'handleTabKey', + newEditor => { + editor = newEditor; + + editor.getDOMSelection = () => ({ + type: 'range', + range: { + collapsed: collapsed, + }, + }); + + keyboardTab(editor, mockedEvent); + }, + input, + expectedResult, + calledTimes + ); + } + + it('collapsed range | tab on the end of paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, false, expectedResult); + }); + + it('collapsed range | tab on the start of paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, false, expectedResult); + }); + + it('collapsed range | tab on the middle of paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, false, expectedResult); + }); + + it('collapsed range | shift tab on the end of paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, true, input, 0); + }); + + it('collapsed range | shift tab on the start of paragraph indented', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, true, expectedResult); + }); + + it('collapsed range | shift tab on the end of paragraph indented', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, true, expectedResult); + }); + + it('collapsed range | shift tab on the middle of paragraph indented', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, true, expectedResult); + }); + + it('expanded range | tab on paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '456', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', false, false, expectedResult); + }); + + it('expanded range | shift tab on paragraph', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '456', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, 'Tab', false, true, expectedResult); + }); + + it('expanded range | multiple paragraphs', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + + runTest(input, 'Tab', false, false, expectedResult); + }); + + it('expanded range | outdent paragraphs', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + }; + runTest(input, 'Tab', false, true, expectedResult); + }); + + it('collapsed range | middle list', () => { + const input: 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: { + startNumberOverride: 1, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + }; + const expectedResult: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '0px', + }, + }, + ], + format: {}, + }; + runTest(input, 'Tab', true, false, expectedResult); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts new file mode 100644 index 00000000000..6f457fb0ae6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnListTest.ts @@ -0,0 +1,304 @@ +import { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; +import { handleTabOnList } from '../../../lib/edit/tabUtils/handleTabOnList'; + +describe('handleTabOnList', () => { + function runTest( + model: ContentModelDocument, + listItem: ContentModelListItem, + rawEvent: KeyboardEvent, + expectedReturnValue: boolean + ) { + // Act + const result = handleTabOnList(model, listItem, rawEvent); + + // Assert + expect(result).toBe(expectedReturnValue); + } + + it('should return true when the cursor is at the start of the list item', () => { + // Arrange + 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: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }; + const rawEvent = { + shiftKey: false, + preventDefault: () => {}, + } as KeyboardEvent; + const expectedReturnValue = true; + + // Act + runTest(model, listItem, rawEvent, expectedReturnValue); + }); + + it('Outdent - should return true when the cursor is at the start of the list item', () => { + // Arrange + 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: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }; + const rawEvent = { + shiftKey: false, + preventDefault: () => {}, + } as KeyboardEvent; + const expectedReturnValue = true; + + // Act + runTest(model, listItem, rawEvent, expectedReturnValue); + }); + + it('should return true when the cursor is not at the start of the list item', () => { + // Arrange + 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: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }; + const rawEvent = { + shiftKey: false, + preventDefault: () => {}, + } as KeyboardEvent; + + // Act + runTest(model, listItem, rawEvent, true); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts new file mode 100644 index 00000000000..9f79f57b793 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnParagraphTest.ts @@ -0,0 +1,429 @@ +import { handleTabOnParagraph } from '../../../lib/edit/tabUtils/handleTabOnParagraph'; +import { + ContentModelDocument, + ContentModelParagraph, + RangeSelection, +} from 'roosterjs-content-model-types'; + +describe('handleTabOnParagraph', () => { + function runTest( + model: ContentModelDocument, + paragraph: ContentModelParagraph, + rawEvent: KeyboardEvent, + selection: RangeSelection, + expectedReturnValue: boolean + ) { + // Act + const result = handleTabOnParagraph(model, paragraph, rawEvent); + + // Assert + expect(result).toBe(expectedReturnValue); + } + + it('Indent - collapsed range should return true when cursor is at the end', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Outdent - collapsed range should return false when cursor is at the end', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, false); + }); + + it('Indent - collapsed range should return true when cursor is at the start', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Outdent - collapsed range should return true when cursor is at the start', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Indent - collapsed range should return true when cursor is at the middle', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Outdent - collapsed range should return true when cursor is at the middle', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, false); + }); + + it('Outdent - Intended - collapsed range should return true when cursor is at the end', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Outdent - Intended - collapsed range should return true when cursor is at the middle', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'Text', + text: '    ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: true, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('Indent - expanded range should return true', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '456', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const selection = { + type: 'range', + range: { + collapsed: false, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); + + it('outdent - expanded range should return true', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '123', + format: {}, + }, + { + segmentType: 'Text', + text: '456', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '789', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const paragraph = model.blocks[0] as ContentModelParagraph; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const selection = { + type: 'range', + range: { + collapsed: false, + }, + } as RangeSelection; + runTest(model, paragraph, rawEvent, selection, true); + }); +}); 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 5af29820fa0..f016f1a0e15 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 @@ -4,7 +4,7 @@ import * as getPasteSource from '../../lib/paste/pasteSourceValidations/getPaste import * as PowerPointFile from '../../lib/paste/PowerPoint/processPastedContentFromPowerPoint'; 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 { BeforePasteEvent, IEditor } from 'roosterjs-content-model-types'; import { PastePlugin } from '../../lib/paste/PastePlugin'; import { PastePropertyNames } from '../../lib/paste/pasteSourceValidations/constants'; @@ -12,12 +12,12 @@ const trustedHTMLHandler = (val: string) => val; const DEFAULT_TIMES_ADD_PARSER_CALLED = 4; describe('Content Model Paste Plugin Test', () => { - let editor: IStandaloneEditor; + let editor: IEditor; beforeEach(() => { editor = ({ getTrustedHTMLHandler: () => trustedHTMLHandler, - } as any) as IStandaloneEditor; + } as any) as IEditor; spyOn(addParser, 'default').and.callThrough(); spyOn(setProcessor, 'setProcessor').and.callThrough(); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts index 9bb91be927e..1e955d6f64a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -1,6 +1,6 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { expectEqual, initEditor } from './testUtils'; -import type { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ClipboardData, IEditor } from 'roosterjs-content-model-types'; const ID = 'CM_Paste_From_ExcelOnline_E2E'; const clipboardData = ({ @@ -18,7 +18,7 @@ const clipboardData = ({ }); describe(ID, () => { - let editor: IStandaloneEditor = undefined!; + let editor: IEditor = undefined!; beforeEach(() => { editor = initEditor(ID); @@ -88,6 +88,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -131,6 +136,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -172,6 +182,11 @@ describe(ID, () => { format: { textAlign: 'center', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -218,6 +233,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -268,6 +288,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(5, 99, 193)', + }, }, ], format: { @@ -309,6 +334,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(219, 219, 219)', + }, }, ], format: { @@ -356,6 +386,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -406,6 +441,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(5, 99, 193)', + }, }, ], format: { @@ -447,6 +487,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(219, 219, 219)', + }, }, ], format: { @@ -494,6 +539,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -544,6 +594,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(5, 99, 193)', + }, }, ], format: { @@ -585,6 +640,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(219, 219, 219)', + }, }, ], format: { @@ -632,6 +692,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -682,6 +747,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(5, 99, 193)', + }, }, ], format: { @@ -723,6 +793,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(219, 219, 219)', + }, }, ], format: { @@ -770,6 +845,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -820,6 +900,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(5, 99, 193)', + }, }, ], format: { @@ -861,6 +946,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(219, 219, 219)', + }, }, ], format: { @@ -908,6 +998,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -958,6 +1053,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(5, 99, 193)', + }, }, ], format: { @@ -999,6 +1099,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(219, 219, 219)', + }, }, ], format: { @@ -1046,6 +1151,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'black', + }, }, ], format: { @@ -1096,6 +1206,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(5, 99, 193)', + }, }, ], format: { @@ -1137,6 +1252,11 @@ describe(ID, () => { textAlign: 'center', whiteSpace: 'nowrap', }, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11pt', + textColor: 'rgb(219, 219, 219)', + }, }, ], format: { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index 31c4ca31710..75592382d73 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -1,7 +1,7 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { expectEqual, initEditor } from './testUtils'; import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; -import type { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ClipboardData, IEditor } from 'roosterjs-content-model-types'; const ID = 'CM_Paste_From_Excel_E2E'; const clipboardData = ({ @@ -19,7 +19,7 @@ const clipboardData = ({ }); describe(ID, () => { - let editor: IStandaloneEditor = undefined!; + let editor: IEditor = undefined!; beforeEach(() => { editor = initEditor(ID); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts index 19986e9eb1a..3836607d3b5 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts @@ -1,11 +1,11 @@ import * as processPastedContentWacComponents from '../../../lib/paste/WacComponents/processPastedContentWacComponents'; -import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IEditor } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; const ID = 'CM_Paste_From_WORD_Online_E2E'; describe(ID, () => { - let editor: IStandaloneEditor = undefined!; + let editor: IEditor = undefined!; let clipboardData: ClipboardData; beforeEach(() => { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index 00865771548..35c259ccc68 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -1,5 +1,5 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IEditor } from 'roosterjs-content-model-types'; import { cloneModel } from 'roosterjs-content-model-core'; import { expectEqual, initEditor } from './testUtils'; import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; @@ -18,7 +18,7 @@ const clipboardData = ({ }); describe(ID, () => { - let editor: IStandaloneEditor = undefined!; + let editor: IEditor = undefined!; beforeEach(() => { editor = initEditor(ID); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts index 6fb885a9889..d0e38ab9dda 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts @@ -1,12 +1,12 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IEditor } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; const ID = 'CM_Paste_E2E'; describe(ID, () => { - let editor: IStandaloneEditor = undefined!; + let editor: IEditor = undefined!; beforeEach(() => { editor = initEditor(ID); 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 7cdc51cda32..f9663cf89e1 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,17 +1,13 @@ -import { cloneModel, StandaloneEditor } from 'roosterjs-content-model-core'; +import { cloneModel, Editor } from 'roosterjs-content-model-core'; +import { ContentModelDocument, EditorOptions, IEditor } from 'roosterjs-content-model-types'; import { PastePlugin } from '../../../lib/paste/PastePlugin'; -import { - ContentModelDocument, - IStandaloneEditor, - StandaloneEditorOptions, -} from 'roosterjs-content-model-types'; -export function initEditor(id: string): IStandaloneEditor { +export function initEditor(id: string): IEditor { let node = document.createElement('div'); node.id = id; document.body.insertBefore(node, document.body.childNodes[0]); - let options: StandaloneEditorOptions = { + let options: EditorOptions = { plugins: [new PastePlugin()], coreApiOverride: { getVisibleViewport: () => { @@ -25,7 +21,7 @@ export function initEditor(id: string): IStandaloneEditor { }, }; - let editor = new StandaloneEditor(node as HTMLDivElement, options); + let editor = new Editor(node as HTMLDivElement, options); return editor; } diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index e818e12b1ae..2fa1514ea94 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -350,6 +350,7 @@ describe('processPastedContentFromWordDesktopTest', () => { ], format: {}, isImplicit: true, + segmentFormat: { fontSize: '2em' }, }, ], levels: [ @@ -441,6 +442,7 @@ describe('processPastedContentFromWordDesktopTest', () => { ], format: {}, isImplicit: true, + segmentFormat: { fontSize: '2em' }, }, ], levels: [ @@ -541,6 +543,7 @@ describe('processPastedContentFromWordDesktopTest', () => { ], format: {}, isImplicit: true, + segmentFormat: { fontSize: '2em' }, }, ], levels: [ @@ -648,6 +651,7 @@ describe('processPastedContentFromWordDesktopTest', () => { ], format: {}, isImplicit: true, + segmentFormat: { fontSize: '2em' }, }, ], levels: [ @@ -1302,6 +1306,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], format: {}, isImplicit: true, + segmentFormat: { + fontFamily: 'Calibri, sans-serif', + fontSize: '12pt', + }, }, ], levels: [ @@ -1372,6 +1380,7 @@ describe('processPastedContentFromWordDesktopTest', () => { ], format: {}, isImplicit: true, + segmentFormat: { fontFamily: 'Arial, sans-serif' }, }, ], levels: [ @@ -1407,6 +1416,7 @@ describe('processPastedContentFromWordDesktopTest', () => { ], format: {}, isImplicit: true, + segmentFormat: { fontFamily: 'Arial, sans-serif' }, }, ], levels: [ @@ -1512,6 +1522,9 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + textColor: 'rgb(70, 120, 134)', + }, }, ], }, @@ -1554,6 +1567,9 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + textColor: 'rgb(70, 120, 134)', + }, }, ], }, @@ -1596,6 +1612,9 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + textColor: 'rgb(70, 120, 134)', + }, }, ], }, @@ -1660,6 +1679,9 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + textColor: 'rgb(70, 120, 134)', + }, }, ], }, @@ -1702,6 +1724,9 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + textColor: 'rgb(70, 120, 134)', + }, }, ], }, @@ -2516,6 +2541,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -2579,6 +2608,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -2654,6 +2687,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -2741,6 +2778,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -2840,6 +2881,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -2951,6 +2996,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3074,6 +3123,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3209,6 +3262,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3356,6 +3413,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3490,6 +3551,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3612,6 +3677,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3722,6 +3791,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3820,6 +3893,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3906,6 +3983,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -3980,6 +4061,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -4042,6 +4127,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, @@ -4092,6 +4181,10 @@ describe('processPastedContentFromWordDesktopTest', () => { ], blockType: 'Paragraph', format: {}, + segmentFormat: { + fontFamily: 'Aptos, sans-serif', + fontSize: '12pt', + }, }, ], }, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts new file mode 100644 index 00000000000..0c4a69a47d0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts @@ -0,0 +1,589 @@ +import * as changeFontSize from 'roosterjs-content-model-api/lib/publicApi/segment/changeFontSize'; +import * as clearFormat from 'roosterjs-content-model-api/lib/publicApi/format/clearFormat'; +import * as redo from 'roosterjs-content-model-core/lib/publicApi/undo/redo'; +import * as toggleBold from 'roosterjs-content-model-api/lib/publicApi/segment/toggleBold'; +import * as toggleBullet from 'roosterjs-content-model-api/lib/publicApi/list/toggleBullet'; +import * as toggleItalic from 'roosterjs-content-model-api/lib/publicApi/segment/toggleItalic'; +import * as toggleNumbering from 'roosterjs-content-model-api/lib/publicApi/list/toggleNumbering'; +import * as toggleUnderline from 'roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline'; +import * as undo from 'roosterjs-content-model-core/lib/publicApi/undo/undo'; +import { EditorEnvironment, IEditor, PluginEvent } from 'roosterjs-content-model-types'; +import { ShortcutPlugin } from '../../lib/shortcut/ShortcutPlugin'; + +const enum Keys { + BACKSPACE = 8, + SPACE = 32, + A = 65, + B = 66, + I = 73, + U = 85, + Y = 89, + Z = 90, + COMMA = 188, + PERIOD = 190, + FORWARD_SLASH = 191, +} + +describe('ShortcutPlugin', () => { + let preventDefaultSpy: jasmine.Spy; + let mockedEditor: IEditor; + let mockedEnvironment: EditorEnvironment; + + beforeEach(() => { + preventDefaultSpy = jasmine.createSpy('preventDefault'); + mockedEnvironment = {}; + mockedEditor = { + getEnvironment: () => mockedEnvironment, + } as any; + }); + + function createMockedEvent( + which: number, + ctrlKey: boolean, + altKey: boolean, + shiftKey: boolean, + metaKey: boolean + ): KeyboardEvent { + return { + which, + ctrlKey, + shiftKey, + altKey, + metaKey, + preventDefault: preventDefaultSpy, + } as any; + } + + describe('Windows', () => { + it('not a shortcut', () => { + const apiSpy = spyOn(toggleBold, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.A, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeFalse(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeUndefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).not.toHaveBeenCalled(); + }); + + it('bold', () => { + const apiSpy = spyOn(toggleBold, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.B, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('italic', () => { + const apiSpy = spyOn(toggleItalic, 'default'); + const plugin = new ShortcutPlugin(); + + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.I, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('underline', () => { + const apiSpy = spyOn(toggleUnderline, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.U, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('clear format', () => { + const apiSpy = spyOn(clearFormat, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.SPACE, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('undo 1', () => { + const apiSpy = spyOn(undo, 'undo'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.Z, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('undo 2', () => { + const apiSpy = spyOn(undo, 'undo'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.BACKSPACE, false, true, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('redo 1', () => { + const apiSpy = spyOn(redo, 'redo'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.Y, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('redo 2', () => { + const apiSpy = spyOn(redo, 'redo'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.Z, true, false, true, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeFalse(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeUndefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).not.toHaveBeenCalled(); + }); + + it('bullet list', () => { + const apiSpy = spyOn(toggleBullet, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.PERIOD, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('numbering list', () => { + const apiSpy = spyOn(toggleNumbering, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.FORWARD_SLASH, true, false, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('increase font', () => { + const apiSpy = spyOn(changeFontSize, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.PERIOD, true, false, true, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'increase'); + }); + + it('decrease font', () => { + const apiSpy = spyOn(changeFontSize, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.COMMA, true, false, true, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'decrease'); + }); + }); + + describe('Mac', () => { + beforeEach(() => { + mockedEnvironment.isMac = true; + }); + + it('not a shortcut', () => { + const apiSpy = spyOn(toggleBold, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.A, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeFalse(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeUndefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).not.toHaveBeenCalled(); + }); + + it('bold', () => { + const apiSpy = spyOn(toggleBold, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.B, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('italic', () => { + const apiSpy = spyOn(toggleItalic, 'default'); + const plugin = new ShortcutPlugin(); + + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.I, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('underline', () => { + const apiSpy = spyOn(toggleUnderline, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.U, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('clear format', () => { + const apiSpy = spyOn(clearFormat, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.SPACE, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('undo 1', () => { + const apiSpy = spyOn(undo, 'undo'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.Z, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('undo 2', () => { + const apiSpy = spyOn(undo, 'undo'); + + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.BACKSPACE, false, true, false, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeFalse(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeUndefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).not.toHaveBeenCalled(); + }); + + it('redo 1', () => { + const apiSpy = spyOn(redo, 'redo'); + + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.Y, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeFalse(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeUndefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).not.toHaveBeenCalled(); + }); + + it('redo 2', () => { + const apiSpy = spyOn(redo, 'redo'); + + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.Z, false, false, true, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('bullet list', () => { + const apiSpy = spyOn(toggleBullet, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.PERIOD, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('numbering list', () => { + const apiSpy = spyOn(toggleNumbering, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.FORWARD_SLASH, false, false, false, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); + }); + + it('increase font', () => { + const apiSpy = spyOn(changeFontSize, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.PERIOD, false, false, true, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'increase'); + }); + + it('decrease font', () => { + const apiSpy = spyOn(changeFontSize, 'default'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.COMMA, false, false, true, true), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'decrease'); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/TableEditTestHelper.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/TableEditTestHelper.ts new file mode 100644 index 00000000000..4450662d622 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/TableEditTestHelper.ts @@ -0,0 +1,253 @@ +import * as TestHelper from '../TestHelper'; +import { DOMEventHandlerFunction } from 'roosterjs-editor-types'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { normalizeTable } from 'roosterjs-content-model-core'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; +import { + ContentModelTable, + DOMEventRecord, + EditorCore, + IEditor, +} from 'roosterjs-content-model-types'; + +/** + * Function to be called before each Table Edit test + * @param TEST_ID The id of the editor div + * @returns The editor, plugin, and handler to be used in the test + */ +export function beforeTableTest(TEST_ID: string) { + const plugin = new TableEditPlugin(); + + let handler: Record = {}; + const attachDomEvent = jasmine + .createSpy('attachDomEvent') + .and.callFake((core: EditorCore, eventMap: Record>) => { + getObjectKeys(eventMap || {}).forEach(key => { + const eventname = key as keyof HTMLElementEventMap; + const { beforeDispatch } = eventMap[key]; + const onEvent = (event: HTMLElementEventMap[typeof eventname]) => { + beforeDispatch && beforeDispatch(event); + }; + handler[eventname] = onEvent; + }); + return () => { + handler = {}; + }; + }); + + const coreApiOverride = { + attachDomEvent, + }; + const editor = TestHelper.initEditor(TEST_ID, [plugin], undefined, coreApiOverride); + + plugin.initialize(editor); + + return { editor, plugin, handler }; +} + +/** + * Function to be called after each Table Edit test + * @param editor The editor to be disposed + * @param plugin The plugin to be disposed + * @param TEST_ID The id of the editor div + */ +export function afterTableTest(editor: IEditor, plugin: TableEditPlugin, TEST_ID: string) { + editor.dispose(); + plugin.dispose(); + TestHelper.removeElement(TEST_ID); + document.body = document.createElement('body'); +} + +/** + * Function to get the current table in the editor + * @param editor The editor to get the table from + * @returns The current table in the editor + */ +export function getCurrentTable(editor: IEditor): HTMLTableElement { + const table = editor.getDOMHelper().queryElements('table')[0] as HTMLTableElement; + return table; +} + +/** + * Function to get the number of rows in the table + * @param table The table to get the number of rows from + * @returns The number of rows in the table + */ +export function getTableRows(table: HTMLTableElement): number { + return table.rows.length; +} + +/** + * Function to get the number of columns in the table + * @param table The table to get the number of columns from + * @returns The number of columns in the table + */ +export function getTableColumns(table: HTMLTableElement): number { + return table.rows[0].cells.length; +} + +/** + * Function to get the rect of a cell in the table + * @param editor The editor to get the table from + * @param i The row index of the cell + * @param j The column index of the cell + * @returns The rect of the cell + */ +export function getCellRect(editor: IEditor, i: number, j: number): DOMRect | undefined { + const tables = editor.getDOMHelper().queryElements('table'); + if (!tables || tables.length < 1) { + return undefined; + } + + const table = tables[0]; + if (i >= table.rows.length || j >= table.rows[i].cells.length) { + return undefined; + } + + const cell = table.rows[i].cells[j]; + return cell.getBoundingClientRect(); +} + +/** + * Insert the content model table on the edito + * @param editor The editor to insert the table into + * @param table The table to insert + * @param isRtl Whether the table is RTL + * @returns The rect of the table + */ +export function initialize( + editor: IEditor, + table: ContentModelTable, + isRtl: boolean = false +): DOMRect { + if (isRtl) { + editor.getDocument().body.style.direction = 'rtl'; + } + editor.formatContentModel((model, context) => { + normalizeTable(table); + model.blocks = [table]; + return true; + }); + const DOMTable = editor.getDOMHelper().queryElements('table')[0]; + return DOMTable.getBoundingClientRect(); +} + +/* Used to specify mouse coordinates */ +export type Position = { + x: number; + y: number; +}; +/* Used to specify the direction of the resize */ +export type resizeDirection = 'horizontal' | 'vertical' | 'both'; + +/* IDs for the resizers */ +const VERTICAL_RESIZER_ID = 'verticalResizer'; +const HORIZONTAL_RESIZER_ID = 'horizontalResizer'; +const TABLE_RESIZER_ID = '_Table_Resizer'; + +/** + * Function to move and resize the table + * @param mouseStart The starting position of the mouse + * @param mouseEnd The ending position of the mouse + * @param resizeState The direction of the resize + * @param editor The editor to resize the table in + * @param handler The handler to handle the mouse events + * @param TEST_ID The id of the editor div + */ +export function moveAndResize( + mouseStart: Position, + mouseEnd: Position, + resizeState: resizeDirection, + editor: IEditor, + handler: Record, + TEST_ID: string +) { + const editorDiv = editor.getDocument().getElementById(TEST_ID); + let resizerId: string; + switch (resizeState) { + case 'both': + resizerId = TABLE_RESIZER_ID; + break; + case 'horizontal': + resizerId = HORIZONTAL_RESIZER_ID; + break; + case 'vertical': + resizerId = VERTICAL_RESIZER_ID; + break; + default: + resizerId = ''; + } + + // Move mouse to show resizer + const mouseMoveEvent = new MouseEvent('mousemove', { + clientX: mouseStart.x, + clientY: mouseStart.y, + }); + handler.mousemove(mouseMoveEvent); + + let resizer = editor.getDocument().getElementById(resizerId); + if (!!resizer && editorDiv) { + const tableBeforeClick = getTableRectSet(getCurrentTable(editor)); + // Click on the resizer to start resizing + const mouseClickEvent = new MouseEvent('mousedown', { + clientX: mouseStart.x, + clientY: mouseStart.y, + }); + resizer.dispatchEvent(mouseClickEvent); + const tableAfterClick = getTableRectSet(getCurrentTable(editor)); + + // Validate the table doesn't shift after clicking on the resizer + runTableShapeTest(tableBeforeClick, tableAfterClick); + + // Move mouse and resize + const mouseMoveResize = new MouseEvent('mousemove', { + clientX: mouseEnd.x, + clientY: mouseEnd.y, + }); + + editorDiv.dispatchEvent(mouseMoveResize); + handler.mousemove(mouseMoveResize); + + // Release mouse and stop resizing + const mouseMoveEndEvent = new MouseEvent('mouseup'); + editorDiv.dispatchEvent(mouseMoveEndEvent); + } +} + +/** + * Function to ckeck if the table rects are the same + * @param tableRectSet1 The first set of table rects + * @param tableRectSet2 The second set of table rects + */ +function runTableShapeTest(tableRectSet1: DOMRect[], tableRectSet2: DOMRect[]) { + expect(tableRectSet1.length).toBe(tableRectSet2.length); + const isSameRect = (rect1: DOMRect, rect2: DOMRect): boolean => { + return ( + rect1.left == rect2.left && + rect1.right == rect2.right && + rect1.top == rect2.top && + rect1.bottom == rect2.bottom + ); + }; + tableRectSet1.forEach((rect, i) => { + expect(isSameRect(rect, tableRectSet2[i])).toBe(true); + }); +} + +/** + * Get all rects from a table + * @param table The table to get the rects from + * @returns The set of rects for the table, first the whole table rect and then the cell rects + */ +export function getTableRectSet(table: HTMLTableElement): DOMRect[] { + const rectSet: DOMRect[] = []; + if (!!table) { + rectSet.push(table.getBoundingClientRect()); + } + for (let i = 0; i < table.rows.length; i++) { + for (let j = 0; j < table.rows[i].cells.length; j++) { + rectSet.push(table.rows[i].cells[j].getBoundingClientRect()); + } + } + return rectSet; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/cellResizerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/cellResizerTest.ts new file mode 100644 index 00000000000..d1a47fbc6b5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/cellResizerTest.ts @@ -0,0 +1,157 @@ +import { ContentModelTable, DOMEventHandlerFunction, IEditor } from 'roosterjs-content-model-types'; +import { getModelTable } from './tableData'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; +import { + afterTableTest, + beforeTableTest, + getCellRect, + getCurrentTable, + initialize, + moveAndResize, +} from './TableEditTestHelper'; + +describe('Cell Resizer tests', () => { + let editor: IEditor; + let plugin: TableEditPlugin; + const TEST_ID = 'cellResizerTest'; + let handler: Record; + + beforeEach(() => { + const setup = beforeTableTest(TEST_ID); + editor = setup.editor; + plugin = setup.plugin; + handler = setup.handler; + }); + + afterEach(() => { + afterTableTest(editor, plugin, TEST_ID); + }); + + /************************ Resizing row related tests ************************/ + + function resizeRowTest( + table: ContentModelTable, + growth: number, + cellRow: number, + cellColumn: number + ) { + initialize(editor, table); + const delta = 50 * growth; + const cellRect = getCellRect(editor, cellRow, cellColumn); + const targetPos: number = cellRect.bottom + delta; + + const beforeHeight = getCurrentTable(editor).rows[cellRow].getBoundingClientRect().height; + moveAndResize( + { x: cellRect.left + cellRect.width / 2, y: cellRect.bottom }, + { x: cellRect.left + cellRect.width / 2, y: targetPos }, + 'horizontal', + editor, + handler, + TEST_ID + ); + const afterHeight = getCurrentTable(editor).rows[cellRow].getBoundingClientRect().height; + + growth > 0 + ? expect(afterHeight).toBeGreaterThan(beforeHeight) + : expect(afterHeight).toBeLessThan(beforeHeight); + } + + it('increases the height of the first row', () => { + resizeRowTest(getModelTable(), 1, 0, 0); + }); + + it('increases the height of the last row', () => { + const MODEL_TABLE = getModelTable(); + resizeRowTest(MODEL_TABLE, 1, MODEL_TABLE.rows.length - 1, MODEL_TABLE.widths.length - 1); + }); + + it('decreases the height of the first row', () => { + resizeRowTest(getModelTable(), -1, 0, 0); + }); + + it('decreases the height of the last row', () => { + const MODEL_TABLE = getModelTable(); + resizeRowTest(MODEL_TABLE, -1, MODEL_TABLE.rows.length - 1, MODEL_TABLE.widths.length - 1); + }); + + /************************ Resizing column related tests ************************/ + + function resizeColumnTest( + table: ContentModelTable, + direction: number, + cellRow: number, + cellColumn: number + ) { + initialize(editor, table); + const delta = 20 * direction; + const cellRect = getCellRect(editor, cellRow, cellColumn); + const targetPos: number = cellRect.right + delta; + + const beforeWidth = getCurrentTable(editor).rows[cellRow].cells[ + cellColumn + ].getBoundingClientRect().width; + const beforeNextWidth = + cellColumn < table.widths.length - 1 + ? getCurrentTable(editor).rows[cellRow].cells[ + cellColumn + 1 + ].getBoundingClientRect().width + : undefined; + + moveAndResize( + { x: cellRect.right, y: cellRect.top + cellRect.height / 2 }, + { x: targetPos, y: cellRect.top + cellRect.height / 2 }, + 'vertical', + editor, + handler, + TEST_ID + ); + + const afterWidth = getCurrentTable(editor).rows[cellRow].cells[ + cellColumn + ].getBoundingClientRect().width; + const afterNextWidth = + cellColumn < table.widths.length - 1 + ? getCurrentTable(editor).rows[cellRow].cells[ + cellColumn + 1 + ].getBoundingClientRect().width + : undefined; + + direction > 0 + ? expect(afterWidth).toBeGreaterThan(beforeWidth) + : expect(afterWidth).toBeLessThan(beforeWidth); + + if (beforeNextWidth && afterNextWidth) { + direction > 0 + ? expect(afterNextWidth).toBeLessThan(beforeNextWidth) + : expect(afterNextWidth).toBeGreaterThan(beforeNextWidth); + } + } + + it('increases the width of the first column', () => { + resizeColumnTest(getModelTable(), 1, 0, 0); + }); + + it('increases the width of the last column', () => { + const MODEL_TABLE = getModelTable(); + resizeColumnTest( + MODEL_TABLE, + 1, + MODEL_TABLE.rows.length - 1, + MODEL_TABLE.widths.length - 1 + ); + }); + + it('decreases the width of the first column', () => { + resizeColumnTest(getModelTable(), -1, 0, 0); + }); + + it('decreases the width of the last column', () => { + const MODEL_TABLE = getModelTable(); + resizeColumnTest( + MODEL_TABLE, + -1, + MODEL_TABLE.rows.length - 1, + MODEL_TABLE.widths.length - 1 + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableData.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableData.ts new file mode 100644 index 00000000000..802f58c1713 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableData.ts @@ -0,0 +1,862 @@ +import { ContentModelTable } from 'roosterjs-content-model-types'; + +export const WORD_TABLE = + '
dsfsdf2342323423
Sdf323234234234234
23232343242erfwfwf
'; +export const EXCEL_TABLE = + '
111222333
222333444
777666555
'; +export const DEFAULT_TABLE = + '









'; +export const DEFAULT_TABLE_MERGED = + '
















'; + +/** + * Regular 3 x 3 Table + */ +export function getModelTable(): ContentModelTable { + /* + * —————————————— + * | a1 | b1 | c1 | + * —————————————— + * | a2 | b2 | c2 | + * —————————————— + * | a3 | b3 | c3 | + * —————————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50, 50], + dataset: {}, + }; +} + +/** + * 3 x 3 Table with merged central column + */ +export function getMergedCenterColumnTable(): ContentModelTable { + /* + * —————————————— + * | a1 | | c1 | + * ———— ———— + * | a2 | b1 | c2 | + * ———— ———— + * | a3 | | c3 | + * —————————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: true, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: true, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50, 50], + dataset: {}, + }; +} + +/** + * 3 x 3 Table with merged central row + */ +export function getMergedCenterRowTable(): ContentModelTable { + /* + * —————————————— + * | a1 | b1 | c1 | + * —————————————— + * | a2 | + * —————————————— + * | a3 | b3 | c3 | + * —————————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50, 50], + dataset: {}, + }; +} + +/** + * 2 x 2 Table with merged top row + */ +export function getMergedTopRowTable(): ContentModelTable { + /* + * ————————— + * | a1 | + * ————————— + * | a2 | b2 | + * ————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50], + dataset: {}, + }; +} + +/** + * 2 x 2 Table with merged first column + */ +export function getMergedFirstColumnTable(): ContentModelTable { + /* + * ————————— + * | a1 | b1 | + * ———— + * | | b2 | + * ————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: true, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50], + dataset: {}, + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts new file mode 100644 index 00000000000..664ea407aed --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts @@ -0,0 +1,229 @@ +import * as TestHelper from '../TestHelper'; +import createElement from '../../lib/pluginUtils/CreateElement/createElement'; +import { getModelTable } from './tableData'; +import { IEditor } from 'roosterjs-content-model-types'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; + +describe('TableEditPlugin', () => { + let editor: IEditor; + let plugin: TableEditPlugin; + const TEST_ID = 'inserterTest'; + + let mouseOutListener: undefined | ((this: HTMLElement, ev: MouseEvent) => any); + + beforeEach(() => { + editor = TestHelper.initEditor(TEST_ID); + plugin = new TableEditPlugin(); + + spyOn(editor, 'getScrollContainer').and.returnValue(({ + addEventListener: ( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ) => { + if (type == 'mouseout') { + mouseOutListener = listener as (this: HTMLElement, ev: MouseEvent) => any; + } + }, + removeEventListener: ( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventListenerOptions + ) => { + if (type == 'mouseout') { + mouseOutListener = undefined; + } + }, + })); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + editor.dispose(); + TestHelper.removeElement(TEST_ID); + document.body = document.createElement('body'); + }); + + it('setTableEditor - Dismiss table editor on mouse out', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + const table = editor.getDOMHelper().queryElements('table')[0]; + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).toHaveBeenCalledWith(null); + } + }); + + it('setTableEditor - Do not dismiss table editor on mouse out, related target is contained in scroll container', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + const table = editor.getDOMHelper().queryElements('table')[0]; + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(true); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('setTableEditor - Do not dismiss table editor on mouse out, table editor not', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('setTableEditor - Do not dismiss table editor on mouse out, related target null', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + const table = editor.getDOMHelper().queryElements('table')[0]; + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: null, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('setTableEditor - Do not dismiss table editor on mouse out, currentTarget null', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + const table = editor.getDOMHelper().queryElements('table')[0]; + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(false); + boundedListener(({ + currentTarget: null, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('returns the actual plugin name', () => { + const expectedName = 'TableEdit'; + const pluginName = plugin.getName(); + expect(pluginName).toBe(expectedName); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts new file mode 100644 index 00000000000..17901bace20 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts @@ -0,0 +1,115 @@ +import { DOMEventHandlerFunction, IEditor } from 'roosterjs-content-model-types'; +import { getMergedFirstColumnTable, getMergedTopRowTable, getModelTable } from './tableData'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; +import { + Position, + afterTableTest, + beforeTableTest, + getCurrentTable, + getTableColumns, + getTableRows, + initialize, +} from './TableEditTestHelper'; + +const VERTICAL_INSERTER_ID = 'verticalInserter'; +const HORIZONTAL_INSERTER_ID = 'horizontalInserter'; + +describe('Table Inserter tests', () => { + let editor: IEditor; + let plugin: TableEditPlugin; + const insideTheOffset = 5; + const TEST_ID = 'inserterTest'; + let handler: Record; + + beforeEach(() => { + const setup = beforeTableTest(TEST_ID); + editor = setup.editor; + plugin = setup.plugin; + handler = setup.handler; + }); + + afterEach(() => { + afterTableTest(editor, plugin, TEST_ID); + }); + + function isClickInsideInserter(click: Position, rect: DOMRect) { + return ( + click.x >= rect.left && + click.x <= rect.right && + click.y >= rect.top && + click.y <= rect.bottom + ); + } + + function runInserterTest(inserterType: string, mouseEnd: Position) { + handler.mousemove( + new MouseEvent('mousemove', { + clientX: mouseEnd.x, + clientY: mouseEnd.y, + }) + ); + + const inserter = editor.getDocument().getElementById(inserterType); + if (!!inserter) { + const inserterRect = inserter.getBoundingClientRect(); + if (!isClickInsideInserter(mouseEnd, inserterRect)) { + // Inserter is visible, but pointer is not over it + return 'not clickable'; + } + const table = getCurrentTable(editor); + const rows = getTableRows(table); + const cols = getTableColumns(table); + inserter.dispatchEvent(new MouseEvent('click')); + const newRows = getTableRows(table); + const newCols = getTableColumns(table); + expect(newRows).toBe(inserterType == VERTICAL_INSERTER_ID ? rows : rows + 1); + expect(newCols).toBe(inserterType == HORIZONTAL_INSERTER_ID ? cols : cols + 1); + } + return !!inserter ? 'found' : 'not found'; + } + + it('adds a new column if the vertical inserter is detected and clicked', () => { + const rect = initialize(editor, getModelTable()); + const inserterFound = runInserterTest(VERTICAL_INSERTER_ID, { + x: rect.right, + y: rect.top - insideTheOffset, + }); + expect(inserterFound).toBe('found'); + }); + + it('adds a new row if the horizontal inserter is detected and clicked', () => { + const rect = initialize(editor, getModelTable()); + const inserterFound = runInserterTest(HORIZONTAL_INSERTER_ID, { + x: rect.left - insideTheOffset, + y: rect.bottom, + }); + expect(inserterFound).toBe('found'); + }); + + it('does not add inserter if top left corner hovered', () => { + const rect = initialize(editor, getModelTable()); + const inserterFound = runInserterTest(VERTICAL_INSERTER_ID, { + x: rect.left - insideTheOffset, + y: rect.top - insideTheOffset, + }); + expect(inserterFound).toBe('not found'); + }); + + it('does not add new column if top middle clicked on merged top row', () => { + const rect = initialize(editor, getMergedTopRowTable()); + const inserterFound = runInserterTest(VERTICAL_INSERTER_ID, { + x: (rect.right - rect.left) / 2 + 10, + y: rect.top - insideTheOffset, + }); + expect(inserterFound).toBe('not clickable'); + }); + + it('does not add new row if left middle clicked on merged first column', () => { + const rect = initialize(editor, getMergedFirstColumnTable()); + const inserterFound = runInserterTest(HORIZONTAL_INSERTER_ID, { + x: rect.left - insideTheOffset, + y: (rect.bottom - rect.top) / 2, + }); + expect(inserterFound).toBe('not clickable'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts new file mode 100644 index 00000000000..2a9a49448a6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts @@ -0,0 +1,230 @@ +import createTableMover from '../../lib/tableEdit/editors/features/TableMover'; +import TableEditor from '../../lib/tableEdit/editors/TableEditor'; +import { Editor } from 'roosterjs-content-model-core'; +import { EditorOptions, IEditor } from 'roosterjs-content-model-types'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; + +describe('Table Mover Tests', () => { + let editor: IEditor; + let id = 'tableSelectionContainerId'; + let targetId = 'tableSelectionTestId'; + let tableEdit: TableEditPlugin; + let node: HTMLDivElement; + + beforeEach(() => { + document.body.innerHTML = ''; + node = document.createElement('div'); + node.id = id; + document.body.insertBefore(node, document.body.childNodes[0]); + tableEdit = new TableEditPlugin(); + + let options: EditorOptions = { + plugins: [tableEdit], + initialModel: { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 20, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'z1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 20, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'z2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + id: `${targetId}`, + }, + widths: [10, 10], + dataset: {}, + }, + ], + format: {}, + }, + }; + + editor = new Editor(node, options); + }); + + afterEach(() => { + editor.dispose(); + const div = document.getElementById(id); + div?.parentNode?.removeChild(div); + node.parentElement?.removeChild(node); + }); + + it('Display component on mouse move inside table', () => { + runTest(0, true); + }); + + it('Do not display component, top of table is no visible in the container.', () => { + //Arrange + runTest(15, false); + }); + + it('Do not display component, Top of table is no visible in the scroll container.', () => { + //Arrange + const scrollContainer = document.createElement('div'); + document.body.insertBefore(scrollContainer, document.body.childNodes[0]); + scrollContainer.append(node); + spyOn(editor, 'getScrollContainer').and.returnValue(scrollContainer); + + runTest(15, false); + }); + + it('Display component, Top of table is visible in the scroll container scrolled down.', () => { + //Arrange + const scrollContainer = document.createElement('div'); + scrollContainer.innerHTML = '
'; + document.body.insertBefore(scrollContainer, document.body.childNodes[0]); + scrollContainer.append(node); + spyOn(editor, 'getScrollContainer').and.returnValue(scrollContainer); + + runTest(0, true); + }); + + it('On click event', () => { + const table = document.getElementById(targetId) as HTMLTableElement; + + const tableEditor = new TableEditor(editor, table, () => true); + + tableEditor.onSelect(table); + + const selection = editor.getDOMSelection(); + expect(selection?.type).toBe('table'); + if (selection?.type == 'table') { + expect(selection).toEqual({ + table, + firstRow: 0, + firstColumn: 0, + lastRow: 1, + lastColumn: 1, + type: 'table', + }); + } + }); + + function runTest(scrollTop: number, isNotNull: boolean | null) { + //Arrange + node.style.height = '10px'; + node.style.overflowX = 'auto'; + node.scrollTop = scrollTop; + const target = document.getElementById(targetId); + editor.focus(); + + //Act + const result = createTableMover( + target as HTMLTableElement, + editor, + false, + () => {}, + () => () => {}, + node + ); + + //Assert + if (!isNotNull) { + expect(result).toBeNull(); + } else { + expect(result).toBeDefined(); + } + } +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/teableResizerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/teableResizerTest.ts new file mode 100644 index 00000000000..d4bba0575ab --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/teableResizerTest.ts @@ -0,0 +1,197 @@ +import { getModelTable } from './tableData'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; +import { + ContentModelTable, + DOMEventHandlerFunction, + IEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; +import { + Position, + afterTableTest, + beforeTableTest, + getCellRect, + getCurrentTable, + getTableRectSet, + initialize, + moveAndResize, + resizeDirection, +} from './TableEditTestHelper'; + +const TABLE_RESIZER_ID = '_Table_Resizer'; + +describe('Table Resizer tests', () => { + let editor: IEditor; + let plugin: TableEditPlugin; + const TEST_ID = 'resizerTest'; + let handler: Record; + + beforeEach(() => { + const setup = beforeTableTest(TEST_ID); + editor = setup.editor; + plugin = setup.plugin; + handler = setup.handler; + }); + + afterEach(() => { + afterTableTest(editor, plugin, TEST_ID); + }); + + /************************** Resizier removing tests **************************/ + + function removeResizerTest(pluginEvent: PluginEvent) { + let resizer: HTMLElement | null = null; + plugin.initialize(editor); + initialize(editor, getModelTable()); + const cellRect = getCellRect(editor, 0, 0); + handler.mousemove( + new MouseEvent('mousemove', { clientX: cellRect?.right, clientY: cellRect?.bottom }) + ); + resizer = editor.getDocument().getElementById(TABLE_RESIZER_ID); + expect(!!resizer).toBe(true); + plugin.onPluginEvent(pluginEvent); + resizer = editor.getDocument().getElementById(TABLE_RESIZER_ID); + expect(!!resizer).toBe(false); + } + + it('removes table resizer on input', () => { + const pluginEvent: PluginEvent = { + eventType: 'input', + rawEvent: null, + }; + removeResizerTest(pluginEvent); + }); + + it('removes table resizer on content change', () => { + const pluginEvent: PluginEvent = { + eventType: 'contentChanged', + source: null, + }; + removeResizerTest(pluginEvent); + }); + + it('removes table resizer on scrolling', () => { + const pluginEvent: PluginEvent = { + eventType: 'scroll', + scrollContainer: editor.getDocument().body as HTMLElement, + rawEvent: null, + }; + removeResizerTest(pluginEvent); + }); + + /************************ Resizing table related tests ************************/ + + function resizeWholeTableTest( + table: ContentModelTable, + growth: number, + direction: resizeDirection + ) { + const delta = 20 * growth; + const tableRect = initialize(editor, table); + const mouseStart = { x: tableRect.right + 3, y: tableRect.bottom + 3 }; + let mouseEnd: Position = { x: 0, y: 0 }; + switch (direction) { + case 'horizontal': + mouseEnd = { x: tableRect.right + 3 + delta, y: tableRect.bottom + 3 }; + break; + case 'vertical': + mouseEnd = { x: tableRect.right + 3, y: tableRect.bottom + 3 + delta }; + break; + case 'both': + mouseEnd = { x: tableRect.right + 3 + delta, y: tableRect.bottom + 3 + delta }; + break; + } + const beforeSize = getTableRectSet(getCurrentTable(editor)); + moveAndResize(mouseStart, mouseEnd, 'both', editor, handler, TEST_ID); + const afterSize = getTableRectSet(getCurrentTable(editor)); + compareTableRects(beforeSize, afterSize, growth, direction); + } + + function verifyTableRectChange( + rect1: DOMRect, + rect2: DOMRect, + growth: number, + direction: resizeDirection + ): boolean { + switch (direction) { + case 'horizontal': + return growth > 0 ? rect1.width < rect2.width : rect1.width > rect2.width; + case 'vertical': + return growth > 0 ? rect1.height < rect2.height : rect1.height > rect2.height; + case 'both': + return growth > 0 + ? rect1.width < rect2.width && rect1.height < rect2.height + : rect1.width > rect2.width && rect1.height > rect2.height; + } + } + + function verifyCellRectChange( + rect1: DOMRect, + rect2: DOMRect, + growth: number, + direction: resizeDirection + ): boolean { + switch (direction) { + case 'horizontal': + return rect1.top == rect2.top && rect1.bottom == rect2.bottom && growth > 0 + ? rect1.left <= rect2.left && rect1.right <= rect2.right + : rect1.left >= rect2.left && rect1.right >= rect2.right; + case 'vertical': + return rect1.left == rect2.left && rect1.right == rect2.right && growth > 0 + ? rect1.top <= rect2.top && rect1.bottom <= rect2.bottom + : rect1.top >= rect2.top && rect1.bottom >= rect2.bottom; + case 'both': + return growth > 0 + ? rect1.left <= rect2.left && + rect1.right <= rect2.right && + rect1.top <= rect2.top && + rect1.bottom <= rect2.bottom + : rect1.left >= rect2.left && + rect1.right >= rect2.right && + rect1.top >= rect2.top && + rect1.bottom >= rect2.bottom; + } + } + + function compareTableRects( + beforeTableRectSet1: DOMRect[], + afterTableRectSet2: DOMRect[], + growth: number, + direction: resizeDirection + ) { + expect(beforeTableRectSet1.length).toBe(afterTableRectSet2.length); + beforeTableRectSet1.forEach((rect, i) => { + i == 0 + ? expect( + verifyTableRectChange(rect, afterTableRectSet2[i], growth, direction) + ).toBe(true) // Verify a change to whole table size + : expect(verifyCellRectChange(rect, afterTableRectSet2[i], growth, direction)).toBe( + true // Verify a change to each cell size + ); + }); + } + + it('increases the width of the table', () => { + resizeWholeTableTest(getModelTable(), 1, 'horizontal'); + }); + + it('increases the height of the table', () => { + resizeWholeTableTest(getModelTable(), 1, 'vertical'); + }); + + it('increases the width and height of the table', () => { + resizeWholeTableTest(getModelTable(), 1, 'both'); + }); + + it('decreases the width of the table', () => { + resizeWholeTableTest(getModelTable(), -1, 'horizontal'); + }); + + it('decreases the height of the table', () => { + resizeWholeTableTest(getModelTable(), -1, 'vertical'); + }); + + it('decreases the width and height of the table', () => { + resizeWholeTableTest(getModelTable(), -1, 'both'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/EditorCore.ts similarity index 79% rename from packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts rename to packages-content-model/roosterjs-content-model-types/lib/editor/EditorCore.ts index a8450f864f5..dfacba10408 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -25,38 +25,38 @@ import type { /** * Create a EditorContext object used by ContentModel API - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param saveIndex True to allow saving index info into node using domIndexer, otherwise false */ -export type CreateEditorContext = (core: StandaloneEditorCore, saveIndex: boolean) => EditorContext; +export type CreateEditorContext = (core: EditorCore, saveIndex: boolean) => EditorContext; /** * Create Content Model from DOM tree in this editor - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param option The option to customize the behavior of DOM to Content Model conversion * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export type CreateContentModel = ( - core: StandaloneEditorCore, + core: EditorCore, option?: DomToModelOption, selectionOverride?: DOMSelection ) => ContentModelDocument; /** * Get current DOM selection from editor - * @param core The StandaloneEditorCore object + * @param core The EditorCore object */ -export type GetDOMSelection = (core: StandaloneEditorCore) => DOMSelection | null; +export type GetDOMSelection = (core: EditorCore) => DOMSelection | null; /** * Set content with content model. This is the replacement of core API getSelectionRangeEx - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param model The content model to set * @param option Additional options to customize the behavior of Content Model to DOM conversion * @param onNodeCreated An optional callback that will be called when a DOM node is created */ export type SetContentModel = ( - core: StandaloneEditorCore, + core: EditorCore, model: ContentModelDocument, option?: ModelToDomOption, onNodeCreated?: OnNodeCreated @@ -64,12 +64,12 @@ export type SetContentModel = ( /** * Set current DOM selection from editor. This is the replacement of core API select - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param selection The selection to set * @param skipSelectionChangedEvent @param Pass true to skip triggering a SelectionChangedEvent */ export type SetDOMSelection = ( - core: StandaloneEditorCore, + core: EditorCore, selection: DOMSelection | null, skipSelectionChangedEvent?: boolean ) => void; @@ -79,125 +79,117 @@ export type SetDOMSelection = ( * It will grab a Content Model for current editor content, and invoke a callback function * 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 core The StandaloneEditorCore object + * @param core The EditorCore object * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatContentModelOptions */ export type FormatContentModel = ( - core: StandaloneEditorCore, + core: EditorCore, formatter: ContentModelFormatter, options?: FormatContentModelOptions ) => void; /** * Switch the Shadow Edit mode of editor On/Off - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param isOn True to switch On, False to switch Off */ -export type SwitchShadowEdit = (core: StandaloneEditorCore, isOn: boolean) => void; +export type SwitchShadowEdit = (core: EditorCore, isOn: boolean) => void; /** * Trigger a plugin event - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param pluginEvent The event object to trigger * @param broadcast Set to true to skip the shouldHandleEventExclusively check */ -export type TriggerEvent = ( - core: StandaloneEditorCore, - pluginEvent: PluginEvent, - broadcast: boolean -) => void; +export type TriggerEvent = (core: EditorCore, pluginEvent: PluginEvent, broadcast: boolean) => void; /** * Add an undo snapshot to current undo snapshot stack - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). * @param entityStates @optional Entity states related to this snapshot. * Each entity state will cause an EntityOperation event with operation = EntityOperation.UpdateEntityState * when undo/redo to this snapshot */ export type AddUndoSnapshot = ( - core: StandaloneEditorCore, + core: EditorCore, canUndoByBackspace: boolean, entityStates?: EntityState[] ) => Snapshot | null; /** * Retrieves the rect of the visible viewport of the editor. - * @param core The StandaloneEditorCore object + * @param core The EditorCore object */ -export type GetVisibleViewport = (core: StandaloneEditorCore) => Rect | null; +export type GetVisibleViewport = (core: EditorCore) => Rect | null; /** * Check if the editor has focus now - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @returns True if the editor has focus, otherwise false */ -export type HasFocus = (core: StandaloneEditorCore) => boolean; +export type HasFocus = (core: EditorCore) => boolean; /** * Focus to editor. If there is a cached selection range, use it as current selection - * @param core The StandaloneEditorCore object + * @param core The EditorCore object */ -export type Focus = (core: StandaloneEditorCore) => void; +export type Focus = (core: EditorCore) => void; /** * Attach a DOM event to the editor content DIV - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param eventMap A map from event name to its handler */ export type AttachDomEvent = ( - core: StandaloneEditorCore, + core: EditorCore, eventMap: Record ) => () => void; /** * Restore an undo snapshot into editor - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param step Steps to move, can be 0, positive or negative */ -export type RestoreUndoSnapshot = (core: StandaloneEditorCore, snapshot: Snapshot) => void; +export type RestoreUndoSnapshot = (core: EditorCore, snapshot: Snapshot) => void; /** * Paste into editor using a clipboardData object - * @param core The StandaloneEditorCore object. + * @param core The EditorCore object. * @param clipboardData Clipboard data retrieved from clipboard * @param pasteType Type of content to paste. @default normal */ -export type Paste = ( - core: StandaloneEditorCore, - clipboardData: ClipboardData, - pasteType: PasteType -) => void; +export type Paste = (core: EditorCore, clipboardData: ClipboardData, pasteType: PasteType) => void; /** - * The interface for the map of core API for Content Model editor. - * Editor can call call API from this map under StandaloneEditorCore object + * The interface for the map of core API for Editor. + * Editor can call call API from this map under EditorCore object */ -export interface StandaloneCoreApiMap { +export interface CoreApiMap { /** * Create a EditorContext object used by ContentModel API - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param saveIndex True to allow saving index info into node using domIndexer, otherwise false */ createEditorContext: CreateEditorContext; /** * Create Content Model from DOM tree in this editor - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param option The option to customize the behavior of DOM to Content Model conversion */ createContentModel: CreateContentModel; /** * Get current DOM selection from editor - * @param core The StandaloneEditorCore object + * @param core The EditorCore object */ getDOMSelection: GetDOMSelection; /** * Set content with content model - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param model The content model to set * @param option Additional options to customize the behavior of Content Model to DOM conversion */ @@ -205,7 +197,7 @@ export interface StandaloneCoreApiMap { /** * Set current DOM selection from editor. This is the replacement of core API select - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param selection The selection to set * @param skipSelectionChangedEvent @param Pass true to skip triggering a SelectionChangedEvent */ @@ -216,7 +208,7 @@ export interface StandaloneCoreApiMap { * It will grab a Content Model for current editor content, and invoke a callback function * 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 core The StandaloneEditorCore object + * @param core The EditorCore object * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatContentModelOptions */ @@ -224,33 +216,33 @@ export interface StandaloneCoreApiMap { /** * Switch the Shadow Edit mode of editor On/Off - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param isOn True to switch On, False to switch Off */ switchShadowEdit: SwitchShadowEdit; /** * Retrieves the rect of the visible viewport of the editor. - * @param core The StandaloneEditorCore object + * @param core The EditorCore object */ getVisibleViewport: GetVisibleViewport; /** * Check if the editor has focus now - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @returns True if the editor has focus, otherwise false */ hasFocus: HasFocus; /** * Focus to editor. If there is a cached selection range, use it as current selection - * @param core The StandaloneEditorCore object + * @param core The EditorCore object */ focus: Focus; /** * Add an undo snapshot to current undo snapshot stack - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). * @param entityStates @optional Entity states related to this snapshot. * Each entity state will cause an EntityOperation event with operation = EntityOperation.UpdateEntityState @@ -267,14 +259,14 @@ export interface StandaloneCoreApiMap { /** * Attach a DOM event to the editor content DIV - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param eventMap A map from event name to its handler */ attachDomEvent: AttachDomEvent; /** * Trigger a plugin event - * @param core The StandaloneEditorCore object + * @param core The EditorCore object * @param pluginEvent The event object to trigger * @param broadcast Set to true to skip the shouldHandleEventExclusively check */ @@ -290,23 +282,30 @@ export interface StandaloneCoreApiMap { } /** - * Represents the core data structure of a Content Model editor + * Represents the core data structure of an editor */ -export interface StandaloneEditorCore extends PluginState { +export interface EditorCore extends PluginState { + /** + * The root DIV element of this editor (formerly contentDiv) + */ + readonly physicalRoot: HTMLDivElement; + /** - * The content DIV element of this editor + * The content DIV element that operations should be applied to + * By default, the logical root is the same as the physical root, + * but if nested editors are used, the logical root changes to that of the inner editor */ - readonly contentDiv: HTMLDivElement; + logicalRoot: HTMLDivElement; /** * Core API map of this editor */ - readonly api: StandaloneCoreApiMap; + readonly api: CoreApiMap; /** * 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: StandaloneCoreApiMap; + readonly originalApi: CoreApiMap; /** * An array of editor plugins. diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/EditorCorePlugins.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts rename to packages-content-model/roosterjs-content-model-types/lib/editor/EditorCorePlugins.ts index 9b3394c214f..e5430a822a8 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/EditorCorePlugins.ts @@ -10,9 +10,9 @@ import type { CachePluginState } from '../pluginState/CachePluginState'; import type { FormatPluginState } from '../pluginState/FormatPluginState'; /** - * Core plugins for standalone editor + * Core plugins for editor */ -export interface StandaloneEditorCorePlugins { +export interface EditorCorePlugins { /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/EditorOptions.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts rename to packages-content-model/roosterjs-content-model-types/lib/editor/EditorOptions.ts index d3a75d37053..91847753530 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/EditorOptions.ts @@ -1,7 +1,8 @@ +import type { PasteType } from '../enum/PasteType'; import type { Colors, ColorTransformFunction } from '../context/DarkColorHandler'; import type { EditorPlugin } from './EditorPlugin'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; -import type { StandaloneCoreApiMap } from './StandaloneEditorCore'; +import type { CoreApiMap } from './EditorCore'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; import type { ContentModelDocument } from '../group/ContentModelDocument'; @@ -9,9 +10,9 @@ import type { Snapshots } from '../parameter/Snapshot'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; /** - * Options for Content Model editor + * Options for editor */ -export interface StandaloneEditorOptions { +export interface EditorOptions { /** * Default options used for DOM to Content Model conversion */ @@ -76,7 +77,7 @@ export interface StandaloneEditorOptions { * A function map to override default core API implementation * Default value is null */ - coreApiOverride?: Partial; + coreApiOverride?: Partial; /** * Color of the border of a selectedImage. Default color: '#DB626C' @@ -109,4 +110,9 @@ export interface StandaloneEditorOptions { * @param error The error object we got */ disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + + /** + * Default paste type. By default will use the normal (as-is) paste type. + */ + defaultPasteType?: PasteType; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/EditorPlugin.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/EditorPlugin.ts index 0da11b5b855..187003aa9dc 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/EditorPlugin.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/EditorPlugin.ts @@ -1,5 +1,5 @@ import type { PluginEvent } from '../event/PluginEvent'; -import type { IStandaloneEditor } from './IStandaloneEditor'; +import type { IEditor } from './IEditor'; /** * Interface of an editor plugin @@ -16,7 +16,7 @@ export interface EditorPlugin { * editor reference so that it can call to any editor method or format API later. * @param editor The editor object */ - initialize: (editor: IStandaloneEditor) => void; + initialize: (editor: IEditor) => void; /** * The last method that editor will call to a plugin before it is disposed. diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IEditor.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts rename to packages-content-model/roosterjs-content-model-types/lib/editor/IEditor.ts index 87c657be187..301a2367ace 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -17,12 +17,12 @@ import type { import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; import type { Rect } from '../parameter/Rect'; +import type { EntityState } from '../parameter/FormatContentModelContext'; /** - * An interface of standalone Content Model editor. - * (This interface is still under development, and may still be changed in the future with some breaking changes) + * An interface of Editor, built on top of Content Model */ -export interface IStandaloneEditor { +export interface IEditor { /** * Create Content Model from DOM tree in this editor * @param mode What kind of Content Model we want. Currently we support the following values: @@ -126,8 +126,9 @@ export interface IStandaloneEditor { /** * Add a single undo snapshot to undo stack + * @param entityState @optional State for entity if we want to add entity state for this snapshot */ - takeSnapshot(): Snapshot | null; + takeSnapshot(entityState?: EntityState): Snapshot | null; /** * Restore an undo snapshot into editor diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemFormat.ts index 8fdcd75c06d..f2d7b550b62 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemFormat.ts @@ -1,3 +1,4 @@ +import type { BackgroundColorFormat } from './formatParts/BackgroundColorFormat'; import type { DirectionFormat } from './formatParts/DirectionFormat'; import type { LineHeightFormat } from './formatParts/LineHeightFormat'; import type { ListStyleFormat } from './formatParts/ListStyleFormat'; @@ -15,4 +16,5 @@ export type ContentModelListItemFormat = DirectionFormat & PaddingFormat & TextAlignFormat & ListStyleFormat & - TextIndentFormat; + TextIndentFormat & + BackgroundColorFormat; 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 69dceefa3dd..d7ed66a4434 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -201,8 +201,8 @@ export { } from './metadata/Definition'; export { DarkColorHandler, Colors, ColorTransformFunction } from './context/DarkColorHandler'; -export { IStandaloneEditor } from './editor/IStandaloneEditor'; -export { StandaloneEditorOptions } from './editor/StandaloneEditorOptions'; +export { IEditor } from './editor/IEditor'; +export { EditorOptions } from './editor/EditorOptions'; export { CreateContentModel, CreateEditorContext, @@ -210,8 +210,8 @@ export { SetContentModel, SetDOMSelection, FormatContentModel, - StandaloneCoreApiMap, - StandaloneEditorCore, + CoreApiMap, + EditorCore, ContentModelSettings, SwitchShadowEdit, TriggerEvent, @@ -222,8 +222,8 @@ export { RestoreUndoSnapshot, GetVisibleViewport, Paste, -} from './editor/StandaloneEditorCore'; -export { StandaloneEditorCorePlugins } from './editor/StandaloneEditorCorePlugins'; +} from './editor/EditorCore'; +export { EditorCorePlugins } from './editor/EditorCorePlugins'; export { EditorPlugin } from './editor/EditorPlugin'; export { PluginWithState } from './editor/PluginWithState'; export { ContextMenuProvider } from './editor/ContextMenuProvider'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts index e936fd88255..8844a0c1e9c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts @@ -21,4 +21,9 @@ export interface InsertEntityOptions { * Whether skip adding an undo snapshot around */ skipUndoSnapshot?: boolean; + + /** + * Initial entity state, this is used when restore an undo snapshot to right after entity is inserted, this state will be used for set initial state of entity + */ + initialEntityState?: string; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/CopyPastePluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/CopyPastePluginState.ts index 02cd0442006..04ba00a1968 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/CopyPastePluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/CopyPastePluginState.ts @@ -1,3 +1,5 @@ +import type { PasteType } from '../enum/PasteType'; + /** * The state object for CopyPastePlugin */ @@ -12,4 +14,9 @@ export interface CopyPastePluginState { * A temporary DIV element used for cut/copy content */ tempDiv: HTMLDivElement | null; + + /** + * Default paste type. By default will use the normal (as-is) paste type. + */ + defaultPasteType?: PasteType; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/PluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/PluginState.ts index 879609b30ef..d209122478c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/PluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/PluginState.ts @@ -1,24 +1,24 @@ import type { PluginWithState } from '../editor/PluginWithState'; -import type { StandaloneEditorCorePlugins } from '../editor/StandaloneEditorCorePlugins'; +import type { EditorCorePlugins } from '../editor/EditorCorePlugins'; /** * Names of core plugins */ -export type PluginKey = keyof StandaloneEditorCorePlugins; +export type PluginKey = keyof EditorCorePlugins; /** * Names of the core plugins that have plugin state */ export type KeyOfStatePlugin< Key extends PluginKey -> = StandaloneEditorCorePlugins[Key] extends PluginWithState ? Key : never; +> = EditorCorePlugins[Key] extends PluginWithState ? Key : never; /** * Get type of a plugin with state */ export type TypeOfStatePlugin< Key extends PluginKey -> = StandaloneEditorCorePlugins[Key] extends PluginWithState ? U : never; +> = EditorCorePlugins[Key] extends PluginWithState ? U : never; /** * All names of plugins with plugin state diff --git a/packages-content-model/roosterjs-content-model/lib/createEditor.ts b/packages-content-model/roosterjs-content-model/lib/createEditor.ts index eed17791252..8c2558508e9 100644 --- a/packages-content-model/roosterjs-content-model/lib/createEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createEditor.ts @@ -1,10 +1,10 @@ +import { Editor } from 'roosterjs-content-model-core'; import { EditPlugin, PastePlugin } from 'roosterjs-content-model-plugins'; -import { StandaloneEditor } from 'roosterjs-content-model-core'; import type { ContentModelDocument, EditorPlugin, - IStandaloneEditor, - StandaloneEditorOptions, + IEditor, + EditorOptions, } from 'roosterjs-content-model-types'; /** @@ -19,10 +19,10 @@ export function createEditor( contentDiv: HTMLDivElement, additionalPlugins?: EditorPlugin[], initialModel?: ContentModelDocument -): IStandaloneEditor { +): IEditor { const plugins = [new PastePlugin(), new EditPlugin(), ...(additionalPlugins ?? [])]; - const options: StandaloneEditorOptions = { + const options: EditorOptions = { plugins: plugins, initialModel, defaultSegmentFormat: { @@ -31,5 +31,5 @@ export function createEditor( textColor: '#000000', }, }; - return new StandaloneEditor(contentDiv, options); + return new Editor(contentDiv, options); } diff --git a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts index a91e0fb2a5f..7d1a76bac2a 100644 --- a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts +++ b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts @@ -1,9 +1,9 @@ +import { cacheGetEventData } from 'roosterjs-content-model-core'; import { createDarkColorHandler } from '../editor/DarkColorHandlerImpl'; import { createEditPlugin } from './EditPlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import type { EditorPlugin as LegacyEditorPlugin, - PluginEvent as LegacyPluginEvent, ContextMenuProvider as LegacyContextMenuProvider, IEditor as ILegacyEditor, ExperimentalFeatures, @@ -12,13 +12,10 @@ import type { CustomData, DarkColorHandler, } from 'roosterjs-editor-types'; -import type { - ContextMenuProvider, - IStandaloneEditor, - PluginEvent, -} from 'roosterjs-content-model-types'; +import type { ContextMenuProvider, IEditor, PluginEvent } from 'roosterjs-content-model-types'; const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; +const OldEventKey = '__OldEventFromNewEvent'; /** * @internal @@ -93,7 +90,7 @@ export class BridgePlugin implements ContextMenuProvider { * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize(editor: IStandaloneEditor) { + initialize(editor: IEditor) { const outerEditor = this.onInitialize(this.createEditorCore(editor)); this.legacyPlugins.forEach(plugin => plugin.initialize(outerEditor)); @@ -111,33 +108,14 @@ export class BridgePlugin implements ContextMenuProvider { } willHandleEventExclusively(event: PluginEvent) { - let oldEvent: LegacyPluginEvent | undefined; - - if (this.checkExclusivelyHandling && (oldEvent = newEventToOldEvent(event))) { - for (let i = 0; i < this.legacyPlugins.length; i++) { - const plugin = this.legacyPlugins[i]; - - if (plugin.willHandleEventExclusively?.(oldEvent)) { - if (!event.eventDataCache) { - event.eventDataCache = {}; - } - - event.eventDataCache[ExclusivelyHandleEventPluginKey] = plugin; - return true; - } - } - } - - return false; + return this.checkExclusivelyHandling && !!this.cacheGetExclusivelyHandlePlugin(event); } onPluginEvent(event: PluginEvent) { - const oldEvent = newEventToOldEvent(event); + const oldEvent = this.cacheGetOldEvent(event); if (oldEvent) { - const exclusivelyHandleEventPlugin = event.eventDataCache?.[ - ExclusivelyHandleEventPluginKey - ] as LegacyEditorPlugin | undefined; + const exclusivelyHandleEventPlugin = this.cacheGetExclusivelyHandlePlugin(event); if (exclusivelyHandleEventPlugin) { exclusivelyHandleEventPlugin.onPluginEvent?.(oldEvent); @@ -171,7 +149,29 @@ export class BridgePlugin implements ContextMenuProvider { return allItems; } - private createEditorCore(editor: IStandaloneEditor): EditorAdapterCore { + private cacheGetExclusivelyHandlePlugin(event: PluginEvent) { + return cacheGetEventData(event, ExclusivelyHandleEventPluginKey, event => { + const oldEvent = this.cacheGetOldEvent(event); + + if (oldEvent) { + for (let i = 0; i < this.legacyPlugins.length; i++) { + const plugin = this.legacyPlugins[i]; + + if (plugin.willHandleEventExclusively?.(oldEvent)) { + return plugin; + } + } + } + + return null; + }); + } + + private cacheGetOldEvent(event: PluginEvent) { + return cacheGetEventData(event, OldEventKey, newEventToOldEvent); + } + + private createEditorCore(editor: IEditor): EditorAdapterCore { return { customData: {}, experimentalFeatures: this.experimentalFeatures ?? [], @@ -187,7 +187,7 @@ export class BridgePlugin implements ContextMenuProvider { * @internal Export for test only. This function is only used for compatibility from older build */ -export function createSizeTransformer(editor: IStandaloneEditor): SizeTransformer { +export function createSizeTransformer(editor: IEditor): SizeTransformer { return size => size / editor.getDOMHelper().calculateZoomScale(); } diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 80dd8a4a7f6..84a85f712fc 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -14,7 +14,7 @@ import { isBold, redo, retrieveModelFormatState, - StandaloneEditor, + Editor, transformColor, undo, } from 'roosterjs-content-model-core'; @@ -54,7 +54,7 @@ import type { TableSelection, DOMEventHandlerObject, DarkColorHandler, - IEditor, + IEditor as ILegacyEditor, } from 'roosterjs-editor-types'; import { convertDomSelectionToRangeEx, @@ -93,8 +93,8 @@ import type { ContentModelFormatState, DOMEventRecord, ExportContentMode, - IStandaloneEditor, - StandaloneEditorOptions, + IEditor, + EditorOptions, } from 'roosterjs-content-model-types'; const GetContentModeMap: Record = { @@ -109,7 +109,7 @@ const GetContentModeMap: Record = { * Editor for Content Model. * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export class EditorAdapter extends StandaloneEditor implements IEditor { +export class EditorAdapter extends Editor implements ILegacyEditor { private contentModelEditorCore: EditorAdapterCore | undefined; /** @@ -139,7 +139,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { options.defaultSegmentFormat ) : options.initialModel; - const standaloneEditorOptions: StandaloneEditorOptions = { + const standaloneEditorOptions: EditorOptions = { ...options, plugins, initialModel, @@ -199,14 +199,14 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { insertToRegionRoot: false, }; - const { contentDiv } = this.getCore(); + const { physicalRoot } = this.getCore(); if (option.updateCursor) { this.focus(); } if (option.position == ContentPosition.Outside) { - contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); + physicalRoot.parentNode?.insertBefore(node, physicalRoot.nextSibling); } else { if (this.isDarkMode()) { transformColor( @@ -217,7 +217,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { ); } - const selection = insertNode(contentDiv, this.getDOMSelection(), node, option); + const selection = insertNode(physicalRoot, this.getDOMSelection(), node, option); if (selection && option.updateCursor) { this.setDOMSelection(selection); @@ -274,14 +274,14 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { * @returns The BlockElement result */ getBlockElementAtNode(node: Node): BlockElement | null { - return getBlockElementAtNode(this.getCore().contentDiv, node); + return getBlockElementAtNode(this.getCore().logicalRoot, node); } contains(arg: Node | Range | null): boolean { if (!arg) { return false; } - return contains(this.getCore().contentDiv, arg); + return contains(this.getCore().logicalRoot, arg); } queryElements( @@ -300,10 +300,16 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { const selectionEx = scope == QueryScope.Body ? null : this.getSelectionRangeEx(); if (selectionEx) { selectionEx.ranges.forEach(range => { - result.push(...queryElements(core.contentDiv, selector, callback, scope, range)); + result.push(...queryElements(core.logicalRoot, selector, callback, scope, range)); }); } else { - return queryElements(core.contentDiv, selector, callback, scope, undefined /* range */); + return queryElements( + core.logicalRoot, + selector, + callback, + scope, + undefined /* range */ + ); } return result; @@ -321,7 +327,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { * otherwise just return start and end */ collapseNodes(start: Node, end: Node, canSplitParent: boolean): Node[] { - return collapseNodes(this.getCore().contentDiv, start, end, canSplitParent); + return collapseNodes(this.getCore().physicalRoot, start, end, canSplitParent); } //#endregion @@ -334,7 +340,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { * @returns True if there's no visible content, otherwise false */ isEmpty(trim?: boolean): boolean { - return isNodeEmpty(this.getCore().contentDiv, trim); + return isNodeEmpty(this.getCore().physicalRoot, trim); } /** @@ -357,7 +363,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { */ setContent(content: string, triggerContentChangedEvent: boolean = true) { const core = this.getCore(); - const { contentDiv, api, trustedHTMLHandler, lifecycle, darkColorHandler } = core; + const { physicalRoot, api, trustedHTMLHandler, lifecycle, darkColorHandler } = core; api.triggerEvent( core, @@ -387,7 +393,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { false /*broadcast*/ ); } else if (lifecycle.isDarkMode) { - transformColor(contentDiv, false /*includeSelf*/, 'lightToDark', darkColorHandler); + transformColor(physicalRoot, false /*includeSelf*/, 'lightToDark', darkColorHandler); } } @@ -429,7 +435,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { deleteSelectedContent(): NodePosition | null { const range = this.getSelectionRange(); if (range && !range.collapsed) { - return deleteSelectedContent(this.getCore().contentDiv, range); + return deleteSelectedContent(this.getCore().physicalRoot, range); } return null; } @@ -497,7 +503,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { */ getSelectionPath(): SelectionPath | null { const range = this.getSelectionRange(); - return range && getSelectionPath(this.getCore().contentDiv, range); + return range && getSelectionPath(this.getCore().physicalRoot, range); } select( @@ -507,7 +513,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { arg4?: number | PositionType ): boolean { const core = this.getCore(); - const rangeEx = buildRangeEx(core.contentDiv, arg1, arg2, arg3, arg4); + const rangeEx = buildRangeEx(core.physicalRoot, arg1, arg2, arg3, arg4); const selection = convertRangeExToDomSelection(rangeEx); this.setDOMSelection(selection); @@ -558,7 +564,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { } return ( startFrom && - findClosestElementAncestor(startFrom, this.getCore().contentDiv, selector) + findClosestElementAncestor(startFrom, this.getCore().physicalRoot, selector) ); }) ?? null ); @@ -571,7 +577,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { * @returns True if position is at beginning of the editor, otherwise false */ isPositionAtBeginning(position: NodePosition): boolean { - return isPositionAtBeginningOf(position, this.getCore().contentDiv); + return isPositionAtBeginningOf(position, this.getCore().logicalRoot); } /** @@ -580,9 +586,9 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { getSelectedRegions(type: RegionType | CompatibleRegionType = RegionType.Table): Region[] { const selection = this.getSelectionRangeEx(); const result: Region[] = []; - const contentDiv = this.getCore().contentDiv; + const logicalRoot = this.getCore().logicalRoot; selection.ranges.forEach(range => { - result.push(...(range ? getRegionsFromRange(contentDiv, range, type) : [])); + result.push(...(range ? getRegionsFromRange(logicalRoot, range, type) : [])); }); return result.filter((value, index, self) => { return self.indexOf(value) === index; @@ -826,7 +832,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { * @param startNode The node to start from. If not passed, it will start from the beginning of the body */ getBodyTraverser(startNode?: Node): IContentTraverser { - return ContentTraverser.createBodyTraverser(this.getCore().contentDiv, startNode); + return ContentTraverser.createBodyTraverser(this.getCore().logicalRoot, startNode); } /** @@ -836,7 +842,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { getSelectionTraverser(range?: Range): IContentTraverser | null { range = range ?? this.getSelectionRange() ?? undefined; return range - ? ContentTraverser.createSelectionTraverser(this.getCore().contentDiv, range) + ? ContentTraverser.createSelectionTraverser(this.getCore().logicalRoot, range) : null; } @@ -850,7 +856,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { ): IContentTraverser | null { const range = this.getSelectionRange(); return range - ? ContentTraverser.createBlockTraverser(this.getCore().contentDiv, range, startFrom) + ? ContentTraverser.createBlockTraverser(this.getCore().logicalRoot, range, startFrom) : null; } @@ -865,7 +871,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { const range = this.getSelectionRange(); return ( range && - new PositionContentSearcher(this.getCore().contentDiv, Position.getStart(range)) + new PositionContentSearcher(this.getCore().logicalRoot, Position.getStart(range)) ); }); } @@ -875,8 +881,8 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { * @param callback The callback function to run * @returns a function to cancel this async run */ - runAsync(callback: (editor: IEditor & IStandaloneEditor) => void) { - const win = this.getCore().contentDiv.ownerDocument.defaultView || window; + runAsync(callback: (editor: ILegacyEditor & IEditor) => void) { + const win = this.getCore().physicalRoot.ownerDocument.defaultView || window; const handle = win.requestAnimationFrame(() => { if (!this.isDisposed() && callback) { callback(this); @@ -916,8 +922,8 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { */ getRelativeDistanceToEditor(element: HTMLElement, addScroll?: boolean): number[] | null { if (this.contains(element)) { - const contentDiv = this.getCore().contentDiv; - const editorRect = contentDiv.getBoundingClientRect(); + const physicalRoot = this.getCore().physicalRoot; + const editorRect = physicalRoot.getBoundingClientRect(); const elementRect = element.getBoundingClientRect(); if (editorRect && elementRect) { @@ -925,8 +931,8 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { let y = elementRect.top - editorRect?.top; if (addScroll) { - x += contentDiv.scrollLeft; - y += contentDiv.scrollTop; + x += physicalRoot.scrollLeft; + y += physicalRoot.scrollTop; } return [x, y]; diff --git a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts index b5ed78fe6fe..69621390e57 100644 --- a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts @@ -1,10 +1,10 @@ -import type { StandaloneEditorOptions } from 'roosterjs-content-model-types'; +import type { EditorOptions } from 'roosterjs-content-model-types'; import type { EditorPlugin, ExperimentalFeatures } from 'roosterjs-editor-types'; /** * Options for editor adapter */ -export interface EditorAdapterOptions extends StandaloneEditorOptions { +export interface EditorAdapterOptions extends EditorOptions { /** * Initial HTML content * Default value is whatever already inside the editor content DIV diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index ec3a93d133e..a766c9f950f 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -223,6 +223,7 @@ describe('BridgePlugin', () => { expect(mockedEvent).toEqual({ eventDataCache: { __ExclusivelyHandleEventPlugin: mockedPlugin2, + __OldEventFromNewEvent: 'NEW_[object Object]', }, }); expect(eventConverter.newEventToOldEvent).toHaveBeenCalledTimes(1); @@ -298,12 +299,20 @@ describe('BridgePlugin', () => { { eventType: 'new_old_newEvent' as any, data: 'plugin2', + eventDataCache: { + __ExclusivelyHandleEventPlugin: null, + __OldEventFromNewEvent: { eventType: 'old_newEvent', data: 'plugin2' }, + }, } ); expect(mockedEvent).toEqual({ eventType: 'new_old_newEvent', data: 'plugin2', + eventDataCache: { + __ExclusivelyHandleEventPlugin: null, + __OldEventFromNewEvent: { eventType: 'old_newEvent', data: 'plugin2' }, + }, }); plugin.dispose(); @@ -343,7 +352,7 @@ describe('BridgePlugin', () => { const mockedEvent = { eventType: 'newEvent', eventDataCache: { - ['__ExclusivelyHandleEventPlugin']: mockedPlugin2, + __ExclusivelyHandleEventPlugin: mockedPlugin2, }, } as any; @@ -354,7 +363,8 @@ describe('BridgePlugin', () => { expect(onPluginEventSpy2).toHaveBeenCalledWith({ eventType: 'old_newEvent', eventDataCache: { - ['__ExclusivelyHandleEventPlugin']: mockedPlugin2, + __ExclusivelyHandleEventPlugin: mockedPlugin2, + __OldEventFromNewEvent: jasmine.anything(), }, }); expect(eventConverter.newEventToOldEvent).toHaveBeenCalledTimes(1); diff --git a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts index 55bb8dc7381..b958bb7a5b7 100644 --- a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts @@ -3,11 +3,7 @@ import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/d import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; import { EditorAdapter } from '../../lib/editor/EditorAdapter'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; -import { - ContentModelDocument, - EditorContext, - StandaloneEditorCore, -} from 'roosterjs-content-model-types'; +import { ContentModelDocument, EditorContext, EditorCore } from 'roosterjs-content-model-types'; const editorContext: EditorContext = { isDarkMode: false, @@ -193,7 +189,7 @@ describe('EditorAdapter', () => { it('getPendingFormat', () => { const div = document.createElement('div'); const editor = new EditorAdapter(div); - const core: StandaloneEditorCore = (editor as any).core; + const core: EditorCore = (editor as any).core; const mockedFormat = 'FORMAT' as any; expect(editor.getPendingFormat()).toBeNull(); diff --git a/versions.json b/versions.json index 78946a750fd..89ec5688002 100644 --- a/versions.json +++ b/versions.json @@ -1,9 +1,9 @@ { "packages": "8.60.0", "packages-ui": "8.55.0", - "packages-content-model": "0.26.4", + "packages-content-model": "0.27.0", "overrides": { "roosterjs-editor-plugins": "8.60.2", - "roosterjs-editor-adapter": "0.26.3" + "roosterjs-editor-adapter": "0.27.0" } } diff --git a/yarn.lock b/yarn.lock index 8c977c6d941..6c4d59c9629 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3652,9 +3652,9 @@ ip-regex@^2.1.0: integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= ip@^1.1.0, ip@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" - integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= + version "1.1.9" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.9.tgz#8dfbcc99a754d07f425310b86a99546b1151e396" + integrity sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ== ipaddr.js@1.9.1: version "1.9.1"