diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 704414e9081..1a277e010f0 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -7,19 +7,26 @@ import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModel import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; +import ContentModelRooster from './contentModel/editor/ContentModelRooster'; import getToggleablePlugins from './getToggleablePlugins'; -import MainPaneBase from './MainPaneBase'; +import MainPaneBase, { MainPaneBaseState } from './MainPaneBase'; import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; -import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { ContentModelEditPlugin } from 'roosterjs-content-model-plugins'; +import { ContentModelEditPlugin, EntityDelimiterPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; -import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; +import { EditorPlugin } from 'roosterjs-editor-types'; import { PartialTheme } from '@fluentui/react/lib/Theme'; +import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; +import { + ContentModelEditor, + ContentModelEditorOptions, + IContentModelEditor, +} from 'roosterjs-content-model-editor'; const styles = require('./ContentModelEditorMainPane.scss'); @@ -77,7 +84,11 @@ const DarkTheme: PartialTheme = { }, }; -class ContentModelEditorMainPane extends MainPaneBase { +interface ContentModelMainPaneState extends MainPaneBaseState { + editorCreator: (div: HTMLDivElement, options: ContentModelEditorOptions) => IContentModelEditor; +} + +class ContentModelEditorMainPane extends MainPaneBase { private formatStatePlugin: ContentModelFormatStatePlugin; private editorOptionPlugin: ContentModelEditorOptionsPlugin; private eventViewPlugin: ContentModelEventViewPlugin; @@ -87,6 +98,7 @@ class ContentModelEditorMainPane extends MainPaneBase { private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; + private entityDelimiterPlugin: EntityDelimiterPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private formatPainterPlugin: ContentModelFormatPainterPlugin; private sampleEntityPlugin: SampleEntityPlugin; @@ -104,6 +116,7 @@ class ContentModelEditorMainPane extends MainPaneBase { this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); + this.entityDelimiterPlugin = new EntityDelimiterPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); this.state = { @@ -166,6 +179,7 @@ class ContentModelEditorMainPane extends MainPaneBase { this.contentModelEditPlugin, this.pasteOptionPlugin, this.emojiPlugin, + this.entityDelimiterPlugin, this.formatPainterPlugin, this.sampleEntityPlugin, ]; @@ -182,7 +196,7 @@ class ContentModelEditorMainPane extends MainPaneBase { resetEditor() { this.toggleablePlugins = null; this.setState({ - editorCreator: (div: HTMLDivElement, options: EditorOptions) => + editorCreator: (div: HTMLDivElement, options: ContentModelEditorOptions) => new ContentModelEditor(div, { ...options, cacheModel: this.state.initState.cacheModel, @@ -190,6 +204,52 @@ class ContentModelEditorMainPane extends MainPaneBase { }); } + renderEditor() { + const styles = this.getStyles(); + const allPlugins = this.getPlugins(); + const editorStyles = { + transform: `scale(${this.state.scale})`, + transformOrigin: this.state.isRtl ? 'right top' : 'left top', + height: `calc(${100 / this.state.scale}%)`, + width: `calc(${100 / this.state.scale}%)`, + }; + const format = this.state.initState.defaultFormat; + const defaultFormat: ContentModelSegmentFormat = { + fontWeight: format.bold ? 'bold' : undefined, + italic: format.italic || undefined, + underline: format.underline || undefined, + fontFamily: format.fontFamily || undefined, + fontSize: format.fontSize || undefined, + textColor: format.textColors?.lightModeColor || format.textColor || undefined, + backgroundColor: + format.backgroundColors?.lightModeColor || format.backgroundColor || undefined, + }; + + this.updateContentPlugin.forceUpdate(); + + return ( +
+
+ {this.state.editorCreator && ( + + )} +
+
+ ); + } + getTheme(isDark: boolean): PartialTheme { return isDark ? DarkTheme : LightTheme; } diff --git a/demo/scripts/controls/MainPane.tsx b/demo/scripts/controls/MainPane.tsx index ec18675e5b2..ad5e38acdc4 100644 --- a/demo/scripts/controls/MainPane.tsx +++ b/demo/scripts/controls/MainPane.tsx @@ -5,7 +5,7 @@ import EditorOptionsPlugin from './sidePane/editorOptions/EditorOptionsPlugin'; import EventViewPlugin from './sidePane/eventViewer/EventViewPlugin'; import FormatStatePlugin from './sidePane/formatState/FormatStatePlugin'; import getToggleablePlugins from './getToggleablePlugins'; -import MainPaneBase from './MainPaneBase'; +import MainPaneBase, { MainPaneBaseState } from './MainPaneBase'; import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; @@ -13,10 +13,12 @@ import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; import { darkMode, DarkModeButtonStringKey } from './ribbonButtons/darkMode'; import { Editor } from 'roosterjs-editor-core'; -import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; +import { EditorOptions, EditorPlugin, IEditor } from 'roosterjs-editor-types'; import { ExportButtonStringKey, exportContent } from './ribbonButtons/export'; +import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { popout, PopoutButtonStringKey } from './ribbonButtons/popout'; +import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { zoom, ZoomButtonStringKey } from './ribbonButtons/zoom'; import { createRibbonPlugin, @@ -28,6 +30,7 @@ import { AllButtonStringKeys, getButtons, AllButtonKeys, + Rooster, } from 'roosterjs-react'; const styles = require('./MainPane.scss'); @@ -92,7 +95,11 @@ const DarkTheme: PartialTheme = { }, }; -class MainPane extends MainPaneBase { +interface MainPaneState extends MainPaneBaseState { + editorCreator: (div: HTMLDivElement, options: EditorOptions) => IEditor; +} + +class MainPane extends MainPaneBase { private formatStatePlugin: FormatStatePlugin; private editorOptionPlugin: EditorOptionsPlugin; private eventViewPlugin: EventViewPlugin; @@ -204,6 +211,42 @@ class MainPane extends MainPaneBase { return isDark ? DarkTheme : LightTheme; } + renderEditor() { + const styles = this.getStyles(); + const allPlugins = this.getPlugins(); + const editorStyles = { + transform: `scale(${this.state.scale})`, + transformOrigin: this.state.isRtl ? 'right top' : 'left top', + height: `calc(${100 / this.state.scale}%)`, + width: `calc(${100 / this.state.scale}%)`, + }; + + this.updateContentPlugin.forceUpdate(); + + return ( +
+
+ {this.state.editorCreator && ( + + )} +
+
+ ); + } + private getSidePanePlugins() { return [ this.formatStatePlugin, diff --git a/demo/scripts/controls/MainPaneBase.tsx b/demo/scripts/controls/MainPaneBase.tsx index f68d5acf7fb..00b375a339d 100644 --- a/demo/scripts/controls/MainPaneBase.tsx +++ b/demo/scripts/controls/MainPaneBase.tsx @@ -4,18 +4,12 @@ import BuildInPluginState from './BuildInPluginState'; import SidePane from './sidePane/SidePane'; import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; import { Border } from 'roosterjs-content-model-types'; -import { EditorOptions, EditorPlugin, IEditor } from 'roosterjs-editor-types'; -import { getDarkColor } from 'roosterjs-color-utils'; +import { EditorPlugin } from 'roosterjs-editor-types'; import { PartialTheme, ThemeProvider } from '@fluentui/react/lib/Theme'; import { registerWindowForCss, unregisterWindowForCss } from '../utils/cssMonitor'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { WindowProvider } from '@fluentui/react/lib/WindowProvider'; -import { - createUpdateContentPlugin, - Rooster, - UpdateContentPlugin, - UpdateMode, -} from 'roosterjs-react'; +import { createUpdateContentPlugin, UpdateContentPlugin, UpdateMode } from 'roosterjs-react'; export interface MainPaneBaseState { showSidePane: boolean; @@ -23,7 +17,6 @@ export interface MainPaneBaseState { initState: BuildInPluginState; scale: number; isDarkMode: boolean; - editorCreator: (div: HTMLDivElement, options: EditorOptions) => IEditor; isRtl: boolean; tableBorderFormat?: Border; } @@ -34,9 +27,12 @@ const POPOUT_FEATURES = 'menubar=no,statusbar=no,width=1200,height=800'; const POPOUT_URL = 'about:blank'; const POPOUT_TARGET = '_blank'; -export default abstract class MainPaneBase extends React.Component<{}, MainPaneBaseState> { +export default abstract class MainPaneBase extends React.Component< + {}, + T +> { private mouseX: number; - private static instance: MainPaneBase; + private static instance: MainPaneBase; private popoutRoot: HTMLElement; protected sidePane = React.createRef(); @@ -70,6 +66,8 @@ export default abstract class MainPaneBase extends React.Component<{}, MainPaneB abstract getTheme(isDark: boolean): PartialTheme; + abstract renderEditor(): JSX.Element; + render() { const styles = this.getStyles(); @@ -187,42 +185,6 @@ export default abstract class MainPaneBase extends React.Component<{}, MainPaneB ); } - private renderEditor() { - const styles = this.getStyles(); - const allPlugins = this.getPlugins(); - const editorStyles = { - transform: `scale(${this.state.scale})`, - transformOrigin: this.state.isRtl ? 'right top' : 'left top', - height: `calc(${100 / this.state.scale}%)`, - width: `calc(${100 / this.state.scale}%)`, - }; - - this.updateContentPlugin.forceUpdate(); - - return ( -
-
- {this.state.editorCreator && ( - - )} -
-
- ); - } - private renderSidePaneButton() { const styles = this.getStyles(); diff --git a/demo/scripts/controls/contentModel/components/format/formatPart/TableMetadataFormatRenders.ts b/demo/scripts/controls/contentModel/components/format/formatPart/TableMetadataFormatRenders.ts index b07c50f53d4..01ebf662005 100644 --- a/demo/scripts/controls/contentModel/components/format/formatPart/TableMetadataFormatRenders.ts +++ b/demo/scripts/controls/contentModel/components/format/formatPart/TableMetadataFormatRenders.ts @@ -2,7 +2,9 @@ import { createCheckboxFormatRenderer } from '../utils/createCheckboxFormatRende import { createColorFormatRenderer } from '../utils/createColorFormatRender'; import { createDropDownFormatRenderer } from '../utils/createDropDownFormatRenderer'; import { FormatRenderer } from '../utils/FormatRenderer'; -import { TableBorderFormat, TableMetadataFormat } from 'roosterjs-content-model-types'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { TableBorderFormat } from 'roosterjs-content-model-core'; +import { TableMetadataFormat } from 'roosterjs-content-model-types'; export const TableMetadataFormatRenders: FormatRenderer[] = [ createColorFormatRenderer( @@ -60,17 +62,20 @@ export const TableMetadataFormatRenders: FormatRenderer[] = createDropDownFormatRenderer( 'TableBorderFormat', [ - 'DEFAULT', - 'LIST_WITH_SIDE_BORDERS', - 'NO_HEADER_BORDERS', - 'NO_SIDE_BORDERS', - 'FIRST_COLUMN_HEADER_EXTERNAL', - 'ESPECIAL_TYPE_1', - 'ESPECIAL_TYPE_2', - 'ESPECIAL_TYPE_3', - 'CLEAR', + 'Default', + 'ListWithSideBorders', + 'NoHeaderBorders', + 'NoSideBorders', + 'FirstColumnHeaderExternal', + 'EspecialType1', + 'EspecialType2', + 'EspecialType3', + 'Clear', ], - format => TableBorderFormat[format.tableBorderFormat] as keyof typeof TableBorderFormat, + format => + getObjectKeys(TableBorderFormat)[ + Object.values(TableBorderFormat).indexOf(format.tableBorderFormat) + ], (format, newValue) => (format.tableBorderFormat = TableBorderFormat[newValue]) ), ]; diff --git a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx new file mode 100644 index 00000000000..eeaa8475c7f --- /dev/null +++ b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { createUIUtilities, ReactEditorPlugin } from 'roosterjs-react'; +import { divProperties, getNativeProps } from '@fluentui/react/lib/Utilities'; +import { useTheme } from '@fluentui/react/lib/Theme'; +import { + ContentModelEditor, + ContentModelEditorOptions, + IContentModelEditor, +} from 'roosterjs-content-model-editor'; +import type { EditorPlugin } from 'roosterjs-editor-types'; + +/** + * Properties for Rooster react component + */ +export interface ContentModelRoosterProps + extends ContentModelEditorOptions, + React.HTMLAttributes { + /** + * 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: ContentModelEditorOptions + ) => IContentModelEditor; + + /** + * Whether editor should get focus once it is created + * Changing of this value after editor is created will not reset editor + */ + focusOnInit?: boolean; +} + +/** + * Main component of react wrapper for roosterjs + * @param props Properties of this component + * @returns The react component + */ +export default function ContentModelRooster(props: ContentModelRoosterProps) { + const editorDiv = React.useRef(null); + const editor = React.useRef(null); + const theme = useTheme(); + + const { focusOnInit, editorCreator, zoomScale, inDarkMode, plugins } = props; + + React.useEffect(() => { + if (plugins && editorDiv.current) { + const uiUtilities = createUIUtilities(editorDiv.current, theme); + + plugins.forEach(plugin => { + if (isReactEditorPlugin(plugin)) { + plugin.setUIUtilities(uiUtilities); + } + }); + } + }, [theme, editorCreator]); + + React.useEffect(() => { + if (editorDiv.current) { + editor.current = (editorCreator || defaultEditorCreator)(editorDiv.current, props); + } + + if (focusOnInit) { + editor.current?.focus(); + } + + return () => { + if (editor.current) { + editor.current.dispose(); + editor.current = null; + } + }; + }, [editorCreator]); + + React.useEffect(() => { + editor.current?.setDarkModeState(!!inDarkMode); + }, [inDarkMode]); + + React.useEffect(() => { + if (zoomScale) { + editor.current?.setZoomScale(zoomScale); + } + }, [zoomScale]); + + const divProps = getNativeProps>(props, divProperties); + return
; +} + +function defaultEditorCreator(div: HTMLDivElement, options: ContentModelEditorOptions) { + return new ContentModelEditor(div, options); +} + +function isReactEditorPlugin(plugin: EditorPlugin): plugin is ReactEditorPlugin { + return !!(plugin as ReactEditorPlugin)?.setUIUtilities; +} diff --git a/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts index e0b0a2236fb..9c81a9b9662 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts @@ -1,7 +1,8 @@ import { formatTable } from 'roosterjs-content-model-api'; import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; -import { TableBorderFormat, TableMetadataFormat } from 'roosterjs-content-model-types'; +import { TableBorderFormat } from 'roosterjs-content-model-core'; +import { TableMetadataFormat } from 'roosterjs-content-model-types'; const PREDEFINED_STYLES: Record< string, @@ -16,7 +17,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.DEFAULT /** tableBorderFormat */, + TableBorderFormat.Default /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -30,7 +31,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.DEFAULT /** tableBorderFormat */, + TableBorderFormat.Default /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -44,7 +45,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.NO_SIDE_BORDERS /** tableBorderFormat */, + TableBorderFormat.NoSideBorders /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -58,7 +59,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.DEFAULT /** tableBorderFormat */, + TableBorderFormat.Default /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -72,7 +73,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.FIRST_COLUMN_HEADER_EXTERNAL /** tableBorderFormat */, + TableBorderFormat.FirstColumnHeaderExternal /** tableBorderFormat */, '#B0B0B0' /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -86,7 +87,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.LIST_WITH_SIDE_BORDERS /** tableBorderFormat */, + TableBorderFormat.ListWithSideBorders /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -100,7 +101,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.NO_HEADER_BORDERS /** tableBorderFormat */, + TableBorderFormat.NoHeaderBorders /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -114,7 +115,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.ESPECIAL_TYPE_1 /** tableBorderFormat */, + TableBorderFormat.EspecialType1 /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -128,7 +129,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.ESPECIAL_TYPE_2 /** tableBorderFormat */, + TableBorderFormat.EspecialType2 /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -142,7 +143,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.ESPECIAL_TYPE_3 /** tableBorderFormat */, + TableBorderFormat.EspecialType3 /** tableBorderFormat */, lightColor /** bgColorEven */, null /** bgColorOdd */, color /** headerRowColor */ @@ -156,7 +157,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.CLEAR /** tableBorderFormat */, + TableBorderFormat.Clear /** tableBorderFormat */, lightColor /** bgColorEven */, null /** bgColorOdd */, color /** headerRowColor */ @@ -171,7 +172,7 @@ export function createTableFormat( bandedColumns?: boolean, headerRow?: boolean, firstColumn?: boolean, - borderFormat?: TableBorderFormat, + borderFormat?: number, bgColorEven?: string, bgColorOdd?: string, headerRowColor?: string diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts index abbb0052cfe..d95efa096c7 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts @@ -1,4 +1,4 @@ -import { BulletListType } from 'roosterjs-content-model-types'; +import { BulletListType } from 'roosterjs-content-model-core'; import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; import { setListStyle } from 'roosterjs-content-model-api'; @@ -21,7 +21,7 @@ export const setBulletedListStyleButton: RibbonButton<'ribbonButtonBulletedListS iconName: 'BulletedList', isDisabled: formatState => !formatState.isBullet, onClick: (editor, key) => { - const value = parseInt(key) as BulletListType; + const value = parseInt(key); if (isContentModelEditor(editor)) { setListStyle(editor, { diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts index 87fe7369ef7..ae195e3fb3a 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts @@ -1,5 +1,5 @@ import { isContentModelEditor } from 'roosterjs-content-model-editor'; -import { NumberingListType } from 'roosterjs-content-model-types'; +import { NumberingListType } from 'roosterjs-content-model-core'; import { RibbonButton } from 'roosterjs-react'; import { setListStyle } from 'roosterjs-content-model-api'; @@ -33,7 +33,7 @@ export const setNumberedListStyleButton: RibbonButton<'ribbonButtonNumberedListS iconName: 'NumberedList', isDisabled: formatState => !formatState.isNumbering, onClick: (editor, key) => { - const value = parseInt(key) as NumberingListType; + const value = parseInt(key); if (isContentModelEditor(editor)) { setListStyle(editor, { diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts index ff557da027d..76bf4ab95f7 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts @@ -1,7 +1,7 @@ -import { isBold } from '../../publicApi/segment/toggleBold'; import { extractBorderValues, getClosestAncestorBlockGroupIndex, + isBold, iterateSelections, updateTableMetadata, } from 'roosterjs-content-model-core'; 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 f20eae3930a..94440c2c7d0 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,4 +1,5 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; +import { isBold } from 'roosterjs-content-model-core'; import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -22,12 +23,3 @@ export default function toggleBold(editor: IStandaloneEditor) { ) ); } - -/** - * @internal - */ -export function isBold(boldStyle?: string): boolean { - return ( - !!boldStyle && (boldStyle == 'bold' || boldStyle == 'bolder' || parseInt(boldStyle) >= 600) - ); -} 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 b63fcf8da10..b3c4f48538f 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 @@ -330,7 +330,9 @@ describe('insertLink', () => { getName: () => 'mock', onPluginEvent: onPluginEvent, }; - const editor = new ContentModelEditor(div, { plugins: [mockedPlugin] }); + const editor = new ContentModelEditor(div, { + plugins: [mockedPlugin], + }); editor.focus(); @@ -341,7 +343,6 @@ describe('insertLink', () => { const a = div.querySelector('a'); expect(a!.outerHTML).toBe('http://test.com'); - expect(onPluginEvent).toHaveBeenCalledTimes(4); expect(onPluginEvent).toHaveBeenCalledWith({ eventType: PluginEventType.ContentChanged, source: ChangeSource.CreateLink, @@ -351,6 +352,7 @@ describe('insertLink', () => { }, contentModel: jasmine.anything(), selection: jasmine.anything(), + changedEntities: [], }); document.body.removeChild(div); diff --git a/packages-content-model/roosterjs-content-model-core/lib/constants/BulletListType.ts b/packages-content-model/roosterjs-content-model-core/lib/constants/BulletListType.ts new file mode 100644 index 00000000000..70fcf326c41 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/constants/BulletListType.ts @@ -0,0 +1,49 @@ +/** + * Enum used to control the different types of bullet list + */ +export const BulletListType = { + /** + * Minimum value of the enum + */ + Min: 1, + /** + * Bullet triggered by * + */ + Disc: 1, + /** + * Bullet triggered by - + */ + Dash: 2, + /** + * Bullet triggered by -- + */ + Square: 3, + /** + * Bullet triggered by > + */ + ShortArrow: 4, + /** + * Bullet triggered by -> + */ + LongArrow: 5, + /** + * Bullet triggered by => + */ + UnfilledArrow: 6, + /** + * Bullet triggered by — + */ + Hyphen: 7, + /** + * Bullet triggered by --> + */ + DoubleLongArrow: 8, + /** + * Bullet type circle + */ + Circle: 9, + /** + * Maximum value of the enum + */ + Max: 9, +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/constants/NumberingListType.ts b/packages-content-model/roosterjs-content-model-core/lib/constants/NumberingListType.ts new file mode 100644 index 00000000000..2a2e8cb72a8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/constants/NumberingListType.ts @@ -0,0 +1,93 @@ +/** + * Enum used to control the different types of numbering list + */ +export const NumberingListType = { + /** + * Minimum value of the enum + */ + Min: 1, + /** + * Numbering triggered by 1. + */ + Decimal: 1, + /** + * Numbering triggered by 1- + */ + DecimalDash: 2, + /** + * Numbering triggered by 1) + */ + DecimalParenthesis: 3, + /** + * Numbering triggered by (1) + */ + DecimalDoubleParenthesis: 4, + /** + * Numbering triggered by a. + */ + LowerAlpha: 5, + /** + * Numbering triggered by a) + */ + LowerAlphaParenthesis: 6, + /** + * Numbering triggered by (a) + */ + LowerAlphaDoubleParenthesis: 7, + /** + * Numbering triggered by a- + */ + LowerAlphaDash: 8, + /** + * Numbering triggered by A. + */ + UpperAlpha: 9, + /** + * Numbering triggered by A) + */ + UpperAlphaParenthesis: 10, + /** + * Numbering triggered by (A) + */ + UpperAlphaDoubleParenthesis: 11, + /** + * Numbering triggered by A- + */ + UpperAlphaDash: 12, + /** + * Numbering triggered by i. + */ + LowerRoman: 13, + /** + * Numbering triggered by i) + */ + LowerRomanParenthesis: 14, + /** + * Numbering triggered by (i) + */ + LowerRomanDoubleParenthesis: 15, + /** + * Numbering triggered by i- + */ + LowerRomanDash: 16, + /** + * Numbering triggered by I. + */ + UpperRoman: 17, + /** + * Numbering triggered by I) + */ + UpperRomanParenthesis: 18, + /** + * Numbering triggered by (I) + */ + UpperRomanDoubleParenthesis: 19, + /** + * Numbering triggered by I- + */ + UpperRomanDash: 20, + /** + * Maximum value of the enum + */ + Max: 20, +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/constants/TableBorderFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/constants/TableBorderFormat.ts new file mode 100644 index 00000000000..21a81fa3246 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/constants/TableBorderFormat.ts @@ -0,0 +1,91 @@ +/** + * Table format border + */ +export const TableBorderFormat = { + /** + * Minimum value + */ + Min: 0, + + /** + * All border of the table are displayed + * __ __ __ + * |__|__|__| + * |__|__|__| + * |__|__|__| + */ + Default: 0, + + /** + * Middle vertical border are not displayed + * __ __ __ + * |__ __ __| + * |__ __ __| + * |__ __ __| + */ + ListWithSideBorders: 1, + + /** + * All borders except header rows borders are displayed + * __ __ __ + * __|__|__ + * __|__|__ + */ + NoHeaderBorders: 2, + + /** + * The left and right border of the table are not displayed + * __ __ __ + * __|__|__ + * __|__|__ + * __|__|__ + */ + NoSideBorders: 3, + + /** + * Only the borders that divides the header row, first column and externals are displayed + * __ __ __ + * |__ __ __| + * | | | + * |__|__ __| + */ + FirstColumnHeaderExternal: 4, + + /** + * The header row has no vertical border, except for the first one + * The first column has no horizontal border, except for the first one + * __ __ __ + * |__ __ __ + * | |__|__| + * | |__|__| + */ + EspecialType1: 5, + + /** + * The header row has no vertical border, except for the first one + * The only horizontal border of the table is the top and bottom of header row + * __ __ __ + * |__ __ __ + * | | | + * | | | + */ + EspecialType2: 6, + + /** + * The only borders are the bottom of header row and the right border of first column + * __ __ __ + * | + * | + */ + EspecialType3: 7, + + /** + * No border + */ + Clear: 8, + + /** + * Maximum value + */ + Max: 8, +}; 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 a4428a172ac..8d160466b20 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 @@ -4,7 +4,6 @@ import { createDomToModelContextWithConfig, domToContentModel, } from 'roosterjs-content-model-dom'; -import type { EditorCore } from 'roosterjs-editor-types'; import type { DOMSelection, DomToModelOption, @@ -43,7 +42,7 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv }; function internalCreateContentModel( - core: StandaloneEditorCore & EditorCore, + core: StandaloneEditorCore, selection?: DOMSelection, option?: DomToModelOption ) { 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 86e3365d913..a81ae4f7be0 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 @@ -1,10 +1,9 @@ import { ChangeSource } from '../constants/ChangeSource'; -import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import type { EditorCore, Entity } from 'roosterjs-editor-types'; +import { PluginEventType } from 'roosterjs-editor-types'; import type { + ChangedEntity, ContentModelContentChangedEvent, DOMSelection, - EntityRemovalOperation, FormatContentModel, FormatWithContentModelContext, StandaloneEditorCore, @@ -35,8 +34,6 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) if (formatter(model, context)) { const writeBack = () => { - handleNewEntities(core, context); - handleDeletedEntities(core, context); handleImages(core, context); selection = @@ -69,6 +66,21 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) additionalData: { formatApiName: apiName, }, + changedEntities: context.newEntities + .map( + (entity): ChangedEntity => ({ + entity, + operation: 'newEntity', + rawEvent, + }) + ) + .concat( + context.deletedEntities.map(entry => ({ + entity: entry.entity, + operation: entry.operation, + rawEvent, + })) + ), }; core.api.triggerEvent(core, eventData, true /*broadcast*/); } else { @@ -81,67 +93,9 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) } }; -function handleNewEntities(core: EditorCore, context: FormatWithContentModelContext) { - // TODO: Ideally we can trigger NewEntity event here. But to be compatible with original editor code, we don't do it here for now. - // Once Content Model Editor can be standalone, we can change this behavior to move triggering NewEntity event code - // from EntityPlugin to here - - if (core.lifecycle.isDarkMode) { - context.newEntities.forEach(entity => { - core.api.transformColor( - core, - entity.wrapper, - true /*includeSelf*/, - null /*callback*/, - ColorTransformDirection.LightToDark - ); - }); - } -} - -// This is only used for compatibility with old editor -// TODO: Remove this map once we have standalone editor -const EntityOperationMap: Record = { - overwrite: EntityOperation.Overwrite, - removeFromEnd: EntityOperation.RemoveFromEnd, - removeFromStart: EntityOperation.RemoveFromStart, -}; - -function handleDeletedEntities(core: EditorCore, context: FormatWithContentModelContext) { - context.deletedEntities.forEach( - ({ - entity: { - wrapper, - entityFormat: { id, entityType, isReadonly }, - }, - operation, - }) => { - if (id && entityType) { - // TODO: Revisit this entity parameter for standalone editor, we may just directly pass ContentModelEntity object instead - const entity: Entity = { - id, - type: entityType, - isReadonly: !!isReadonly, - wrapper, - }; - core.api.triggerEvent( - core, - { - eventType: PluginEventType.EntityOperation, - entity, - operation: EntityOperationMap[operation], - rawEvent: context.rawEvent, - }, - false /*broadcast*/ - ); - } - } - ); -} - -function handleImages(core: EditorCore, context: FormatWithContentModelContext) { +function handleImages(core: StandaloneEditorCore, context: FormatWithContentModelContext) { if (context.newImages.length > 0) { - const viewport = core.getVisibleViewport(); + const viewport = core.api.getVisibleViewport(core); if (viewport) { const { left, right } = viewport; 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 9297df45613..559a7f98d8e 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,6 +1,9 @@ import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { EditorCore } from 'roosterjs-editor-types'; -import type { DOMSelection, GetDOMSelection } from 'roosterjs-content-model-types'; +import type { + DOMSelection, + GetDOMSelection, + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; /** * @internal @@ -9,7 +12,7 @@ export const getDOMSelection: GetDOMSelection = core => { return core.cache.cachedSelection ?? getNewSelection(core); }; -function getNewSelection(core: EditorCore): DOMSelection | null { +function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { // TODO: Get rid of getSelectionRangeEx when we have standalone editor const rangeEx = core.api.getSelectionRangeEx(core); 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 new file mode 100644 index 00000000000..de6dc3d30c3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts @@ -0,0 +1,69 @@ +import type { Rect } from 'roosterjs-editor-types'; +import type { GetVisibleViewport } from 'roosterjs-content-model-types'; + +/** + * @internal + * Retrieves the rect of the visible viewport of the editor. + * @param core The StandaloneEditorCore object + */ +export const getVisibleViewport: GetVisibleViewport = core => { + const scrollContainer = core.domEvent.scrollContainer; + + return getIntersectedRect( + scrollContainer == core.contentDiv ? [scrollContainer] : [scrollContainer, core.contentDiv] + ); +}; + +/** + * Get the intersected Rect of elements provided + * + * @example + * The result of the following Elements Rects would be: + { + top: Element2.top, + bottom: Element1.bottom, + left: Element2.left, + right: Element2.right + } + +-------------------------+ + | Element 1 | + | +-----------------+ | + | | Element2 | | + | | | | + | | | | + +-------------------------+ + | | + +-----------------+ + + * @param elements Elements to use. + * @param additionalRects additional rects to use + * @returns If the Rect is valid return the rect, if not, return null. + */ +function getIntersectedRect(elements: HTMLElement[], additionalRects: Rect[] = []): Rect | null { + const rects = elements + .map(element => normalizeRect(element.getBoundingClientRect())) + .concat(additionalRects) + .filter((rect: Rect | null): rect is Rect => !!rect); + + const result: Rect = { + top: Math.max(...rects.map(r => r.top)), + bottom: Math.min(...rects.map(r => r.bottom)), + left: Math.max(...rects.map(r => r.left)), + right: Math.min(...rects.map(r => r.right)), + }; + + return result.top < result.bottom && result.left < result.right ? result : null; +} + +function normalizeRect(clientRect: DOMRect): Rect | null { + const { left, right, top, bottom } = + clientRect || { left: 0, right: 0, top: 0, bottom: 0 }; + return left === 0 && right === 0 && top === 0 && bottom === 0 + ? null + : { + left: Math.round(left), + right: Math.round(right), + top: Math.round(top), + bottom: Math.round(bottom), + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts index f0cb94159b6..d7f4eed9161 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 @@ -33,7 +33,7 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea if (!option?.ignoreSelection) { core.api.setDOMSelection(core, selection); } else if (selection.type == 'range') { - core.domEvent.selectionRange = selection.range; + core.selection.selectionRange = selection.range; } } 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 42329e710a2..cdc8324d67e 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 @@ -1,17 +1,17 @@ import { iterateSelections } from '../publicApi/selection/iterateSelections'; +import { moveChildNodes } from 'roosterjs-content-model-dom'; import { PluginEventType } from 'roosterjs-editor-types'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { EditorCore, SelectionPath, SwitchShadowEdit } from 'roosterjs-editor-types'; +import type { SwitchShadowEdit } from 'roosterjs-content-model-types'; /** * @internal * Switch the Shadow Edit mode of editor On/Off - * @param editorCore The EditorCore object + * @param editorCore The StandaloneEditorCore object * @param isOn True to switch On, False to switch Off */ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { // TODO: Use strong-typed editor core object - const core = editorCore as StandaloneEditorCore & EditorCore; + const core = editorCore; if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { @@ -20,17 +20,16 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { // Fake object, not used in Content Model Editor, just to satisfy original editor code // TODO: we can remove them once we have standalone Content Model Editor const fragment = core.contentDiv.ownerDocument.createDocumentFragment(); - const selectionPath: SelectionPath = { - start: [], - end: [], - }; + const clonedRoot = core.contentDiv.cloneNode(true /*deep*/); + + moveChildNodes(fragment, clonedRoot); core.api.triggerEvent( core, { eventType: PluginEventType.EnteredShadowEdit, fragment, - selectionPath, + selectionPath: null, }, false /*broadcast*/ ); @@ -41,11 +40,9 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { core.cache.cachedModel = model; } - core.lifecycle.shadowEditSelectionPath = selectionPath; core.lifecycle.shadowEditFragment = fragment; } else { core.lifecycle.shadowEditFragment = null; - core.lifecycle.shadowEditSelectionPath = null; core.api.triggerEvent( core, diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts index 107e36a6b15..72d272f827b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts @@ -1,10 +1,12 @@ import { areSameSelection } from './utils/areSameSelection'; +import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState, ContentModelContentChangedEvent, IStandaloneEditor, + StandaloneEditorOptions, } from 'roosterjs-content-model-types'; import type { IEditor, @@ -16,15 +18,18 @@ import type { /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ -export class ContentModelCachePlugin implements PluginWithState { +class ContentModelCachePlugin implements PluginWithState { private editor: (IEditor & IStandaloneEditor) | null = null; + private state: ContentModelCachePluginState; /** * Construct a new instance of ContentModelEditPlugin class - * @param state State of this plugin + * @param option The editor option */ - constructor(private state: ContentModelCachePluginState) { - // TODO: Remove tempState parameter once we have standalone Content Model editor + constructor(option: StandaloneEditorOptions) { + this.state = { + domIndexer: option.cacheModel ? contentModelDomIndexer : undefined, + }; } /** @@ -188,9 +193,10 @@ export class ContentModelCachePlugin implements PluginWithState { + return new ContentModelCachePlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index c0ce1892653..03576c2c392 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -16,7 +16,12 @@ import { toArray, wrap, } from 'roosterjs-content-model-dom'; -import type { DOMSelection, IStandaloneEditor, OnNodeCreated } from 'roosterjs-content-model-types'; +import type { + DOMSelection, + IStandaloneEditor, + OnNodeCreated, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; import type { CopyPastePluginState, IEditor, @@ -27,15 +32,20 @@ import type { /** * Copy and paste plugin for handling onCopy and onPaste event */ -export class ContentModelCopyPastePlugin implements PluginWithState { +class ContentModelCopyPastePlugin implements PluginWithState { private editor: (IStandaloneEditor & IEditor) | null = null; private disposer: (() => void) | null = null; + private state: CopyPastePluginState; /** * Construct a new instance of CopyPastePlugin - * @param options The editor options + * @param option The editor option */ - constructor(private state: CopyPastePluginState) {} + constructor(option: StandaloneEditorOptions) { + this.state = { + allowedCustomPasteType: option.allowedCustomPasteType || [], + }; + } /** * Get a friendly name of this plugin @@ -289,8 +299,10 @@ export const onNodeCreated: OnNodeCreated = (_, node): void => { /** * @internal * Create a new instance of ContentModelCopyPastePlugin - * @param state The plugin state object + * @param option The editor option */ -export function createContentModelCopyPastePlugin(state: CopyPastePluginState) { - return new ContentModelCopyPastePlugin(state); +export function createContentModelCopyPastePlugin( + option: StandaloneEditorOptions +): PluginWithState { + return new ContentModelCopyPastePlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts index c5f516e1d9a..ae05ee4b1e4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts @@ -7,6 +7,7 @@ import type { IEditor, PluginEvent, PluginWithState } from 'roosterjs-editor-typ import type { ContentModelFormatPluginState, IStandaloneEditor, + StandaloneEditorOptions, } from 'roosterjs-content-model-types'; // During IME input, KeyDown event will have "Process" as key @@ -27,16 +28,20 @@ const CursorMovingKeys = new Set([ * This includes: * 1. Handle pending format changes when selection is collapsed */ -export class ContentModelFormatPlugin implements PluginWithState { +class ContentModelFormatPlugin implements PluginWithState { private editor: (IStandaloneEditor & IEditor) | null = null; private hasDefaultFormat = false; + private state: ContentModelFormatPluginState; /** * Construct a new instance of ContentModelEditPlugin class - * @param state State of this plugin + * @param option The editor option */ - constructor(private state: ContentModelFormatPluginState) { - // TODO: Remove tempState parameter once we have standalone Content Model editor + constructor(option: StandaloneEditorOptions) { + this.state = { + defaultFormat: { ...option.defaultSegmentFormat }, + pendingFormat: null, + }; } /** @@ -163,8 +168,10 @@ export class ContentModelFormatPlugin implements PluginWithState { + return new ContentModelFormatPlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts deleted file mode 100644 index a128c862f50..00000000000 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { EditorPlugin } from 'roosterjs-editor-types'; - -/** - * Dummy plugin, just to skip original TypeInContainerPlugin's behavior - */ -export class ContentModelTypeInContainerPlugin implements EditorPlugin { - /** - * Get name of this plugin - */ - getName() { - return 'ContentModelTypeInContainer'; - } - - /** - * 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() {} - - /** - * 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() {} -} 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 new file mode 100644 index 00000000000..8d639bec281 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts @@ -0,0 +1,249 @@ +import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; +import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; +import type { + DOMEventPluginState, + IStandaloneEditor, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; +import type { + ContextMenuProvider, + DOMEventHandler, + EditorPlugin, + IEditor, + PluginWithState, +} from 'roosterjs-editor-types'; + +/** + * DOMEventPlugin handles customized DOM events, including: + * 1. Keyboard event + * 2. Mouse event + * 3. IME state + * 4. Drop event + * 5. Focus and blur event + * 6. Input event + * 7. Scroll event + * 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 & IEditor) | null = null; + private disposer: (() => void) | null = null; + private state: DOMEventPluginState; + + /** + * Construct a new instance of DOMEventPlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor(options: StandaloneEditorOptions, contentDiv: HTMLDivElement) { + this.state = { + isInIME: false, + scrollContainer: options.scrollContainer || contentDiv, + contextMenuProviders: + options.plugins?.filter>(isContextMenuProvider) || [], + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'DOMEvent'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor as IStandaloneEditor & IEditor; + + const document = this.editor.getDocument(); + //Record + const eventHandlers: Partial< + { [P in keyof HTMLElementEventMap]: DOMEventHandler } + > = { + // 1. Keyboard event + keypress: this.getEventHandler(PluginEventType.KeyPress), + keydown: this.getEventHandler(PluginEventType.KeyDown), + keyup: this.getEventHandler(PluginEventType.KeyUp), + + // 2. Mouse event + mousedown: this.onMouseDown, + contextmenu: this.onContextMenuEvent, + + // 3. IME state management + compositionstart: () => (this.state.isInIME = true), + compositionend: (rawEvent: CompositionEvent) => { + this.state.isInIME = false; + editor.triggerPluginEvent(PluginEventType.CompositionEnd, { + rawEvent, + }); + }, + + // 4. Drag and Drop event + dragstart: this.onDragStart, + drop: this.onDrop, + + // 5. Input event + input: this.getEventHandler(PluginEventType.Input), + }; + + this.disposer = editor.addDomEventHandler(>eventHandlers); + + // 7. Scroll event + this.state.scrollContainer.addEventListener('scroll', this.onScroll); + document.defaultView?.addEventListener('scroll', this.onScroll); + document.defaultView?.addEventListener('resize', this.onScroll); + } + + /** + * Dispose this plugin + */ + dispose() { + this.removeMouseUpEventListener(); + + const document = this.editor?.getDocument(); + + document?.defaultView?.removeEventListener('resize', this.onScroll); + document?.defaultView?.removeEventListener('scroll', this.onScroll); + this.state.scrollContainer.removeEventListener('scroll', this.onScroll); + this.disposer?.(); + this.disposer = null; + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + private onDragStart = (e: Event) => { + const dragEvent = e as DragEvent; + const element = this.editor?.getElementAtCursor('*', dragEvent.target as Node); + + if (element && !element.isContentEditable) { + dragEvent.preventDefault(); + } + }; + private onDrop = () => { + this.editor?.runAsync(editor => { + editor.addUndoSnapshot(() => {}, ChangeSource.Drop); + }); + }; + + private onScroll = (e: Event) => { + this.editor?.triggerPluginEvent(PluginEventType.Scroll, { + rawEvent: e, + scrollContainer: this.state.scrollContainer, + }); + }; + + private getEventHandler(eventType: PluginEventType): DOMEventHandler { + const beforeDispatch = (event: Event) => + eventType == PluginEventType.Input + ? this.onInputEvent(event) + : this.onKeyboardEvent(event); + + return { + pluginEventType: eventType, + beforeDispatch, + }; + } + + private onKeyboardEvent = (event: KeyboardEvent) => { + if (isCharacterValue(event) || (event.which >= Keys.PAGEUP && event.which <= Keys.DOWN)) { + // Stop propagation for Character keys and Up/Down/Left/Right/Home/End/PageUp/PageDown + // since editor already handles these keys and no need to propagate to parents + event.stopPropagation(); + } + }; + + private onInputEvent = (event: InputEvent) => { + event.stopPropagation(); + }; + + private onMouseDown = (event: MouseEvent) => { + if (this.editor) { + if (!this.state.mouseUpEventListerAdded) { + this.editor + .getDocument() + .addEventListener('mouseup', this.onMouseUp, true /*setCapture*/); + this.state.mouseUpEventListerAdded = true; + this.state.mouseDownX = event.pageX; + this.state.mouseDownY = event.pageY; + } + + this.editor.triggerPluginEvent(PluginEventType.MouseDown, { + rawEvent: event, + }); + } + }; + + private onMouseUp = (rawEvent: MouseEvent) => { + if (this.editor) { + this.removeMouseUpEventListener(); + this.editor.triggerPluginEvent(PluginEventType.MouseUp, { + rawEvent, + isClicking: + this.state.mouseDownX == rawEvent.pageX && + this.state.mouseDownY == rawEvent.pageY, + }); + } + }; + + private onContextMenuEvent = (event: MouseEvent) => { + const allItems: any[] = []; + + // TODO: Remove dependency to ContentSearcher + const searcher = this.editor?.getContentSearcherOfCursor(); + const elementBeforeCursor = searcher?.getInlineElementBefore(); + + let eventTargetNode = event.target as Node; + if (event.button != 2 && elementBeforeCursor) { + eventTargetNode = elementBeforeCursor.getContainerNode(); + } + this.state.contextMenuProviders.forEach(provider => { + const items = provider.getContextMenuItems(eventTargetNode) ?? []; + if (items?.length > 0) { + if (allItems.length > 0) { + allItems.push(null); + } + + allItems.push(...items); + } + }); + this.editor?.triggerPluginEvent(PluginEventType.ContextMenu, { + rawEvent: event, + items: allItems, + }); + }; + + private removeMouseUpEventListener() { + if (this.editor && this.state.mouseUpEventListerAdded) { + this.state.mouseUpEventListerAdded = false; + this.editor.getDocument().removeEventListener('mouseup', this.onMouseUp, true); + } + } +} + +function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvider { + return !!(>source)?.getContextMenuItems; +} + +/** + * @internal + * Create a new instance of DOMEventPlugin. + * @param option The editor option + * @param contentDiv The editor content DIV element + */ +export function createDOMEventPlugin( + option: StandaloneEditorOptions, + 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 new file mode 100644 index 00000000000..2dc12f09383 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -0,0 +1,279 @@ +import { findAllEntities } from './utils/findAllEntities'; +import { + createEntity, + generateEntityClassNames, + getAllEntityWrappers, + getObjectKeys, + isEntityElement, + parseEntityClassName, +} from 'roosterjs-content-model-dom'; +import { + ColorTransformDirection, + EntityOperation as LegacyEntityOperation, + PluginEventType, +} from 'roosterjs-editor-types'; +import type { + ChangedEntity, + ContentModelContentChangedEvent, + ContentModelEntityFormat, + EntityOperation, + EntityPluginState, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; +import type { + ContentChangedEvent, + IEditor, + PluginEvent, + PluginMouseUpEvent, + PluginWithState, +} from 'roosterjs-editor-types'; + +const ENTITY_ID_REGEX = /_(\d{1,8})$/; + +// This is only used for compatibility with old editor +// TODO: Remove this map once we have standalone editor +const EntityOperationMap: Record = { + newEntity: LegacyEntityOperation.NewEntity, + overwrite: LegacyEntityOperation.Overwrite, + removeFromEnd: LegacyEntityOperation.RemoveFromEnd, + removeFromStart: LegacyEntityOperation.RemoveFromStart, + replaceTemporaryContent: LegacyEntityOperation.ReplaceTemporaryContent, + updateEntityState: LegacyEntityOperation.UpdateEntityState, + click: LegacyEntityOperation.Click, +}; + +/** + * Entity Plugin helps handle all operations related to an entity and generate entity specified events + */ +class EntityPlugin implements PluginWithState { + private editor: (IEditor & IStandaloneEditor) | null = null; + private state: EntityPluginState; + + /** + * Construct a new instance of EntityPlugin + */ + constructor() { + this.state = { + entityMap: {}, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Entity'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor as IStandaloneEditor & IEditor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + this.state.entityMap = {}; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case PluginEventType.MouseUp: + this.handleMouseUpEvent(this.editor, event); + break; + case PluginEventType.ContentChanged: + this.handleContentChangedEvent(this.editor, event); + break; + + case PluginEventType.EditorReady: + this.handleContentChangedEvent(this.editor); + break; + case PluginEventType.ExtractContentWithDom: + this.handleExtractContentWithDomEvent(this.editor, event.clonedRoot); + break; + } + } + } + + private handleMouseUpEvent(editor: IEditor & IStandaloneEditor, event: PluginMouseUpEvent) { + const { rawEvent, isClicking } = event; + let node: Node | null = rawEvent.target as Node; + + if (isClicking && this.editor) { + while (node && this.editor.contains(node)) { + if (isEntityElement(node)) { + this.triggerEvent(editor, node as HTMLElement, 'click', rawEvent); + break; + } else { + node = node.parentNode; + } + } + } + } + + private handleContentChangedEvent( + editor: IStandaloneEditor & IEditor, + event?: ContentChangedEvent + ) { + const modifiedEntities: ChangedEntity[] = + (event as ContentModelContentChangedEvent)?.changedEntities ?? + this.getChangedEntities(editor); + + modifiedEntities.forEach(entry => { + const { entity, operation, rawEvent } = entry; + const { + entityFormat: { id, entityType, isFakeEntity }, + wrapper, + } = entity; + + if (entityType && !isFakeEntity) { + if (operation == 'newEntity') { + entity.entityFormat.id = this.ensureUniqueId(entityType, id ?? '', wrapper); + wrapper.className = generateEntityClassNames(entity.entityFormat); + + const eventResult = this.triggerEvent(editor, wrapper, operation, rawEvent); + + this.state.entityMap[entity.entityFormat.id] = { + element: wrapper, + canPersist: eventResult?.shouldPersist, + }; + + if (editor.isDarkMode()) { + editor.transformToDarkColor(wrapper, ColorTransformDirection.LightToDark); + } + } else if (id) { + const mapEntry = this.state.entityMap[id]; + + if (mapEntry) { + mapEntry.isDeleted = true; + } + + this.triggerEvent(editor, wrapper, operation, rawEvent); + } + } + }); + } + + private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] { + const result: ChangedEntity[] = []; + + findAllEntities(editor.createContentModel(), result); + + getObjectKeys(this.state.entityMap).forEach(id => { + const entry = this.state.entityMap[id]; + + if (!entry.isDeleted) { + const index = result.findIndex( + x => + x.operation == 'newEntity' && + x.entity.entityFormat.id == id && + x.entity.wrapper == entry.element + ); + + if (index >= 0) { + // Found matched entity in editor, so there is no change to this entity, + // we can safely remove it from the new entity array + 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; + }); + + if (isEntity) { + result.push({ + entity: tempEntity, + operation: 'overwrite', + }); + } + } + } + }); + + return result; + } + + private handleExtractContentWithDomEvent( + editor: IEditor & IStandaloneEditor, + root: HTMLElement + ) { + getAllEntityWrappers(root).forEach(element => { + element.removeAttribute('contentEditable'); + + this.triggerEvent(editor, element, 'replaceTemporaryContent'); + }); + } + + private triggerEvent( + editor: IEditor & IStandaloneEditor, + wrapper: HTMLElement, + operation: EntityOperation, + rawEvent?: Event + ) { + const format: ContentModelEntityFormat = {}; + wrapper.classList.forEach(name => { + parseEntityClassName(name, format); + }); + + return format.id && format.entityType && !format.isFakeEntity + ? editor.triggerPluginEvent(PluginEventType.EntityOperation, { + operation: EntityOperationMap[operation], + rawEvent, + entity: { + id: format.id, + type: format.entityType, + isReadonly: !!format.isReadonly, + wrapper, + }, + }) + : null; + } + + private ensureUniqueId(type: string, id: string, wrapper: HTMLElement): string { + const match = ENTITY_ID_REGEX.exec(id); + const baseId = (match ? id.substr(0, id.length - match[0].length) : id) || type; + + // Make sure entity id is unique + let newId = ''; + + for (let num = (match && parseInt(match[1])) || 0; ; num++) { + newId = num > 0 ? `${baseId}_${num}` : baseId; + + const item = this.state.entityMap[newId]; + + if (!item || item.element == wrapper) { + break; + } + } + + return newId; + } +} + +/** + * @internal + * Create a new instance of EntityPlugin. + */ +export function createEntityPlugin(): PluginWithState { + return new EntityPlugin(); +} 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 new file mode 100644 index 00000000000..79cd28964c9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts @@ -0,0 +1,209 @@ +import { ChangeSource } from '../constants/ChangeSource'; +import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; +import { + createBr, + createContentModelDocument, + createParagraph, + createSelectionMarker, + setColor, +} from 'roosterjs-content-model-dom'; +import type { + ContentModelBlock, + ContentModelBlockGroup, + ContentModelDecorator, + ContentModelDocument, + ContentModelEntity, + ContentModelSegment, + ContentModelSegmentFormat, + ContentModelTableRow, + IStandaloneEditor, + LifecyclePluginState, + OnNodeCreated, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; +import type { IEditor, PluginWithState, PluginEvent } from 'roosterjs-editor-types'; + +const ContentEditableAttributeName = 'contenteditable'; +const DefaultTextColor = '#000000'; +const DefaultBackColor = '#ffffff'; + +/** + * Lifecycle plugin handles editor initialization and disposing + */ +class LifecyclePlugin implements PluginWithState { + private editor: (IStandaloneEditor & IEditor) | null = null; + private state: LifecyclePluginState; + private initialModel: ContentModelDocument; + private initializer: (() => void) | null = null; + private disposer: (() => void) | null = null; + private adjustColor: () => void; + + /** + * Construct a new instance of LifecyclePlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor(options: StandaloneEditorOptions, contentDiv: HTMLDivElement) { + this.initialModel = + options.initialModel ?? this.createInitModel(options.defaultSegmentFormat); + + // Make the container editable and set its selection styles + if (contentDiv.getAttribute(ContentEditableAttributeName) === null) { + this.initializer = () => { + contentDiv.contentEditable = 'true'; + contentDiv.style.userSelect = 'text'; + }; + this.disposer = () => { + contentDiv.style.userSelect = ''; + contentDiv.removeAttribute(ContentEditableAttributeName); + }; + } + this.adjustColor = options.doNotAdjustEditorColor + ? () => {} + : () => { + this.adjustContainerColor(contentDiv); + }; + + this.state = { + isDarkMode: !!options.inDarkMode, + onExternalContentTransform: null, + shadowEditFragment: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Lifecycle'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor as IEditor & IStandaloneEditor; + + this.editor.setContentModel( + this.initialModel, + { ignoreSelection: true }, + this.editor.isDarkMode() ? this.onInitialNodeCreated : undefined + ); + + // Initial model is only used once. After that we can just clean it up to make sure we don't cache anything useless + // including the cached DOM element inside the model. + this.initialModel = createContentModelDocument(); + + // Set content DIV to be editable + this.initializer?.(); + + // Set editor background color for dark mode + this.adjustColor(); + + // Let other plugins know that we are ready + this.editor.triggerPluginEvent(PluginEventType.EditorReady, {}, true /*broadcast*/); + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor?.triggerPluginEvent(PluginEventType.BeforeDispose, {}, true /*broadcast*/); + + if (this.disposer) { + this.disposer(); + this.disposer = null; + this.initializer = null; + } + + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if ( + event.eventType == PluginEventType.ContentChanged && + (event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode) + ) { + this.state.isDarkMode = event.source == ChangeSource.SwitchToDarkMode; + this.adjustColor(); + } + } + + private adjustContainerColor(contentDiv: HTMLElement) { + if (this.editor) { + const { isDarkMode } = this.state; + const darkColorHandler = this.editor.getDarkColorHandler(); + + setColor( + contentDiv, + DefaultTextColor, + false /*isBackground*/, + darkColorHandler, + isDarkMode + ); + setColor( + contentDiv, + DefaultBackColor, + true /*isBackground*/, + darkColorHandler, + isDarkMode + ); + } + } + + private createInitModel(format?: ContentModelSegmentFormat) { + const model = createContentModelDocument(format); + const paragraph = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, format); + + paragraph.segments.push(createSelectionMarker(format), createBr(format)); + model.blocks.push(paragraph); + + return model; + } + + private onInitialNodeCreated: OnNodeCreated = (model, node) => { + if (isEntity(model) && this.editor) { + this.editor.transformToDarkColor(node, ColorTransformDirection.LightToDark); + } + }; +} + +function isEntity( + modelElement: + | ContentModelBlock + | ContentModelBlockGroup + | ContentModelSegment + | ContentModelDecorator + | ContentModelTableRow +): modelElement is ContentModelEntity { + return ( + (modelElement as ContentModelSegment).segmentType == 'Entity' || + (modelElement as ContentModelBlock).blockType == 'Entity' + ); +} + +/** + * @internal + * Create a new instance of LifecyclePlugin. + * @param option The editor option + * @param contentDiv The editor content DIV element + */ +export function createLifecyclePlugin( + option: StandaloneEditorOptions, + 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 new file mode 100644 index 00000000000..c2093e1c984 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -0,0 +1,127 @@ +import type { IEditor, PluginWithState } from 'roosterjs-editor-types'; +import type { + IStandaloneEditor, + SelectionPluginState, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +class SelectionPlugin implements PluginWithState { + private editor: (IStandaloneEditor & IEditor) | null = null; + private state: SelectionPluginState; + private disposer: (() => void) | null = null; + + constructor(options: StandaloneEditorOptions) { + this.state = { + selectionRange: null, + tableSelectionRange: null, + imageSelectionRange: null, + selectionStyleNode: null, + imageSelectionBorderColor: options.imageSelectionBorderColor, // TODO: Move to Selection core plugin + }; + } + + getName() { + return 'Selection'; + } + + initialize(editor: IEditor) { + this.editor = editor as IEditor & IStandaloneEditor; + + const doc = this.editor.getDocument(); + const styleNode = doc.createElement('style'); + + doc.head.appendChild(styleNode); + this.state.selectionStyleNode = styleNode; + + const env = this.editor.getEnvironment(); + const document = this.editor.getDocument(); + + if (env.isSafari) { + document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); + document.addEventListener('keydown', this.onKeyDownDocument); + document.defaultView?.addEventListener('blur', this.onBlur); + this.disposer = this.editor.addDomEventHandler('focus', this.onFocus); + } else { + this.disposer = this.editor.addDomEventHandler({ + focus: this.onFocus, + blur: this.onBlur, + }); + } + } + + dispose() { + if (this.state.selectionStyleNode) { + this.state.selectionStyleNode.parentNode?.removeChild(this.state.selectionStyleNode); + this.state.selectionStyleNode = null; + } + + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + + if (this.editor) { + const document = this.editor.getDocument(); + + document.removeEventListener( + 'mousedown', + this.onMouseDownDocument, + true /*useCapture*/ + ); + document.removeEventListener('keydown', this.onKeyDownDocument); + document.defaultView?.removeEventListener('blur', this.onBlur); + + this.editor = null; + } + } + + getState(): SelectionPluginState { + return this.state; + } + + private onFocus = () => { + if (!this.state.skipReselectOnFocus && this.editor) { + const { table, coordinates } = this.state.tableSelectionRange || {}; + const { image } = this.state.imageSelectionRange || {}; + + if (table && coordinates) { + this.editor.select(table, coordinates); + } else if (image) { + this.editor.select(image); + } else if (this.state.selectionRange) { + this.editor.select(this.state.selectionRange); + } + } + + this.state.selectionRange = null; + }; + + private onBlur = () => { + if (!this.state.selectionRange && this.editor) { + this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); + } + }; + + private onKeyDownDocument = (event: KeyboardEvent) => { + if (event.key == 'Tab' && !event.defaultPrevented) { + this.onBlur(); + } + }; + + private onMouseDownDocument = (event: MouseEvent) => { + if (this.editor && !this.editor.contains(event.target as Node)) { + this.onBlur(); + } + }; +} + +/** + * @internal + * Create a new instance of SelectionPlugin. + * @param option The editor option + */ +export function createSelectionPlugin( + options: StandaloneEditorOptions +): PluginWithState { + return new SelectionPlugin(options); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts new file mode 100644 index 00000000000..de2080bc6c3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -0,0 +1,31 @@ +import { createContentModelCachePlugin } from './ContentModelCachePlugin'; +import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; +import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; +import { createDOMEventPlugin } from './DOMEventPlugin'; +import { createEntityPlugin } from './EntityPlugin'; +import { createLifecyclePlugin } from './LifecyclePlugin'; +import { createSelectionPlugin } from './SelectionPlugin'; +import type { + StandaloneEditorCorePlugins, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * Create core plugins for standalone editor + * @param options Options of editor + */ +export function createStandaloneEditorCorePlugins( + options: StandaloneEditorOptions, + contentDiv: HTMLDivElement +): StandaloneEditorCorePlugins { + return { + cache: createContentModelCachePlugin(options), + format: createContentModelFormatPlugin(options), + copyPaste: createContentModelCopyPastePlugin(options), + domEvent: createDOMEventPlugin(options, contentDiv), + lifecycle: createLifecyclePlugin(options, contentDiv), + entity: createEntityPlugin(), + selection: createSelectionPlugin(options), + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts new file mode 100644 index 00000000000..0f0b466eb92 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts @@ -0,0 +1,44 @@ +import type { ChangedEntity, ContentModelBlockGroup } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function findAllEntities(group: ContentModelBlockGroup, entities: ChangedEntity[]) { + group.blocks.forEach(block => { + switch (block.blockType) { + case 'BlockGroup': + findAllEntities(block, entities); + break; + + case 'Entity': + entities.push({ + entity: block, + operation: 'newEntity', + }); + break; + + case 'Paragraph': + block.segments.forEach(segment => { + switch (segment.segmentType) { + case 'Entity': + entities.push({ + entity: segment, + operation: 'newEntity', + }); + break; + + case 'General': + findAllEntities(segment, entities); + break; + } + }); + break; + + case 'Table': + block.rows.forEach(row => + row.cells.forEach(cell => findAllEntities(cell, entities)) + ); + break; + } + }); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts new file mode 100644 index 00000000000..cda3a6bb61d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts @@ -0,0 +1,205 @@ +import * as Color from 'color'; +import { getObjectKeys, parseColor, setColor } from 'roosterjs-editor-dom'; +import type { + ColorKeyAndValue, + DarkColorHandler, + ModeIndependentColor, +} from 'roosterjs-editor-types'; + +const DefaultLightness = 21.25; // Lightness for #333333 +const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/; +const VARIABLE_PREFIX = 'var('; +const COLOR_VAR_PREFIX = 'darkColor'; +const enum ColorAttributeEnum { + CssColor = 0, + HtmlColor = 1, +} +const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ + { + [ColorAttributeEnum.CssColor]: 'color', + [ColorAttributeEnum.HtmlColor]: 'color', + }, + { + [ColorAttributeEnum.CssColor]: 'background-color', + [ColorAttributeEnum.HtmlColor]: 'bgcolor', + }, +]; + +/** + * @internal + */ +export class DarkColorHandlerImpl implements DarkColorHandler { + private knownColors: Record> = {}; + readonly baseLightness: number; + + constructor(private contentDiv: HTMLElement, baseDarkColor?: string) { + this.baseLightness = getLightness(baseDarkColor); + } + + /** + * Get a copy of known colors + * @returns + */ + getKnownColorsCopy() { + return Object.values(this.knownColors); + } + + /** + * Given a light mode color value and an optional dark mode color value, register this color + * so that editor can handle it, then return the CSS color value for current color mode. + * @param lightModeColor Light mode color value + * @param isDarkMode Whether current color mode is dark mode + * @param darkModeColor Optional dark mode color value. If not passed, we will calculate one. + */ + registerColor(lightModeColor: string, isDarkMode: boolean, darkModeColor?: string): string { + const parsedColor = this.parseColorValue(lightModeColor); + let colorKey: string | undefined; + + if (parsedColor) { + lightModeColor = parsedColor.lightModeColor; + darkModeColor = parsedColor.darkModeColor || darkModeColor; + colorKey = parsedColor.key; + } + + if (isDarkMode && lightModeColor) { + colorKey = + colorKey || `--${COLOR_VAR_PREFIX}_${lightModeColor.replace(/[^\d\w]/g, '_')}`; + + if (!this.knownColors[colorKey]) { + darkModeColor = darkModeColor || getDarkColor(lightModeColor, this.baseLightness); + + this.knownColors[colorKey] = { lightModeColor, darkModeColor }; + this.contentDiv.style.setProperty(colorKey, darkModeColor); + } + + return `var(${colorKey}, ${lightModeColor})`; + } else { + return lightModeColor; + } + } + + /** + * Reset known color record, clean up registered color variables. + */ + reset(): void { + getObjectKeys(this.knownColors).forEach(key => this.contentDiv.style.removeProperty(key)); + this.knownColors = {}; + } + + /** + * Parse an existing color value, if it is in variable-based color format, extract color key, + * light color and query related dark color if any + * @param color The color string to parse + * @param isInDarkMode Whether current content is in dark mode. When set to true, if the color value is not in dark var format, + * we will treat is as a dark mode color and try to find a matched dark mode color. + */ + parseColorValue(color: string | undefined | null, isInDarkMode?: boolean): ColorKeyAndValue { + let key: string | undefined; + let lightModeColor = ''; + let darkModeColor: string | undefined; + + if (color) { + const match = color.startsWith(VARIABLE_PREFIX) ? VARIABLE_REGEX.exec(color) : null; + + if (match) { + if (match[2]) { + key = match[1]; + lightModeColor = match[2]; + darkModeColor = this.knownColors[key]?.darkModeColor; + } else { + lightModeColor = ''; + } + } else if (isInDarkMode) { + // If editor is in dark mode but the color is not in dark color format, it is possible the color was inserted from external code + // without any light color info. So we first try to see if there is a known dark color can match this color, and use its related + // light color as light mode color. Otherwise we need to drop this color to avoid show "white on white" content. + lightModeColor = this.findLightColorFromDarkColor(color) || ''; + + if (lightModeColor) { + darkModeColor = color; + } + } else { + lightModeColor = color; + } + } + + return { key, lightModeColor, darkModeColor }; + } + + /** + * Find related light mode color from dark mode color. + * @param darkColor The existing dark color + */ + findLightColorFromDarkColor(darkColor: string): string | null { + const rgbSearch = parseColor(darkColor); + + if (rgbSearch) { + const key = getObjectKeys(this.knownColors).find(key => { + const rgbCurrent = parseColor(this.knownColors[key].darkModeColor); + + return ( + rgbCurrent && + rgbCurrent[0] == rgbSearch[0] && + rgbCurrent[1] == rgbSearch[1] && + rgbCurrent[2] == rgbSearch[2] + ); + }); + + if (key) { + return this.knownColors[key].lightModeColor; + } + } + + return null; + } + + /** + * Transform element color, from dark to light or from light to dark + * @param element The element to transform color + * @param fromDarkMode Whether this is transforming color from dark mode + * @param toDarkMode Whether this is transforming color to dark mode + */ + transformElementColor(element: HTMLElement, fromDarkMode: boolean, toDarkMode: boolean): void { + ColorAttributeName.forEach((names, i) => { + const color = this.parseColorValue( + element.style.getPropertyValue(names[ColorAttributeEnum.CssColor]) || + element.getAttribute(names[ColorAttributeEnum.HtmlColor]), + !!fromDarkMode + ).lightModeColor; + + element.style.setProperty(names[ColorAttributeEnum.CssColor], null); + element.removeAttribute(names[ColorAttributeEnum.HtmlColor]); + + if (color && color != 'inherit') { + setColor(element, color, i != 0, toDarkMode, false /*shouldAdaptFontColor*/, this); + } + }); + } +} + +function getDarkColor(color: string, baseLightness: number): string { + try { + const computedColor = Color(color || undefined); + const colorLab = computedColor.lab().array(); + const newLValue = (100 - colorLab[0]) * ((100 - baseLightness) / 100) + baseLightness; + color = Color.lab(newLValue, colorLab[1], colorLab[2]) + .rgb() + .alpha(computedColor.alpha()) + .toString(); + } catch {} + + return color; +} + +function getLightness(color?: string): number { + let result = DefaultLightness; + + if (color) { + try { + const computedColor = Color(color || undefined); + result = computedColor.lab().array()[0]; + } catch {} + } + + return result; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts deleted file mode 100644 index e847a849fd9..00000000000 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { contentModelDomIndexer } from '../corePlugin/utils/contentModelDomIndexer'; -import { ContentModelTypeInContainerPlugin } from '../corePlugin/ContentModelTypeInContainerPlugin'; -import { createContentModelCachePlugin } from '../corePlugin/ContentModelCachePlugin'; -import { createContentModelCopyPastePlugin } from '../corePlugin/ContentModelCopyPastePlugin'; -import { createContentModelFormatPlugin } from '../corePlugin/ContentModelFormatPlugin'; -import { createEditorCore } from 'roosterjs-editor-core'; -import { promoteToContentModelEditorCore } from './promoteToContentModelEditorCore'; -import type { CoreCreator, EditorCore, EditorOptions } from 'roosterjs-editor-types'; -import type { - ContentModelPluginState, - StandaloneEditorCore, - StandaloneEditorOptions, -} from 'roosterjs-content-model-types'; - -/** - * Editor Core creator for Content Model editor - */ -export const createContentModelEditorCore: CoreCreator< - EditorCore & StandaloneEditorCore, - EditorOptions & StandaloneEditorOptions -> = (contentDiv, options) => { - const pluginState = getPluginState(options); - const modifiedOptions: EditorOptions & StandaloneEditorOptions = { - ...options, - plugins: [ - createContentModelCachePlugin(pluginState.cache), - createContentModelFormatPlugin(pluginState.format), - ...(options.plugins || []), - ], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: createContentModelCopyPastePlugin(pluginState.copyPaste), - ...options.corePluginOverride, - }, - }; - - const core = createEditorCore(contentDiv, modifiedOptions) as EditorCore & StandaloneEditorCore; - - promoteToContentModelEditorCore(core, modifiedOptions, pluginState); - - return core; -}; - -function getPluginState(options: EditorOptions & StandaloneEditorOptions): ContentModelPluginState { - const format = options.defaultFormat || {}; - return { - cache: { - domIndexer: options.cacheModel ? contentModelDomIndexer : undefined, - }, - copyPaste: { - allowedCustomPasteType: options.allowedCustomPasteType || [], - }, - format: { - defaultFormat: { - fontWeight: format.bold ? 'bold' : undefined, - italic: format.italic || undefined, - underline: format.underline || undefined, - fontFamily: format.fontFamily || undefined, - fontSize: format.fontSize || undefined, - textColor: format.textColors?.lightModeColor || format.textColor || undefined, - backgroundColor: - format.backgroundColors?.lightModeColor || format.backgroundColor || undefined, - }, - pendingFormat: null, - }, - }; -} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts new file mode 100644 index 00000000000..aa92da5fad5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -0,0 +1,84 @@ +import { createStandaloneEditorCorePlugins } from '../corePlugin/createStandaloneEditorCorePlugins'; +import { createStandaloneEditorDefaultSettings } from './createStandaloneEditorDefaultSettings'; +import { DarkColorHandlerImpl } from './DarkColorHandlerImpl'; +import { standaloneCoreApiMap } from './standaloneCoreApiMap'; +import type { EditorPlugin } from 'roosterjs-editor-types'; +import type { + EditorEnvironment, + StandaloneEditorCore, + StandaloneEditorCorePluginState, + StandaloneEditorCorePlugins, + StandaloneEditorOptions, + UnportedCoreApiMap, + UnportedCorePluginState, +} from 'roosterjs-content-model-types'; + +/** + * A temporary function to create Standalone Editor core + * @param contentDiv Editor content DIV + * @param options Editor options + */ +export function createStandaloneEditorCore( + contentDiv: HTMLDivElement, + options: StandaloneEditorOptions, + unportedCoreApiMap: UnportedCoreApiMap, + unportedCorePluginState: UnportedCorePluginState, + tempPlugins: EditorPlugin[] +): StandaloneEditorCore { + const corePlugins = createStandaloneEditorCorePlugins(options, contentDiv); + + return { + contentDiv, + api: { ...standaloneCoreApiMap, ...unportedCoreApiMap, ...options.coreApiOverride }, + originalApi: { ...standaloneCoreApiMap, ...unportedCoreApiMap }, + plugins: [ + corePlugins.cache, + corePlugins.format, + corePlugins.copyPaste, + corePlugins.domEvent, + corePlugins.selection, + corePlugins.entity, + ...tempPlugins, + corePlugins.lifecycle, + ], + environment: createEditorEnvironment(), + darkColorHandler: new DarkColorHandlerImpl(contentDiv, options.baseDarkColor), + trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, + ...createStandaloneEditorDefaultSettings(options), + ...getPluginState(corePlugins), + ...unportedCorePluginState, + }; +} + +function createEditorEnvironment(): EditorEnvironment { + // It is ok to use global window here since the environment should always be the same for all windows in one session + const userAgent = window.navigator.userAgent; + + return { + isMac: window.navigator.appVersion.indexOf('Mac') != -1, + isAndroid: /android/i.test(userAgent), + isSafari: + userAgent.indexOf('Safari') >= 0 && + userAgent.indexOf('Chrome') < 0 && + userAgent.indexOf('Android') < 0, + }; +} + +/** + * @internal export for test only + */ +export function defaultTrustHtmlHandler(html: string) { + return html; +} + +function getPluginState(corePlugins: StandaloneEditorCorePlugins): StandaloneEditorCorePluginState { + return { + domEvent: corePlugins.domEvent.getState(), + copyPaste: corePlugins.copyPaste.getState(), + cache: corePlugins.cache.getState(), + format: corePlugins.format.getState(), + lifecycle: corePlugins.lifecycle.getState(), + entity: corePlugins.entity.getState(), + selection: corePlugins.selection.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/createStandaloneEditorDefaultSettings.ts new file mode 100644 index 00000000000..e7a600fd4bd --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts @@ -0,0 +1,43 @@ +import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; +import { listItemMetadataApplier, listLevelMetadataApplier } from '../metadata/updateListMetadata'; +import { tablePreProcessor } from '../override/tablePreProcessor'; +import type { + DomToModelOption, + ModelToDomOption, + StandaloneEditorDefaultSettings, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * Create default DOM and Content Model conversion settings for a standalone editor + * @param options The editor options + */ +export function createStandaloneEditorDefaultSettings( + options: StandaloneEditorOptions +): StandaloneEditorDefaultSettings { + const defaultDomToModelOptions: (DomToModelOption | undefined)[] = [ + { + processorOverride: { + table: tablePreProcessor, + }, + }, + options.defaultDomToModelOptions, + ]; + const defaultModelToDomOptions: (ModelToDomOption | undefined)[] = [ + { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + options.defaultModelToDomOptions, + ]; + + return { + defaultDomToModelOptions, + defaultModelToDomOptions, + defaultDomToModelConfig: createDomToModelConfig(defaultDomToModelOptions), + defaultModelToDomConfig: createModelToDomConfig(defaultModelToDomOptions), + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts deleted file mode 100644 index 99902794182..00000000000 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { createContentModel } from '../coreApi/createContentModel'; -import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; -import { createEditorContext } from '../coreApi/createEditorContext'; -import { formatContentModel } from '../coreApi/formatContentModel'; -import { getDOMSelection } from '../coreApi/getDOMSelection'; -import { listItemMetadataApplier, listLevelMetadataApplier } from '../metadata/updateListMetadata'; -import { setContentModel } from '../coreApi/setContentModel'; -import { setDOMSelection } from '../coreApi/setDOMSelection'; -import { switchShadowEdit } from '../coreApi/switchShadowEdit'; -import { tablePreProcessor } from '../override/tablePreProcessor'; -import type { - ContentModelPluginState, - StandaloneEditorCore, - StandaloneEditorOptions, -} from 'roosterjs-content-model-types'; -import type { EditorCore, EditorOptions } from 'roosterjs-editor-types'; - -/** - * Creator Content Model Editor Core from Editor Core - * @param core The original EditorCore object - * @param options Options of this editor - */ -export function promoteToContentModelEditorCore( - core: EditorCore, - options: EditorOptions & StandaloneEditorOptions, - pluginState: ContentModelPluginState -) { - const cmCore = core as EditorCore & StandaloneEditorCore; - - promoteCorePluginState(cmCore, pluginState); - promoteContentModelInfo(cmCore, options); - promoteCoreApi(cmCore); - promoteEnvironment(cmCore); -} - -function promoteCorePluginState( - cmCore: StandaloneEditorCore, - pluginState: ContentModelPluginState -) { - Object.assign(cmCore, pluginState); -} - -function promoteContentModelInfo(cmCore: StandaloneEditorCore, options: StandaloneEditorOptions) { - cmCore.defaultDomToModelOptions = [ - { - processorOverride: { - table: tablePreProcessor, - }, - }, - options.defaultDomToModelOptions, - ]; - cmCore.defaultModelToDomOptions = [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - options.defaultModelToDomOptions, - ]; - cmCore.defaultDomToModelConfig = createDomToModelConfig(cmCore.defaultDomToModelOptions); - cmCore.defaultModelToDomConfig = createModelToDomConfig(cmCore.defaultModelToDomOptions); -} - -function promoteCoreApi(cmCore: StandaloneEditorCore) { - cmCore.api.createEditorContext = createEditorContext; - cmCore.api.createContentModel = createContentModel; - cmCore.api.setContentModel = setContentModel; - cmCore.api.switchShadowEdit = switchShadowEdit; - cmCore.api.getDOMSelection = getDOMSelection; - cmCore.api.setDOMSelection = setDOMSelection; - cmCore.api.formatContentModel = formatContentModel; - cmCore.originalApi.createEditorContext = createEditorContext; - cmCore.originalApi.createContentModel = createContentModel; - cmCore.originalApi.setContentModel = setContentModel; - cmCore.originalApi.getDOMSelection = getDOMSelection; - cmCore.originalApi.setDOMSelection = setDOMSelection; - cmCore.originalApi.formatContentModel = formatContentModel; -} - -function promoteEnvironment(cmCore: StandaloneEditorCore) { - cmCore.environment = {}; - - // It is ok to use global window here since the environment should always be the same for all windows in one session - cmCore.environment.isMac = window.navigator.appVersion.indexOf('Mac') != -1; - cmCore.environment.isAndroid = /android/i.test(window.navigator.userAgent); -} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts new file mode 100644 index 00000000000..5092a757f71 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts @@ -0,0 +1,24 @@ +import { createContentModel } from '../coreApi/createContentModel'; +import { createEditorContext } from '../coreApi/createEditorContext'; +import { formatContentModel } from '../coreApi/formatContentModel'; +import { getDOMSelection } from '../coreApi/getDOMSelection'; +import { getVisibleViewport } from '../coreApi/getVisibleViewport'; +import { setContentModel } from '../coreApi/setContentModel'; +import { setDOMSelection } from '../coreApi/setDOMSelection'; +import { switchShadowEdit } from '../coreApi/switchShadowEdit'; +import type { PortedCoreApiMap } from 'roosterjs-content-model-types'; + +/** + * @internal + * Core API map for Standalone Content Model Editor + */ +export const standaloneCoreApiMap: PortedCoreApiMap = { + createContentModel: createContentModel, + createEditorContext: createEditorContext, + formatContentModel: formatContentModel, + getDOMSelection: getDOMSelection, + setContentModel: setContentModel, + setDOMSelection: setDOMSelection, + switchShadowEdit: switchShadowEdit, + getVisibleViewport: getVisibleViewport, +}; 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 a22614c4e03..73d35cdb658 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -6,6 +6,8 @@ export { getClosestAncestorBlockGroupIndex, TypeOfBlockGroup, } from './publicApi/model/getClosestAncestorBlockGroupIndex'; +export { isBold } from './publicApi/model/isBold'; +export { createModelFromHtml } from './publicApi/model/createModelFromHtml'; export { iterateSelections, @@ -40,11 +42,9 @@ export { updateTableCellMetadata } from './metadata/updateTableCellMetadata'; export { updateTableMetadata } from './metadata/updateTableMetadata'; export { updateListMetadata } from './metadata/updateListMetadata'; -export { promoteToContentModelEditorCore } from './editor/promoteToContentModelEditorCore'; -export { createContentModelEditorCore } from './editor/createContentModelEditorCore'; export { ChangeSource } from './constants/ChangeSource'; +export { BulletListType } from './constants/BulletListType'; +export { NumberingListType } from './constants/NumberingListType'; +export { TableBorderFormat } from './constants/TableBorderFormat'; -export { ContentModelCachePlugin } from './corePlugin/ContentModelCachePlugin'; -export { ContentModelCopyPastePlugin } from './corePlugin/ContentModelCopyPastePlugin'; -export { ContentModelFormatPlugin } from './corePlugin/ContentModelFormatPlugin'; -export { ContentModelTypeInContainerPlugin } from './corePlugin/ContentModelTypeInContainerPlugin'; +export { createStandaloneEditorCore } from './editor/createStandaloneEditorCore'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts index 9b53e901fd0..dcc31ec1b62 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts @@ -1,6 +1,7 @@ -import { BulletListType, NumberingListType } from 'roosterjs-content-model-types'; +import { BulletListType } from '../constants/BulletListType'; import { createNumberDefinition, createObjectDefinition } from './definitionCreators'; import { getObjectKeys, updateMetadata } from 'roosterjs-content-model-dom'; +import { NumberingListType } from '../constants/NumberingListType'; import type { ContentModelListItemFormat, ContentModelListItemLevelFormat, @@ -28,7 +29,7 @@ const RomanValues: Record = { IV: 4, I: 1, }; -const OrderedMap: Record = { +const OrderedMap: Record = { [NumberingListType.Decimal]: 'decimal', [NumberingListType.DecimalDash]: '"${Number}- "', [NumberingListType.DecimalParenthesis]: '"${Number}) "', @@ -50,7 +51,7 @@ const OrderedMap: Record = { [NumberingListType.UpperRomanParenthesis]: '"${UpperRoman}) "', [NumberingListType.UpperRomanDoubleParenthesis]: '"(${UpperRoman}) "', }; -const UnorderedMap: Record = { +const UnorderedMap: Record = { [BulletListType.Disc]: 'disc', [BulletListType.Square]: '"∎ "', [BulletListType.Circle]: 'circle', diff --git a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts index 4ecfa7ac824..9f345be483e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts @@ -1,4 +1,4 @@ -import { TableBorderFormat } from 'roosterjs-content-model-types'; +import { TableBorderFormat } from '../constants/TableBorderFormat'; import { updateMetadata } from 'roosterjs-content-model-dom'; import { createBooleanDefinition, @@ -31,8 +31,8 @@ const TableFormatDefinition = createObjectDefinition= 600) + ); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts index 3a9804013c4..c191ee6721d 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts @@ -14,6 +14,7 @@ import type { } from 'roosterjs-content-model-types'; import type { ClipboardData, IEditor } from 'roosterjs-editor-types'; import { + AllowedEntityClasses, applySegmentFormatToElement, createDomToModelContext, domToContentModel, @@ -106,7 +107,11 @@ export function paste( } if (originalFormat) { - context.newPendingFormat = { ...EmptySegmentFormat, ...originalFormat }; // Use empty format as initial value to clear any other format inherits from pasted content + context.newPendingFormat = { + ...EmptySegmentFormat, + ...model.format, + ...originalFormat, + }; // Use empty format as initial value to clear any other format inherits from pasted content } return true; @@ -163,6 +168,8 @@ function createBeforePasteEventData( ): ContentModelBeforePasteEventData { const options = createDefaultHtmlSanitizerOptions(); + options.additionalAllowedCssClasses.push(...AllowedEntityClasses); + // Remove "caret-color" style generated by Safari to make sure caret shows in right color after paste options.cssStyleCallbacks['caret-color'] = () => false; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/applyTableFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/applyTableFormat.ts index ef4455ff0c0..c192d4a2525 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/applyTableFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/applyTableFormat.ts @@ -1,7 +1,7 @@ import { BorderKeys } from 'roosterjs-content-model-dom'; import { combineBorderValue, extractBorderValues } from '../domUtils/borderValues'; import { setTableCellBackgroundColor } from './setTableCellBackgroundColor'; -import { TableBorderFormat } from 'roosterjs-content-model-types'; +import { TableBorderFormat } from '../../constants/TableBorderFormat'; import { updateTableCellMetadata } from '../../metadata/updateTableCellMetadata'; import { updateTableMetadata } from '../../metadata/updateTableMetadata'; import type { @@ -22,7 +22,7 @@ const DEFAULT_FORMAT: Required = { bgColorEven: null, bgColorOdd: '#ABABAB20', headerRowColor: '#ABABAB', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: null, }; @@ -116,15 +116,15 @@ type ShouldUseTransparentBorder = (indexProp: { lastColumn: boolean; }) => [boolean, boolean, boolean, boolean]; -const BorderFormatters: Record = { - [TableBorderFormat.DEFAULT]: _ => [false, false, false, false], - [TableBorderFormat.LIST_WITH_SIDE_BORDERS]: ({ lastColumn, firstColumn }) => [ +const BorderFormatters: Record = { + [TableBorderFormat.Default]: _ => [false, false, false, false], + [TableBorderFormat.ListWithSideBorders]: ({ lastColumn, firstColumn }) => [ false, !lastColumn, false, !firstColumn, ], - [TableBorderFormat.FIRST_COLUMN_HEADER_EXTERNAL]: ({ + [TableBorderFormat.FirstColumnHeaderExternal]: ({ firstColumn, firstRow, lastColumn, @@ -135,37 +135,37 @@ const BorderFormatters: Record = !lastRow && !firstRow, !firstColumn, ], - [TableBorderFormat.NO_HEADER_BORDERS]: ({ firstRow, firstColumn, lastColumn }) => [ + [TableBorderFormat.NoHeaderBorders]: ({ firstRow, firstColumn, lastColumn }) => [ firstRow, firstRow || lastColumn, false, firstRow || firstColumn, ], - [TableBorderFormat.NO_SIDE_BORDERS]: ({ firstColumn, lastColumn }) => [ + [TableBorderFormat.NoSideBorders]: ({ firstColumn, lastColumn }) => [ false, lastColumn, false, firstColumn, ], - [TableBorderFormat.ESPECIAL_TYPE_1]: ({ firstRow, firstColumn }) => [ + [TableBorderFormat.EspecialType1]: ({ firstRow, firstColumn }) => [ firstColumn && !firstRow, firstRow, firstColumn && !firstRow, firstRow && !firstColumn, ], - [TableBorderFormat.ESPECIAL_TYPE_2]: ({ firstRow, firstColumn }) => [ + [TableBorderFormat.EspecialType2]: ({ firstRow, firstColumn }) => [ !firstRow, firstRow || !firstColumn, !firstRow, !firstColumn, ], - [TableBorderFormat.ESPECIAL_TYPE_3]: ({ firstColumn, firstRow }) => [ + [TableBorderFormat.EspecialType3]: ({ firstColumn, firstRow }) => [ true, firstRow || !firstColumn, !firstRow, true, ], - [TableBorderFormat.CLEAR]: () => [true, true, true, true], + [TableBorderFormat.Clear]: () => [true, true, true, true], }; /* @@ -181,10 +181,11 @@ function formatCells( rows.forEach((row, rowIndex) => { row.cells.forEach((cell, colIndex) => { // Format Borders - if (!metaOverrides.borderOverrides[rowIndex][colIndex]) { - const transparentBorderMatrix = BorderFormatters[ - format.tableBorderFormat as TableBorderFormat - ]({ + if ( + !metaOverrides.borderOverrides[rowIndex][colIndex] && + typeof format.tableBorderFormat == 'number' + ) { + const transparentBorderMatrix = BorderFormatters[format.tableBorderFormat]?.({ firstRow: rowIndex === 0, lastRow: rowIndex === rows.length - 1, firstColumn: colIndex === 0, @@ -198,7 +199,7 @@ function formatCells( format.verticalBorderColor, ]; - transparentBorderMatrix.forEach((alwaysUseTransparent, i) => { + transparentBorderMatrix?.forEach((alwaysUseTransparent, i) => { const borderColor = (!alwaysUseTransparent && formatColor[i]) || ''; cell.format[BorderKeys[i]] = combineBorderValue({ diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts index 816ac4e5505..3fb0593f789 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts @@ -43,28 +43,81 @@ export function setTableCellBackgroundColor( delete cell.format.textColor; } - if (applyToSegments && cell.format.textColor) { - cell.blocks.forEach(block => { - if (block.blockType == 'Paragraph') { + if (applyToSegments) { + setAdaptiveCellColor(cell); + } + } else { + delete cell.format.backgroundColor; + delete cell.format.textColor; + if (applyToSegments) { + removeAdaptiveCellColor(cell); + } + } + + delete cell.cachedElement; +} + +function removeAdaptiveCellColor(cell: ContentModelTableCell) { + cell.blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + if ( + block.segmentFormat?.textColor && + shouldRemoveColor(block.segmentFormat?.textColor, cell.format.backgroundColor || '') + ) { + delete block.segmentFormat.textColor; + } + block.segments.forEach(segment => { + if ( + segment.format.textColor && + shouldRemoveColor(segment.format.textColor, cell.format.backgroundColor || '') + ) { + delete segment.format.textColor; + } + }); + } + }); +} + +function setAdaptiveCellColor(cell: ContentModelTableCell) { + if (cell.format.textColor) { + cell.blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + if (!block.segmentFormat?.textColor) { block.segmentFormat = { ...block.segmentFormat, textColor: cell.format.textColor, }; - block.segments.forEach(segment => { + } + block.segments.forEach(segment => { + if (!segment.format?.textColor) { segment.format = { ...segment.format, textColor: cell.format.textColor, }; - }); - } - }); - } - } else { - delete cell.format.backgroundColor; - delete cell.format.textColor; + } + }); + } + }); } +} - delete cell.cachedElement; +/** + * If the cell background color is too dark or too bright, and the text color is white or black, we should remove the text color + * @param textColor the segment or block text color + * @param cellBackgroundColor the cell background color + * @returns + */ +function shouldRemoveColor(textColor: string, cellBackgroundColor: string) { + const lightness = calculateLightness(cellBackgroundColor); + if ( + ([White, 'rgb(255,255,255)'].indexOf(textColor) > -1 && + (lightness > BRIGHT_COLORS_LIGHTNESS || cellBackgroundColor == '')) || + ([Black, 'rgb(0,0,0)'].indexOf(textColor) > -1 && + (lightness < DARK_COLORS_LIGHTNESS || cellBackgroundColor == '')) + ) { + return true; + } + return false; } function calculateLightness(color: string) { diff --git a/packages-content-model/roosterjs-content-model-core/package.json b/packages-content-model/roosterjs-content-model-core/package.json index c932d7dfbde..40d2aab4f68 100644 --- a/packages-content-model/roosterjs-content-model-core/package.json +++ b/packages-content-model/roosterjs-content-model-core/package.json @@ -3,9 +3,9 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", + "color": "^3.0.0", "roosterjs-editor-types": "", "roosterjs-editor-dom": "", - "roosterjs-editor-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" }, 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 b518e96e7e2..cd2832176d4 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 @@ -1,12 +1,7 @@ import { ChangeSource } from '../../lib/constants/ChangeSource'; import { createImage } from 'roosterjs-content-model-dom'; +import { EditorCore, PluginEventType } from 'roosterjs-editor-types'; import { formatContentModel } from '../../lib/coreApi/formatContentModel'; -import { - ColorTransformDirection, - EditorCore, - EntityOperation, - PluginEventType, -} from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelSegmentFormat, @@ -108,6 +103,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -144,6 +140,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -177,6 +174,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -218,6 +216,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -251,6 +250,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -291,27 +291,7 @@ describe('formatContentModel', () => { expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); - expect(triggerEvent).toHaveBeenCalledTimes(3); - expect(triggerEvent).toHaveBeenCalledWith( - core, - { - eventType: PluginEventType.EntityOperation, - entity: { id: 'E1', type: 'E', isReadonly: true, wrapper: entity1.wrapper }, - operation: EntityOperation.RemoveFromStart, - rawEvent: rawEvent, - }, - false - ); - expect(triggerEvent).toHaveBeenCalledWith( - core, - { - eventType: PluginEventType.EntityOperation, - entity: { id: 'E2', type: 'E', isReadonly: true, wrapper: entity2.wrapper }, - operation: EntityOperation.RemoveFromEnd, - rawEvent: rawEvent, - }, - false - ); + expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, { @@ -323,6 +303,18 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [ + { + entity: entity1, + operation: 'removeFromStart', + rawEvent: 'RawEvent', + }, + { + entity: entity2, + operation: 'removeFromEnd', + rawEvent: 'RawEvent', + }, + ], }, true ); @@ -374,24 +366,22 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [ + { + entity: entity1, + operation: 'newEntity', + rawEvent: 'RawEvent', + }, + { + entity: entity2, + operation: 'newEntity', + rawEvent: 'RawEvent', + }, + ], }, true ); - expect(transformToDarkColorSpy).toHaveBeenCalledTimes(2); - expect(transformToDarkColorSpy).toHaveBeenCalledWith( - core, - wrapper1, - true, - null, - ColorTransformDirection.LightToDark - ); - expect(transformToDarkColorSpy).toHaveBeenCalledWith( - core, - wrapper2, - true, - null, - ColorTransformDirection.LightToDark - ); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); it('With selectionOverride', () => { @@ -418,6 +408,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -429,7 +420,7 @@ describe('formatContentModel', () => { const getVisibleViewportSpy = jasmine .createSpy('getVisibleViewport') .and.returnValue({ top: 100, bottom: 200, left: 100, right: 200 }); - core.getVisibleViewport = getVisibleViewportSpy; + core.api.getVisibleViewport = getVisibleViewportSpy; formatContentModel( core, @@ -459,6 +450,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -491,6 +483,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); 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 new file mode 100644 index 00000000000..785895374bb --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts @@ -0,0 +1,38 @@ +import { getVisibleViewport } from '../../lib/coreApi/getVisibleViewport'; + +describe('getVisibleViewport', () => { + it('scrollContainer is same with contentDiv', () => { + const div = { + getBoundingClientRect: () => ({ left: 100, right: 200, top: 300, bottom: 400 }), + }; + const core = { + contentDiv: div, + domEvent: { + scrollContainer: div, + }, + } as any; + + const result = getVisibleViewport(core); + + expect(result).toEqual({ left: 100, right: 200, top: 300, bottom: 400 }); + }); + + it('scrollContainer is different than contentDiv', () => { + const div1 = { + getBoundingClientRect: () => ({ left: 100, right: 200, top: 300, bottom: 400 }), + }; + const div2 = { + getBoundingClientRect: () => ({ left: 150, right: 250, top: 350, bottom: 450 }), + }; + const core = { + contentDiv: div1, + domEvent: { + scrollContainer: div2, + }, + } as any; + + const result = getVisibleViewport(core); + + expect(result).toEqual({ left: 150, right: 200, top: 350, bottom: 400 }); + }); +}); 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 effd9f25284..19fc938f8e6 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 @@ -46,7 +46,7 @@ describe('switchShadowEdit', () => { { eventType: PluginEventType.EnteredShadowEdit, fragment: document.createDocumentFragment(), - selectionPath: { start: [], end: [] }, + selectionPath: null, }, false ); @@ -67,7 +67,7 @@ describe('switchShadowEdit', () => { { eventType: PluginEventType.EnteredShadowEdit, fragment: document.createDocumentFragment(), - selectionPath: { start: [], end: [] }, + selectionPath: null, }, false ); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts index ca503a4feaf..801e7066e3c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts @@ -1,5 +1,5 @@ -import { ContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; -import { IEditor, PluginEventType } from 'roosterjs-editor-types'; +import { createContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; +import { IEditor, PluginEventType, PluginWithState } from 'roosterjs-editor-types'; import { ContentModelCachePluginState, ContentModelDomIndexer, @@ -7,8 +7,7 @@ import { } from 'roosterjs-content-model-types'; describe('ContentModelCachePlugin', () => { - let plugin: ContentModelCachePlugin; - let state: ContentModelCachePluginState; + let plugin: PluginWithState; let editor: IStandaloneEditor & IEditor; let addEventListenerSpy: jasmine.Spy; @@ -29,7 +28,6 @@ describe('ContentModelCachePlugin', () => { reconcileSelection: reconcileSelectionSpy, } as any; - state = {}; editor = ({ getDOMSelection: getDOMSelectionSpy, isInShadowEdit: isInShadowEditSpy, @@ -41,7 +39,7 @@ describe('ContentModelCachePlugin', () => { }, } as any) as IStandaloneEditor & IEditor; - plugin = new ContentModelCachePlugin(state); + plugin = createContentModelCachePlugin({}); plugin.initialize(editor); } @@ -70,9 +68,10 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ + expect(plugin.getState()).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); }); @@ -84,10 +83,15 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined }); + expect(plugin.getState()).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + domIndexer: undefined, + }); }); it('Other key with collapsed selection', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'range', range: { collapsed: true } as any, @@ -102,10 +106,12 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedSelection: { type: 'range', range: { collapsed: true } as any }, + domIndexer: undefined, }); }); it('Expanded selection with text input', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'range', range: { collapsed: false } as any, @@ -118,10 +124,15 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined }); + expect(state).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + domIndexer: undefined, + }); }); it('Expanded selection with arrow input', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'range', range: { collapsed: false } as any, @@ -139,10 +150,12 @@ describe('ContentModelCachePlugin', () => { type: 'range', range: { collapsed: false } as any, }, + domIndexer: undefined, }); }); it('Table selection', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'table', } as any; @@ -154,10 +167,15 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined }); + expect(state).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + domIndexer: undefined, + }); }); it('Image selection', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'image', } as any; @@ -169,10 +187,15 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined }); + expect(state).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + domIndexer: undefined, + }); }); it('Do not clear cache when in shadow edit', () => { + const state = plugin.getState(); isInShadowEditSpy.and.returnValue(true); plugin.onPluginEvent({ @@ -182,7 +205,9 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({}); + expect(state).toEqual({ + domIndexer: undefined, + }); }); }); @@ -193,6 +218,7 @@ describe('ContentModelCachePlugin', () => { }); it('No cached range, no cached model', () => { + const state = plugin.getState(); state.cachedModel = undefined; state.cachedSelection = undefined; @@ -207,12 +233,14 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); }); it('No cached range, has cached model', () => { const selection = 'MockedRange' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = undefined; @@ -227,12 +255,14 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); }); it('No cached range, has cached model, reconcile succeed', () => { const selection = 'MockedRange' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = undefined; @@ -257,6 +287,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -270,6 +301,7 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); }); @@ -277,6 +309,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -307,6 +340,7 @@ describe('ContentModelCachePlugin', () => { it('Same range', () => { const selection = 'MockedRange' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = selection; @@ -332,6 +366,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -357,6 +392,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -387,6 +423,7 @@ describe('ContentModelCachePlugin', () => { it('No domIndexer, no model in event', () => { const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = undefined; @@ -399,6 +436,7 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); @@ -407,6 +445,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -423,6 +462,7 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); @@ -431,6 +471,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts index 40ae5ce1799..26fc2ab2e6c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts @@ -17,13 +17,14 @@ import { IStandaloneEditor, } from 'roosterjs-content-model-types'; import { - ContentModelCopyPastePlugin, + createContentModelCopyPastePlugin, onNodeCreated, } from '../../lib/corePlugin/ContentModelCopyPastePlugin'; import { ClipboardData, ColorTransformDirection, DOMEventHandlerFunction, + EditorPlugin, IEditor, } from 'roosterjs-editor-types'; @@ -36,7 +37,7 @@ const allowedCustomPasteType = ['Test']; describe('ContentModelCopyPastePlugin |', () => { let editor: IEditor = null!; - let plugin: ContentModelCopyPastePlugin; + let plugin: EditorPlugin; let domEvents: Record = {}; let div: HTMLDivElement; @@ -93,7 +94,7 @@ describe('ContentModelCopyPastePlugin |', () => { spyOn(addRangeToSelection, 'addRangeToSelection'); - plugin = new ContentModelCopyPastePlugin({ + plugin = createContentModelCopyPastePlugin({ allowedCustomPasteType, }); editor = ({ diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts index ba2bfeb59fb..e71818fb02f 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts @@ -1,11 +1,7 @@ import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; -import { ContentModelFormatPlugin } from '../../lib/corePlugin/ContentModelFormatPlugin'; +import { createContentModelFormatPlugin } from '../../lib/corePlugin/ContentModelFormatPlugin'; import { IEditor, PluginEventType } from 'roosterjs-editor-types'; -import { - ContentModelFormatPluginState, - IStandaloneEditor, - PendingFormat, -} from 'roosterjs-content-model-types'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, @@ -26,11 +22,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, isDarkMode: () => false, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: ({} as any) as PendingFormat, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); plugin.initialize(editor); plugin.onPluginEvent({ @@ -41,7 +33,7 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); - expect(state.pendingFormat).toBeNull(); + expect(plugin.getState().pendingFormat).toBeNull(); }); it('no selection, trigger input event', () => { @@ -52,16 +44,15 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); const model = createContentModelDocument(); - plugin.initialize(editor); + const state = plugin.getState(); + + (state.pendingFormat = { + format: mockedFormat, + } as any), + plugin.initialize(editor); plugin.onPluginEvent({ eventType: PluginEventType.Input, @@ -90,14 +81,14 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); plugin.initialize(editor); + + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; plugin.onPluginEvent({ eventType: PluginEventType.Input, rawEvent: ({ data: 'a', isComposing: true } as any) as InputEvent, @@ -107,7 +98,7 @@ describe('ContentModelFormatPlugin', () => { expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, - }); + } as any); }); it('with pending format and selection, trigger CompositionEnd event', () => { @@ -124,13 +115,12 @@ describe('ContentModelFormatPlugin', () => { triggerPluginEvent, getVisibleViewport, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; plugin.initialize(editor); plugin.onPluginEvent({ @@ -154,14 +144,16 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + + const plugin = createContentModelFormatPlugin({}); plugin.initialize(editor); + + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; + plugin.onPluginEvent({ eventType: PluginEventType.KeyDown, rawEvent: ({ which: 17 } as any) as KeyboardEvent, @@ -171,7 +163,7 @@ describe('ContentModelFormatPlugin', () => { expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, - }); + } as any); }); it('Content changed event', () => { @@ -184,13 +176,13 @@ describe('ContentModelFormatPlugin', () => { }, cacheContentModel: () => {}, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + + const plugin = createContentModelFormatPlugin({}); + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(false); @@ -213,13 +205,13 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); + + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(false); @@ -243,13 +235,12 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(true); @@ -263,7 +254,7 @@ describe('ContentModelFormatPlugin', () => { expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, - }); + } as any); expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); }); @@ -297,11 +288,11 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, text input, under editor directly', () => { - const state: ContentModelFormatPluginState = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultSegmentFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'a' } as any; getDOMSelection.and.returnValue({ @@ -346,16 +337,18 @@ describe('ContentModelFormatPlugin for default format', () => { }); expect(context).toEqual({ - newPendingFormat: { fontFamily: 'Arial' }, + newPendingFormat: { + fontFamily: 'Arial', + }, }); }); it('Expanded range, text input, under editor directly', () => { - const state = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultSegmentFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'a' } as any; const context = {} as any; @@ -404,11 +397,11 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, IME input, under editor directly', () => { - const state = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultSegmentFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'Process' } as any; const context = {} as any; @@ -452,16 +445,18 @@ describe('ContentModelFormatPlugin for default format', () => { }); expect(context).toEqual({ - newPendingFormat: { fontFamily: 'Arial' }, + newPendingFormat: { + fontFamily: 'Arial', + }, }); }); it('Collapsed range, other input, under editor directly', () => { - const state: ContentModelFormatPluginState = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultSegmentFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'Up' } as any; const context = {} as any; @@ -508,11 +503,11 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, normal input, not under editor directly, no style', () => { - const state = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultSegmentFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'a' } as any; const div = document.createElement('div'); const context = {} as any; @@ -558,16 +553,18 @@ describe('ContentModelFormatPlugin for default format', () => { }); expect(context).toEqual({ - newPendingFormat: { fontFamily: 'Arial' }, + newPendingFormat: { + fontFamily: 'Arial', + }, }); }); it('Collapsed range, text input, under editor directly, has pending format', () => { - const state = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultSegmentFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'a' } as any; const context = {} as any; @@ -617,7 +614,10 @@ describe('ContentModelFormatPlugin for default format', () => { }); expect(context).toEqual({ - newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt' }, + newPendingFormat: { + fontFamily: 'Arial', + fontSize: '10pt', + }, }); }); }); 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 new file mode 100644 index 00000000000..fa608f331c1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts @@ -0,0 +1,487 @@ +import * as eventUtils from '../../lib/publicApi/domUtils/eventUtils'; +import { ChangeSource, IEditor, PluginEventType, PluginWithState } from 'roosterjs-editor-types'; +import { createDOMEventPlugin } from '../../lib/corePlugin/DOMEventPlugin'; +import { DOMEventPluginState, IStandaloneEditor } from 'roosterjs-content-model-types'; + +const getDocument = () => document; + +describe('DOMEventPlugin', () => { + it('init and dispose', () => { + const addEventListener = jasmine.createSpy('addEventListener'); + const removeEventListener = jasmine.createSpy('removeEventListener'); + const div = { + addEventListener, + removeEventListener, + }; + const plugin = createDOMEventPlugin({}, div); + const disposer = jasmine.createSpy('disposer'); + const addDomEventHandler = jasmine + .createSpy('addDomEventHandler') + .and.returnValue(disposer); + const state = plugin.getState(); + const editor = ({ + getDocument, + addDomEventHandler, + getEnvironment: () => ({}), + } as any) as IStandaloneEditor & IEditor; + + plugin.initialize(editor); + + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(addEventListener.calls.argsFor(0)[0]).toBe('scroll'); + + expect(state).toEqual({ + isInIME: false, + scrollContainer: div, + contextMenuProviders: [], + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + expect(addDomEventHandler).toHaveBeenCalled(); + expect(removeEventListener).not.toHaveBeenCalled(); + expect(disposer).not.toHaveBeenCalled(); + + plugin.dispose(); + + expect(removeEventListener).toHaveBeenCalled(); + expect(disposer).toHaveBeenCalled(); + }); + + it('init with different options', () => { + const addEventListener1 = jasmine.createSpy('addEventListener1'); + const addEventListener2 = jasmine.createSpy('addEventListener2'); + const div = { + addEventListener: addEventListener1, + }; + const divScrollContainer = { + addEventListener: addEventListener2, + removeEventListener: jasmine.createSpy('removeEventListener'), + }; + const plugin = createDOMEventPlugin( + { + scrollContainer: divScrollContainer, + }, + div + ); + const state = plugin.getState(); + + const addDomEventHandler = jasmine + .createSpy('addDomEventHandler') + .and.returnValue(jasmine.createSpy('disposer')); + plugin.initialize(({ + getDocument, + addDomEventHandler, + getEnvironment: () => ({}), + })); + + expect(addEventListener1).not.toHaveBeenCalledTimes(1); + expect(addEventListener2).toHaveBeenCalledTimes(1); + expect(addEventListener2.calls.argsFor(0)[0]).toBe('scroll'); + + expect(state).toEqual({ + isInIME: false, + scrollContainer: divScrollContainer, + contextMenuProviders: [], + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + + expect(addDomEventHandler).toHaveBeenCalled(); + + plugin.dispose(); + }); +}); + +describe('DOMEventPlugin verify event handlers while disallow keyboard event propagation', () => { + let eventMap: Record; + let plugin: PluginWithState; + + beforeEach(() => { + const div = { + addEventListener: jasmine.createSpy('addEventListener1'), + removeEventListener: jasmine.createSpy('removeEventListener'), + }; + + plugin = createDOMEventPlugin({}, div); + plugin.initialize(({ + getDocument, + addDomEventHandler: (map: Record) => { + eventMap = map; + return jasmine.createSpy('disposer'); + }, + getEnvironment: () => ({}), + })); + }); + + afterEach(() => { + plugin.dispose(); + eventMap = undefined!; + }); + + it('check events are mapped', () => { + expect(eventMap).toBeDefined(); + expect(eventMap.keypress.pluginEventType).toBe(PluginEventType.KeyPress); + expect(eventMap.keydown.pluginEventType).toBe(PluginEventType.KeyDown); + expect(eventMap.keyup.pluginEventType).toBe(PluginEventType.KeyUp); + expect(eventMap.input.pluginEventType).toBe(PluginEventType.Input); + expect(eventMap.keypress.beforeDispatch).toBeDefined(); + expect(eventMap.keydown.beforeDispatch).toBeDefined(); + expect(eventMap.keyup.beforeDispatch).toBeDefined(); + expect(eventMap.input.beforeDispatch).toBeDefined(); + expect(eventMap.mousedown).toBeDefined(); + expect(eventMap.contextmenu).toBeDefined(); + expect(eventMap.compositionstart).toBeDefined(); + expect(eventMap.compositionend).toBeDefined(); + expect(eventMap.dragstart).toBeDefined(); + expect(eventMap.drop).toBeDefined(); + expect(eventMap.mouseup).not.toBeDefined(); + }); + + it('verify keydown event for non-character value', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(false); + const stopPropagation = jasmine.createSpy(); + eventMap.keydown.beforeDispatch(({ + stopPropagation, + })); + expect(stopPropagation).not.toHaveBeenCalled(); + }); + + it('verify keydown event for character value', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + eventMap.keydown.beforeDispatch(({ + stopPropagation, + })); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('verify input event for non-character value', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(false); + const stopPropagation = jasmine.createSpy(); + eventMap.input.beforeDispatch(({ + stopPropagation, + })); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('verify input event for character value', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + eventMap.input.beforeDispatch(({ + stopPropagation, + })); + expect(stopPropagation).toHaveBeenCalled(); + }); +}); + +describe('DOMEventPlugin handle mouse down and mouse up event', () => { + let plugin: PluginWithState; + let addEventListener: jasmine.Spy; + let removeEventListener: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; + let eventMap: Record; + let scrollContainer: HTMLElement; + let onMouseUp: Function; + + beforeEach(() => { + addEventListener = jasmine + .createSpy('addEventListener') + .and.callFake((eventName, handler, useCapture) => { + expect(eventName).toBe('mouseup'); + expect(useCapture).toBe(true); + + onMouseUp = handler; + }); + removeEventListener = jasmine.createSpy('.removeEventListener'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + scrollContainer = { + addEventListener: () => {}, + removeEventListener: () => {}, + } as any; + plugin = createDOMEventPlugin( + { + scrollContainer, + }, + null! + ); + plugin.initialize(({ + getDocument: () => ({ + addEventListener, + removeEventListener, + }), + triggerPluginEvent, + getEnvironment: () => ({}), + addDomEventHandler: (map: Record) => { + eventMap = map; + return jasmine.createSpy('disposer'); + }, + })); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Trigger mouse down event', () => { + const mockedEvent = { + pageX: 100, + pageY: 200, + }; + eventMap.mousedown(mockedEvent); + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(addEventListener.calls.argsFor(0)[0]).toBe('mouseup'); + expect(addEventListener.calls.argsFor(0)[2]).toBe(true); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: true, + }); + }); + + it('Trigger mouse up event, isClicking', () => { + expect(eventMap.mouseup).toBeUndefined(); + const mockedEvent = { + pageX: 100, + pageY: 200, + }; + eventMap.mousedown(mockedEvent); + + expect(eventMap.mouseup).toBeUndefined(); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: true, + }); + expect(addEventListener).toHaveBeenCalled(); + + onMouseUp(mockedEvent); + + expect(removeEventListener).toHaveBeenCalled(); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.MouseUp, { + rawEvent: mockedEvent, + isClicking: true, + }); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: false, + }); + }); + + it('Trigger mouse up event, isClicking = false', () => { + expect(eventMap.mouseup).toBeUndefined(); + const mockedEvent1 = { + pageX: 100, + pageY: 200, + }; + const mockedEvent2 = { + pageX: 100, + pageY: 300, + }; + eventMap.mousedown(mockedEvent1); + + expect(eventMap.mouseup).toBeUndefined(); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: true, + }); + expect(addEventListener).toHaveBeenCalled(); + + onMouseUp(mockedEvent2); + + expect(removeEventListener).toHaveBeenCalled(); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.MouseUp, { + rawEvent: mockedEvent2, + isClicking: false, + }); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: false, + }); + }); +}); + +describe('DOMEventPlugin handle other event', () => { + let plugin: PluginWithState; + let addEventListener: jasmine.Spy; + let removeEventListener: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; + let eventMap: Record; + let scrollContainer: HTMLElement; + let getElementAtCursorSpy: jasmine.Spy; + let editor: IEditor; + + beforeEach(() => { + addEventListener = jasmine.createSpy('addEventListener'); + removeEventListener = jasmine.createSpy('.removeEventListener'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); + + scrollContainer = { + addEventListener: () => {}, + removeEventListener: () => {}, + } as any; + plugin = createDOMEventPlugin( + { + scrollContainer, + }, + null! + ); + + editor = ({ + getDocument: () => ({ + addEventListener, + removeEventListener, + }), + triggerPluginEvent, + getEnvironment: () => ({}), + addDomEventHandler: (map: Record) => { + eventMap = map; + return jasmine.createSpy('disposer'); + }, + getElementAtCursor: getElementAtCursorSpy, + }); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Trigger compositionstart and compositionend event', () => { + eventMap.compositionstart(); + expect(plugin.getState()).toEqual({ + isInIME: true, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + + expect(triggerPluginEvent).not.toHaveBeenCalled(); + + const mockedEvent = 'EVENT' as any; + eventMap.compositionend(mockedEvent); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.CompositionEnd, { + rawEvent: mockedEvent, + }); + }); + + it('Trigger onDragStart event', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const mockedEvent = { + preventDefault: preventDefaultSpy, + } as any; + + getElementAtCursorSpy.and.returnValue({ + isContentEditable: true, + }); + eventMap.dragstart(mockedEvent); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + + expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('Trigger onDragStart event on readonly element', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const mockedEvent = { + preventDefault: preventDefaultSpy, + } as any; + + getElementAtCursorSpy.and.returnValue({ + isContentEditable: false, + }); + eventMap.dragstart(mockedEvent); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + + expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('Trigger onDrop event', () => { + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + editor.runAsync = (callback: Function) => callback(editor); + editor.addUndoSnapshot = addUndoSnapshotSpy; + + eventMap.drop(); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + contextMenuProviders: [], + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(jasmine.anything(), ChangeSource.Drop); + }); + + it('Trigger contextmenu event, skip reselect', () => { + editor.getContentSearcherOfCursor = () => null!; + const state = plugin.getState(); + const mockedItems1 = ['Item1', 'Item2']; + const mockedItems2 = ['Item3', 'Item4']; + + state.contextMenuProviders = [ + { + getContextMenuItems: () => mockedItems1, + } as any, + { + getContextMenuItems: () => mockedItems2, + } as any, + ]; + + const mockedEvent = { + target: {}, + }; + + eventMap.contextmenu(mockedEvent); + + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContextMenu, { + rawEvent: mockedEvent, + items: ['Item1', 'Item2', null, 'Item3', 'Item4'], + }); + }); +}); 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 new file mode 100644 index 00000000000..9bdf710ffe1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -0,0 +1,650 @@ +import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import { createContentModelDocument, createEntity } from '../../../roosterjs-content-model-dom/lib'; +import { createEntityPlugin } from '../../lib/corePlugin/EntityPlugin'; +import { + ColorTransformDirection, + EntityOperation, + EntityPluginState, + IEditor, + PluginEventType, + PluginWithState, +} from 'roosterjs-editor-types'; + +describe('EntityPlugin', () => { + let editor: IEditor; + let plugin: PluginWithState; + let createContentModelSpy: jasmine.Spy; + let triggerPluginEventSpy: jasmine.Spy; + let isDarkModeSpy: jasmine.Spy; + let containsSpy: jasmine.Spy; + let transformToDarkColorSpy: jasmine.Spy; + + beforeEach(() => { + createContentModelSpy = jasmine.createSpy('createContentModel'); + triggerPluginEventSpy = jasmine.createSpy('triggerPluginEvent'); + isDarkModeSpy = jasmine.createSpy('isDarkMode'); + containsSpy = jasmine.createSpy('contains'); + transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + + editor = { + createContentModel: createContentModelSpy, + triggerPluginEvent: triggerPluginEventSpy, + isDarkMode: isDarkModeSpy, + contains: containsSpy, + transformToDarkColor: transformToDarkColorSpy, + } as any; + plugin = createEntityPlugin(); + plugin.initialize(editor); + }); + + it('ctor', () => { + const state = plugin.getState(); + + expect(state).toEqual({ + entityMap: {}, + }); + }); + + describe('EditorReady event', () => { + it('empty doc', () => { + createContentModelSpy.and.returnValue(createContentModelDocument()); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: {}, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Doc with entity', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Doc with entity, can persist', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + triggerPluginEventSpy.and.returnValue({ + shouldPersist: true, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: true, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('ContentChanged event', () => { + it('No changedEntity param', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('New entity in dark mode', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + isDarkModeSpy.and.returnValue(true); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).toHaveBeenCalledTimes(1); + expect(transformToDarkColorSpy).toHaveBeenCalledWith( + wrapper, + ColorTransformDirection.LightToDark + ); + }); + + it('No changedEntity param, has deleted entity', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + const state = plugin.getState(); + + const wrapper2 = document.createElement('div'); + wrapper2.className = '_Entity _EType_T2 _EId_T2 _EReadonly_1'; + + state.entityMap['T2'] = { + element: wrapper2, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + T2: { + element: wrapper2, + isDeleted: true, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.Overwrite, + rawEvent: undefined, + entity: { + id: 'T2', + type: 'T2', + isReadonly: true, + wrapper: wrapper2, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Do not trigger event for already deleted entity', () => { + const doc = createContentModelDocument(); + + createContentModelSpy.and.returnValue(doc); + const state = plugin.getState(); + + const wrapper2 = document.createElement('div'); + wrapper2.className = '_Entity _EType_T2 _EId_T2 _EReadonly_1'; + + state.entityMap['T2'] = { + element: wrapper2, + isDeleted: true, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + expect(state).toEqual({ + entityMap: { + T2: { + element: wrapper2, + isDeleted: true, + }, + }, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Add back a deleted entity', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + const state = plugin.getState(); + + state.entityMap['Entity1'] = { + element: wrapper, + isDeleted: true, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Has changedEntities parameter', () => { + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + + wrapper1.className = '_Entity _EType_E1 _EId_E1 _EReadonly_1'; + wrapper2.className = '_Entity _EType_E2 _EId_E2 _EReadonly_1'; + + const entity1 = createEntity(wrapper1, true, undefined, 'E1', 'E1'); + const entity2 = createEntity(wrapper2, true, undefined, 'E2', 'E2'); + const mockedEvent = 'EVENT' as any; + const state = plugin.getState(); + + state.entityMap['E1'] = { + element: wrapper1, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + changedEntities: [ + { + entity: entity1, + operation: 'removeFromStart', + rawEvent: mockedEvent, + }, + { + entity: entity2, + operation: 'newEntity', + rawEvent: mockedEvent, + }, + ], + } as any); + + expect(state).toEqual({ + entityMap: { + E1: { + element: wrapper1, + isDeleted: true, + }, + E2: { + element: wrapper2, + canPersist: undefined, + }, + }, + }); + expect(wrapper1.outerHTML).toBe( + '
' + ); + expect(wrapper2.outerHTML).toBe( + '
' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: mockedEvent, + entity: { + id: 'E2', + type: 'E2', + isReadonly: true, + wrapper: wrapper2, + }, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.RemoveFromStart, + rawEvent: mockedEvent, + entity: { + id: 'E1', + type: 'E1', + isReadonly: true, + wrapper: wrapper1, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Handle conflict id', () => { + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + + wrapper1.className = '_Entity _EType_E1 _EId_E1 _EReadonly_1'; + wrapper2.className = '_Entity _EType_E2 _EId_E1 _EReadonly_1'; + + const entity2 = createEntity(wrapper2, true, undefined, 'E2', 'E1'); + const mockedEvent = 'EVENT' as any; + const state = plugin.getState(); + + state.entityMap['E1'] = { + element: wrapper1, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + changedEntities: [ + { + entity: entity2, + operation: 'newEntity', + rawEvent: mockedEvent, + }, + ], + } as any); + + expect(state).toEqual({ + entityMap: { + E1: { + element: wrapper1, + }, + E1_1: { + element: wrapper2, + canPersist: undefined, + }, + }, + }); + expect(wrapper1.outerHTML).toBe( + '
' + ); + expect(wrapper2.outerHTML).toBe( + '
' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: mockedEvent, + entity: { + id: 'E1_1', + type: 'E2', + isReadonly: true, + wrapper: wrapper2, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('MouseUp event', () => { + it('Not on entity', () => { + const mockedNode = { + parentNode: null as any, + } as any; + const mockedEvent = { + target: mockedNode, + } as any; + + containsSpy.and.returnValue(true); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + rawEvent: mockedEvent, + isClicking: true, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + }); + + it('Click on entity', () => { + const mockedNode = { + parentNode: null as any, + classList: ['_ENtity', '_EType_A', '_EId_A'], + } as any; + const mockedEvent = { + target: mockedNode, + } as any; + + containsSpy.and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(true); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + rawEvent: mockedEvent, + isClicking: true, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.Click, + rawEvent: mockedEvent, + entity: { + id: 'A', + type: 'A', + isReadonly: false, + wrapper: mockedNode, + }, + }); + }); + + it('Click on child of entity', () => { + const mockedNode1 = { + parentNode: null as any, + classList: ['_ENtity', '_EType_A', '_EId_A'], + } as any; + + const mockedNode2 = { + parentNode: mockedNode1, + } as any; + const mockedEvent = { + target: mockedNode2, + } as any; + + containsSpy.and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.callFake(node => node == mockedNode1); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + rawEvent: mockedEvent, + isClicking: true, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.Click, + rawEvent: mockedEvent, + entity: { + id: 'A', + type: 'A', + isReadonly: false, + wrapper: mockedNode1, + }, + }); + }); + + it('Not clicking', () => { + const mockedNode = { + parentNode: null as any, + classList: ['_ENtity', '_EType_A', '_EId_A'], + } as any; + const mockedEvent = { + target: mockedNode, + } as any; + + containsSpy.and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(true); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + rawEvent: mockedEvent, + isClicking: false, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('ExtractContentWithDom event', () => { + it('no entity', () => { + spyOn(entityUtils, 'getAllEntityWrappers').and.returnValue([]); + + plugin.onPluginEvent({ + eventType: PluginEventType.ExtractContentWithDom, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + }); + + it('Has entity', () => { + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + + wrapper1.className = '_Entity _EType_E1 _EId_E1 _EReadonly_1'; + wrapper2.className = '_Entity _EType_E2 _EId_E2 _EReadonly_1'; + + spyOn(entityUtils, 'getAllEntityWrappers').and.returnValue([wrapper1, wrapper2]); + + plugin.onPluginEvent({ + eventType: PluginEventType.ExtractContentWithDom, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.ReplaceTemporaryContent, + rawEvent: undefined, + entity: { + id: 'E1', + type: 'E1', + isReadonly: true, + wrapper: wrapper1, + }, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.ReplaceTemporaryContent, + rawEvent: undefined, + entity: { + id: 'E2', + type: 'E2', + isReadonly: true, + wrapper: wrapper2, + }, + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..b061a78ab19 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts @@ -0,0 +1,188 @@ +import { createLifecyclePlugin } from '../../lib/corePlugin/LifecyclePlugin'; +import { DarkColorHandler, IEditor, PluginEventType } from 'roosterjs-editor-types'; + +describe('LifecyclePlugin', () => { + it('init', () => { + const div = document.createElement('div'); + const plugin = createLifecyclePlugin({}, div); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + const state = plugin.getState(); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + + plugin.initialize(({ + triggerPluginEvent, + setContent: (content: string) => (div.innerHTML = content), + getFocusedPosition: () => null, + getDarkColorHandler: () => null, + isDarkMode: () => false, + setContentModel: setContentModelSpy, + })); + + expect(state).toEqual({ + isDarkMode: false, + onExternalContentTransform: null, + shadowEditFragment: null, + }); + + expect(div.isContentEditable).toBeTrue(); + expect(div.style.userSelect).toBe('text'); + expect(div.innerHTML).toBe(''); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent.calls.argsFor(0)[0]).toBe(PluginEventType.EditorReady); + expect(setContentModelSpy).toHaveBeenCalledTimes(1); + expect(setContentModelSpy).toHaveBeenCalledWith( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }, + { ignoreSelection: true }, + undefined + ); + + plugin.dispose(); + expect(div.isContentEditable).toBeFalse(); + }); + + it('init with options', () => { + const mockedModel = 'MODEL' as any; + const div = document.createElement('div'); + const plugin = createLifecyclePlugin( + { + defaultSegmentFormat: { + fontFamily: 'arial', + }, + initialModel: mockedModel, + }, + div + ); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + const state = plugin.getState(); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + + plugin.initialize(({ + triggerPluginEvent, + setContent: (content: string) => (div.innerHTML = content), + getFocusedPosition: () => null, + getDarkColorHandler: () => null, + isDarkMode: () => false, + setContentModel: setContentModelSpy, + })); + + expect(state).toEqual({ + isDarkMode: false, + onExternalContentTransform: null, + shadowEditFragment: null, + }); + + expect(setContentModelSpy).toHaveBeenCalledTimes(1); + expect(setContentModelSpy).toHaveBeenCalledWith( + mockedModel, + { ignoreSelection: true }, + undefined + ); + + expect(div.isContentEditable).toBeTrue(); + expect(div.style.userSelect).toBe('text'); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent.calls.argsFor(0)[0]).toBe(PluginEventType.EditorReady); + + plugin.dispose(); + expect(div.isContentEditable).toBeFalse(); + }); + + it('init with DIV which already has contenteditable attribute', () => { + const div = document.createElement('div'); + div.contentEditable = 'true'; + const plugin = createLifecyclePlugin({}, div); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + + plugin.initialize(({ + triggerPluginEvent, + setContent: (content: string) => (div.innerHTML = content), + getFocusedPosition: () => null, + getDarkColorHandler: () => null, + isDarkMode: () => false, + setContentModel: setContentModelSpy, + })); + + expect(div.isContentEditable).toBeTrue(); + expect(div.style.userSelect).toBe(''); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent.calls.argsFor(0)[0]).toBe(PluginEventType.EditorReady); + + expect(setContentModelSpy).toHaveBeenCalledTimes(1); + expect(setContentModelSpy).toHaveBeenCalledWith( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }, + { ignoreSelection: true }, + undefined + ); + + plugin.dispose(); + expect(div.isContentEditable).toBeTrue(); + }); + + it('init with DIV which already has contenteditable attribute and set to false', () => { + const div = document.createElement('div'); + div.contentEditable = 'false'; + const plugin = createLifecyclePlugin({}, div); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + + plugin.initialize(({ + triggerPluginEvent, + setContent: (content: string) => (div.innerHTML = content), + getFocusedPosition: () => null, + getDarkColorHandler: () => null, + isDarkMode: () => false, + setContentModel: setContentModelSpy, + })); + + expect(setContentModelSpy).toHaveBeenCalledTimes(1); + expect(setContentModelSpy).toHaveBeenCalledWith( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }, + { ignoreSelection: true }, + undefined + ); + expect(div.isContentEditable).toBeFalse(); + expect(div.style.userSelect).toBe(''); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent.calls.argsFor(0)[0]).toBe(PluginEventType.EditorReady); + + plugin.dispose(); + expect(div.isContentEditable).toBeFalse(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts new file mode 100644 index 00000000000..2106383876f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts @@ -0,0 +1,214 @@ +import { ChangedEntity } from 'roosterjs-content-model-types'; +import { findAllEntities } from '../../../lib/corePlugin/utils/findAllEntities'; +import { + createContentModelDocument, + createEntity, + createFormatContainer, + createGeneralBlock, + createGeneralSegment, + createListItem, + createListLevel, + createParagraph, + createTable, + createTableCell, +} from 'roosterjs-content-model-dom'; + +describe('findAllEntities', () => { + it('Empty model', () => { + const model = createContentModelDocument(); + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([]); + }); + + it('Root level block entity', () => { + const model = createContentModelDocument(); + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper); + + model.blocks.push(entity); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under root level paragraph', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + + para.segments.push(entity); + model.blocks.push(para); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Mixed entities', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('span'); + const entity1 = createEntity(wrapper1); + const entity2 = createEntity(wrapper2); + + para.segments.push(entity2); + model.blocks.push(para, entity1); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity: entity2, + operation: 'newEntity', + }, + { + entity: entity1, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under general model', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + const generalElement = document.createElement('div'); + const generalBlock = createGeneralBlock(generalElement); + + para.segments.push(entity); + generalBlock.blocks.push(para); + model.blocks.push(generalBlock); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under general segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + const generalElement = document.createElement('span'); + const generalSegment = createGeneralSegment(generalElement); + + generalSegment.blocks.push(entity); + para.segments.push(generalSegment); + model.blocks.push(para); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under list item', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + + const listItem = createListItem([createListLevel('OL')]); + + para.segments.push(entity); + listItem.blocks.push(para); + model.blocks.push(listItem); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under format container', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + + const container = createFormatContainer('div'); + + para.segments.push(entity); + container.blocks.push(para); + model.blocks.push(container); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under table', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + + const table = createTable(1); + const cell = createTableCell(); + + para.segments.push(entity); + cell.blocks.push(para); + table.rows[0].cells.push(cell); + model.blocks.push(table); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts new file mode 100644 index 00000000000..0372c39c164 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts @@ -0,0 +1,549 @@ +import { ColorKeyAndValue } from 'roosterjs-editor-types'; +import { DarkColorHandlerImpl } from '../../lib/editor/DarkColorHandlerImpl'; + +describe('DarkColorHandlerImpl.ctor', () => { + it('No additional param', () => { + const div = document.createElement('div'); + const handler = new DarkColorHandlerImpl(div); + + expect(handler).toBeDefined(); + expect(handler.baseLightness).toBe(21.25); + }); + + it('With customized base color', () => { + const div = document.createElement('div'); + const handler = new DarkColorHandlerImpl(div, '#555555'); + + expect(handler).toBeDefined(); + expect(Math.round(handler.baseLightness)).toBe(36); + }); + + it('Calculate color using customized base color', () => { + const div = document.createElement('div'); + const handler = new DarkColorHandlerImpl(div, '#555555'); + + const darkColor = handler.registerColor('red', true); + const parsedColor = handler.parseColorValue(darkColor); + + expect(darkColor).toBe('var(--darkColor_red, red)'); + expect(parsedColor).toEqual({ + key: '--darkColor_red', + lightModeColor: 'red', + darkModeColor: 'rgb(255, 72, 40)', + }); + }); +}); + +describe('DarkColorHandlerImpl.parseColorValue', () => { + let div: HTMLElement; + let handler: DarkColorHandlerImpl; + + beforeEach(() => { + div = document.createElement('div'); + handler = new DarkColorHandlerImpl(div); + }); + + function runTest(input: string, expectedOutput: ColorKeyAndValue) { + const result = handler.parseColorValue(input); + + expect(result).toEqual(expectedOutput); + } + + it('empty color', () => { + runTest(null!, { + key: undefined, + lightModeColor: '', + darkModeColor: undefined, + }); + }); + + it('simple color', () => { + runTest('aa', { + key: undefined, + lightModeColor: 'aa', + darkModeColor: undefined, + }); + }); + + it('var color without fallback', () => { + runTest('var(--bb)', { + key: undefined, + lightModeColor: '', + darkModeColor: undefined, + }); + }); + + it('var color with fallback', () => { + runTest('var(--bb,cc)', { + key: '--bb', + lightModeColor: 'cc', + darkModeColor: undefined, + }); + }); + + it('var color with fallback, has dark color', () => { + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'dd', + darkModeColor: 'ee', + }, + }; + runTest('var(--bb,cc)', { + key: '--bb', + lightModeColor: 'cc', + darkModeColor: 'ee', + }); + }); + + function runDarkTest(input: string, expectedOutput: ColorKeyAndValue) { + const result = handler.parseColorValue(input, true); + + expect(result).toEqual(expectedOutput); + } + + it('simple color in dark mode', () => { + runDarkTest('aa', { + key: undefined, + lightModeColor: '', + darkModeColor: undefined, + }); + }); + + it('var color in dark mode', () => { + runDarkTest('var(--aa, bb)', { + key: '--aa', + lightModeColor: 'bb', + darkModeColor: undefined, + }); + }); + + it('known simple color in dark mode', () => { + (handler as any).knownColors = { + '--bb': { + lightModeColor: '#ff0000', + darkModeColor: '#00ffff', + }, + }; + runDarkTest('#00ffff', { + key: undefined, + lightModeColor: '#ff0000', + darkModeColor: '#00ffff', + }); + }); +}); + +describe('DarkColorHandlerImpl.registerColor', () => { + let setProperty: jasmine.Spy; + let handler: DarkColorHandlerImpl; + + beforeEach(() => { + setProperty = jasmine.createSpy('setProperty'); + const div = ({ + style: { + setProperty, + }, + } as any) as HTMLElement; + handler = new DarkColorHandlerImpl(div); + }); + + function runTest( + input: string, + isDark: boolean, + darkColor: string | undefined, + expectedOutput: string, + expectedKnownColors: Record, + expectedSetPropertyCalls: [string, string][] + ) { + const result = handler.registerColor(input, isDark, darkColor); + + expect(result).toEqual(expectedOutput); + expect((handler as any).knownColors).toEqual(expectedKnownColors); + expect(setProperty).toHaveBeenCalledTimes(expectedSetPropertyCalls.length); + + expectedSetPropertyCalls.forEach(v => { + expect(setProperty).toHaveBeenCalledWith(...v); + }); + } + + it('empty color, light mode', () => { + runTest('', false, undefined, '', {}, []); + }); + + it('simple color, light mode', () => { + runTest('red', false, undefined, 'red', {}, []); + }); + + it('empty color, dark mode', () => { + runTest('', true, undefined, '', {}, []); + }); + + it('simple color, dark mode', () => { + runTest( + 'red', + true, + undefined, + 'var(--darkColor_red, red)', + { + '--darkColor_red': { + lightModeColor: 'red', + darkModeColor: 'rgb(255, 39, 17)', + }, + }, + [['--darkColor_red', 'rgb(255, 39, 17)']] + ); + }); + + it('simple color, dark mode, with dark color', () => { + runTest( + 'red', + true, + 'blue', + 'var(--darkColor_red, red)', + { + '--darkColor_red': { + lightModeColor: 'red', + darkModeColor: 'blue', + }, + }, + [['--darkColor_red', 'blue']] + ); + }); + + it('var color, light mode', () => { + runTest('var(--aa, bb)', false, undefined, 'bb', {}, []); + }); + + it('var color, dark mode', () => { + runTest( + 'var(--aa, red)', + true, + undefined, + 'var(--aa, red)', + { + '--aa': { + lightModeColor: 'red', + darkModeColor: 'rgb(255, 39, 17)', + }, + }, + [['--aa', 'rgb(255, 39, 17)']] + ); + }); + + it('var color, dark mode with dark color', () => { + runTest( + 'var(--aa, bb)', + true, + 'cc', + 'var(--aa, bb)', + { + '--aa': { + lightModeColor: 'bb', + darkModeColor: 'cc', + }, + }, + [['--aa', 'cc']] + ); + }); + + it('var color, dark mode with dark color and existing dark color', () => { + (handler as any).knownColors['--aa'] = { + lightModeColor: 'dd', + darkModeColor: 'ee', + }; + runTest( + 'var(--aa, bb)', + true, + 'cc', + 'var(--aa, bb)', + { + '--aa': { + lightModeColor: 'dd', + darkModeColor: 'ee', + }, + }, + [] + ); + }); +}); + +describe('DarkColorHandlerImpl.reset', () => { + it('Reset', () => { + const removeProperty = jasmine.createSpy('removeProperty'); + const div = ({ + style: { + removeProperty, + }, + } as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--aa': { + lightModeColor: 'bb', + darkModeColor: 'cc', + }, + '--dd': { + lightModeColor: 'ee', + darkModeColor: 'ff', + }, + }; + + handler.reset(); + + expect((handler as any).knownColors).toEqual({}); + expect(removeProperty).toHaveBeenCalledTimes(2); + expect(removeProperty).toHaveBeenCalledWith('--aa'); + expect(removeProperty).toHaveBeenCalledWith('--dd'); + }); +}); + +describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { + it('Not found', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + const result = handler.findLightColorFromDarkColor('#010203'); + + expect(result).toEqual(null); + }); + + it('Found: HEX to RGB', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'bb', + darkModeColor: 'rgb(4,5,6)', + }, + '--aa': { + lightModeColor: 'aa', + darkModeColor: 'rgb(1,2,3)', + }, + }; + + const result = handler.findLightColorFromDarkColor('#010203'); + + expect(result).toEqual('aa'); + }); + + it('Found: HEX to HEX', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'bb', + darkModeColor: 'rgb(4,5,6)', + }, + '--aa': { + lightModeColor: 'aa', + darkModeColor: '#010203', + }, + }; + + const result = handler.findLightColorFromDarkColor('#010203'); + + expect(result).toEqual('aa'); + }); + + it('Found: RGB to HEX', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'bb', + darkModeColor: 'rgb(4,5,6)', + }, + '--aa': { + lightModeColor: 'aa', + darkModeColor: '#010203', + }, + }; + + const result = handler.findLightColorFromDarkColor('rgb(1,2,3)'); + + expect(result).toEqual('aa'); + }); + + it('Found: RGB to RGB', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'bb', + darkModeColor: 'rgb(4,5,6)', + }, + '--aa': { + lightModeColor: 'aa', + darkModeColor: 'rgb(1, 2, 3)', + }, + }; + + const result = handler.findLightColorFromDarkColor('rgb(1,2,3)'); + + expect(result).toEqual('aa'); + }); +}); + +describe('DarkColorHandlerImpl.transformElementColor', () => { + let parseColorSpy: jasmine.Spy; + let registerColorSpy: jasmine.Spy; + let handler: DarkColorHandlerImpl; + let contentDiv: HTMLDivElement; + + beforeEach(() => { + contentDiv = document.createElement('div'); + handler = new DarkColorHandlerImpl(contentDiv); + + parseColorSpy = spyOn(handler, 'parseColorValue').and.callThrough(); + registerColorSpy = spyOn(handler, 'registerColor').and.callThrough(); + }); + + it('No color, light to dark', () => { + const span = document.createElement('span'); + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in HTML, light to dark', () => { + const span = document.createElement('span'); + + span.setAttribute('color', 'red'); + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('Has simple color in CSS, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('Has color in both text and background, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + span.style.backgroundColor = 'green'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe( + '' + ); + expect(parseColorSpy).toHaveBeenCalledTimes(4); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith('green', false); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith('green'); + expect(registerColorSpy).toHaveBeenCalledTimes(2); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + expect(registerColorSpy).toHaveBeenCalledWith('green', true, undefined); + }); + + it('Has var-based color, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'var(--darkColor_red, red)'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('var(--darkColor_red, red)', false); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('No color, dark to light', () => { + const span = document.createElement('span'); + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in HTML, dark to light', () => { + const span = document.createElement('span'); + + span.setAttribute('color', 'red'); + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in CSS, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has color in both text and background, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + span.style.backgroundColor = 'green'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith('green', true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has var-based color, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'var(--darkColor_red, red)'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('var(--darkColor_red, red)', true); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', false, undefined); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts deleted file mode 100644 index fb1cb935557..00000000000 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts +++ /dev/null @@ -1,237 +0,0 @@ -import * as ContentModelCachePlugin from '../../lib/corePlugin/ContentModelCachePlugin'; -import * as ContentModelCopyPastePlugin from '../../lib/corePlugin/ContentModelCopyPastePlugin'; -import * as ContentModelFormatPlugin from '../../lib/corePlugin/ContentModelFormatPlugin'; -import * as createEditorCore from 'roosterjs-editor-core/lib/editor/createEditorCore'; -import * as promoteToContentModelEditorCore from '../../lib/editor/promoteToContentModelEditorCore'; -import { contentModelDomIndexer } from '../../lib/corePlugin/utils/contentModelDomIndexer'; -import { ContentModelTypeInContainerPlugin } from '../../lib/corePlugin/ContentModelTypeInContainerPlugin'; -import { createContentModelEditorCore } from '../../lib/editor/createContentModelEditorCore'; -import { EditorOptions } from 'roosterjs-editor-types'; -import { StandaloneEditorOptions } from 'roosterjs-content-model-types'; - -const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; -const mockedFormatPlugin = 'FORMATPLUGIN' as any; -const mockedCachePlugin = 'CACHPLUGIN' as any; -const mockedCopyPastePlugin = 'COPYPASTE' as any; -const mockedCopyPastePlugin2 = 'COPYPASTE2' as any; - -describe('createContentModelEditorCore', () => { - let createEditorCoreSpy: jasmine.Spy; - let promoteToContentModelEditorCoreSpy: jasmine.Spy; - let mockedCore: any; - let contentDiv: any; - - beforeEach(() => { - contentDiv = { - style: {}, - } as any; - - mockedCore = { - lifecycle: { - experimentalFeatures: [], - }, - api: { - switchShadowEdit: mockedSwitchShadowEdit, - }, - originalApi: { - a: 'b', - }, - contentDiv, - } as any; - - createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.returnValue( - mockedCore - ); - promoteToContentModelEditorCoreSpy = spyOn( - promoteToContentModelEditorCore, - 'promoteToContentModelEditorCore' - ); - spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( - mockedFormatPlugin - ); - spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( - mockedCachePlugin - ); - spyOn(ContentModelCopyPastePlugin, 'createContentModelCopyPastePlugin').and.returnValue( - mockedCopyPastePlugin - ); - }); - - it('No additional option', () => { - const core = createContentModelEditorCore(contentDiv, {}); - - const expectedOptions = { - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: mockedCopyPastePlugin, - }, - }; - const expectedPluginState: any = { - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, - pendingFormat: null, - }, - }; - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); - expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( - core, - expectedOptions, - expectedPluginState - ); - }); - - it('With additional option', () => { - const defaultDomToModelOptions = { a: '1' } as any; - const defaultModelToDomOptions = { b: '2' } as any; - - const options = { - defaultDomToModelOptions, - defaultModelToDomOptions, - corePluginOverride: { - copyPaste: mockedCopyPastePlugin2, - }, - }; - const core = createContentModelEditorCore(contentDiv, options); - - const expectedOptions = { - defaultDomToModelOptions, - defaultModelToDomOptions, - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: mockedCopyPastePlugin2, - }, - }; - const expectedPluginState: any = { - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, - pendingFormat: null, - }, - }; - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); - expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( - core, - expectedOptions, - expectedPluginState - ); - }); - - it('With default format', () => { - const options = { - defaultFormat: { - bold: true, - italic: true, - underline: true, - fontFamily: 'Arial', - fontSize: '10pt', - textColor: 'red', - backgroundColor: 'blue', - }, - }; - - const core = createContentModelEditorCore(contentDiv, options); - - const expectedOptions = { - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: mockedCopyPastePlugin, - }, - defaultFormat: { - bold: true, - italic: true, - underline: true, - fontFamily: 'Arial', - fontSize: '10pt', - textColor: 'red', - backgroundColor: 'blue', - }, - }; - const expectedPluginState: any = { - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: { - fontWeight: 'bold', - italic: true, - underline: true, - fontFamily: 'Arial', - fontSize: '10pt', - textColor: 'red', - backgroundColor: 'blue', - }, - pendingFormat: null, - }, - }; - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); - expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( - core, - expectedOptions, - expectedPluginState - ); - }); - - it('Allow dom indexer', () => { - const options: StandaloneEditorOptions & EditorOptions = { - cacheModel: true, - }; - - const core = createContentModelEditorCore(contentDiv, options); - - const expectedOptions = { - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: mockedCopyPastePlugin, - }, - cacheModel: true, - }; - const expectedPluginState: any = { - cache: { domIndexer: contentModelDomIndexer }, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, - pendingFormat: null, - }, - }; - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); - expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( - core, - expectedOptions, - expectedPluginState - ); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts deleted file mode 100644 index 3b9d35f8840..00000000000 --- a/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts +++ /dev/null @@ -1,180 +0,0 @@ -import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; -import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; -import { ContentModelPluginState } from 'roosterjs-content-model-types'; -import { createContentModel } from '../../lib/coreApi/createContentModel'; -import { createEditorContext } from '../../lib/coreApi/createEditorContext'; -import { EditorCore } from 'roosterjs-editor-types'; -import { formatContentModel } from '../../lib/coreApi/formatContentModel'; -import { getDOMSelection } from '../../lib/coreApi/getDOMSelection'; -import { promoteToContentModelEditorCore } from '../../lib/editor/promoteToContentModelEditorCore'; -import { setContentModel } from '../../lib/coreApi/setContentModel'; -import { setDOMSelection } from '../../lib/coreApi/setDOMSelection'; -import { switchShadowEdit } from '../../lib/coreApi/switchShadowEdit'; -import { tablePreProcessor } from '../../lib/override/tablePreProcessor'; -import { - listItemMetadataApplier, - listLevelMetadataApplier, -} from '../../lib/metadata/updateListMetadata'; - -describe('promoteToContentModelEditorCore', () => { - let pluginState: ContentModelPluginState; - let core: EditorCore; - const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; - const mockedDomToModelConfig = { - config: 'mockedDomToModelConfig', - } as any; - const mockedModelToDomConfig = { - config: 'mockedModelToDomConfig', - } as any; - - const baseResult: any = { - contentDiv: null!, - darkColorHandler: null!, - domEvent: null!, - edit: null!, - entity: null!, - getVisibleViewport: null!, - lifecycle: null!, - pendingFormatState: null!, - trustedHTMLHandler: null!, - undo: null!, - sizeTransformer: null!, - zoomScale: 1, - plugins: [], - }; - - beforeEach(() => { - pluginState = { - cache: {}, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: {}, - pendingFormat: null, - }, - }; - core = { - ...baseResult, - api: { - switchShadowEdit: mockedSwitchShadowEdit, - } as any, - originalApi: { - switchShadowEdit: mockedSwitchShadowEdit, - } as any, - copyPaste: { allowedCustomPasteType: [] }, - }; - - spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue( - mockedDomToModelConfig - ); - spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue( - mockedModelToDomConfig - ); - }); - - it('No additional option', () => { - promoteToContentModelEditorCore(core, {}, pluginState); - - expect(core).toEqual({ - ...baseResult, - api: { - switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - switchShadowEdit: mockedSwitchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - undefined, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - undefined, - ], - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, - format: { - defaultFormat: {}, - pendingFormat: null, - }, - cache: {}, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); - }); - - it('With additional option', () => { - const defaultDomToModelOptions = { a: '1' } as any; - const defaultModelToDomOptions = { b: '2' } as any; - const mockedPlugin = 'PLUGIN' as any; - const options = { - defaultDomToModelOptions, - defaultModelToDomOptions, - corePluginOverride: { - copyPaste: mockedPlugin, - }, - }; - - promoteToContentModelEditorCore(core, options, pluginState); - - expect(core).toEqual({ - ...baseResult, - api: { - switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - switchShadowEdit: mockedSwitchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - defaultDomToModelOptions, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - defaultModelToDomOptions, - ], - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, - format: { - defaultFormat: {}, - pendingFormat: null, - }, - cache: {}, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts index 939fec274ed..64ba07e807a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts @@ -1,11 +1,11 @@ +import { BulletListType } from '../../lib/constants/BulletListType'; import { createModelToDomContext } from 'roosterjs-content-model-dom'; +import { NumberingListType } from '../../lib/constants/NumberingListType'; import { - BulletListType, ContentModelListItemFormat, ContentModelWithDataset, ListMetadataFormat, ModelToDomContext, - NumberingListType, } from 'roosterjs-content-model-types'; import { listItemMetadataApplier, @@ -392,7 +392,7 @@ describe('listItemMetadataApplier', () => { }); describe('OrderedListStyleValue', () => { - function runTest(formatNum: NumberingListType, itemNum: number, result: string) { + function runTest(formatNum: number, itemNum: number, result: string) { context.listFormat.nodeStack = [ { node: {} as Node, @@ -489,7 +489,7 @@ describe('listItemMetadataApplier', () => { }); describe('UnorderedListStyleValue', () => { - function runTest(formatNum: BulletListType, result: string) { + function runTest(formatNum: number, result: string) { context.listFormat.nodeStack = [ { node: {} as Node, @@ -740,7 +740,7 @@ describe('listLevelMetadataApplier', () => { }); describe('OrderedListStyleValue', () => { - function runTest(formatNum: NumberingListType, itemNum: number, result: string) { + function runTest(formatNum: number, itemNum: number, result: string) { context.listFormat.nodeStack = [ { node: {} as Node, @@ -802,7 +802,7 @@ describe('listLevelMetadataApplier', () => { }); describe('UnorderedListStyleValue', () => { - function runTest(formatNum: BulletListType, result: string) { + function runTest(formatNum: number, result: string) { context.listFormat.nodeStack = [ { node: {} as Node, diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts index a4ecc891d51..f698510f986 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts @@ -1,9 +1,6 @@ +import { ContentModelTable, TableMetadataFormat } from 'roosterjs-content-model-types'; +import { TableBorderFormat } from '../../lib/constants/TableBorderFormat'; import { updateTableMetadata } from '../../lib/metadata/updateTableMetadata'; -import { - ContentModelTable, - TableBorderFormat, - TableMetadataFormat, -} from 'roosterjs-content-model-types'; describe('updateTableMetadata', () => { it('No value', () => { @@ -64,7 +61,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { @@ -104,7 +101,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { @@ -147,7 +144,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { @@ -195,7 +192,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { @@ -267,7 +264,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts index 419354eb2fd..08ba9666e0a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts @@ -1,9 +1,9 @@ import { applyTableFormat } from '../../../lib/publicApi/table/applyTableFormat'; +import { TableBorderFormat } from '../../../lib/constants/TableBorderFormat'; import { ContentModelTable, ContentModelTableCell, ContentModelTableRow, - TableBorderFormat, TableMetadataFormat, } from 'roosterjs-content-model-types'; @@ -135,7 +135,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.FIRST_COLUMN_HEADER_EXTERNAL, + tableBorderFormat: TableBorderFormat.FirstColumnHeaderExternal, }, [ [TC, TC, TC, TC], @@ -183,7 +183,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.NO_HEADER_BORDERS, + tableBorderFormat: TableBorderFormat.NoHeaderBorders, }, [ [TC, TC, TC, TC], @@ -232,7 +232,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.NO_SIDE_BORDERS, + tableBorderFormat: TableBorderFormat.NoSideBorders, }, [ [TC, TC, TC, TC], @@ -280,7 +280,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.ESPECIAL_TYPE_1, + tableBorderFormat: TableBorderFormat.EspecialType1, }, [ [TC, TC, TC, TC], @@ -328,7 +328,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.ESPECIAL_TYPE_2, + tableBorderFormat: TableBorderFormat.EspecialType2, }, [ [TC, TC, TC, TC], @@ -376,7 +376,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.ESPECIAL_TYPE_3, + tableBorderFormat: TableBorderFormat.EspecialType3, }, [ [TC, TC, TC, TC], @@ -423,7 +423,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.CLEAR, + tableBorderFormat: TableBorderFormat.Clear, }, [ [TC, TC, TC, TC], @@ -486,6 +486,7 @@ describe('applyTableFormat', () => { expect(table.rows[0].cells[0].format.backgroundColor).toBe('blue'); expect(table.rows[0].cells[0].dataset.editingInfo).toBe('{"bgColorOverride":true}'); }); + it('Has borderOverride', () => { const table = createTable(1, 1); table.rows[0].cells[0].format.borderLeft = '1px solid red'; @@ -510,4 +511,124 @@ describe('applyTableFormat', () => { expect(table.rows[0].cells[0].format.borderTop).toBe('1px solid green'); expect(table.rows[0].cells[0].dataset.editingInfo).toBe('{"borderOverride":true}'); }); + + it('Adaptive text color', () => { + const table = createTable(1, 1); + + const format: TableMetadataFormat = { + topBorderColor: '#000000', + bottomBorderColor: '#000000', + verticalBorderColor: '#000000', + hasHeaderRow: false, + hasFirstColumn: false, + hasBandedRows: false, + hasBandedColumns: false, + bgColorEven: null, + bgColorOdd: '#00000020', + headerRowColor: '#000000', + tableBorderFormat: TableBorderFormat.Default, + verticalAlign: null, + }; + + // Try to apply default format black + applyTableFormat(table, format); + + //apply HeaderRowColor + applyTableFormat(table, { ...format, hasHeaderRow: true }); + + //expect HeaderRowColor text color to be applied + table.rows[0].cells[0].blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + expect(block.segmentFormat?.textColor).toBe('#ffffff'); + block.segments.forEach(segment => { + expect(segment.format?.textColor).toBe('#ffffff'); + }); + } + }); + }); + + it(' Should not set adaptive text color', () => { + const table = createTable(1, 1); + table.rows[0].cells[0].blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + block.segmentFormat = { + textColor: '#ABABAB', + }; + block.segments.forEach(segment => { + segment.format = { + textColor: '#ABABAB', + }; + }); + } + }); + + const format: TableMetadataFormat = { + topBorderColor: '#000000', + bottomBorderColor: '#000000', + verticalBorderColor: '#000000', + hasHeaderRow: false, + hasFirstColumn: false, + hasBandedRows: false, + hasBandedColumns: false, + bgColorEven: null, + bgColorOdd: '#00000020', + headerRowColor: '#000000', + tableBorderFormat: TableBorderFormat.Default, + verticalAlign: null, + }; + + // Try to apply default format black + applyTableFormat(table, format); + + //apply HeaderRowColor + applyTableFormat(table, { ...format, hasHeaderRow: true }); + + //expect HeaderRowColor text color to be applied + table.rows[0].cells[0].blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + expect(block.segmentFormat?.textColor).toBe('#ABABAB'); + block.segments.forEach(segment => { + expect(segment.format?.textColor).toBe('#ABABAB'); + }); + } + }); + }); + + it('Remove adaptive text color', () => { + const table = createTable(1, 1); + + const format: TableMetadataFormat = { + topBorderColor: '#000000', + bottomBorderColor: '#000000', + verticalBorderColor: '#000000', + hasHeaderRow: false, + hasFirstColumn: false, + hasBandedRows: false, + hasBandedColumns: false, + bgColorEven: null, + bgColorOdd: '#00000020', + headerRowColor: '#000000', + tableBorderFormat: TableBorderFormat.Default, + verticalAlign: null, + }; + + // Try to apply default format black + applyTableFormat(table, format); + + //apply HeaderRowColor + applyTableFormat(table, { ...format, hasHeaderRow: true }); + + //Toggle HeaderRowColor + applyTableFormat(table, { ...format, hasHeaderRow: false }); + + //expect HeaderRowColor text color to be applied + table.rows[0].cells[0].blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + expect(block.segmentFormat?.textColor).toBe(undefined); + block.segments.forEach(segment => { + expect(segment.format?.textColor).toBe(undefined); + }); + } + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/imageProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/imageProcessor.ts index df62e8361af..bf37ae26adc 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/imageProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/imageProcessor.ts @@ -12,11 +12,14 @@ export const imageProcessor: ElementProcessor = (group, elemen stackFormat(context, { segment: 'shallowClone' }, () => { const imageFormat: ContentModelImageFormat = context.segmentFormat; + // Use getAttribute('src') instead of retrieving src directly, in case the src has port and may be stripped by browser + const src = element.getAttribute('src') ?? ''; + parseFormat(element, context.formatParsers.segment, imageFormat, context); parseFormat(element, context.formatParsers.image, imageFormat, context); parseFormat(element, context.formatParsers.block, context.blockFormat, context); - const image = createImage(element.src, imageFormat); + const image = createImage(src, imageFormat); const alt = element.alt; const title = element.title; 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 778dcdbf768..10eb0c81c09 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -1,3 +1,4 @@ +import toArray from './toArray'; import { isElementOfType } from './isElementOfType'; import { isNodeOfType } from './isNodeOfType'; import type { ContentModelEntityFormat } from 'roosterjs-content-model-types'; @@ -11,14 +12,25 @@ const DELIMITER_BEFORE = 'entityDelimiterBefore'; const DELIMITER_AFTER = 'entityDelimiterAfter'; /** - * @internal + * Check if the given DOM Node is an entity wrapper element */ export function isEntityElement(node: Node): boolean { return isNodeOfType(node, 'ELEMENT_NODE') && node.classList.contains(ENTITY_INFO_NAME); } /** - * @internal + * Get all entity wrapper elements under the given root element + * @param root The root element to query from + * @returns An array of entity wrapper elements + */ +export function getAllEntityWrappers(root: HTMLElement): HTMLElement[] { + return toArray(root.querySelectorAll('.' + ENTITY_INFO_NAME)) as HTMLElement[]; +} + +/** + * Parse entity class names from entity wrapper element + * @param className Class names of entity + * @param format The output entity format object */ export function parseEntityClassName( className: string, @@ -36,7 +48,9 @@ export function parseEntityClassName( } /** - * @internal + * Generate Entity class names for an entity wrapper + * @param format The source entity format object + * @returns A combined CSS class name string for entity wrapper */ export function generateEntityClassNames(format: ContentModelEntityFormat): string { return format.isFakeEntity @@ -59,15 +73,38 @@ export function isEntityDelimiter(element: HTMLElement): boolean { } /** - * @internal * Adds delimiters to the element provided. If the delimiters already exists, will not be added * @param element the node to add the delimiters */ export function addDelimiters(doc: Document, element: HTMLElement): HTMLElement[] { - return [ - insertDelimiter(doc, element, true /*isAfter*/), - insertDelimiter(doc, element, false /*isAfter*/), - ]; + let [delimiterAfter, delimiterBefore] = getDelimiters(element); + + if (!delimiterAfter) { + delimiterAfter = insertDelimiter(doc, element, true /*isAfter*/); + } + + if (!delimiterBefore) { + delimiterBefore = insertDelimiter(doc, element, false /*isAfter*/); + } + + return [delimiterAfter, delimiterBefore]; +} + +function getDelimiters(entityWrapper: HTMLElement): (HTMLElement | undefined)[] { + const result: (HTMLElement | undefined)[] = []; + const { nextElementSibling, previousElementSibling } = entityWrapper; + result.push( + isDelimiter(nextElementSibling, DELIMITER_AFTER), + isDelimiter(previousElementSibling, DELIMITER_BEFORE) + ); + + return result; +} + +function isDelimiter(el: Element | null, className: string): HTMLElement | undefined { + return el?.classList.contains(className) && el.textContent == ZERO_WIDTH_SPACE + ? (el as HTMLElement) + : undefined; } function insertDelimiter(doc: Document, element: Element, isAfter: boolean) { @@ -79,3 +116,16 @@ function insertDelimiter(doc: Document, element: Element, isAfter: boolean) { return span; } + +/** + * Allowed CSS selector for entity, used by HtmlSanitizer. + * TODO: Revisit paste logic and check if we can remove HtmlSanitizer + */ +export const AllowedEntityClasses: ReadonlyArray = [ + '^' + ENTITY_INFO_NAME + '$', + '^' + ENTITY_ID_PREFIX, + '^' + ENTITY_TYPE_PREFIX, + '^' + ENTITY_READONLY_PREFIX, + '^' + DELIMITER_BEFORE + '$', + '^' + DELIMITER_AFTER + '$', +]; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts index 5a24444305f..185190e8289 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts @@ -30,7 +30,11 @@ export const DeprecatedColors: string[] = [ ]; /** - * @internal + * Get color from given HTML element + * @param element The element to get color from + * @param isBackground True to get background color, false to get text color + * @param darkColorHandler The dark color handler object to help manager dark mode color + * @param isDarkMode Whether element is in dark mode now */ export function getColor( element: HTMLElement, @@ -55,7 +59,12 @@ export function getColor( } /** - * @internal + * Set color to given HTML element + * @param element The element to set color to + * @param lightModeColor The color to set, always pass in color in light mode + * @param isBackground True to set background color, false to set text color + * @param darkColorHandler The dark color handler object to help manager dark mode color + * @param isDarkMode Whether element is in dark mode now */ export function setColor( element: HTMLElement, 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 a2264ed0050..27cb62585d7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -20,6 +20,14 @@ export { getObjectKeys } from './domUtils/getObjectKeys'; export { default as toArray } from './domUtils/toArray'; export { moveChildNodes, wrapAllChildNodes } from './domUtils/moveChildNodes'; export { wrap } from './domUtils/wrap'; +export { + AllowedEntityClasses, + isEntityElement, + getAllEntityWrappers, + parseEntityClassName, + generateEntityClassNames, + addDelimiters, +} from './domUtils/entityUtils'; export { createBr } from './modelApi/creators/createBr'; export { createListItem } from './modelApi/creators/createListItem'; @@ -55,7 +63,7 @@ export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplici export { parseValueWithUnit } from './formatHandlers/utils/parseValueWithUnit'; export { BorderKeys } from './formatHandlers/common/borderFormatHandler'; -export { DeprecatedColors } from './formatHandlers/utils/color'; +export { DeprecatedColors, getColor, setColor } from './formatHandlers/utils/color'; export { createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts index cf591255318..4fc31b81bbb 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts @@ -71,6 +71,34 @@ describe('imageProcessor', () => { }); }); + it('Image with src and port', () => { + const doc = createContentModelDocument(); + const img = document.createElement('img'); + + img.src = 'http://test.com:80/testSrc'; + + imageProcessor(doc, img, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Image', + format: {}, + src: 'http://test.com:80/testSrc', + dataset: {}, + }, + ], + }, + ], + }); + }); + it('Image with regular selection', () => { const doc = createContentModelDocument(); const img = document.createElement('img'); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts index f1c5f42e485..fc8447c9f4c 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts @@ -1,14 +1,11 @@ import * as stackFormat from '../../../lib/domToModel/utils/stackFormat'; +import { BulletListType } from 'roosterjs-content-model-core/lib/constants/BulletListType'; import { childProcessor as originalChildProcessor } from '../../../lib/domToModel/processors/childProcessor'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { DomToModelContext, ElementProcessor } from 'roosterjs-content-model-types'; import { listProcessor } from '../../../lib/domToModel/processors/listProcessor'; -import { - BulletListType, - DomToModelContext, - ElementProcessor, - NumberingListType, -} from 'roosterjs-content-model-types'; +import { NumberingListType } from 'roosterjs-content-model-core/lib/constants/NumberingListType'; describe('listProcessor', () => { let context: DomToModelContext; 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 7cdcac33cd7..74d896e4da0 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 @@ -1,6 +1,9 @@ import { ContentModelEntityFormat } from 'roosterjs-content-model-types'; import { + addDelimiters, generateEntityClassNames, + getAllEntityWrappers, + isEntityDelimiter, isEntityElement, parseEntityClassName, } from '../../lib/domUtils/entityUtils'; @@ -151,3 +154,135 @@ describe('generateEntityClassNames', () => { expect(className).toBe(''); }); }); + +describe('getAllEntityWrappers', () => { + it('No entity', () => { + const div = document.createElement('div'); + div.innerHTML = '
test
'; + + const result = getAllEntityWrappers(div); + + expect(result).toEqual([]); + }); + + it('Has entities', () => { + const div = document.createElement('div'); + const child1 = document.createElement('span'); + const child2 = document.createElement('span'); + const child3 = document.createElement('span'); + const child4 = document.createElement('span'); + + child1.className = 'c1'; + child2.className = '_Entity _EType_A'; + child3.className = 'c3'; + child4.className = '_Entity _EType_B'; + + div.appendChild(child1); + div.appendChild(child2); + div.appendChild(child3); + div.appendChild(child4); + + const result = getAllEntityWrappers(div); + + expect(result).toEqual([child2, child4]); + }); +}); + +describe('isEntityDelimiter', () => { + it('Not a delimiter - empty span', () => { + const span = document.createElement('span'); + + const result = isEntityDelimiter(span); + + expect(result).toBeFalse(); + }); + + it('Not a delimiter - wrong content', () => { + const span = document.createElement('span'); + + span.className = 'entityDelimiterBefore'; + span.textContent = 'aa'; + + const result = isEntityDelimiter(span); + + expect(result).toBeFalse(); + }); + + it('Not a delimiter - wrong class name', () => { + const span = document.createElement('span'); + + span.className = 'test'; + span.textContent = '\u200B'; + + const result = isEntityDelimiter(span); + + expect(result).toBeFalse(); + }); + + it('Not a delimiter - wrong tag name', () => { + const span = document.createElement('div'); + + span.className = 'entityDelimiterBefore'; + span.textContent = '\u200B'; + + const result = isEntityDelimiter(span); + + expect(result).toBeFalse(); + }); + + it('delimiter before', () => { + const span = document.createElement('span'); + + span.className = 'entityDelimiterBefore'; + span.textContent = '\u200B'; + + const result = isEntityDelimiter(span); + + expect(result).toBeTrue(); + }); + + it('delimiter after', () => { + const span = document.createElement('span'); + + span.className = 'entityDelimiterAfter'; + span.textContent = '\u200B'; + + const result = isEntityDelimiter(span); + + expect(result).toBeTrue(); + }); +}); + +describe('addDelimiters', () => { + it('no delimiter', () => { + const parent = document.createElement('div'); + const entity = document.createElement('span'); + + parent.appendChild(entity); + + const result = addDelimiters(document, entity); + + expect(parent.innerHTML).toBe( + '\u200B\u200B' + ); + expect(result[0]).toBe(parent.lastChild as any); + expect(result[1]).toBe(parent.firstChild as any); + }); + + it('already has delimiter', () => { + const parent = document.createElement('div'); + + parent.innerHTML = + '\u200B\u200B'; + + const entity = parent.querySelector('._Entity') as HTMLElement; + + const result = addDelimiters(document, entity); + + expect(parent.innerHTML).toBe( + '\u200B\u200B' + ); + expect(result[0]).toBe(parent.lastChild as any); + expect(result[1]).toBe(parent.firstChild as any); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts index 4e8b02318c8..959beb060f1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts @@ -1,4 +1,3 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { backgroundColorFormatHandler } from '../../../lib/formatHandlers/common/backgroundColorFormatHandler'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; @@ -95,7 +94,6 @@ describe('backgroundColorFormatHandler.apply', () => { it('Simple color', () => { format.backgroundColor = 'red'; - context.darkColorHandler = new DarkColorHandlerImpl(div, s => 'darkMock:' + s); backgroundColorFormatHandler.apply(format, div, context); @@ -105,14 +103,14 @@ describe('backgroundColorFormatHandler.apply', () => { it('Simple color in dark mode', () => { format.backgroundColor = 'red'; context.isDarkMode = true; - context.darkColorHandler = new DarkColorHandlerImpl(div, s => 'darkMock:' + s); + context.darkColorHandler = { + registerColor: (color: string, isDarkMode: boolean) => + isDarkMode ? `var(--darkColor_${color}, ${color})` : color, + } as any; backgroundColorFormatHandler.apply(format, div, context); - const expectedResult = [ - '
', - '
', - ]; + const expectedResult = ['
']; expectHtml(div.outerHTML, expectedResult); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts index 00feb9fd174..68bc1ecad41 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts @@ -1,4 +1,3 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; @@ -19,7 +18,6 @@ describe('textColorFormatHandler.parse', () => { beforeEach(() => { div = document.createElement('div'); context = createDomToModelContext(); - context.darkColorHandler = new DarkColorHandlerImpl(div, s => 'darkMock: ' + s); format = {}; }); @@ -109,7 +107,10 @@ describe('textColorFormatHandler.apply', () => { beforeEach(() => { div = document.createElement('div'); context = createModelToDomContext(); - context.darkColorHandler = new DarkColorHandlerImpl(div, s => 'darkMock: ' + s); + context.darkColorHandler = { + registerColor: (color: string, isDarkMode: boolean) => + isDarkMode ? `var(--darkColor_${color}, ${color})` : color, + } as any; format = {}; }); @@ -134,10 +135,7 @@ describe('textColorFormatHandler.apply', () => { textColorFormatHandler.apply(format, div, context); - const expectedResult = [ - '
', - '
', - ]; + const expectedResult = ['
']; expectHtml(div.outerHTML, expectedResult); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts index 9f3dfbc414f..abddc4827ca 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts @@ -1,4 +1,3 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { ContentModelBr, ModelToDomContext } from 'roosterjs-content-model-types'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { handleBr } from '../../../lib/modelToDom/handlers/handleBr'; @@ -10,7 +9,6 @@ describe('handleSegment', () => { beforeEach(() => { parent = document.createElement('div'); context = createModelToDomContext(); - context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); }); it('Br segment', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts index edde168a1f1..19de98dc6f7 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts @@ -1,14 +1,11 @@ +import { BulletListType } from 'roosterjs-content-model-core/lib/constants/BulletListType'; +import { ContentModelListItem, ModelToDomContext } from 'roosterjs-content-model-types'; import { createListItem } from '../../../lib/modelApi/creators/createListItem'; import { createListLevel } from '../../../lib/modelApi/creators/createListLevel'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { handleList } from '../../../lib/modelToDom/handlers/handleList'; -import { - BulletListType, - ContentModelListItem, - ModelToDomContext, - NumberingListType, -} from 'roosterjs-content-model-types'; +import { NumberingListType } from 'roosterjs-content-model-core/lib/constants/NumberingListType'; describe('handleList without format handlers', () => { let context: ModelToDomContext; diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts index 242074a48f1..3cd180aae63 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts @@ -1,4 +1,3 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { handleSegmentDecorator } from '../../../lib/modelToDom/handlers/handleSegmentDecorator'; @@ -68,8 +67,6 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); - runTest(link, undefined, 'test', [ 'test', ]); @@ -227,7 +224,6 @@ describe('handleSegmentDecorator', () => { }, dataset: {}, }; - context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); runTest( link, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index a6fd119a446..eef92223996 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -1,5 +1,4 @@ import * as handleBlock from '../../../lib/modelToDom/handlers/handleBlock'; -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; @@ -17,7 +16,6 @@ describe('handleTable', () => { beforeEach(() => { spyOn(handleBlock, 'handleBlock'); context = createModelToDomContext({ allowCacheElement: true }); - context.darkColorHandler = new DarkColorHandlerImpl(null!, s => 'darkMock: ' + s); }); function runTest(model: ContentModelTable, expectedInnerHTML: string) { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts index 4737c071f3d..8bfa17f2a14 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts @@ -1,5 +1,4 @@ import * as stackFormat from '../../../lib/modelToDom/utils/stackFormat'; -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { ContentModelText, ModelToDomContext } from 'roosterjs-content-model-types'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { handleText } from '../../../lib/modelToDom/handlers/handleText'; @@ -31,7 +30,6 @@ describe('handleText', () => { text: 'test', format: { textColor: 'red' }, }; - context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); handleText(document, parent, text, context, []); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts index 5ed00d3f49f..8a3b3c923a4 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts @@ -1,4 +1,3 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createText } from '../../../lib/modelApi/creators/createText'; import { handleSegmentCommon } from '../../../lib/modelToDom/utils/handleSegmentCommon'; @@ -17,10 +16,6 @@ describe('handleSegmentCommon', () => { const context = createModelToDomContext(); context.onNodeCreated = onNodeCreated; - context.darkColorHandler = new DarkColorHandlerImpl( - document.createElement('div'), - s => 'darkMock: ' + s - ); segment.link = { dataset: {}, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts new file mode 100644 index 00000000000..ac38b4dea87 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts @@ -0,0 +1,132 @@ +import { getSelectionPath, Position } from 'roosterjs-editor-dom'; +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { + EntityState, + ContentChangedEvent, + ContentMetadata, + SelectionRangeEx, +} from 'roosterjs-editor-types'; +import type { AddUndoSnapshot, StandaloneEditorCore } from 'roosterjs-content-model-types'; + +/** + * @internal + * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. + * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. + * @param core The StandaloneEditorCore object + * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. + * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent + * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). + * @param additionalData @optional parameter to provide additional data related to the ContentChanged Event. + */ +export const addUndoSnapshot: AddUndoSnapshot = ( + core, + callback, + changeSource, + canUndoByBackspace, + additionalData +) => { + const undoState = core.undo; + const isNested = undoState.isNested; + let data: any; + + if (!isNested) { + undoState.isNested = true; + + // When there is getEntityState, it means this is triggered by an entity change. + // So if HTML content is not changed (hasNewContent is false), no need to add another snapshot before change + if (core.undo.hasNewContent || !additionalData?.getEntityState || !callback) { + addUndoSnapshotInternal(core, canUndoByBackspace, additionalData?.getEntityState?.()); + } + } + + try { + if (callback) { + const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + data = callback( + range && Position.getStart(range).normalize(), + range && Position.getEnd(range).normalize() + ); + + if (!isNested) { + const entityStates = additionalData?.getEntityState?.(); + addUndoSnapshotInternal(core, false /*isAutoCompleteSnapshot*/, entityStates); + } + } + } finally { + if (!isNested) { + undoState.isNested = false; + } + } + + if (callback && changeSource) { + const event: ContentChangedEvent = { + eventType: PluginEventType.ContentChanged, + source: changeSource, + data: data, + additionalData, + }; + core.api.triggerEvent(core, event, true /*broadcast*/); + } + + if (canUndoByBackspace) { + const range = core.api.getSelectionRange(core, false /*tryGetFromCache*/); + + if (range) { + core.undo.hasNewContent = false; + core.undo.autoCompletePosition = Position.getStart(range); + } + } +}; + +function addUndoSnapshotInternal( + core: StandaloneEditorCore, + canUndoByBackspace: boolean, + entityStates?: EntityState[] +) { + if (!core.lifecycle.shadowEditFragment) { + const rangeEx = core.api.getSelectionRangeEx(core); + const isDarkMode = core.lifecycle.isDarkMode; + const metadata = createContentMetadata(core.contentDiv, rangeEx, isDarkMode) || null; + + core.undo.snapshotsService.addSnapshot( + { + html: core.contentDiv.innerHTML, + metadata, + knownColors: core.darkColorHandler?.getKnownColorsCopy() || [], + entityStates, + }, + canUndoByBackspace + ); + core.undo.hasNewContent = false; + } +} + +function createContentMetadata( + root: HTMLElement, + rangeEx: SelectionRangeEx, + isDarkMode: boolean +): ContentMetadata | undefined { + switch (rangeEx?.type) { + case SelectionRangeTypes.TableSelection: + return { + type: SelectionRangeTypes.TableSelection, + tableId: rangeEx.table.id, + isDarkMode: !!isDarkMode, + ...rangeEx.coordinates!, + }; + case SelectionRangeTypes.ImageSelection: + return { + type: SelectionRangeTypes.ImageSelection, + imageId: rangeEx.image.id, + isDarkMode: !!isDarkMode, + }; + case SelectionRangeTypes.Normal: + return { + type: SelectionRangeTypes.Normal, + isDarkMode: !!isDarkMode, + start: [], + end: [], + ...(getSelectionPath(root, rangeEx.ranges[0]) || {}), + }; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts new file mode 100644 index 00000000000..c7e0dbc92f6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts @@ -0,0 +1,60 @@ +import { getObjectKeys } from 'roosterjs-editor-dom'; +import type { AttachDomEvent } from 'roosterjs-content-model-types'; +import type { + DOMEventHandler, + DOMEventHandlerObject, + PluginDomEvent, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Attach a DOM event to the editor content DIV + * @param core The StandaloneEditorCore 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 + */ +export const attachDomEvent: AttachDomEvent = (core, eventMap) => { + const disposers = getObjectKeys(eventMap || {}).map(key => { + const { pluginEventType, beforeDispatch } = extractHandler(eventMap[key]); + const eventName = key as keyof HTMLElementEventMap; + const onEvent = (event: HTMLElementEventMap[typeof eventName]) => { + if (beforeDispatch) { + beforeDispatch(event); + } + if (pluginEventType != null) { + core.api.triggerEvent( + core, + { + eventType: pluginEventType, + rawEvent: event, + }, + false /*broadcast*/ + ); + } + }; + + core.contentDiv.addEventListener(eventName, onEvent); + + return () => { + core.contentDiv.removeEventListener(eventName, onEvent); + }; + }); + return () => disposers.forEach(disposers => disposers()); +}; + +function extractHandler(handlerObj: DOMEventHandler): DOMEventHandlerObject { + let result: DOMEventHandlerObject = { + pluginEventType: null, + beforeDispatch: null, + }; + + if (typeof handlerObj === 'number') { + result.pluginEventType = handlerObj; + } else if (typeof handlerObj === 'function') { + result.beforeDispatch = handlerObj; + } else if (typeof handlerObj === 'object') { + result = handlerObj; + } + return result; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts new file mode 100644 index 00000000000..f0b17b15c2f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -0,0 +1,43 @@ +import { addUndoSnapshot } from './addUndoSnapshot'; +import { attachDomEvent } from './attachDomEvent'; +import { ensureTypeInContainer } from './ensureTypeInContainer'; +import { focus } from './focus'; +import { getContent } from './getContent'; +import { getSelectionRange } from './getSelectionRange'; +import { getSelectionRangeEx } from './getSelectionRangeEx'; +import { getStyleBasedFormatState } from './getStyleBasedFormatState'; +import { hasFocus } from './hasFocus'; +import { insertNode } from './insertNode'; +import { restoreUndoSnapshot } from './restoreUndoSnapshot'; +import { select } from './select'; +import { selectImage } from './selectImage'; +import { selectRange } from './selectRange'; +import { selectTable } from './selectTable'; +import { setContent } from './setContent'; +import { transformColor } from './transformColor'; +import { triggerEvent } from './triggerEvent'; +import type { UnportedCoreApiMap } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const coreApiMap: UnportedCoreApiMap = { + attachDomEvent, + addUndoSnapshot, + ensureTypeInContainer, + focus, + getContent, + getSelectionRange, + getSelectionRangeEx, + getStyleBasedFormatState, + hasFocus, + insertNode, + restoreUndoSnapshot, + select, + selectRange, + setContent, + transformColor, + triggerEvent, + selectTable, + selectImage, +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts new file mode 100644 index 00000000000..3980ae9ce54 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts @@ -0,0 +1,74 @@ +import { ContentPosition, KnownCreateElementDataIndex, PositionType } from 'roosterjs-editor-types'; +import { + createElement, + createRange, + findClosestElementAncestor, + getBlockElementAtNode, + isNodeEmpty, + Position, + safeInstanceOf, +} from 'roosterjs-editor-dom'; +import type { EnsureTypeInContainer } from 'roosterjs-content-model-types'; + +/** + * @internal + * When typing goes directly under content div, many things can go wrong + * We fix it by wrapping it with a div and reposition cursor within the div + */ +export const ensureTypeInContainer: EnsureTypeInContainer = (core, position, keyboardEvent) => { + const table = findClosestElementAncestor(position.node, core.contentDiv, 'table'); + let td: HTMLElement | null; + + if (table && (td = table.querySelector('td,th'))) { + position = new Position(td, PositionType.Begin); + } + position = position.normalize(); + + const block = getBlockElementAtNode(core.contentDiv, position.node); + let formatNode: HTMLElement | null; + + if (block) { + formatNode = block.collapseToSingleElement(); + if (isNodeEmpty(formatNode, false /* trimContent */, true /* shouldCountBrAsVisible */)) { + const brEl = formatNode.ownerDocument.createElement('br'); + formatNode.append(brEl); + } + // if the block is empty, apply default format + // Otherwise, leave it as it is as we don't want to change the style for existing data + // unless the block was just created by the keyboard event (e.g. ctrl+a & start typing) + const shouldSetNodeStyles = + isNodeEmpty(formatNode) || + (keyboardEvent && wasNodeJustCreatedByKeyboardEvent(keyboardEvent, formatNode)); + formatNode = formatNode && shouldSetNodeStyles ? formatNode : null; + } else { + // Only reason we don't get the selection block is that we have an empty content div + // which can happen when users removes everything (i.e. select all and DEL, or backspace from very end to begin) + // The fix is to add a DIV wrapping, apply default format and move cursor over + formatNode = createElement( + KnownCreateElementDataIndex.EmptyLine, + core.contentDiv.ownerDocument + ) as HTMLElement; + core.api.insertNode(core, formatNode, { + position: ContentPosition.End, + updateCursor: false, + replaceSelection: false, + insertOnNewLine: false, + }); + + // element points to a wrapping node we added "

". We should move the selection left to
+ position = new Position(formatNode, PositionType.Begin); + } + + // If this is triggered by a keyboard event, let's select the new position + if (keyboardEvent) { + core.api.selectRange(core, createRange(new Position(position))); + } +}; + +function wasNodeJustCreatedByKeyboardEvent(event: KeyboardEvent, formatNode: HTMLElement) { + return ( + safeInstanceOf(event.target, 'Node') && + event.target.contains(formatNode) && + event.key === formatNode.innerText + ); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts new file mode 100644 index 00000000000..2df2921d98a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts @@ -0,0 +1,44 @@ +import { createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; +import { PositionType } from 'roosterjs-editor-types'; +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 + */ +export const focus: Focus = core => { + if (!core.lifecycle.shadowEditFragment) { + if ( + !core.api.hasFocus(core) || + !core.api.getSelectionRange(core, false /*tryGetFromCache*/) + ) { + // Focus (document.activeElement indicates) and selection are mostly in sync, but could be out of sync in some extreme cases. + // i.e. if you programmatically change window selection to point to a non-focusable DOM element (i.e. tabindex=-1 etc.). + // On Chrome/Firefox, it does not change document.activeElement. On Edge/IE, it change document.activeElement to be body + // Although on Chrome/Firefox, document.activeElement points to editor, you cannot really type which we don't want (no cursor). + // So here we always do a live selection pull on DOM and make it point in Editor. The pitfall is, the cursor could be reset + // to very begin to of editor since we don't really have last saved selection (created on blur which does not fire in this case). + // It should be better than the case you cannot type + if ( + !core.selection.selectionRange || + !core.api.selectRange(core, core.selection.selectionRange, true /*skipSameRange*/) + ) { + const node = getFirstLeafNode(core.contentDiv) || core.contentDiv; + core.api.selectRange( + core, + createRange(node, PositionType.Begin), + true /*skipSameRange*/ + ); + } + } + + // remember to clear cached selection range + core.selection.selectionRange = null; + + // This is more a fallback to ensure editor gets focus if it didn't manage to move focus to editor + if (!core.api.hasFocus(core)) { + core.contentDiv.focus(); + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts new file mode 100644 index 00000000000..622bbdbbc7b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts @@ -0,0 +1,86 @@ +import { ColorTransformDirection, GetContentMode, PluginEventType } from 'roosterjs-editor-types'; +import { + createRange, + getHtmlWithSelectionPath, + getSelectionPath, + getTextContent, + safeInstanceOf, +} from 'roosterjs-editor-dom'; +import type { GetContent } from 'roosterjs-content-model-types'; + +/** + * @internal + * Get current editor content as HTML string + * @param core The StandaloneEditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ +export const getContent: GetContent = (core, mode): string => { + let content: string | null = ''; + const triggerExtractContentEvent = mode == GetContentMode.CleanHTML; + const includeSelectionMarker = mode == GetContentMode.RawHTMLWithSelection; + + // When there is fragment for shadow edit, always use the cached fragment as document since HTML node in editor + // has been changed by uncommitted shadow edit which should be ignored. + const root = core.lifecycle.shadowEditFragment || core.contentDiv; + + if (mode == GetContentMode.PlainTextFast) { + content = root.textContent; + } else if (mode == GetContentMode.PlainText) { + content = getTextContent(root); + } else { + const clonedRoot = cloneNode(root); + clonedRoot.normalize(); + + const originalRange = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + const path = + !includeSelectionMarker || core.lifecycle.shadowEditFragment + ? null + : originalRange + ? getSelectionPath(core.contentDiv, originalRange) + : null; + const range = path && createRange(clonedRoot, path.start, path.end); + + core.api.transformColor( + core, + clonedRoot, + false /*includeSelf*/, + null /*callback*/, + ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + core.lifecycle.isDarkMode + ); + + if (triggerExtractContentEvent) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.ExtractContentWithDom, + clonedRoot, + }, + true /*broadcast*/ + ); + + content = clonedRoot.innerHTML; + } else if (range) { + // range is not null, which means we want to include a selection path in the content + content = getHtmlWithSelectionPath(clonedRoot, range); + } else { + content = clonedRoot.innerHTML; + } + } + + return content ?? ''; +}; + +function cloneNode(node: HTMLElement | DocumentFragment): HTMLElement { + let clonedNode: HTMLElement; + if (safeInstanceOf(node, 'DocumentFragment')) { + clonedNode = node.ownerDocument.createElement('div'); + clonedNode.appendChild(node.cloneNode(true /*deep*/)); + } else { + clonedNode = node.cloneNode(true /*deep*/) as HTMLElement; + } + + return clonedNode; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts new file mode 100644 index 00000000000..b357d56860c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts @@ -0,0 +1,33 @@ +import { contains } from 'roosterjs-editor-dom'; +import type { GetSelectionRange } from 'roosterjs-content-model-types'; + +/** + * @internal + * Get current or cached selection range + * @param core The StandaloneEditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ +export const getSelectionRange: GetSelectionRange = (core, tryGetFromCache: boolean) => { + let result: Range | null = null; + + if (core.lifecycle.shadowEditFragment) { + return null; + } else { + if (!tryGetFromCache || core.api.hasFocus(core)) { + const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (contains(core.contentDiv, range)) { + result = range; + } + } + } + + if (!result && tryGetFromCache) { + result = core.selection.selectionRange; + } + + return result; + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts new file mode 100644 index 00000000000..3d1f59e1861 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts @@ -0,0 +1,55 @@ +import { contains } from 'roosterjs-editor-dom'; +import { SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { GetSelectionRangeEx } from 'roosterjs-content-model-types'; +import type { SelectionRangeEx } from 'roosterjs-editor-types'; + +/** + * @internal + * Get current or cached selection range + * @param core The StandaloneEditorCore object + * @returns A Range object of the selection range + */ +export const getSelectionRangeEx: GetSelectionRangeEx = core => { + const result: SelectionRangeEx | null = null; + if (core.lifecycle.shadowEditFragment) { + return createNormalSelectionEx([]); + } else { + if (core.api.hasFocus(core)) { + if (core.selection.tableSelectionRange) { + return core.selection.tableSelectionRange; + } + + if (core.selection.imageSelectionRange) { + return core.selection.imageSelectionRange; + } + + const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); + if (!result && selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (contains(core.contentDiv, range)) { + return createNormalSelectionEx([range]); + } + } + } + + return ( + core.selection.tableSelectionRange ?? + core.selection.imageSelectionRange ?? + createNormalSelectionEx( + core.selection.selectionRange ? [core.selection.selectionRange] : [] + ) + ); + } +}; + +function createNormalSelectionEx(ranges: Range[]): SelectionRangeEx { + return { + type: SelectionRangeTypes.Normal, + ranges: ranges, + areAllCollapsed: checkAllCollapsed(ranges), + }; +} + +function checkAllCollapsed(ranges: Range[]): boolean { + return ranges.filter(range => range?.collapsed).length == ranges.length; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts new file mode 100644 index 00000000000..230236ca8e0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts @@ -0,0 +1,81 @@ +import { contains, getComputedStyles } from 'roosterjs-editor-dom'; +import { NodeType } from 'roosterjs-editor-types'; +import type { GetStyleBasedFormatState } from 'roosterjs-content-model-types'; + +/** + * @internal + * Get style based format state from current selection, including font name/size and colors + * @param core The StandaloneEditorCore objects + * @param node The node to get style from + */ +export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, node) => { + if (!node) { + return {}; + } + + const styles = node + ? getComputedStyles(node, [ + 'font-family', + 'font-size', + 'color', + 'background-color', + 'line-height', + 'margin-top', + 'margin-bottom', + 'text-align', + 'direction', + 'font-weight', + ]) + : []; + const { contentDiv, darkColorHandler } = core; + + let styleTextColor: string | undefined; + let styleBackColor: string | undefined; + + while ( + node && + contains(contentDiv, node, true /*treatSameNodeAsContain*/) && + !(styleTextColor && styleBackColor) + ) { + if (node.nodeType == NodeType.Element) { + const element = node as HTMLElement; + + styleTextColor = styleTextColor || element.style.getPropertyValue('color'); + styleBackColor = styleBackColor || element.style.getPropertyValue('background-color'); + } + node = node.parentNode; + } + + if (!core.lifecycle.isDarkMode && node == core.contentDiv) { + styleTextColor = styleTextColor || styles[2]; + styleBackColor = styleBackColor || styles[3]; + } + + const textColor = darkColorHandler.parseColorValue(styleTextColor); + const backColor = darkColorHandler.parseColorValue(styleBackColor); + + return { + fontName: styles[0], + fontSize: styles[1], + textColor: textColor.lightModeColor, + backgroundColor: backColor.lightModeColor, + textColors: textColor.darkModeColor + ? { + lightModeColor: textColor.lightModeColor, + darkModeColor: textColor.darkModeColor, + } + : undefined, + backgroundColors: backColor.darkModeColor + ? { + lightModeColor: backColor.lightModeColor, + darkModeColor: backColor.darkModeColor, + } + : undefined, + lineHeight: styles[4], + marginTop: styles[5], + marginBottom: styles[6], + textAlign: styles[7], + direction: styles[8], + fontWeight: styles[9], + }; +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts new file mode 100644 index 00000000000..c5a67d878cc --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts @@ -0,0 +1,15 @@ +import { contains } from 'roosterjs-editor-dom'; +import type { HasFocus } from 'roosterjs-content-model-types'; + +/** + * @internal + * Check if the editor has focus now + * @param core The StandaloneEditorCore object + * @returns True if the editor has focus, otherwise false + */ +export const hasFocus: HasFocus = core => { + const activeElement = core.contentDiv.ownerDocument.activeElement; + return !!( + activeElement && contains(core.contentDiv, activeElement, true /*treatSameNodeAsContain*/) + ); +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts new file mode 100644 index 00000000000..18f9bf74963 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts @@ -0,0 +1,238 @@ +import type { BlockElement, InsertOption, NodePosition } from 'roosterjs-editor-types'; +import { + ContentPosition, + ColorTransformDirection, + NodeType, + PositionType, + RegionType, +} from 'roosterjs-editor-types'; +import { + createRange, + getBlockElementAtNode, + getFirstLastBlockElement, + isBlockElement, + isVoidHtmlElement, + Position, + safeInstanceOf, + toArray, + wrap, + adjustInsertPosition, + getRegionsFromRange, + splitTextNode, + splitParentNode, +} from 'roosterjs-editor-dom'; +import type { InsertNode, StandaloneEditorCore } from 'roosterjs-content-model-types'; + +function getInitialRange( + core: StandaloneEditorCore, + option: InsertOption +): { range: Range | null; rangeToRestore: Range | null } { + // Selection start replaces based on the current selection. + // Range inserts based on a provided range. + // Both have the potential to use the current selection to restore cursor position + // So in both cases we need to store the selection state. + let range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + let rangeToRestore = null; + if (option.position == ContentPosition.Range) { + rangeToRestore = range; + range = option.range; + } else if (range) { + rangeToRestore = range.cloneRange(); + } + + return { range, rangeToRestore }; +} + +/** + * @internal + * Insert a DOM node into editor content + * @param core The StandaloneEditorCore object. No op if null. + * @param option An insert option object to specify how to insert the node + */ +export const insertNode: InsertNode = ( + core: StandaloneEditorCore, + node: Node, + option: InsertOption | null +) => { + option = option || { + position: ContentPosition.SelectionStart, + insertOnNewLine: false, + updateCursor: true, + replaceSelection: true, + insertToRegionRoot: false, + }; + const contentDiv = core.contentDiv; + + if (option.updateCursor) { + core.api.focus(core); + } + + if (option.position == ContentPosition.Outside) { + contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); + return true; + } + + core.api.transformColor( + core, + node, + true /*includeSelf*/, + () => { + if (!option) { + return; + } + switch (option.position) { + case ContentPosition.Begin: + case ContentPosition.End: { + const isBegin = option.position == ContentPosition.Begin; + const block = getFirstLastBlockElement(contentDiv, isBegin); + let insertedNode: Node | Node[] | undefined; + if (block) { + const refNode = isBegin ? block.getStartNode() : block.getEndNode(); + if ( + option.insertOnNewLine || + refNode.nodeType == NodeType.Text || + isVoidHtmlElement(refNode) + ) { + // For insert on new line, or refNode is text or void html element (HR, BR etc.) + // which cannot have children, i.e.
hello
world
. 'hello', 'world' are the + // first and last node. Insert before 'hello' or after 'world', but still inside DIV + if (safeInstanceOf(node, 'DocumentFragment')) { + // if the node to be inserted is DocumentFragment, use its childNodes as insertedNode + // because insertBefore() returns an empty DocumentFragment + insertedNode = toArray(node.childNodes); + refNode.parentNode?.insertBefore( + node, + isBegin ? refNode : refNode.nextSibling + ); + } else { + insertedNode = refNode.parentNode?.insertBefore( + node, + isBegin ? refNode : refNode.nextSibling + ); + } + } else { + // if the refNode can have child, use appendChild (which is like to insert as first/last child) + // i.e.
hello
, the content will be inserted before/after hello + insertedNode = refNode.insertBefore( + node, + isBegin ? refNode.firstChild : null + ); + } + } else { + // No first block, this can happen when editor is empty. Use appendChild to insert the content in contentDiv + insertedNode = contentDiv.appendChild(node); + } + + // Final check to see if the inserted node is a block. If not block and the ask is to insert on new line, + // add a DIV wrapping + if (insertedNode && option.insertOnNewLine) { + const nodes = Array.isArray(insertedNode) ? insertedNode : [insertedNode]; + if (!isBlockElement(nodes[0]) || !isBlockElement(nodes[nodes.length - 1])) { + wrap(nodes); + } + } + + break; + } + case ContentPosition.DomEnd: + // Use appendChild to insert the node at the end of the content div. + const insertedNode = contentDiv.appendChild(node); + // Final check to see if the inserted node is a block. If not block and the ask is to insert on new line, + // add a DIV wrapping + if (insertedNode && option.insertOnNewLine && !isBlockElement(insertedNode)) { + wrap(insertedNode); + } + break; + case ContentPosition.Range: + case ContentPosition.SelectionStart: + let { range, rangeToRestore } = getInitialRange(core, option); + if (!range) { + return; + } + + // if to replace the selection and the selection is not collapsed, remove the the content at selection first + if (option.replaceSelection && !range.collapsed) { + range.deleteContents(); + } + + let pos: NodePosition = Position.getStart(range); + let blockElement: BlockElement | null; + + if (option.insertOnNewLine && option.insertToRegionRoot) { + pos = adjustInsertPositionRegionRoot(core, range, pos); + } else if ( + option.insertOnNewLine && + (blockElement = getBlockElementAtNode(contentDiv, pos.normalize().node)) + ) { + pos = adjustInsertPositionNewLine(blockElement, core, pos); + } else { + pos = adjustInsertPosition(contentDiv, node, pos, range); + } + + const nodeForCursor = + node.nodeType == NodeType.DocumentFragment ? node.lastChild : node; + + range = createRange(pos); + range.insertNode(node); + + if (option.updateCursor && nodeForCursor) { + rangeToRestore = createRange( + new Position(nodeForCursor, PositionType.After).normalize() + ); + } + + if (rangeToRestore) { + core.api.selectRange(core, rangeToRestore); + } + + break; + } + }, + ColorTransformDirection.LightToDark + ); + + return true; +}; + +function adjustInsertPositionRegionRoot( + core: StandaloneEditorCore, + range: Range, + position: NodePosition +) { + const region = getRegionsFromRange(core.contentDiv, range, RegionType.Table)[0]; + let node: Node | null = position.node; + + if (region) { + if (node.nodeType == NodeType.Text && !position.isAtEnd) { + node = splitTextNode(node as Text, position.offset, true /*returnFirstPart*/); + } + + if (node != region.rootNode) { + while (node && node.parentNode != region.rootNode) { + splitParentNode(node, false /*splitBefore*/); + node = node.parentNode; + } + } + + if (node) { + position = new Position(node, PositionType.After); + } + } + + return position; +} + +function adjustInsertPositionNewLine( + blockElement: BlockElement, + core: StandaloneEditorCore, + pos: Position +) { + let tempPos = new Position(blockElement.getEndNode(), PositionType.After); + if (safeInstanceOf(tempPos.node, 'HTMLTableRowElement')) { + const div = core.contentDiv.ownerDocument.createElement('div'); + const range = createRange(pos); + range.insertNode(div); + tempPos = new Position(div, PositionType.Begin); + } + return tempPos; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts new file mode 100644 index 00000000000..c0d20589ea3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts @@ -0,0 +1,69 @@ +import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; +import { getEntityFromElement, getEntitySelector, queryElements } from 'roosterjs-editor-dom'; +import type { RestoreUndoSnapshot } from 'roosterjs-content-model-types'; + +/** + * @internal + * Restore an undo snapshot into editor + * @param core The editor core object + * @param step Steps to move, can be 0, positive or negative + */ +export const restoreUndoSnapshot: RestoreUndoSnapshot = (core, step) => { + if (core.undo.hasNewContent && step < 0) { + core.api.addUndoSnapshot( + core, + null /*callback*/, + null /*changeSource*/, + false /*canUndoByBackspace*/ + ); + } + + const snapshot = core.undo.snapshotsService.move(step); + + if (snapshot && snapshot.html != null) { + try { + core.undo.isRestoring = true; + core.api.setContent( + core, + snapshot.html, + true /*triggerContentChangedEvent*/, + snapshot.metadata ?? undefined + ); + + const darkColorHandler = core.darkColorHandler; + const isDarkModel = core.lifecycle.isDarkMode; + + snapshot.knownColors.forEach(color => { + darkColorHandler.registerColor( + color.lightModeColor, + isDarkModel, + color.darkModeColor + ); + }); + + snapshot.entityStates?.forEach(entityState => { + const { type, id, state } = entityState; + const wrapper = queryElements( + core.contentDiv, + getEntitySelector(type, id) + )[0] as HTMLElement; + const entity = wrapper && getEntityFromElement(wrapper); + + if (entity) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.EntityOperation, + operation: EntityOperation.UpdateEntityState, + entity: entity, + state, + }, + false + ); + } + }); + } finally { + core.undo.isRestoring = false; + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts new file mode 100644 index 00000000000..b47997a30b3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts @@ -0,0 +1,178 @@ +import { contains, createRange, safeInstanceOf } from 'roosterjs-editor-dom'; +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { Select, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import type { + NodePosition, + PositionType, + SelectionPath, + SelectionRangeEx, + TableSelection, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Select content according to the given information. + * There are a bunch of allowed combination of parameters. See IEditor.select for more details + * @param core The editor core object + * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path + * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection + * @param arg3 (optional) A Node + * @param arg4 (optional) An offset number, or a PositionType + */ +export const select: Select = (core, arg1, arg2, arg3, arg4) => { + const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); + + if (rangeEx) { + const skipReselectOnFocus = core.selection.skipReselectOnFocus; + + // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. + // Set skipReselectOnFocus to skip this behavior + core.selection.skipReselectOnFocus = true; + + try { + applyRangeEx(core, rangeEx); + } finally { + core.selection.skipReselectOnFocus = skipReselectOnFocus; + } + } else { + core.selection.tableSelectionRange = core.api.selectTable(core, null); + core.selection.imageSelectionRange = core.api.selectImage(core, null); + } + + return !!rangeEx; +}; + +function buildRangeEx( + core: StandaloneEditorCore, + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType +) { + let rangeEx: SelectionRangeEx | null = null; + + if (isSelectionRangeEx(arg1)) { + rangeEx = arg1; + } else if (safeInstanceOf(arg1, 'HTMLTableElement') && isTableSelectionOrNull(arg2)) { + rangeEx = { + type: SelectionRangeTypes.TableSelection, + ranges: [], + areAllCollapsed: false, + table: arg1, + coordinates: arg2 ?? undefined, + }; + } else if (safeInstanceOf(arg1, 'HTMLImageElement') && typeof arg2 == 'undefined') { + rangeEx = { + type: SelectionRangeTypes.ImageSelection, + ranges: [], + areAllCollapsed: false, + image: arg1, + }; + } else { + const range = !arg1 + ? null + : safeInstanceOf(arg1, 'Range') + ? arg1 + : isSelectionPath(arg1) + ? createRange(core.contentDiv, arg1.start, arg1.end) + : isNodePosition(arg1) || safeInstanceOf(arg1, 'Node') + ? createRange( + arg1, + arg2, + arg3, + arg4 + ) + : null; + + rangeEx = range + ? { + type: SelectionRangeTypes.Normal, + ranges: [range], + areAllCollapsed: range.collapsed, + } + : null; + } + + return rangeEx; +} + +function applyRangeEx(core: StandaloneEditorCore, rangeEx: SelectionRangeEx | null) { + switch (rangeEx?.type) { + case SelectionRangeTypes.TableSelection: + if (contains(core.contentDiv, rangeEx.table)) { + core.selection.imageSelectionRange = core.api.selectImage(core, null); + core.selection.tableSelectionRange = core.api.selectTable( + core, + rangeEx.table, + rangeEx.coordinates + ); + rangeEx = core.selection.tableSelectionRange; + } + break; + case SelectionRangeTypes.ImageSelection: + if (contains(core.contentDiv, rangeEx.image)) { + core.selection.tableSelectionRange = core.api.selectTable(core, null); + core.selection.imageSelectionRange = core.api.selectImage(core, rangeEx.image); + rangeEx = core.selection.imageSelectionRange; + } + break; + case SelectionRangeTypes.Normal: + core.selection.tableSelectionRange = core.api.selectTable(core, null); + core.selection.imageSelectionRange = core.api.selectImage(core, null); + + if (contains(core.contentDiv, rangeEx.ranges[0])) { + core.api.selectRange(core, rangeEx.ranges[0]); + } else { + rangeEx = null; + } + break; + } + + core.api.triggerEvent( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: rangeEx, + }, + true /** broadcast **/ + ); +} + +function isSelectionRangeEx(obj: any): obj is SelectionRangeEx { + const rangeEx = obj as SelectionRangeEx; + return ( + rangeEx && + typeof rangeEx == 'object' && + typeof rangeEx.type == 'number' && + Array.isArray(rangeEx.ranges) + ); +} + +function isTableSelectionOrNull(obj: any): obj is TableSelection | null { + const selection = obj as TableSelection | null; + + return ( + selection === null || + (selection && + typeof selection == 'object' && + typeof selection.firstCell == 'object' && + typeof selection.lastCell == 'object') + ); +} + +function isSelectionPath(obj: any): obj is SelectionPath { + const path = obj as SelectionPath; + + return path && typeof path == 'object' && Array.isArray(path.start) && Array.isArray(path.end); +} + +function isNodePosition(obj: any): obj is NodePosition { + const pos = obj as NodePosition; + + return ( + pos && + typeof pos == 'object' && + typeof pos.node == 'object' && + typeof pos.offset == 'number' + ); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts new file mode 100644 index 00000000000..cefe659926a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts @@ -0,0 +1,66 @@ +import addUniqueId from './utils/addUniqueId'; +import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + createRange, + Position, + removeGlobalCssStyle, + removeImportantStyleRule, + setGlobalCssStyles, +} from 'roosterjs-editor-dom'; +import type { ImageSelectionRange } from 'roosterjs-editor-types'; +import type { SelectImage, StandaloneEditorCore } from 'roosterjs-content-model-types'; + +const IMAGE_ID = 'imageSelected'; +const CONTENT_DIV_ID = 'contentDiv_'; +const STYLE_ID = 'imageStyle'; +const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; + +/** + * @internal + * Select a image and save data of the selected range + * @param image Image to select + * @returns Selected image information + */ +export const selectImage: SelectImage = (core, image: HTMLImageElement | null) => { + unselect(core); + + let selection: ImageSelectionRange | null = null; + + if (image) { + const range = createRange(image); + + addUniqueId(image, IMAGE_ID); + addUniqueId(core.contentDiv, CONTENT_DIV_ID); + + core.api.selectRange(core, createRange(new Position(image, PositionType.After))); + + select(core, image); + + selection = { + type: SelectionRangeTypes.ImageSelection, + ranges: [range], + image: image, + areAllCollapsed: range.collapsed, + }; + } + + return selection; +}; + +const select = (core: StandaloneEditorCore, image: HTMLImageElement) => { + removeImportantStyleRule(image, ['border', 'margin']); + const borderCSS = buildBorderCSS(core, image.id); + setGlobalCssStyles(core.contentDiv.ownerDocument, borderCSS, STYLE_ID + core.contentDiv.id); +}; + +const buildBorderCSS = (core: StandaloneEditorCore, imageId: string): string => { + const divId = core.contentDiv.id; + const color = core.selection.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR; + + return `#${divId} #${imageId} {outline-style: auto!important;outline-color: ${color}!important;caret-color: transparent!important;}`; +}; + +const unselect = (core: StandaloneEditorCore) => { + const doc = core.contentDiv.ownerDocument; + removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts new file mode 100644 index 00000000000..63b74bf8976 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts @@ -0,0 +1,25 @@ +import { addRangeToSelection, contains } from 'roosterjs-editor-dom'; +import type { SelectRange } from 'roosterjs-content-model-types'; + +/** + * @internal + * Change the editor selection to the given range + * @param core The StandaloneEditorCore object + * @param range The range to select + * @param skipSameRange When set to true, do nothing if the given range is the same with current selection + * in editor, otherwise it will always remove current selection range and set to the given one. + * This parameter is always treat as true in Edge to avoid some weird runtime exception. + */ +export const selectRange: SelectRange = (core, range, skipSameRange) => { + if (!core.lifecycle.shadowEditFragment && contains(core.contentDiv, range)) { + addRangeToSelection(range, skipSameRange); + + if (!core.api.hasFocus(core)) { + core.selection.selectionRange = range; + } + + return true; + } else { + return false; + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts new file mode 100644 index 00000000000..f852786e8a2 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts @@ -0,0 +1,265 @@ +import addUniqueId from './utils/addUniqueId'; +import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + createRange, + getTagOfNode, + isWholeTableSelected, + Position, + removeGlobalCssStyle, + removeImportantStyleRule, + setGlobalCssStyles, + toArray, + VTable, +} from 'roosterjs-editor-dom'; +import type { TableSelection, Coordinates } from 'roosterjs-editor-types'; +import type { SelectTable, StandaloneEditorCore } from 'roosterjs-content-model-types'; + +const TABLE_ID = 'tableSelected'; +const CONTENT_DIV_ID = 'contentDiv_'; +const STYLE_ID = 'tableStyle'; +const SELECTED_CSS_RULE = + '{background-color: rgb(198,198,198) !important; caret-color: transparent}'; +const MAX_RULE_SELECTOR_LENGTH = 9000; + +/** + * @internal + * Select a table and save data of the selected range + * @param core The StandaloneEditorCore object + * @param table table to select + * @param coordinates first and last cell of the selection, if this parameter is null, instead of + * selecting, will unselect the table. + * @returns true if successful + */ +export const selectTable: SelectTable = (core, table, coordinates) => { + unselect(core); + + if (areValidCoordinates(coordinates) && table) { + addUniqueId(table, TABLE_ID); + addUniqueId(core.contentDiv, CONTENT_DIV_ID); + + const { ranges, isWholeTableSelected } = select(core, table, coordinates); + if (!isMergedCell(table, coordinates)) { + const cellToSelect = table.rows + .item(coordinates.firstCell.y) + ?.cells.item(coordinates.firstCell.x); + + if (cellToSelect) { + core.api.selectRange( + core, + createRange(new Position(cellToSelect, PositionType.Begin)) + ); + } + } + + return { + type: SelectionRangeTypes.TableSelection, + ranges, + table, + areAllCollapsed: ranges.filter(range => range?.collapsed).length == ranges.length, + coordinates, + isWholeTableSelected, + }; + } + + return null; +}; + +function buildCss( + table: HTMLTableElement, + coordinates: TableSelection, + contentDivSelector: string +): { cssRules: string[]; ranges: Range[]; isWholeTableSelected: boolean } { + const ranges: Range[] = []; + const selectors: string[] = []; + + const vTable = new VTable(table); + const isAllTableSelected = isWholeTableSelected(vTable, coordinates); + if (isAllTableSelected) { + handleAllTableSelected(contentDivSelector, vTable, selectors, ranges); + } else { + handleTableSelected(coordinates, vTable, contentDivSelector, selectors, ranges); + } + + const cssRules: string[] = []; + let currentRules: string = ''; + while (selectors.length > 0) { + currentRules += (currentRules.length > 0 ? ',' : '') + selectors.shift() || ''; + if ( + currentRules.length + (selectors[0]?.length || 0) > MAX_RULE_SELECTOR_LENGTH || + selectors.length == 0 + ) { + cssRules.push(currentRules + ' ' + SELECTED_CSS_RULE); + currentRules = ''; + } + } + + return { cssRules, ranges, isWholeTableSelected: isAllTableSelected }; +} + +function handleAllTableSelected( + contentDivSelector: string, + vTable: VTable, + selectors: string[], + ranges: Range[] +) { + const table = vTable.table; + const tableSelector = contentDivSelector + ' #' + table.id; + selectors.push(tableSelector, `${tableSelector} *`); + + const tableRange = new Range(); + tableRange.selectNode(table); + ranges.push(tableRange); +} + +function handleTableSelected( + coordinates: TableSelection, + vTable: VTable, + contentDivSelector: string, + selectors: string[], + ranges: Range[] +) { + const tr1 = coordinates.firstCell.y; + const td1 = coordinates.firstCell.x; + const tr2 = coordinates.lastCell.y; + const td2 = coordinates.lastCell.x; + const table = vTable.table; + + let firstSelected: HTMLTableCellElement | null = null; + let lastSelected: HTMLTableCellElement | null = null; + // Get whether table has thead, tbody or tfoot. + const tableChildren = toArray(table.childNodes).filter( + node => ['THEAD', 'TBODY', 'TFOOT'].indexOf(getTagOfNode(node)) > -1 + ); + // Set the start and end of each of the table children, so we can build the selector according the element between the table and the row. + let cont = 0; + const indexes = tableChildren.map(node => { + const result = { + el: getTagOfNode(node), + start: cont, + end: node.childNodes.length + cont, + }; + + cont = result.end; + return result; + }); + + vTable.cells?.forEach((row, rowIndex) => { + let tdCount = 0; + firstSelected = null; + lastSelected = null; + + //Get current TBODY/THEAD/TFOOT + const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0]; + + const middleElSelector = midElement ? '>' + midElement.el + '>' : '>'; + const currentRow = + midElement && rowIndex + 1 >= midElement.start + ? rowIndex + 1 - midElement.start + : rowIndex + 1; + + for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { + const cell = row[cellIndex].td; + if (cell) { + tdCount++; + if (rowIndex >= tr1 && rowIndex <= tr2 && cellIndex >= td1 && cellIndex <= td2) { + removeImportant(cell); + + const selector = generateCssFromCell( + contentDivSelector, + table.id, + middleElSelector, + currentRow, + getTagOfNode(cell), + tdCount + ); + const elementsSelector = selector + ' *'; + + selectors.push(selector, elementsSelector); + firstSelected = firstSelected || table.querySelector(selector); + lastSelected = table.querySelector(selector); + } + } + } + + if (firstSelected && lastSelected) { + const rowRange = new Range(); + rowRange.setStartBefore(firstSelected); + rowRange.setEndAfter(lastSelected); + ranges.push(rowRange); + } + }); +} + +function select( + core: StandaloneEditorCore, + table: HTMLTableElement, + coordinates: TableSelection +): { ranges: Range[]; isWholeTableSelected: boolean } { + const contentDivSelector = '#' + core.contentDiv.id; + const { cssRules, ranges, isWholeTableSelected } = buildCss( + table, + coordinates, + contentDivSelector + ); + cssRules.forEach(css => + setGlobalCssStyles(core.contentDiv.ownerDocument, css, STYLE_ID + core.contentDiv.id) + ); + + return { ranges, isWholeTableSelected }; +} + +const unselect = (core: StandaloneEditorCore) => { + const doc = core.contentDiv.ownerDocument; + removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); +}; + +function generateCssFromCell( + contentDivSelector: string, + tableId: string, + middleElSelector: string, + rowIndex: number, + cellTag: string, + index: number +): string { + return ( + contentDivSelector + + ' #' + + tableId + + middleElSelector + + ' tr:nth-child(' + + rowIndex + + ')>' + + cellTag + + ':nth-child(' + + index + + ')' + ); +} + +function removeImportant(cell: HTMLTableCellElement) { + if (cell) { + removeImportantStyleRule(cell, ['background-color', 'background']); + } +} + +function areValidCoordinates(input?: TableSelection): input is TableSelection { + if (input) { + const { firstCell, lastCell } = input || {}; + if (firstCell && lastCell) { + const handler = (coordinate: Coordinates) => + isValidCoordinate(coordinate.x) && isValidCoordinate(coordinate.y); + return handler(firstCell) && handler(lastCell); + } + } + + return false; +} + +function isValidCoordinate(input: number): boolean { + return (!!input || input == 0) && input > -1; +} + +function isMergedCell(table: HTMLTableElement, coordinates: TableSelection): boolean { + const { firstCell } = coordinates; + return !(table.rows.item(firstCell.y) && table.rows.item(firstCell.y)?.cells.item(firstCell.x)); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts new file mode 100644 index 00000000000..e535eae9f8b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts @@ -0,0 +1,120 @@ +import { + ChangeSource, + ColorTransformDirection, + PluginEventType, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; +import { + createRange, + extractContentMetadata, + queryElements, + restoreContentWithEntityPlaceholder, +} from 'roosterjs-editor-dom'; +import type { ContentMetadata } from 'roosterjs-editor-types'; +import type { SetContent, StandaloneEditorCore } from 'roosterjs-content-model-types'; + +/** + * @internal + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The StandaloneEditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + * @param metadata @optional Metadata of the content that helps editor know the selection and color mode. + * If not passed, we will treat content as in light mode without selection + */ +export const setContent: SetContent = (core, content, triggerContentChangedEvent, metadata) => { + let contentChanged = false; + if (core.contentDiv.innerHTML != content) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.BeforeSetContent, + newContent: content, + }, + true /*broadcast*/ + ); + + const entities = core.entity.entityMap; + const html = content || ''; + const body = new DOMParser().parseFromString( + core.trustedHTMLHandler?.(html) ?? html, + 'text/html' + ).body; + + restoreContentWithEntityPlaceholder(body, core.contentDiv, entities); + + const metadataFromContent = extractContentMetadata(core.contentDiv); + metadata = metadata || metadataFromContent; + selectContentMetadata(core, metadata); + contentChanged = true; + } + + const isDarkMode = core.lifecycle.isDarkMode; + + if ((!metadata && isDarkMode) || (metadata && !!metadata.isDarkMode != !!isDarkMode)) { + core.api.transformColor( + core, + core.contentDiv, + false /*includeSelf*/, + null /*callback*/, + isDarkMode ? ColorTransformDirection.LightToDark : ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + metadata?.isDarkMode + ); + contentChanged = true; + } + + if (triggerContentChangedEvent && contentChanged) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.ContentChanged, + source: ChangeSource.SetContent, + }, + false /*broadcast*/ + ); + } +}; + +function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMetadata | undefined) { + if (!core.lifecycle.shadowEditFragment && metadata) { + core.selection.tableSelectionRange = null; + core.selection.imageSelectionRange = null; + core.selection.selectionRange = null; + + switch (metadata.type) { + case SelectionRangeTypes.Normal: + core.api.selectTable(core, null); + core.api.selectImage(core, null); + + const range = createRange(core.contentDiv, metadata.start, metadata.end); + core.api.selectRange(core, range); + break; + case SelectionRangeTypes.TableSelection: + const table = queryElements( + core.contentDiv, + '#' + metadata.tableId + )[0] as HTMLTableElement; + + if (table) { + core.selection.tableSelectionRange = core.api.selectTable( + core, + table, + metadata + ); + } + break; + case SelectionRangeTypes.ImageSelection: + const image = queryElements( + core.contentDiv, + '#' + metadata.imageId + )[0] as HTMLImageElement; + + if (image) { + core.selection.imageSelectionRange = core.api.selectImage(core, image); + } + break; + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts new file mode 100644 index 00000000000..6f8daea177d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts @@ -0,0 +1,68 @@ +import { ColorTransformDirection } from 'roosterjs-editor-types'; +import type { TransformColor } from 'roosterjs-content-model-types'; + +/** + * @internal + * Edit and transform color of elements between light mode and dark mode + * @param core The StandaloneEditorCore object + * @param rootNode The root HTML elements to transform + * @param includeSelf True to transform the root node as well, otherwise false + * @param callback The callback function to invoke before do color transformation + * @param direction To specify the transform direction, light to dark, or dark to light + * @param forceTransform By default this function will only work when editor core is in dark mode. + * Pass true to this value to force do color transformation even editor core is in light mode + */ +export const transformColor: TransformColor = ( + core, + rootNode, + includeSelf, + callback, + direction, + forceTransform, + fromDarkMode = false +) => { + const { + darkColorHandler, + lifecycle: { onExternalContentTransform }, + } = core; + const toDarkMode = direction == ColorTransformDirection.LightToDark; + if (rootNode && (forceTransform || core.lifecycle.isDarkMode)) { + const transformer = onExternalContentTransform + ? (element: HTMLElement) => { + onExternalContentTransform(element, fromDarkMode, toDarkMode, darkColorHandler); + } + : (element: HTMLElement) => { + darkColorHandler.transformElementColor(element, fromDarkMode, toDarkMode); + }; + + iterateElements(rootNode, transformer, includeSelf); + } + + callback?.(); +}; + +function iterateElements( + root: Node, + transformer: (element: HTMLElement) => void, + includeSelf?: boolean +) { + if (includeSelf && isHTMLElement(root)) { + transformer(root); + } + + for (let child = root.firstChild; child; child = child.nextSibling) { + if (isHTMLElement(child)) { + transformer(child); + } + + iterateElements(child, transformer); + } +} + +// This is not a strict check, we just need to make sure this element has style so that we can set style to it +// We don't use safeInstanceOf() here since this function will be called very frequently when extract html content +// in dark mode, so we need to make sure this check is fast enough +function isHTMLElement(node: Node): node is HTMLElement { + const htmlElement = node; + return node.nodeType == Node.ELEMENT_NODE && !!htmlElement.style; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts new file mode 100644 index 00000000000..70f65066e93 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts @@ -0,0 +1,41 @@ +import { PluginEventType } from 'roosterjs-editor-types'; +import type { TriggerEvent } from 'roosterjs-content-model-types'; +import type { EditorPlugin, PluginEvent } from 'roosterjs-editor-types'; +import type { CompatiblePluginEventType } from 'roosterjs-editor-types/lib/compatibleTypes'; + +const allowedEventsInShadowEdit: (PluginEventType | CompatiblePluginEventType)[] = [ + PluginEventType.EditorReady, + PluginEventType.BeforeDispose, + PluginEventType.ExtractContentWithDom, + PluginEventType.ZoomChanged, +]; + +/** + * @internal + * Trigger a plugin event + * @param core The StandaloneEditorCore object + * @param pluginEvent The event object to trigger + * @param broadcast Set to true to skip the shouldHandleEventExclusively check + */ +export const triggerEvent: TriggerEvent = (core, pluginEvent, broadcast) => { + if ( + (!core.lifecycle.shadowEditFragment || + allowedEventsInShadowEdit.indexOf(pluginEvent.eventType) >= 0) && + (broadcast || !core.plugins.some(plugin => handledExclusively(pluginEvent, plugin))) + ) { + core.plugins.forEach(plugin => { + if (plugin.onPluginEvent) { + plugin.onPluginEvent(pluginEvent); + } + }); + } +}; + +function handledExclusively(event: PluginEvent, plugin: EditorPlugin): boolean { + if (plugin.onPluginEvent && plugin.willHandleEventExclusively?.(event)) { + plugin.onPluginEvent(event); + return true; + } + + return false; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts new file mode 100644 index 00000000000..9d3897bc5a3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts @@ -0,0 +1,31 @@ +/** + * @internal + * Add an unique id to element and ensure that is unique + * @param el The HTMLElement that will receive the id + * @param idPrefix The prefix that will antecede the id (Ex: tableSelected01) + */ +export default function addUniqueId(el: HTMLElement, idPrefix: string) { + const doc = el.ownerDocument; + if (!el.id) { + applyId(el, idPrefix, doc); + } else { + const elements = doc.querySelectorAll(`#${el.id}`); + if (elements.length > 1) { + el.removeAttribute('id'); + applyId(el, idPrefix, doc); + } + } +} + +function applyId(el: HTMLElement, idPrefix: string, doc: Document) { + let cont = 0; + const getElement = () => doc.getElementById(idPrefix + cont); + //Ensure that there are no elements with the same ID + let element = getElement(); + while (element) { + cont++; + element = getElement(); + } + + el.id = idPrefix + cont; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts new file mode 100644 index 00000000000..83fb33ea025 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts @@ -0,0 +1,103 @@ +import { isCtrlOrMetaPressed } from 'roosterjs-editor-dom'; +import { Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { + EditPluginState, + GenericContentEditFeature, + IEditor, + PluginEvent, + PluginWithState, +} from 'roosterjs-editor-types'; + +/** + * Edit Component helps handle Content edit features + */ +class EditPlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: EditPluginState; + + /** + * Construct a new instance of EditPlugin + * @param options The editor options + */ + constructor() { + this.state = { + features: {}, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Edit'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + let hasFunctionKey = false; + let features: GenericContentEditFeature[] | null = null; + let ctrlOrMeta = false; + const isKeyDownEvent = event.eventType == PluginEventType.KeyDown; + + if (isKeyDownEvent) { + const rawEvent = event.rawEvent; + const range = this.editor?.getSelectionRange(); + + ctrlOrMeta = isCtrlOrMetaPressed(rawEvent); + hasFunctionKey = ctrlOrMeta || rawEvent.altKey; + features = + this.state.features[rawEvent.which] || + (range && !range.collapsed && this.state.features[Keys.RANGE]); + } else if (event.eventType == PluginEventType.ContentChanged) { + features = this.state.features[Keys.CONTENTCHANGED]; + } + + for (let i = 0; features && i < features?.length; i++) { + const feature = features[i]; + if ( + (feature.allowFunctionKeys || !hasFunctionKey) && + this.editor && + feature.shouldHandleEvent(event, this.editor, ctrlOrMeta) + ) { + feature.handleEvent(event, this.editor); + if (isKeyDownEvent) { + event.handledByEditFeature = true; + } + break; + } + } + } +} + +/** + * @internal + * Create a new instance of EditPlugin. + */ +export function createEditPlugin(): PluginWithState { + return new EditPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts new file mode 100644 index 00000000000..9871e9ce162 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts @@ -0,0 +1,107 @@ +import { PluginEventType, PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { safeInstanceOf } from 'roosterjs-editor-dom'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; + +const Escape = 'Escape'; +const Delete = 'Delete'; +const mouseMiddleButton = 1; + +/** + * Detect image selection and help highlight the image + */ +class ImageSelection implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'ImageSelection'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor?.select(null); + this.editor = null; + } + + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case PluginEventType.MouseUp: + const target = event.rawEvent.target; + if ( + safeInstanceOf(target, 'HTMLImageElement') && + target.isContentEditable && + event.rawEvent.button != mouseMiddleButton + ) { + this.editor.select(target); + } + break; + case PluginEventType.MouseDown: + const mouseTarget = event.rawEvent.target; + const mouseSelection = this.editor.getSelectionRangeEx(); + if ( + mouseSelection && + mouseSelection.type === SelectionRangeTypes.ImageSelection && + mouseSelection.image !== mouseTarget + ) { + this.editor.select(null); + } + break; + case PluginEventType.KeyDown: + const rawEvent = event.rawEvent; + const key = rawEvent.key; + const keyDownSelection = this.editor.getSelectionRangeEx(); + if ( + !rawEvent.ctrlKey && + !rawEvent.altKey && + !rawEvent.shiftKey && + !rawEvent.metaKey && + keyDownSelection.type === SelectionRangeTypes.ImageSelection + ) { + const imageParent = keyDownSelection.image?.parentNode; + if (key === Escape && imageParent) { + this.editor.select(keyDownSelection.image, PositionType.Before); + this.editor.getSelectionRange()?.collapse(); + event.rawEvent.stopPropagation(); + } else if (key === Delete) { + this.editor.deleteNode(keyDownSelection.image); + event.rawEvent.preventDefault(); + } else if (imageParent) { + this.editor.select(keyDownSelection.image, PositionType.Before); + } + } + break; + case PluginEventType.ContextMenu: + const contextMenuTarget = event.rawEvent.target; + const actualSelection = this.editor.getSelectionRangeEx(); + if ( + safeInstanceOf(contextMenuTarget, 'HTMLImageElement') && + (actualSelection.type !== SelectionRangeTypes.ImageSelection || + actualSelection.image !== contextMenuTarget) + ) { + this.editor.select(contextMenuTarget); + } + } + } + } +} + +/** + * @internal + * Create a new instance of ImageSelection. + */ +export function createImageSelection(): EditorPlugin { + return new ImageSelection(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts new file mode 100644 index 00000000000..f0f3cecda33 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts @@ -0,0 +1,187 @@ +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + changeElementTag, + getTagOfNode, + moveChildNodes, + safeInstanceOf, + toArray, +} from 'roosterjs-editor-dom'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; + +/** + * TODO: Rename this plugin since it is not only for table now + * + * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags + * + * When we retrieve HTML content using innerHTML, browser will always add TBODY around TR nodes if there is not. + * This causes some issue when we restore the HTML content with selection path since the selection path is + * deeply coupled with DOM structure. So we need to always make sure there is already TBODY tag whenever + * new table is inserted, to make sure the selection path we created is correct. + */ +class NormalizeTablePlugin implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'NormalizeTable'; + } + + /** + * 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; + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case PluginEventType.EditorReady: + case PluginEventType.ContentChanged: + if (this.editor) { + this.normalizeTables(this.editor.queryElements('table')); + } + break; + + case PluginEventType.BeforePaste: + this.normalizeTables(toArray(event.fragment.querySelectorAll('table'))); + break; + + case PluginEventType.MouseDown: + this.normalizeTableFromEvent(event.rawEvent); + break; + + case PluginEventType.KeyDown: + if (event.rawEvent.shiftKey) { + this.normalizeTableFromEvent(event.rawEvent); + } + break; + + case PluginEventType.ExtractContentWithDom: + normalizeListsForExport(event.clonedRoot); + break; + } + } + + private normalizeTableFromEvent(event: KeyboardEvent | MouseEvent) { + const table = this.editor?.getElementAtCursor('table', event.target as Node); + + if (table) { + this.normalizeTables([table]); + } + } + + private normalizeTables(tables: HTMLTableElement[]) { + if (this.editor && tables.length > 0) { + const rangeEx = this.editor.getSelectionRangeEx(); + const { startContainer, endContainer, startOffset, endOffset } = + (rangeEx?.type == SelectionRangeTypes.Normal && rangeEx.ranges[0]) || {}; + + const isChanged = normalizeTables(tables); + + if (isChanged) { + if ( + startContainer && + endContainer && + typeof startOffset === 'number' && + typeof endOffset === 'number' + ) { + this.editor.select(startContainer, startOffset, endContainer, endOffset); + } else if ( + rangeEx?.type == SelectionRangeTypes.TableSelection && + rangeEx.coordinates + ) { + this.editor.select(rangeEx.table, rangeEx.coordinates); + } + } + } + } +} + +function normalizeTables(tables: HTMLTableElement[]) { + let isDOMChanged = false; + tables.forEach(table => { + let tbody: HTMLTableSectionElement | null = null; + + for (let child = table.firstChild; child; child = child.nextSibling) { + const tag = getTagOfNode(child); + switch (tag) { + case 'TR': + if (!tbody) { + tbody = table.ownerDocument.createElement('tbody'); + table.insertBefore(tbody, child); + } + + tbody.appendChild(child); + child = tbody; + isDOMChanged = true; + + break; + case 'TBODY': + if (tbody) { + moveChildNodes(tbody, child, true /*keepExistingChildren*/); + child.parentNode?.removeChild(child); + child = tbody; + isDOMChanged = true; + } else { + tbody = child as HTMLTableSectionElement; + } + break; + default: + tbody = null; + break; + } + } + + const colgroups = table.querySelectorAll('colgroup'); + const thead = table.querySelector('thead'); + if (thead) { + colgroups.forEach(colgroup => { + if (!thead.contains(colgroup)) { + thead.appendChild(colgroup); + } + }); + } + }); + + return isDOMChanged; +} + +function normalizeListsForExport(root: ParentNode) { + toArray(root.querySelectorAll('li')).forEach(li => { + const prevElement = li.previousSibling; + + if (li.style.display == 'block' && safeInstanceOf(prevElement, 'HTMLLIElement')) { + li.style.removeProperty('display'); + + prevElement.appendChild(changeElementTag(li, 'div')); + } + }); +} + +/** + * @internal + * Create a new instance of NormalizeTablePlugin. + */ +export function createNormalizeTablePlugin(): EditorPlugin { + return new NormalizeTablePlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts new file mode 100644 index 00000000000..446705a7b00 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts @@ -0,0 +1,265 @@ +import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { + ContentChangedEvent, + IEditor, + PluginEvent, + PluginWithState, + Snapshot, + UndoPluginState, + UndoSnapshotsService, +} from 'roosterjs-editor-types'; +import { + addSnapshotV2, + canMoveCurrentSnapshot, + clearProceedingSnapshotsV2, + createSnapshots, + isCtrlOrMetaPressed, + moveCurrentSnapshot, + canUndoAutoComplete, +} from 'roosterjs-editor-dom'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; + +// Max stack size that cannot be exceeded. When exceeded, old undo history will be dropped +// to keep size under limit. This is kept at 10MB +const MAX_SIZE_LIMIT = 1e7; + +/** + * Provides snapshot based undo service for Editor + */ +class UndoPlugin implements PluginWithState { + private editor: IEditor | null = null; + private lastKeyPress: number | null = null; + private state: UndoPluginState; + + /** + * Construct a new instance of UndoPlugin + * @param options The wrapper of the state object + */ + constructor(options: ContentModelEditorOptions) { + this.state = { + snapshotsService: options.undoMetadataSnapshotService || createUndoSnapshots(), + isRestoring: false, + hasNewContent: false, + isNested: false, + autoCompletePosition: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Undo'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor): void { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Check if the plugin should handle the given event exclusively. + * @param event The event to check + */ + willHandleEventExclusively(event: PluginEvent) { + return ( + event.eventType == PluginEventType.KeyDown && + event.rawEvent.which == Keys.BACKSPACE && + !event.rawEvent.ctrlKey && + this.canUndoAutoComplete() + ); + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent): void { + // if editor is in IME, don't do anything + if (!this.editor || this.editor.isInIME()) { + return; + } + + switch (event.eventType) { + case PluginEventType.EditorReady: + const undoState = this.editor.getUndoState(); + if (!undoState.canUndo && !undoState.canRedo) { + // Only add initial snapshot when there is no existing snapshot + // Otherwise preserved undo/redo state may be ruined + this.addUndoSnapshot(); + } + break; + case PluginEventType.KeyDown: + this.onKeyDown(event.rawEvent); + break; + case PluginEventType.KeyPress: + this.onKeyPress(event.rawEvent); + break; + case PluginEventType.CompositionEnd: + this.clearRedoForInput(); + this.addUndoSnapshot(); + break; + case PluginEventType.ContentChanged: + this.onContentChanged(event); + break; + case PluginEventType.BeforeKeyboardEditing: + this.onBeforeKeyboardEditing(event.rawEvent); + break; + } + } + + private onKeyDown(evt: KeyboardEvent): void { + // Handle backspace/delete when there is a selection to take a snapshot + // since we want the state prior to deletion restorable + // Ignore if keycombo is ALT+BACKSPACE + if ((evt.which == Keys.BACKSPACE && !evt.altKey) || evt.which == Keys.DELETE) { + if (evt.which == Keys.BACKSPACE && !evt.ctrlKey && this.canUndoAutoComplete()) { + evt.preventDefault(); + this.editor?.undo(); + this.state.autoCompletePosition = null; + this.lastKeyPress = evt.which; + } else if (!evt.defaultPrevented) { + const selectionRange = this.editor?.getSelectionRange(); + + // Add snapshot when + // 1. Something has been selected (not collapsed), or + // 2. It has a different key code from the last keyDown event (to prevent adding too many snapshot when keeping press the same key), or + // 3. Ctrl/Meta key is pressed so that a whole word will be deleted + if ( + selectionRange && + (!selectionRange.collapsed || + this.lastKeyPress != evt.which || + isCtrlOrMetaPressed(evt)) + ) { + this.addUndoSnapshot(); + } + + // Since some content is deleted, always set hasNewContent to true so that we will take undo snapshot next time + this.state.hasNewContent = true; + this.lastKeyPress = evt.which; + } + } else if (evt.which >= Keys.PAGEUP && evt.which <= Keys.DOWN) { + // PageUp, PageDown, Home, End, Left, Right, Up, Down + if (this.state.hasNewContent) { + this.addUndoSnapshot(); + } + this.lastKeyPress = 0; + } else if (this.lastKeyPress == Keys.BACKSPACE || this.lastKeyPress == Keys.DELETE) { + if (this.state.hasNewContent) { + this.addUndoSnapshot(); + } + } + } + + private onKeyPress(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. + return; + } + + const range = this.editor?.getSelectionRange(); + if ( + (range && !range.collapsed) || + (evt.which == Keys.SPACE && this.lastKeyPress != Keys.SPACE) || + evt.which == Keys.ENTER + ) { + this.addUndoSnapshot(); + if (evt.which == Keys.ENTER) { + // Treat ENTER as new content so if there is no input after ENTER and undo, + // we restore the snapshot before ENTER + this.state.hasNewContent = true; + } + } else { + this.clearRedoForInput(); + } + + this.lastKeyPress = evt.which; + } + + private onBeforeKeyboardEditing(event: KeyboardEvent) { + // For keyboard event (triggered from Content Model), we can get its keycode from event.data + // And when user is keep pressing the same key, mark editor with "hasNewContent" so that next time user + // do some other action or press a different key, we will add undo snapshot + if (event.which != this.lastKeyPress) { + this.addUndoSnapshot(); + } + + this.lastKeyPress = event.which; + this.state.hasNewContent = true; + } + + private onContentChanged(event: ContentChangedEvent) { + if ( + !( + this.state.isRestoring || + event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode || + event.source == ChangeSource.Keyboard + ) + ) { + this.clearRedoForInput(); + } + } + + private clearRedoForInput() { + this.state.snapshotsService.clearRedo(); + this.lastKeyPress = 0; + this.state.hasNewContent = true; + } + + private canUndoAutoComplete() { + const focusedPosition = this.editor?.getFocusedPosition(); + return ( + this.state.snapshotsService.canUndoAutoComplete() && + !!focusedPosition && + !!this.state.autoCompletePosition?.equalTo(focusedPosition) + ); + } + + private addUndoSnapshot() { + this.editor?.addUndoSnapshot(); + this.state.autoCompletePosition = null; + } +} + +function createUndoSnapshots(): UndoSnapshotsService { + const snapshots = createSnapshots(MAX_SIZE_LIMIT); + + return { + canMove: (delta: number): boolean => canMoveCurrentSnapshot(snapshots, delta), + move: (delta: number): Snapshot | null => moveCurrentSnapshot(snapshots, delta), + addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => + addSnapshotV2(snapshots, snapshot, isAutoCompleteSnapshot), + clearRedo: () => clearProceedingSnapshotsV2(snapshots), + canUndoAutoComplete: () => canUndoAutoComplete(snapshots), + }; +} + +/** + * @internal + * Create a new instance of UndoPlugin. + * @param option The editor option + */ +export function createUndoPlugin( + option: ContentModelEditorOptions +): PluginWithState { + return new UndoPlugin(option); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts new file mode 100644 index 00000000000..35ce4bce8c1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -0,0 +1,37 @@ +import { createEditPlugin } from './EditPlugin'; +import { createImageSelection } from './ImageSelection'; +import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; +import { createUndoPlugin } from './UndoPlugin'; +import type { UnportedCorePlugins } from '../publicTypes/ContentModelCorePlugins'; +import type { UnportedCorePluginState } from 'roosterjs-content-model-types'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; + +/** + * @internal + * Create Core Plugins + * @param options Editor options + */ +export function createCorePlugins(options: ContentModelEditorOptions): UnportedCorePlugins { + const map = options.corePluginOverride || {}; + + // The order matters, some plugin needs to be put before/after others to make sure event + // can be handled in right order + return { + edit: map.edit || createEditPlugin(), + undo: map.undo || createUndoPlugin(options), + imageSelection: map.imageSelection || createImageSelection(), + normalizeTable: map.normalizeTable || createNormalizeTablePlugin(), + }; +} + +/** + * @internal + * Get plugin state of core plugins + * @param corePlugins ContentModelCorePlugins object + */ +export function getPluginState(corePlugins: UnportedCorePlugins): UnportedCorePluginState { + return { + edit: corePlugins.edit.getState(), + undo: corePlugins.undo.getState(), + }; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts new file mode 100644 index 00000000000..edd97016f3c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts @@ -0,0 +1,22 @@ +import type { VCell } from 'roosterjs-editor-types'; +import type { VTable } from 'roosterjs-editor-dom'; + +/** + * @internal + * Executes an action to all the cells within the selection range. + * @param callback action to apply on each selected cell + * @returns the amount of cells modified + */ +export const forEachSelectedCell = (vTable: VTable, callback: (cell: VCell) => void): void => { + if (vTable.selection) { + const { lastCell, firstCell } = vTable.selection; + + for (let y = firstCell.y; y <= lastCell.y; y++) { + for (let x = firstCell.x; x <= lastCell.x; x++) { + if (vTable.cells && vTable.cells[y][x]?.td) { + callback(vTable.cells[y][x]); + } + } + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts new file mode 100644 index 00000000000..e2c75d2f55b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts @@ -0,0 +1,37 @@ +import { isWholeTableSelected } from 'roosterjs-editor-dom'; +import type { VTable } from 'roosterjs-editor-dom'; +import type { VCell } from 'roosterjs-editor-types'; + +/** + * @internal + * Remove the cells outside of the selection. + * @param vTable VTable to remove selection + */ +export const removeCellsOutsideSelection = (vTable: VTable) => { + if (vTable.selection) { + if (isWholeTableSelected(vTable, vTable.selection)) { + return; + } + + vTable.table.style.removeProperty('width'); + vTable.table.style.removeProperty('height'); + + const { firstCell, lastCell } = vTable.selection; + const resultCells: VCell[][] = []; + + const firstX = firstCell.x; + const firstY = firstCell.y; + const lastX = lastCell.x; + const lastY = lastCell.y; + + if (vTable.cells) { + vTable.cells.forEach((row, y) => { + row = row.filter((_, x) => y >= firstY && y <= lastY && x >= firstX && x <= lastX); + if (row.length > 0) { + resultCells.push(row); + } + }); + vTable.cells = resultCells; + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 7ea629c336e..457264905e4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,5 +1,72 @@ -import { createContentModelEditorCore } from 'roosterjs-content-model-core'; -import { EditorBase } from 'roosterjs-editor-core'; +import { createEditorCore } from './createEditorCore'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { getPendableFormatState } from './utils/getPendableFormatState'; +import { isBold, paste } from 'roosterjs-content-model-core'; +import { + ChangeSource, + ColorTransformDirection, + ContentPosition, + GetContentMode, + PluginEventType, + QueryScope, + RegionType, +} from 'roosterjs-editor-types'; +import type { + BlockElement, + ClipboardData, + ContentChangedData, + DOMEventHandler, + DarkColorHandler, + DefaultFormat, + EditorUndoState, + ExperimentalFeatures, + GenericContentEditFeature, + IContentTraverser, + IPositionContentSearcher, + InsertOption, + NodePosition, + PendableFormatState, + PluginEvent, + PluginEventData, + PluginEventFromType, + PositionType, + Rect, + Region, + SelectionPath, + SelectionRangeEx, + SizeTransformer, + StyleBasedFormatState, + TableSelection, + TrustedHTMLHandler, +} from 'roosterjs-editor-types'; +import type { + CompatibleChangeSource, + CompatibleColorTransformDirection, + CompatibleContentPosition, + CompatibleExperimentalFeatures, + CompatibleGetContentMode, + CompatiblePluginEventType, + CompatibleQueryScope, + CompatibleRegionType, +} from 'roosterjs-editor-types/lib/compatibleTypes'; +import { + ContentTraverser, + Position, + PositionContentSearcher, + cacheGetEventData, + collapseNodes, + contains, + deleteSelectedContent, + findClosestElementAncestor, + getBlockElementAtNode, + getRegionsFromRange, + getSelectionPath, + isNodeEmpty, + isPositionAtBeginningOf, + queryElements, + toArray, + wrap, +} from 'roosterjs-editor-dom'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import type { ContentModelEditorOptions, @@ -21,23 +88,17 @@ import type { * Editor for Content Model. * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export default class ContentModelEditor - extends EditorBase - implements IContentModelEditor { +export class ContentModelEditor implements IContentModelEditor { + private core: ContentModelEditorCore | 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: ContentModelEditorOptions = {}) { - super(contentDiv, options, createContentModelEditorCore); - - if (options.cacheModel) { - // Create an initial content model to cache - // TODO: Once we have standalone editor and get rid of `ensureTypeInContainer` function, we can set init content - // using content model and cache the model directly - this.createContentModel(); - } + this.core = createEditorCore(contentDiv, options); + this.core.plugins.forEach(plugin => plugin.initialize(this)); } /** @@ -119,4 +180,924 @@ export default class ContentModelEditor getPendingFormat(): ContentModelSegmentFormat | null { return this.getCore().format.pendingFormat?.format ?? null; } + + /** + * Dispose this editor, dispose all plugins and custom data + */ + dispose(): void { + const core = this.getCore(); + + for (let i = core.plugins.length - 1; i >= 0; i--) { + const plugin = core.plugins[i]; + + try { + plugin.dispose(); + } catch (e) { + // Cache the error and pass it out, then keep going since dispose should always succeed + core.disposeErrorHandler?.(plugin, e as Error); + } + } + + getObjectKeys(core.customData).forEach(key => { + const data = core.customData[key]; + + if (data && data.disposer) { + data.disposer(data.value); + } + + delete core.customData[key]; + }); + + core.darkColorHandler.reset(); + + this.core = null; + } + + /** + * Get whether this editor is disposed + * @returns True if editor is disposed, otherwise false + */ + isDisposed(): boolean { + return !this.core; + } + + /** + * Insert node into editor + * @param node The node to insert + * @param option Insert options. Default value is: + * position: ContentPosition.SelectionStart + * updateCursor: true + * replaceSelection: true + * insertOnNewLine: false + * @returns true if node is inserted. Otherwise false + */ + insertNode(node: Node, option?: InsertOption): boolean { + const core = this.getCore(); + return node ? core.api.insertNode(core, node, option ?? null) : false; + } + + /** + * Delete a node from editor content + * @param node The node to delete + * @returns true if node is deleted. Otherwise false + */ + deleteNode(node: Node): boolean { + // Only remove the node when it falls within editor + if (node && this.contains(node) && node.parentNode) { + node.parentNode.removeChild(node); + return true; + } + + return false; + } + + /** + * Replace a node in editor content with another node + * @param existingNode The existing node to be replaced + * @param toNode node to replace to + * @param transformColorForDarkMode (optional) Whether to transform new node to dark mode. Default is false + * @returns true if node is replaced. Otherwise false + */ + replaceNode(existingNode: Node, toNode: Node, transformColorForDarkMode?: boolean): boolean { + const core = this.getCore(); + // Only replace the node when it falls within editor + if (this.contains(existingNode) && toNode) { + core.api.transformColor( + core, + transformColorForDarkMode ? toNode : null, + true /*includeSelf*/, + () => existingNode.parentNode?.replaceChild(toNode, existingNode), + ColorTransformDirection.LightToDark + ); + + return true; + } + + return false; + } + + /** + * Get BlockElement at given node + * @param node The node to create InlineElement + * @returns The BlockElement result + */ + getBlockElementAtNode(node: Node): BlockElement | null { + return getBlockElementAtNode(this.getCore().contentDiv, node); + } + + contains(arg: Node | Range | null): boolean { + if (!arg) { + return false; + } + return contains(this.getCore().contentDiv, arg); + } + + queryElements( + selector: string, + scopeOrCallback: + | QueryScope + | CompatibleQueryScope + | ((node: Node) => any) = QueryScope.Body, + callback?: (node: Node) => any + ) { + const core = this.getCore(); + const result: HTMLElement[] = []; + const scope = scopeOrCallback instanceof Function ? QueryScope.Body : scopeOrCallback; + callback = scopeOrCallback instanceof Function ? scopeOrCallback : callback; + + const selectionEx = scope == QueryScope.Body ? null : this.getSelectionRangeEx(); + if (selectionEx) { + selectionEx.ranges.forEach(range => { + result.push(...queryElements(core.contentDiv, selector, callback, scope, range)); + }); + } else { + return queryElements(core.contentDiv, selector, callback, scope, undefined /* range */); + } + + return result; + } + + /** + * Collapse nodes within the given start and end nodes to their common ancestor node, + * split parent nodes if necessary + * @param start The start node + * @param end The end node + * @param canSplitParent True to allow split parent node there are nodes before start or after end under the same parent + * and the returned nodes will be all nodes from start through end after splitting + * False to disallow split parent + * @returns When canSplitParent is true, returns all node from start through end after splitting, + * otherwise just return start and end + */ + collapseNodes(start: Node, end: Node, canSplitParent: boolean): Node[] { + return collapseNodes(this.getCore().contentDiv, start, end, canSplitParent); + } + + //#endregion + + //#region Content API + + /** + * Check whether the editor contains any visible content + * @param trim Whether trim the content string before check. Default is false + * @returns True if there's no visible content, otherwise false + */ + isEmpty(trim?: boolean): boolean { + return isNodeEmpty(this.getCore().contentDiv, trim); + } + + /** + * Get current editor content as HTML string + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ + getContent(mode: GetContentMode | CompatibleGetContentMode = GetContentMode.CleanHTML): string { + const core = this.getCore(); + return core.api.getContent(core, mode); + } + + /** + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + */ + setContent(content: string, triggerContentChangedEvent: boolean = true) { + const core = this.getCore(); + core.api.setContent(core, content, triggerContentChangedEvent); + } + + /** + * Insert HTML content into editor + * @param HTML content to insert + * @param option Insert options. Default value is: + * position: ContentPosition.SelectionStart + * updateCursor: true + * replaceSelection: true + * insertOnNewLine: false + */ + insertContent(content: string, option?: InsertOption) { + if (content) { + const doc = this.getDocument(); + const body = new DOMParser().parseFromString( + this.getCore().trustedHTMLHandler(content), + 'text/html' + )?.body; + let allNodes = body?.childNodes ? toArray(body.childNodes) : []; + + // If it is to insert on new line, and there are more than one node in the collection, wrap all nodes with + // a parent DIV before calling insertNode on each top level sub node. Otherwise, every sub node may get wrapped + // separately to show up on its own line + if (option && option.insertOnNewLine && allNodes.length > 1) { + allNodes = [wrap(allNodes)]; + } + + const fragment = doc.createDocumentFragment(); + allNodes.forEach(node => fragment.appendChild(node)); + + this.insertNode(fragment, option); + } + } + + /** + * Delete selected content + */ + deleteSelectedContent(): NodePosition | null { + const range = this.getSelectionRange(); + if (range && !range.collapsed) { + return deleteSelectedContent(this.getCore().contentDiv, range); + } + return null; + } + + /** + * Paste into editor using a clipboardData object + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteAsText Force pasting as plain text. Default value is false + * @param applyCurrentStyle True if apply format of current selection to the pasted content, + * false to keep original format. Default value is false. When pasteAsText is true, this parameter is ignored + * @param pasteAsImage: When set to true, if the clipboardData contains a imageDataUri will paste the image to the editor + */ + paste( + clipboardData: ClipboardData, + pasteAsText: boolean = false, + applyCurrentFormat: boolean = false, + pasteAsImage: boolean = false + ) { + paste( + this, + clipboardData, + pasteAsText + ? 'asPlainText' + : applyCurrentFormat + ? 'mergeFormat' + : pasteAsImage + ? 'asImage' + : 'normal' + ); + } + + //#endregion + + //#region Focus and Selection + + /** + * Get current selection range from Editor. + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now. + * Default value is true + * @returns current selection range, or null if editor never got focus before + */ + getSelectionRange(tryGetFromCache: boolean = true): Range | null { + const core = this.getCore(); + return core.api.getSelectionRange(core, tryGetFromCache); + } + + /** + * Get current selection range from Editor. + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now. + * Default value is true + * @returns current selection range, or null if editor never got focus before + */ + getSelectionRangeEx(): SelectionRangeEx { + const core = this.getCore(); + return core.api.getSelectionRangeEx(core); + } + + /** + * Get current selection in a serializable format + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @returns current selection path, or null if editor never got focus before + */ + getSelectionPath(): SelectionPath | null { + const range = this.getSelectionRange(); + return range && getSelectionPath(this.getCore().contentDiv, range); + } + + /** + * Check if focus is in editor now + * @returns true if focus is in editor, otherwise false + */ + hasFocus(): boolean { + const core = this.getCore(); + return core.api.hasFocus(core); + } + + /** + * Focus to this editor, the selection was restored to where it was before, no unexpected scroll. + */ + focus() { + const core = this.getCore(); + core.api.focus(core); + } + + select( + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType + ): boolean { + const core = this.getCore(); + + return core.api.select(core, arg1, arg2, arg3, arg4); + } + + /** + * Get current focused position. Return null if editor doesn't have focus at this time. + */ + getFocusedPosition(): NodePosition | null { + const sel = this.getDocument().defaultView?.getSelection(); + if (sel?.focusNode && this.contains(sel.focusNode)) { + return new Position(sel.focusNode, sel.focusOffset); + } + + const range = this.getSelectionRange(); + if (range) { + return Position.getStart(range); + } + + return null; + } + + /** + * Get an HTML element from current cursor position. + * When expectedTags is not specified, return value is the current node (if it is HTML element) + * or its parent node (if current node is a Text node). + * When expectedTags is specified, return value is the first ancestor of current node which has + * one of the expected tags. + * If no element found within editor by the given tag, return null. + * @param selector Optional, an HTML selector to find HTML element with. + * @param startFrom Start search from this node. If not specified, start from current focused position + * @param event Optional, if specified, editor will try to get cached result from the event object first. + * If it is not cached before, query from DOM and cache the result into the event object + */ + getElementAtCursor( + selector?: string, + startFrom?: Node, + event?: PluginEvent + ): HTMLElement | null { + event = startFrom ? undefined : event; // Only use cache when startFrom is not specified, for different start position can have different result + + return ( + cacheGetEventData(event ?? null, 'GET_ELEMENT_AT_CURSOR_' + selector, () => { + if (!startFrom) { + const position = this.getFocusedPosition(); + startFrom = position?.node; + } + return ( + startFrom && + findClosestElementAncestor(startFrom, this.getCore().contentDiv, selector) + ); + }) ?? null + ); + } + + /** + * Check if this position is at beginning of the editor. + * This will return true if all nodes between the beginning of target node and the position are empty. + * @param position The position to check + * @returns True if position is at beginning of the editor, otherwise false + */ + isPositionAtBeginning(position: NodePosition): boolean { + return isPositionAtBeginningOf(position, this.getCore().contentDiv); + } + + /** + * Get impacted regions from selection + */ + getSelectedRegions(type: RegionType | CompatibleRegionType = RegionType.Table): Region[] { + const selection = this.getSelectionRangeEx(); + const result: Region[] = []; + const contentDiv = this.getCore().contentDiv; + selection.ranges.forEach(range => { + result.push(...(range ? getRegionsFromRange(contentDiv, range, type) : [])); + }); + return result.filter((value, index, self) => { + return self.indexOf(value) === index; + }); + } + + //#endregion + + //#region EVENT API + + addDomEventHandler( + nameOrMap: string | Record, + handler?: DOMEventHandler + ): () => void { + const eventsToMap = typeof nameOrMap == 'string' ? { [nameOrMap]: handler! } : nameOrMap; + const core = this.getCore(); + return core.api.attachDomEvent(core, eventsToMap); + } + + /** + * Trigger an event to be dispatched to all plugins + * @param eventType Type of the event + * @param data data of the event with given type, this is the rest part of PluginEvent with the given type + * @param broadcast indicates if the event needs to be dispatched to all plugins + * True means to all, false means to allow exclusive handling from one plugin unless no one wants that + * @returns the event object which is really passed into plugins. Some plugin may modify the event object so + * the result of this function provides a chance to read the modified result + */ + triggerPluginEvent( + eventType: T, + data: PluginEventData, + broadcast: boolean = false + ): PluginEventFromType { + const core = this.getCore(); + const event = ({ + eventType, + ...data, + } as any) as PluginEventFromType; + core.api.triggerEvent(core, event, broadcast); + + return event; + } + + /** + * Trigger a ContentChangedEvent + * @param source Source of this event, by default is 'SetContent' + * @param data additional data for this event + */ + triggerContentChangedEvent( + source: ChangeSource | CompatibleChangeSource | string = ChangeSource.SetContent, + data?: any + ) { + this.triggerPluginEvent(PluginEventType.ContentChanged, { + source, + data, + }); + } + + //#endregion + + //#region Undo API + + /** + * Undo last edit operation + */ + undo() { + this.focus(); + const core = this.getCore(); + core.api.restoreUndoSnapshot(core, -1 /*step*/); + } + + /** + * Redo next edit operation + */ + redo() { + this.focus(); + const core = this.getCore(); + core.api.restoreUndoSnapshot(core, 1 /*step*/); + } + + /** + * Add undo snapshot, and execute a format callback function, then add another undo snapshot, then trigger + * ContentChangedEvent with given change source. + * If this function is called nested, undo snapshot will only be added in the outside one + * @param callback The callback function to perform formatting, returns a data object which will be used as + * the data field in ContentChangedEvent if changeSource is not null. + * @param changeSource The change source to use when fire ContentChangedEvent. When the value is not null, + * a ContentChangedEvent will be fired with change source equal to this value + * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). + */ + addUndoSnapshot( + callback?: (start: NodePosition | null, end: NodePosition | null) => any, + changeSource?: ChangeSource | CompatibleChangeSource | string, + canUndoByBackspace?: boolean, + additionalData?: ContentChangedData + ) { + const core = this.getCore(); + core.api.addUndoSnapshot( + core, + callback ?? null, + changeSource ?? null, + canUndoByBackspace ?? false, + additionalData + ); + } + + /** + * Whether there is an available undo/redo snapshot + */ + getUndoState(): EditorUndoState { + const { hasNewContent, snapshotsService } = this.getCore().undo; + return { + canUndo: hasNewContent || snapshotsService.canMove(-1 /*previousSnapshot*/), + canRedo: snapshotsService.canMove(1 /*nextSnapshot*/), + }; + } + + //#endregion + + //#region Misc + + /** + * Get document which contains this editor + * @returns The HTML document which contains this editor + */ + getDocument(): Document { + return this.getCore().contentDiv.ownerDocument; + } + + /** + * Get the scroll container of the editor + */ + getScrollContainer(): HTMLElement { + return this.getCore().domEvent.scrollContainer; + } + + /** + * Get custom data related to this editor + * @param key Key of the custom data + * @param getter Getter function. If custom data for the given key doesn't exist, + * call this function to get one and store it if it is specified. Otherwise return undefined + * @param disposer An optional disposer function to dispose this custom data when + * dispose editor. + */ + getCustomData(key: string, getter?: () => T, disposer?: (value: T) => void): T { + const core = this.getCore(); + return (core.customData[key] = core.customData[key] || { + value: getter ? getter() : undefined, + disposer, + }).value as T; + } + + /** + * Check if editor is in IME input sequence + * @returns True if editor is in IME input sequence, otherwise false + */ + isInIME(): boolean { + return this.getCore().domEvent.isInIME; + } + + /** + * Get default format of this editor + * @returns Default format object of this editor + */ + getDefaultFormat(): DefaultFormat { + const format = this.getCore().format.defaultFormat; + + return { + bold: isBold(format.fontWeight), + italic: format.italic, + underline: format.underline, + fontFamily: format.fontFamily, + fontSize: format.fontSize, + textColor: format.textColor, + backgroundColor: format.backgroundColor, + }; + } + + /** + * Get a content traverser for the whole editor + * @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); + } + + /** + * Get a content traverser for current selection + * @returns A content traverser, or null if editor never got focus before + */ + getSelectionTraverser(range?: Range): IContentTraverser | null { + range = range ?? this.getSelectionRange() ?? undefined; + return range + ? ContentTraverser.createSelectionTraverser(this.getCore().contentDiv, range) + : null; + } + + /** + * Get a content traverser for current block element start from specified position + * @param startFrom Start position of the traverser. Default value is ContentPosition.SelectionStart + * @returns A content traverser, or null if editor never got focus before + */ + getBlockTraverser( + startFrom: ContentPosition | CompatibleContentPosition = ContentPosition.SelectionStart + ): IContentTraverser | null { + const range = this.getSelectionRange(); + return range + ? ContentTraverser.createBlockTraverser(this.getCore().contentDiv, range, startFrom) + : null; + } + + /** + * Get a text traverser of current selection + * @param event Optional, if specified, editor will try to get cached result from the event object first. + * If it is not cached before, query from DOM and cache the result into the event object + * @returns A content traverser, or null if editor never got focus before + */ + getContentSearcherOfCursor(event?: PluginEvent): IPositionContentSearcher | null { + return cacheGetEventData(event ?? null, 'ContentSearcher', () => { + const range = this.getSelectionRange(); + return ( + range && + new PositionContentSearcher(this.getCore().contentDiv, Position.getStart(range)) + ); + }); + } + + /** + * Run a callback function asynchronously + * @param callback The callback function to run + * @returns a function to cancel this async run + */ + runAsync(callback: (editor: IContentModelEditor) => void) { + const win = this.getCore().contentDiv.ownerDocument.defaultView || window; + const handle = win.requestAnimationFrame(() => { + if (!this.isDisposed() && callback) { + callback(this); + } + }); + + return () => { + win.cancelAnimationFrame(handle); + }; + } + + /** + * Set DOM attribute of editor content DIV + * @param name Name of the attribute + * @param value Value of the attribute + */ + setEditorDomAttribute(name: string, value: string | null) { + if (value === null) { + this.getCore().contentDiv.removeAttribute(name); + } else { + this.getCore().contentDiv.setAttribute(name, value); + } + } + + /** + * Get DOM attribute of editor content DIV, null if there is no such attribute. + * @param name Name of the attribute + */ + getEditorDomAttribute(name: string): string | null { + return this.getCore().contentDiv.getAttribute(name); + } + + /** + * @deprecated Use getVisibleViewport() instead. + * + * Get current relative distance from top-left corner of the given element to top-left corner of editor content DIV. + * @param element The element to calculate from. If the given element is not in editor, return value will be null + * @param addScroll When pass true, The return value will also add scrollLeft and scrollTop if any. So the value + * may be different than what user is seeing from the view. When pass false, scroll position will be ignored. + * @returns An [x, y] array which contains the left and top distances, or null if the given element is not in editor. + */ + getRelativeDistanceToEditor(element: HTMLElement, addScroll?: boolean): number[] | null { + if (this.contains(element)) { + const contentDiv = this.getCore().contentDiv; + const editorRect = contentDiv.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + if (editorRect && elementRect) { + let x = elementRect.left - editorRect?.left; + let y = elementRect.top - editorRect?.top; + + if (addScroll) { + x += contentDiv.scrollLeft; + y += contentDiv.scrollTop; + } + + return [x, y]; + } + } + + return null; + } + + /** + * Add a Content Edit feature. + * @param feature The feature to add + */ + addContentEditFeature(feature: GenericContentEditFeature) { + const core = this.getCore(); + feature?.keys.forEach(key => { + const array = core.edit.features[key] || []; + array.push(feature); + core.edit.features[key] = array; + }); + } + + /** + * Remove a Content Edit feature. + * @param feature The feature to remove + */ + removeContentEditFeature(feature: GenericContentEditFeature) { + const core = this.getCore(); + feature?.keys.forEach(key => { + const featureSet = core.edit.features[key]; + const index = featureSet?.indexOf(feature) ?? -1; + if (index >= 0) { + core.edit.features[key].splice(index, 1); + if (core.edit.features[key].length < 1) { + delete core.edit.features[key]; + } + } + }); + } + + /** + * Get style based format state from current selection, including font name/size and colors + */ + getStyleBasedFormatState(node?: Node): StyleBasedFormatState { + if (!node) { + const range = this.getSelectionRange(); + node = (range && Position.getStart(range).normalize().node) ?? undefined; + } + const core = this.getCore(); + return core.api.getStyleBasedFormatState(core, node ?? null); + } + + /** + * Get the pendable format such as underline and bold + * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. + * @returns The pending format state + */ + getPendableFormatState(forceGetStateFromDOM: boolean = false): PendableFormatState { + const core = this.getCore(); + return getPendableFormatState(core); + } + + /** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + */ + ensureTypeInContainer(position: NodePosition, keyboardEvent?: KeyboardEvent) { + const core = this.getCore(); + core.api.ensureTypeInContainer(core, position, keyboardEvent); + } + + //#endregion + + //#region Dark mode APIs + + /** + * Set the dark mode state and transforms the content to match the new state. + * @param nextDarkMode The next status of dark mode. True if the editor should be in dark mode, false if not. + */ + setDarkModeState(nextDarkMode?: boolean) { + const isDarkMode = this.isDarkMode(); + + if (isDarkMode == !!nextDarkMode) { + return; + } + const core = this.getCore(); + + core.api.transformColor( + core, + core.contentDiv, + false /*includeSelf*/, + null /*callback*/, + nextDarkMode + ? ColorTransformDirection.LightToDark + : ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + isDarkMode + ); + + this.triggerContentChangedEvent( + nextDarkMode ? ChangeSource.SwitchToDarkMode : ChangeSource.SwitchToLightMode + ); + } + + /** + * Check if the editor is in dark mode + * @returns True if the editor is in dark mode, otherwise false + */ + isDarkMode(): boolean { + return this.getCore().lifecycle.isDarkMode; + } + + /** + * Transform the given node and all its child nodes to dark mode color if editor is in dark mode + * @param node The node to transform + * @param direction The transform direction. @default ColorTransformDirection.LightToDark + */ + transformToDarkColor( + node: Node, + direction: + | ColorTransformDirection + | CompatibleColorTransformDirection = ColorTransformDirection.LightToDark + ) { + const core = this.getCore(); + core.api.transformColor(core, node, true /*includeSelf*/, null /*callback*/, direction); + } + + /** + * Get a darkColorHandler object for this editor. + */ + getDarkColorHandler(): DarkColorHandler { + return this.getCore().darkColorHandler; + } + + /** + * Make the editor in "Shadow Edit" mode. + * In Shadow Edit mode, all format change will finally be ignored. + * This can be used for building a live preview feature for format button, to allow user + * see format result without really apply it. + * This function can be called repeated. If editor is already in shadow edit mode, we can still + * use this function to do more shadow edit operation. + */ + startShadowEdit() { + const core = this.getCore(); + core.api.switchShadowEdit(core, true /*isOn*/); + } + + /** + * Leave "Shadow Edit" mode, all changes made during shadow edit will be discarded + */ + stopShadowEdit() { + const core = this.getCore(); + core.api.switchShadowEdit(core, false /*isOn*/); + } + + /** + * Check if editor is in Shadow Edit mode + */ + isInShadowEdit() { + return !!this.getCore().lifecycle.shadowEditFragment; + } + + /** + * Check if the given experimental feature is enabled + * @param feature The feature to check + */ + isFeatureEnabled(feature: ExperimentalFeatures | CompatibleExperimentalFeatures): boolean { + return this.getCore().experimentalFeatures.indexOf(feature) >= 0; + } + + /** + * Get a function to convert HTML string to trusted HTML string. + * By default it will just return the input HTML directly. To override this behavior, + * pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types + */ + getTrustedHTMLHandler(): TrustedHTMLHandler { + return this.getCore().trustedHTMLHandler; + } + + /** + * @deprecated Use getZoomScale() instead + */ + getSizeTransformer(): SizeTransformer { + return this.getCore().sizeTransformer; + } + + /** + * Get current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @returns current zoom scale number + */ + getZoomScale(): number { + return this.getCore().zoomScale; + } + + /** + * Set current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @param scale The new scale number to set. It should be positive number and no greater than 10, otherwise it will be ignored. + */ + setZoomScale(scale: number): void { + const core = this.getCore(); + if (scale > 0 && scale <= 10) { + const oldValue = core.zoomScale; + core.zoomScale = scale; + + if (oldValue != scale) { + this.triggerPluginEvent( + PluginEventType.ZoomChanged, + { + oldZoomScale: oldValue, + newZoomScale: scale, + }, + true /*broadcast*/ + ); + } + } + } + + /** + * Retrieves the rect of the visible viewport of the editor. + */ + getVisibleViewport(): Rect | null { + const core = this.getCore(); + + return core.api.getVisibleViewport(core); + } + + /** + * @returns the current ContentModelEditorCore object + * @throws a standard Error if there's no core object + */ + private getCore(): ContentModelEditorCore { + if (!this.core) { + throw new Error('Editor is already disposed'); + } + return this.core; + } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts new file mode 100644 index 00000000000..b1392b88c88 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -0,0 +1,59 @@ +import { coreApiMap } from '../coreApi/coreApiMap'; +import { createCorePlugins, getPluginState } from '../corePlugins/createCorePlugins'; +import { createModelFromHtml, createStandaloneEditorCore } from 'roosterjs-content-model-core'; +import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; +import type { EditorPlugin } from 'roosterjs-editor-types'; + +/** + * @internal + * Create a new instance of Content Model Editor Core + * @param contentDiv The DIV HTML element which will be the container element of editor + * @param options An optional options object to customize the editor + */ +export function createEditorCore( + contentDiv: HTMLDivElement, + options: ContentModelEditorOptions +): ContentModelEditorCore { + const corePlugins = createCorePlugins(options); + const pluginState = getPluginState(corePlugins); + const additionalPlugins: EditorPlugin[] = [ + corePlugins.edit, + ...(options.plugins ?? []), + corePlugins.undo, + corePlugins.imageSelection, + corePlugins.normalizeTable, + ].filter(x => !!x); + + const zoomScale: number = (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1; + const initContent = options.initialContent ?? contentDiv.innerHTML; + + if (initContent && !options.initialModel) { + options.initialModel = createModelFromHtml( + initContent, + options.defaultDomToModelOptions, + options.trustedHTMLHandler, + options.defaultSegmentFormat + ); + } + + const standaloneEditorCore = createStandaloneEditorCore( + contentDiv, + options, + coreApiMap, + pluginState, + additionalPlugins + ); + + const core: ContentModelEditorCore = { + ...standaloneEditorCore, + ...pluginState, + zoomScale: zoomScale, + sizeTransformer: (size: number) => size / zoomScale, + disposeErrorHandler: options.disposeErrorHandler, + customData: {}, + experimentalFeatures: options.experimentalFeatures ?? [], + }; + + return core; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts index 87c731c6674..c2436986be0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts @@ -6,7 +6,7 @@ import type { IEditor } from 'roosterjs-editor-types'; * @param editor The editor to check * @returns True if the given editor is Content Model editor, otherwise false */ -export default function isContentModelEditor(editor: IEditor): editor is IContentModelEditor { +export function isContentModelEditor(editor: IEditor): editor is IContentModelEditor { const contentModelEditor = editor as IContentModelEditor; return !!contentModelEditor.createContentModel; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts new file mode 100644 index 00000000000..61071fdfd22 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts @@ -0,0 +1,83 @@ +import { contains, getObjectKeys, getTagOfNode, Position } from 'roosterjs-editor-dom'; +import { NodeType } from 'roosterjs-editor-types'; +import type { PendableFormatNames } from 'roosterjs-editor-dom'; +import type { NodePosition, PendableFormatState } from 'roosterjs-editor-types'; +import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; + +/** + * @internal + * @param core The StandaloneEditorCore object + * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. + * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. + */ +export function getPendableFormatState(core: StandaloneEditorCore): PendableFormatState { + const range = core.api.getSelectionRange(core, true /* tryGetFromCache*/); + const currentPosition = range && Position.getStart(range).normalize(); + + return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; +} + +const PendableStyleCheckers: Record< + PendableFormatNames, + (tagName: string, style: CSSStyleDeclaration) => boolean +> = { + isBold: (tag, style) => + tag == 'B' || + tag == 'STRONG' || + tag == 'H1' || + tag == 'H2' || + tag == 'H3' || + tag == 'H4' || + tag == 'H5' || + tag == 'H6' || + parseInt(style.fontWeight) >= 700 || + ['bold', 'bolder'].indexOf(style.fontWeight) >= 0, + isUnderline: (tag, style) => tag == 'U' || style.textDecoration.indexOf('underline') >= 0, + isItalic: (tag, style) => tag == 'I' || tag == 'EM' || style.fontStyle === 'italic', + isSubscript: (tag, style) => tag == 'SUB' || style.verticalAlign === 'sub', + isSuperscript: (tag, style) => tag == 'SUP' || style.verticalAlign === 'super', + isStrikeThrough: (tag, style) => + tag == 'S' || tag == 'STRIKE' || style.textDecoration.indexOf('line-through') >= 0, +}; + +/** + * CssFalsyCheckers checks for non pendable format that might overlay a pendable format, then it can prevent getPendableFormatState return falsy pendable format states. + */ + +const CssFalsyCheckers: Record boolean> = { + isBold: style => + (style.fontWeight !== '' && parseInt(style.fontWeight) < 700) || + style.fontWeight === 'normal', + isUnderline: style => + style.textDecoration !== '' && style.textDecoration.indexOf('underline') < 0, + isItalic: style => style.fontStyle !== '' && style.fontStyle !== 'italic', + isSubscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'sub', + isSuperscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'super', + isStrikeThrough: style => + style.textDecoration !== '' && style.textDecoration.indexOf('line-through') < 0, +}; + +function queryCommandStateFromDOM( + core: StandaloneEditorCore, + currentPosition: NodePosition +): PendableFormatState { + let node: Node | null = currentPosition.node; + const formatState: PendableFormatState = {}; + const pendableKeys: PendableFormatNames[] = []; + while (node && contains(core.contentDiv, node)) { + const tag = getTagOfNode(node); + const style = node.nodeType == NodeType.Element && (node as HTMLElement).style; + if (tag && style) { + getObjectKeys(PendableStyleCheckers).forEach(key => { + if (!(pendableKeys.indexOf(key) >= 0)) { + formatState[key] = formatState[key] || PendableStyleCheckers[key](tag, style); + if (CssFalsyCheckers[key](style)) { + pendableKeys.push(key); + } + } + }); + } + node = node.parentNode; + } + return formatState; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index d4b10a5c5de..ac1905956cb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -1,8 +1,9 @@ -export { - ContentModelCoreApiMap, - ContentModelEditorCore, -} from './publicTypes/ContentModelEditorCore'; +export { ContentModelEditorCore } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; +export { + ContentModelCorePlugins, + UnportedCorePlugins, +} from './publicTypes/ContentModelCorePlugins'; -export { default as ContentModelEditor } from './editor/ContentModelEditor'; -export { default as isContentModelEditor } from './editor/isContentModelEditor'; +export { ContentModelEditor } from './editor/ContentModelEditor'; +export { isContentModelEditor } from './editor/isContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts new file mode 100644 index 00000000000..a2a7cadb93c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -0,0 +1,39 @@ +import type { StandaloneEditorCorePlugins } from 'roosterjs-content-model-types'; +import type { + EditPluginState, + EditorPlugin, + PluginWithState, + UndoPluginState, +} from 'roosterjs-editor-types'; + +/** + * An interface for unported core plugins + * TODO: Port these plugins + */ +export interface UnportedCorePlugins { + /** + * Edit plugin handles ContentEditFeatures + */ + readonly edit: PluginWithState; + + /** + * Undo plugin provides the ability to undo/redo + */ + readonly undo: PluginWithState; + + /** + * Image selection Plugin detects image selection and help highlight the image + */ + + readonly imageSelection: EditorPlugin; + + /** + * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags + */ + readonly normalizeTable: EditorPlugin; +} + +/** + * An interface for Content Model editor core plugins. + */ +export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins, UnportedCorePlugins {} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index 7a16d2ee5af..11f1707122f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -1,23 +1,52 @@ -import type { CoreApiMap, EditorCore } from 'roosterjs-editor-types'; +import type { CompatibleExperimentalFeatures } from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { + CustomData, + EditorPlugin, + ExperimentalFeatures, + SizeTransformer, +} from 'roosterjs-editor-types'; import type { StandaloneCoreApiMap, StandaloneEditorCore } from 'roosterjs-content-model-types'; -/** - * The interface for the map of core API for Content Model editor. - * Editor can call call API from this map under ContentModelEditorCore object - */ -export interface ContentModelCoreApiMap extends CoreApiMap, StandaloneCoreApiMap {} - /** * Represents the core data structure of a Content Model editor */ -export interface ContentModelEditorCore extends EditorCore, StandaloneEditorCore { +export interface ContentModelEditorCore extends StandaloneEditorCore { /** * Core API map of this editor */ - readonly api: ContentModelCoreApiMap; + readonly api: StandaloneCoreApiMap; /** * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. */ - readonly originalApi: ContentModelCoreApiMap; + readonly originalApi: StandaloneCoreApiMap; + + /* + * Current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using this property + * to let editor behave correctly especially for those mouse drag/drop behaviors + */ + zoomScale: number; + + /** + * @deprecated Use zoomScale instead + */ + sizeTransformer: SizeTransformer; + + /** + * A callback to be invoked when any exception is thrown during disposing editor + * @param plugin The plugin that causes exception + * @param error The error object we got + */ + disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + + /** + * Custom data of this editor + */ + customData: Record; + + /** + * Enabled experimental features + */ + experimentalFeatures: (ExperimentalFeatures | CompatibleExperimentalFeatures)[]; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 0eabedea19b..e248d13b966 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,4 +1,11 @@ -import type { EditorOptions, IEditor } from 'roosterjs-editor-types'; +import type { ContentModelCorePlugins } from './ContentModelCorePlugins'; +import type { + EditorPlugin, + ExperimentalFeatures, + IEditor, + Snapshot, + UndoSnapshotsService, +} from 'roosterjs-editor-types'; import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -10,4 +17,41 @@ export interface IContentModelEditor extends IEditor, IStandaloneEditor {} /** * Options for Content Model editor */ -export interface ContentModelEditorOptions extends EditorOptions, StandaloneEditorOptions {} +export interface ContentModelEditorOptions extends StandaloneEditorOptions { + /** + * Undo snapshot service based on content metadata. Use this parameter to customize the undo snapshot service. + * When this property is set, value of undoSnapshotService will be ignored. + */ + undoMetadataSnapshotService?: UndoSnapshotsService; + + /** + * Initial HTML content + * Default value is whatever already inside the editor content DIV + */ + initialContent?: string; + + /** + * A plugin map to override default core Plugin implementation + * Default value is null + */ + corePluginOverride?: Partial; + + /** + * Specify the enabled experimental features + */ + experimentalFeatures?: ExperimentalFeatures[]; + + /** + * Current zoom scale, @default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using this property + * to let editor behave correctly especially for those mouse drag/drop behaviors + */ + zoomScale?: number; + + /** + * A callback to be invoked when any exception is thrown during disposing editor + * @param plugin The plugin that causes exception + * @param error The error object we got + */ + disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; +} diff --git a/packages-content-model/roosterjs-content-model-editor/package.json b/packages-content-model/roosterjs-content-model-editor/package.json index 6debbfeb1cb..91e09718abb 100644 --- a/packages-content-model/roosterjs-content-model-editor/package.json +++ b/packages-content-model/roosterjs-content-model-editor/package.json @@ -5,7 +5,6 @@ "tslib": "^2.3.1", "roosterjs-editor-types": "", "roosterjs-editor-dom": "", - "roosterjs-editor-core": "", "roosterjs-content-model-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 47a14d34e74..f9f2d178473 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -2,8 +2,9 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/c import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; -import ContentModelEditor from '../../lib/editor/ContentModelEditor'; +import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; +import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; @@ -23,11 +24,17 @@ describe('ContentModelEditor', () => { mockedContext ); spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); + spyOn(findAllEntities, 'findAllEntities'); const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); + const editor = new ContentModelEditor(div, { + coreApiOverride: { + createEditorContext: jasmine + .createSpy('createEditorContext') + .and.returnValue(editorContext), + setContentModel: jasmine.createSpy('setContentModel'), + }, + }); const model = editor.createContentModel(); @@ -54,11 +61,17 @@ describe('ContentModelEditor', () => { mockedContext ); spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); + spyOn(findAllEntities, 'findAllEntities'); const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); + const editor = new ContentModelEditor(div, { + coreApiOverride: { + createEditorContext: jasmine + .createSpy('createEditorContext') + .and.returnValue(editorContext), + setContentModel: jasmine.createSpy('setContentModel'), + }, + }); const model = editor.createContentModel(); @@ -97,7 +110,7 @@ describe('ContentModelEditor', () => { const selection = editor.setContentModel(mockedModel); - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(1); + expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(2); expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( document, div, @@ -134,7 +147,7 @@ describe('ContentModelEditor', () => { const selection = editor.setContentModel(mockedModel); - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(1); + expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(2); expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( document, div, @@ -175,16 +188,24 @@ describe('ContentModelEditor', () => { expect(model).toEqual({ blockGroupType: 'Document', - blocks: [], - format: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + cachedElement: jasmine.anything(), + }, + ], }); }); @@ -219,20 +240,14 @@ describe('ContentModelEditor', () => { it('default format', () => { const div = document.createElement('div'); const editor = new ContentModelEditor(div, { - defaultFormat: { - bold: true, + defaultSegmentFormat: { + fontWeight: 'bold', italic: true, underline: true, fontFamily: 'Arial', fontSize: '10pt', - textColors: { - lightModeColor: 'black', - darkModeColor: 'white', - }, - backgroundColors: { - lightModeColor: 'white', - darkModeColor: 'black', - }, + textColor: 'black', + backgroundColor: 'white', }, }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts new file mode 100644 index 00000000000..d932279742b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -0,0 +1,193 @@ +import * as ContentModelCachePlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin'; +import * as ContentModelCopyPastePlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin'; +import * as ContentModelFormatPlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin'; +import * as createStandaloneEditorDefaultSettings from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings'; +import * as DOMEventPlugin from 'roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin'; +import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; +import * as EntityPlugin from 'roosterjs-content-model-core/lib/corePlugin/EntityPlugin'; +import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; +import * as LifecyclePlugin from 'roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin'; +import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; +import * as SelectionPlugin from 'roosterjs-content-model-core/lib/corePlugin/SelectionPlugin'; +import * as UndoPlugin from '../../lib/corePlugins/UndoPlugin'; +import { coreApiMap } from '../../lib/coreApi/coreApiMap'; +import { createEditorCore } from '../../lib/editor/createEditorCore'; +import { defaultTrustHtmlHandler } from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorCore'; +import { standaloneCoreApiMap } from 'roosterjs-content-model-core/lib/editor/standaloneCoreApiMap'; + +const mockedDomEventState = 'DOMEVENTSTATE' as any; +const mockedEditState = 'EDITSTATE' as any; +const mockedLifecycleState = 'LIFECYCLESTATE' as any; +const mockedUndoState = 'UNDOSTATE' as any; +const mockedEntityState = 'ENTITYSTATE' as any; +const mockedCopyPasteState = 'COPYPASTESTATE' as any; +const mockedCacheState = 'CACHESTATE' as any; +const mockedFormatState = 'FORMATSTATE' as any; +const mockedSelectionState = 'SELECTION' as any; + +const mockedFormatPlugin = { + getState: () => mockedFormatState, +} as any; +const mockedCachePlugin = { + getState: () => mockedCacheState, +} as any; +const mockedCopyPastePlugin = { + getState: () => mockedCopyPasteState, +} as any; +const mockedEditPlugin = { + getState: () => mockedEditState, +} as any; +const mockedUndoPlugin = { + getState: () => mockedUndoState, +} as any; +const mockedDOMEventPlugin = { + getState: () => mockedDomEventState, +} as any; +const mockedEntityPlugin = { + getState: () => mockedEntityState, +} as any; +const mockedSelectionPlugin = { + getState: () => mockedSelectionState, +} as any; +const mockedImageSelection = 'ImageSelection' as any; +const mockedNormalizeTablePlugin = 'NormalizeTablePlugin' as any; +const mockedLifecyclePlugin = { + getState: () => mockedLifecycleState, +} as any; +const mockedDefaultSettings = { + settings: 'SETTINGS', +} as any; + +describe('createEditorCore', () => { + let contentDiv: any; + + beforeEach(() => { + contentDiv = { + style: {}, + } as any; + + spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( + mockedFormatPlugin + ); + spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( + mockedCachePlugin + ); + spyOn(ContentModelCopyPastePlugin, 'createContentModelCopyPastePlugin').and.returnValue( + mockedCopyPastePlugin + ); + spyOn(EditPlugin, 'createEditPlugin').and.returnValue(mockedEditPlugin); + spyOn(UndoPlugin, 'createUndoPlugin').and.returnValue(mockedUndoPlugin); + spyOn(DOMEventPlugin, 'createDOMEventPlugin').and.returnValue(mockedDOMEventPlugin); + spyOn(SelectionPlugin, 'createSelectionPlugin').and.returnValue(mockedSelectionPlugin); + spyOn(EntityPlugin, 'createEntityPlugin').and.returnValue(mockedEntityPlugin); + spyOn(ImageSelection, 'createImageSelection').and.returnValue(mockedImageSelection); + spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( + mockedNormalizeTablePlugin + ); + spyOn(LifecyclePlugin, 'createLifecyclePlugin').and.returnValue(mockedLifecyclePlugin); + spyOn( + createStandaloneEditorDefaultSettings, + 'createStandaloneEditorDefaultSettings' + ).and.returnValue(mockedDefaultSettings); + }); + + it('No additional option', () => { + const core = createEditorCore(contentDiv, {}); + expect(core).toEqual({ + contentDiv, + api: { ...coreApiMap, ...standaloneCoreApiMap }, + originalApi: { ...coreApiMap, ...standaloneCoreApiMap }, + plugins: [ + mockedCachePlugin, + mockedFormatPlugin, + mockedCopyPastePlugin, + mockedDOMEventPlugin, + mockedSelectionPlugin, + mockedEntityPlugin, + mockedEditPlugin, + mockedUndoPlugin, + mockedImageSelection, + mockedNormalizeTablePlugin, + mockedLifecyclePlugin, + ], + domEvent: mockedDomEventState, + edit: mockedEditState, + lifecycle: mockedLifecycleState, + undo: mockedUndoState, + entity: mockedEntityState, + copyPaste: mockedCopyPasteState, + cache: mockedCacheState, + format: mockedFormatState, + selection: mockedSelectionState, + trustedHTMLHandler: defaultTrustHtmlHandler, + zoomScale: 1, + sizeTransformer: jasmine.anything(), + darkColorHandler: jasmine.anything(), + disposeErrorHandler: undefined, + ...mockedDefaultSettings, + environment: { + isMac: false, + isAndroid: false, + isSafari: false, + }, + customData: {}, + experimentalFeatures: [], + }); + }); + + it('With additional option', () => { + const defaultDomToModelOptions = { a: '1' } as any; + const defaultModelToDomOptions = { b: '2' } as any; + + const options = { + defaultDomToModelOptions, + defaultModelToDomOptions, + }; + const core = createEditorCore(contentDiv, options); + + expect( + createStandaloneEditorDefaultSettings.createStandaloneEditorDefaultSettings + ).toHaveBeenCalledWith(options); + + expect(core).toEqual({ + contentDiv, + api: { ...coreApiMap, ...standaloneCoreApiMap }, + originalApi: { ...coreApiMap, ...standaloneCoreApiMap }, + plugins: [ + mockedCachePlugin, + mockedFormatPlugin, + mockedCopyPastePlugin, + mockedDOMEventPlugin, + mockedSelectionPlugin, + mockedEntityPlugin, + mockedEditPlugin, + mockedUndoPlugin, + mockedImageSelection, + mockedNormalizeTablePlugin, + mockedLifecyclePlugin, + ], + domEvent: mockedDomEventState, + edit: mockedEditState, + lifecycle: mockedLifecycleState, + undo: mockedUndoState, + entity: mockedEntityState, + copyPaste: mockedCopyPasteState, + cache: mockedCacheState, + format: mockedFormatState, + selection: mockedSelectionState, + trustedHTMLHandler: defaultTrustHtmlHandler, + zoomScale: 1, + sizeTransformer: jasmine.anything(), + darkColorHandler: jasmine.anything(), + disposeErrorHandler: undefined, + ...mockedDefaultSettings, + environment: { + isMac: false, + isAndroid: false, + isSafari: false, + }, + customData: {}, + experimentalFeatures: [], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts index 505f3ca385a..19020bb14da 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts @@ -1,13 +1,11 @@ -import ContentModelEditor from '../../lib/editor/ContentModelEditor'; -import isContentModelEditor from '../../lib/editor/isContentModelEditor'; +import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; import { Editor } from 'roosterjs-editor-core'; -import { IEditor } from 'roosterjs-editor-types'; +import { isContentModelEditor } from '../../lib/editor/isContentModelEditor'; describe('isContentModelEditor', () => { it('Legacy editor', () => { const div = document.createElement('div'); - const editor: IEditor = new Editor(div); - + const editor = new Editor(div); const result = isContentModelEditor(editor); expect(result).toBeFalse(); @@ -15,8 +13,7 @@ describe('isContentModelEditor', () => { it('Content Model editor', () => { const div = document.createElement('div'); - const editor: IEditor = new ContentModelEditor(div); - + const editor = new ContentModelEditor(div); const result = isContentModelEditor(editor); expect(result).toBeTrue(); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 02d347a71b0..0581a9d4b39 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -1,4 +1,5 @@ import { keyboardDelete } from './keyboardDelete'; +import { keyboardInput } from './keyboardInput'; import { PluginEventType } from 'roosterjs-editor-types'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; import type { @@ -72,6 +73,11 @@ export class ContentModelEditPlugin implements EditorPlugin { // No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache keyboardDelete(editor, rawEvent); break; + + case 'Enter': + default: + keyboardInput(editor, rawEvent); + break; } } } 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 8c8a279edd3..7ba0ab3649c 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,21 +15,18 @@ import { forwardDeleteCollapsedSelection, } from './deleteSteps/deleteCollapsedSelection'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; -import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; +import type { DOMSelection, DeleteSelectionStep } from 'roosterjs-content-model-types'; /** * @internal * Do keyboard event handling for DELETE/BACKSPACE key * @param editor The Content Model Editor * @param rawEvent DOM keyboard event - * @returns True if the event is handled with this function, otherwise false */ -export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEvent): boolean { +export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); - const range = selection?.type == 'range' ? selection.range : null; - let isDeleted = false; - if (shouldDeleteWithContentModel(range, rawEvent)) { + if (shouldDeleteWithContentModel(selection, rawEvent)) { editor.formatContentModel( (model, context) => { const result = deleteSelection( @@ -38,8 +35,6 @@ export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEv context ).deleteResult; - isDeleted = result != 'notDeleted'; - return handleKeyboardEventResult(editor, model, rawEvent, result, context); }, { @@ -52,8 +47,6 @@ export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEv return true; } - - return isDeleted; } function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelectionStep | null)[] { @@ -71,13 +64,21 @@ function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelecti return [deleteAllSegmentBeforeStep, deleteWordSelection, deleteCollapsedSelection]; } -function shouldDeleteWithContentModel(range: Range | null, rawEvent: KeyboardEvent) { - return !( - range?.collapsed && - isNodeOfType(range.startContainer, 'TEXT_NODE') && - !isModifierKey(rawEvent) && - (canDeleteBefore(rawEvent, range) || canDeleteAfter(rawEvent, range)) - ); +function shouldDeleteWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { + if (!selection) { + return false; // Nothing to delete + } else if (selection.type != 'range' || !selection.range.collapsed) { + return true; // Selection is not collapsed, need to delete all selections + } else { + const range = selection.range; + + // When selection is collapsed and is in middle of text node, no need to use Content Model to delete + return !( + isNodeOfType(range.startContainer, 'TEXT_NODE') && + !isModifierKey(rawEvent) && + (canDeleteBefore(rawEvent, range) || canDeleteAfter(rawEvent, range)) + ); + } } function canDeleteBefore(rawEvent: KeyboardEvent, range: Range) { 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 new file mode 100644 index 00000000000..3680f3c67d5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -0,0 +1,55 @@ +import { deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; +import type { DOMSelection } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function keyboardInput(editor: IContentModelEditor, rawEvent: KeyboardEvent) { + const selection = editor.getDOMSelection(); + + if (shouldInputWithContentModel(selection, rawEvent)) { + editor.addUndoSnapshot(); + + editor.formatContentModel( + (model, context) => { + const result = deleteSelection(model, [], context); + + // We have deleted selection then we will let browser to handle the input. + // With this combined operation, we don't wan to mass up the cached model so clear it + context.clearModelCache = true; + + // Skip undo snapshot here and add undo snapshot before the operation so that we don't add another undo snapshot in middle of this replace operation + context.skipUndoSnapshot = true; + + if (result.deleteResult == 'range') { + // We have deleted something, next input should inherit the segment format from deleted content, so set pending format here + context.newPendingFormat = result.insertPoint?.marker.format; + + // Do not preventDefault since we still want browser to handle the final input for now + return true; + } else { + return false; + } + }, + { + rawEvent, + } + ); + + return true; + } +} + +function shouldInputWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { + if (!selection) { + return false; // Nothing to delete + } else if ( + !isModifierKey(rawEvent) && + (rawEvent.key == 'Enter' || rawEvent.key == 'Space' || rawEvent.key.length == 1) + ) { + return selection.type != 'range' || !selection.range.collapsed; // TODO: Also handle Enter key even selection is collapsed + } else { + return false; + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts new file mode 100644 index 00000000000..600228855fc --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts @@ -0,0 +1,312 @@ +import { isCharacterValue } from 'roosterjs-content-model-core'; +import { + addDelimiters, + isBlockElement, + isEntityElement, + isNodeOfType, +} from 'roosterjs-content-model-dom'; +import { + DelimiterClasses, + Keys, + NodeType, + PluginEventType, + PositionType, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; +import { + Position, + createRange, + getDelimiterFromElement, + getEntityFromElement, + getEntitySelector, + matchesSelector, + splitTextNode, +} from 'roosterjs-editor-dom'; +import type { + EditorPlugin, + IEditor, + PluginEvent, + PluginKeyDownEvent, +} from 'roosterjs-editor-types'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; + +const DELIMITER_SELECTOR = + '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; +const ZERO_WIDTH_SPACE = '\u200B'; +const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); + +/** + * Entity delimiter plugin helps maintain delimiter elements around an entity so that user can put focus before/after an entity + */ +export class EntityDelimiterPlugin implements EditorPlugin { + private editor: IContentModelEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'EntityDelimiter'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor as IContentModelEditor; + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case PluginEventType.ContentChanged: + case PluginEventType.EditorReady: + normalizeDelimitersInEditor(this.editor); + break; + + case PluginEventType.BeforePaste: + const { fragment } = event; + addDelimitersIfNeeded(fragment.querySelectorAll(INLINE_ENTITY_SELECTOR)); + + break; + + case PluginEventType.ExtractContentWithDom: + case PluginEventType.BeforeCutCopy: + event.clonedRoot.querySelectorAll(DELIMITER_SELECTOR).forEach(node => { + if (getDelimiterFromElement(node)) { + removeNode(node); + } else { + removeDelimiterAttr(node); + } + }); + break; + + case PluginEventType.KeyDown: + handleKeyDownEvent(this.editor, event); + break; + } + } + } +} + +function preventTypeInDelimiter(delimiter: HTMLElement) { + delimiter.normalize(); + const textNode = delimiter.firstChild as Node; + const index = textNode.nodeValue?.indexOf(ZERO_WIDTH_SPACE) ?? -1; + if (index >= 0) { + splitTextNode(textNode, index == 0 ? 1 : index, false /* returnFirstPart */); + let nodeToMove: Node | undefined; + delimiter.childNodes.forEach(node => { + if (node.nodeValue !== ZERO_WIDTH_SPACE) { + nodeToMove = node; + } + }); + if (nodeToMove) { + delimiter.parentElement?.insertBefore( + nodeToMove, + delimiter.className == DelimiterClasses.DELIMITER_BEFORE + ? delimiter + : delimiter.nextSibling + ); + const selection = nodeToMove.ownerDocument?.getSelection(); + + if (selection) { + selection.setPosition( + nodeToMove, + new Position(nodeToMove, PositionType.End).offset + ); + } + } + } +} + +/** + * @internal + */ +export function normalizeDelimitersInEditor(editor: IEditor) { + removeInvalidDelimiters(editor.queryElements(DELIMITER_SELECTOR)); + addDelimitersIfNeeded(editor.queryElements(INLINE_ENTITY_SELECTOR)); +} + +function addDelimitersIfNeeded(nodes: Element[] | NodeListOf) { + nodes.forEach(node => { + if (isEntityElement(node)) { + addDelimiters(node.ownerDocument, node as HTMLElement); + } + }); +} + +function removeNode(el: Node | undefined | null) { + el?.parentElement?.removeChild(el); +} + +function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { + nodes.forEach(node => { + if (getDelimiterFromElement(node)) { + const sibling = node.classList.contains(DelimiterClasses.DELIMITER_BEFORE) + ? node.nextElementSibling + : node.previousElementSibling; + if (!(isNodeOfType(sibling, 'ELEMENT_NODE') && getEntityFromElement(sibling))) { + removeNode(node); + } + } else { + removeDelimiterAttr(node); + } + }); +} + +function removeDelimiterAttr(node: Element | undefined | null, checkEntity: boolean = true) { + if (!node) { + return; + } + + const isAfter = node.classList.contains(DelimiterClasses.DELIMITER_AFTER); + const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; + if (checkEntity && entitySibling && isEntityElement(entitySibling)) { + return; + } + + node.classList.remove(DelimiterClasses.DELIMITER_AFTER, DelimiterClasses.DELIMITER_BEFORE); + + node.normalize(); + node.childNodes.forEach(cn => { + const index = cn.textContent?.indexOf(ZERO_WIDTH_SPACE) ?? -1; + if (index >= 0) { + createRange(cn, index, cn, index + 1)?.deleteContents(); + } + }); +} + +function handleCollapsedEnter(editor: IEditor, delimiter: HTMLElement) { + const isAfter = delimiter.classList.contains(DelimiterClasses.DELIMITER_AFTER); + const entity = !isAfter ? delimiter.nextSibling : delimiter.previousSibling; + const block = getBlock(editor, delimiter); + + editor.runAsync(() => { + if (!block) { + return; + } + const blockToCheck = isAfter ? block.nextSibling : block.previousSibling; + if (blockToCheck && isNodeOfType(blockToCheck, 'ELEMENT_NODE')) { + const delimiters = blockToCheck.querySelectorAll(DELIMITER_SELECTOR); + // Check if the last or first delimiter still contain the delimiter class and remove it. + const delimiterToCheck = delimiters.item(isAfter ? 0 : delimiters.length - 1); + removeDelimiterAttr(delimiterToCheck); + } + + if (entity && isEntityElement(entity)) { + const entityElement = entity as HTMLElement; + const { nextElementSibling, previousElementSibling } = entityElement; + [nextElementSibling, previousElementSibling].forEach(el => { + // Check if after Enter the ZWS got removed but we still have a element with the class + // Remove the attributes of the element if it is invalid now. + if (el && matchesSelector(el, DELIMITER_SELECTOR) && !getDelimiterFromElement(el)) { + removeDelimiterAttr(el, false /* checkEntity */); + } + }); + + // Add delimiters to the entity if needed because on Enter we can sometimes lose the ZWS of the element. + addDelimiters(entityElement.ownerDocument, entityElement); + } + }); +} + +const getPosition = (container: HTMLElement | null) => { + if (container && getDelimiterFromElement(container)) { + const isAfter = container.classList.contains(DelimiterClasses.DELIMITER_AFTER); + return new Position(container, isAfter ? PositionType.After : PositionType.Before); + } + return undefined; +}; + +function getBlock(editor: IEditor, element: Node | undefined) { + if (!element) { + return undefined; + } + + let block = editor.getBlockElementAtNode(element)?.getStartNode(); + + while (block && (!isNodeOfType(block, 'ELEMENT_NODE') || !isBlockElement(block))) { + block = editor.contains(block.parentElement) ? block.parentElement! : undefined; + } + + return block; +} + +function handleSelectionNotCollapsed(editor: IEditor, range: Range, event: KeyboardEvent) { + const { startContainer, endContainer, startOffset, endOffset } = range; + + const startElement = editor.getElementAtCursor(DELIMITER_SELECTOR, startContainer); + const endElement = editor.getElementAtCursor(DELIMITER_SELECTOR, endContainer); + + const startUpdate = getPosition(startElement); + const endUpdate = getPosition(endElement); + + if (startUpdate || endUpdate) { + editor.select( + startUpdate ?? new Position(startContainer, startOffset), + endUpdate ?? new Position(endContainer, endOffset) + ); + } + editor.runAsync(aEditor => { + const delimiter = aEditor.getElementAtCursor(DELIMITER_SELECTOR); + if (delimiter) { + preventTypeInDelimiter(delimiter); + if (event.which === Keys.ENTER) { + removeDelimiterAttr(delimiter); + } + } + }); +} + +function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { + const range = editor.getSelectionRangeEx(); + const { rawEvent } = event; + if (range.type != SelectionRangeTypes.Normal) { + return; + } + + if (range.areAllCollapsed && (isCharacterValue(rawEvent) || rawEvent.which === Keys.ENTER)) { + const position = editor.getFocusedPosition()?.normalize(); + if (!position) { + return; + } + + const { element, node } = position; + const refNode = element == node ? element.childNodes.item(position.offset) : element; + + const delimiter = editor.getElementAtCursor(DELIMITER_SELECTOR, refNode); + if (!delimiter) { + return; + } + + if (rawEvent.which === Keys.ENTER) { + handleCollapsedEnter(editor, delimiter); + } else if (delimiter.firstChild?.nodeType == NodeType.Text) { + editor.runAsync(() => preventTypeInDelimiter(delimiter)); + } + } else if (!range.areAllCollapsed && !rawEvent.shiftKey && rawEvent.which != Keys.SHIFT) { + const currentRange = range.ranges[0]; + if (!currentRange) { + return; + } + handleSelectionNotCollapsed(editor, currentRange, rawEvent); + } +} 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 373fd4da9eb..d479dec6ddf 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,2 +1,3 @@ export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; +export { EntityDelimiterPlugin } from './entityDelimiter/EntityDelimiterPlugin'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts index e793fbd99e4..2522eed5b24 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts @@ -1,4 +1,5 @@ import * as keyboardDelete from '../../lib/edit/keyboardDelete'; +import * as keyboardInput from '../../lib/edit/keyboardInput'; import { ContentModelEditPlugin } from '../../lib/edit/ContentModelEditPlugin'; import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; @@ -17,9 +18,11 @@ describe('ContentModelEditPlugin', () => { describe('onPluginEvent', () => { let keyboardDeleteSpy: jasmine.Spy; + let keyboardInputSpy: jasmine.Spy; beforeEach(() => { - keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete').and.returnValue(true); + keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); + keyboardInputSpy = spyOn(keyboardInput, 'keyboardInput'); }); it('Backspace', () => { @@ -34,6 +37,7 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).not.toHaveBeenCalled(); }); it('Delete', () => { @@ -48,11 +52,15 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).not.toHaveBeenCalled(); }); it('Other key', () => { const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: 41 } as any; + const rawEvent = { which: 41, key: 'A' } as any; + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + editor.addUndoSnapshot = addUndoSnapshotSpy; plugin.initialize(editor); @@ -62,6 +70,7 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); }); it('Default prevented', () => { @@ -75,6 +84,7 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); }); it('Trigger entity event first', () => { @@ -110,28 +120,7 @@ describe('ContentModelEditPlugin', () => { expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { key: 'Delete', } as any); - }); - - it('SelectionChanged event should clear cached model', () => { - const plugin = new ContentModelEditPlugin(); - - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.SelectionChanged, - selectionRangeEx: null!, - }); - }); - - it('keyboardDelete returns false', () => { - const plugin = new ContentModelEditPlugin(); - - keyboardDeleteSpy.and.returnValue(false); - - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.SelectionChanged, - selectionRangeEx: null!, - }); + expect(keyboardInputSpy).not.toHaveBeenCalled(); }); }); }); 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 902d3502c8b..8f5e7317224 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 @@ -442,9 +442,8 @@ describe('keyboardDelete', () => { getDOMSelection: () => range, } as any; - const result = keyboardDelete(editor, rawEvent); + keyboardDelete(editor, rawEvent); - expect(result).toBeFalse(); expect(formatWithContentModelSpy).not.toHaveBeenCalled(); }); @@ -464,9 +463,8 @@ describe('keyboardDelete', () => { getDOMSelection: () => range, } as any; - const result = keyboardDelete(editor, rawEvent); + keyboardDelete(editor, rawEvent); - expect(result).toBeFalse(); expect(formatWithContentModelSpy).not.toHaveBeenCalled(); }); 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 new file mode 100644 index 00000000000..93fb2ff88be --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -0,0 +1,370 @@ +import * as deleteSelection from 'roosterjs-content-model-core/lib/publicApi/selection/deleteSelection'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; +import { keyboardInput } from '../../lib/edit/keyboardInput'; +import { + ContentModelDocument, + ContentModelFormatter, + FormatWithContentModelContext, +} from 'roosterjs-content-model-types'; + +describe('keyboardInput', () => { + let editor: IContentModelEditor; + let addUndoSnapshotSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let deleteSelectionSpy: jasmine.Spy; + let mockedModel: ContentModelDocument; + let mockedContext: FormatWithContentModelContext; + let formatResult: boolean | undefined; + + beforeEach(() => { + mockedModel = 'MODEL' as any; + mockedContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + + formatResult = undefined; + addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter) => { + formatResult = callback(mockedModel, mockedContext); + }); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); + + editor = { + getDOMSelection: getDOMSelectionSpy, + addUndoSnapshot: addUndoSnapshotSpy, + formatContentModel: formatContentModelSpy, + } as any; + }); + + it('Letter input, collapsed selection, no modifier key', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'notDeleted', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Letter input, expanded selection, no modifier key, deleteSelection returns not deleted', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'notDeleted', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeFalse(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + }); + }); + + it('Letter input, expanded selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: undefined, + }); + }); + + it('Letter input, table selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: undefined, + }); + }); + + it('Letter input, image selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'image', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: undefined, + }); + }); + + it('Letter input, no selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue(null); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Letter input, expanded selection, has modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + ctrlKey: true, + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Space input, table selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'Space', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: undefined, + }); + }); + + it('Backspace input, table selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'Backspace', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Enter input, table selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'Enter', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: undefined, + }); + }); + + it('Letter input, expanded selection, no modifier key, deleteSelection returns range, has segment format', () => { + const mockedFormat = 'FORMAT' as any; + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + insertPoint: { + marker: { + format: mockedFormat, + }, + }, + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: mockedFormat, + }); + }); +}); 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 dfd1cc6ad7b..a2a0064cb7d 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 @@ -82,15 +82,7 @@ describe(ID, () => { format: {}, }, ], - format: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, + format: {}, }); expect(processPastedContentFromExcel.processPastedContentFromExcel).not.toHaveBeenCalled(); }); 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 ba61bd45854..d0e5ecd4909 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 @@ -96,15 +96,7 @@ describe(ID, () => { decorator: { tagName: 'p', format: {} }, }, ], - format: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, + format: {}, }); }); 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 025c0d7882e..69579d81fb6 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 @@ -14,13 +14,15 @@ export function initEditor(id: string): IContentModelEditor { let options: ContentModelEditorOptions = { plugins: [new ContentModelPastePlugin()], - getVisibleViewport: () => { - return { - top: 100, - bottom: 200, - left: 100, - right: 200, - }; + coreApiOverride: { + getVisibleViewport: () => { + return { + top: 100, + bottom: 200, + left: 100, + right: 200, + }; + }, }, }; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index e505f48d5a0..5144dde363e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -1355,7 +1355,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Image', - src: 'http://www.microsoft.com/', + src: 'http://www.microsoft.com', format: { letterSpacing: 'normal', fontFamily: diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 8be1e5cf3d1..5e92099fe94 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -1,6 +1,33 @@ -import type { EditorCore, SwitchShadowEdit } from 'roosterjs-editor-types'; +import type { + CompatibleColorTransformDirection, + CompatibleGetContentMode, +} from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { + ColorTransformDirection, + ContentChangedData, + ContentMetadata, + DOMEventHandler, + DarkColorHandler, + EditorPlugin, + GetContentMode, + ImageSelectionRange, + InsertOption, + NodePosition, + PluginEvent, + PositionType, + Rect, + SelectionPath, + SelectionRangeEx, + StyleBasedFormatState, + TableSelection, + TableSelectionRange, + TrustedHTMLHandler, +} from 'roosterjs-editor-types'; import type { ContentModelDocument } from '../group/ContentModelDocument'; -import type { ContentModelPluginState } from '../pluginState/ContentModelPluginState'; +import type { + StandaloneEditorCorePluginState, + UnportedCorePluginState, +} from '../pluginState/StandaloneEditorPluginState'; import type { DOMSelection } from '../selection/DOMSelection'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { DomToModelSettings } from '../context/DomToModelSettings'; @@ -17,7 +44,7 @@ import type { * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object */ -export type CreateEditorContext = (core: StandaloneEditorCore & EditorCore) => EditorContext; +export type CreateEditorContext = (core: StandaloneEditorCore) => EditorContext; /** * Create Content Model from DOM tree in this editor @@ -26,7 +53,7 @@ export type CreateEditorContext = (core: StandaloneEditorCore & EditorCore) => E * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export type CreateContentModel = ( - core: StandaloneEditorCore & EditorCore, + core: StandaloneEditorCore, option?: DomToModelOption, selectionOverride?: DOMSelection ) => ContentModelDocument; @@ -35,7 +62,7 @@ export type CreateContentModel = ( * Get current DOM selection from editor * @param core The StandaloneEditorCore object */ -export type GetDOMSelection = (core: StandaloneEditorCore & EditorCore) => DOMSelection | null; +export type GetDOMSelection = (core: StandaloneEditorCore) => DOMSelection | null; /** * Set content with content model. This is the replacement of core API getSelectionRangeEx @@ -45,7 +72,7 @@ export type GetDOMSelection = (core: StandaloneEditorCore & EditorCore) => DOMSe * @param onNodeCreated An optional callback that will be called when a DOM node is created */ export type SetContentModel = ( - core: StandaloneEditorCore & EditorCore, + core: StandaloneEditorCore, model: ContentModelDocument, option?: ModelToDomOption, onNodeCreated?: OnNodeCreated @@ -56,10 +83,7 @@ export type SetContentModel = ( * @param core The StandaloneEditorCore object * @param selection The selection to set */ -export type SetDOMSelection = ( - core: StandaloneEditorCore & EditorCore, - selection: DOMSelection -) => void; +export type SetDOMSelection = (core: StandaloneEditorCore, selection: DOMSelection) => void; /** * The general API to do format change with Content Model @@ -71,16 +95,244 @@ export type SetDOMSelection = ( * @param options More options, see FormatWithContentModelOptions */ export type FormatContentModel = ( - core: StandaloneEditorCore & EditorCore, + core: StandaloneEditorCore, formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ) => void; /** - * The interface for the map of core API for Content Model editor. - * Editor can call call API from this map under StandaloneEditorCore object + * Switch the Shadow Edit mode of editor On/Off + * @param core The StandaloneEditorCore object + * @param isOn True to switch On, False to switch Off + */ +export type SwitchShadowEdit = (core: StandaloneEditorCore, isOn: boolean) => void; + +/** + * TODO: Remove this Core API and use setDOMSelection instead + * Select content according to the given information. + * There are a bunch of allowed combination of parameters. See IEditor.select for more details + * @param core The editor core object + * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path + * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection, or null + * @param arg3 (optional) A Node + * @param arg4 (optional) An offset number, or a PositionType + */ +export type Select = ( + core: StandaloneEditorCore, + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType +) => boolean; + +/** + * Trigger a plugin event + * @param core The StandaloneEditorCore 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; + +/** + * Get current selection range + * @param core The StandaloneEditorCore object + * @returns A Range object of the selection range + */ +export type GetSelectionRangeEx = (core: StandaloneEditorCore) => SelectionRangeEx; + +/** + * Edit and transform color of elements between light mode and dark mode + * @param core The StandaloneEditorCore object + * @param rootNode The root HTML node to transform + * @param includeSelf True to transform the root node as well, otherwise false + * @param callback The callback function to invoke before do color transformation + * @param direction To specify the transform direction, light to dark, or dark to light + * @param forceTransform By default this function will only work when editor core is in dark mode. + * Pass true to this value to force do color transformation even editor core is in light mode + * @param fromDarkModel Whether the given content is already in dark mode + */ +export type TransformColor = ( + core: StandaloneEditorCore, + rootNode: Node | null, + includeSelf: boolean, + callback: (() => void) | null, + direction: ColorTransformDirection | CompatibleColorTransformDirection, + forceTransform?: boolean, + fromDarkMode?: boolean +) => void; + +/** + * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. + * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. + * @param core The StandaloneEditorCore object + * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. + * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent + * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). + * @param additionalData Optional parameter to provide additional data related to the ContentChanged Event. + */ +export type AddUndoSnapshot = ( + core: StandaloneEditorCore, + callback: ((start: NodePosition | null, end: NodePosition | null) => any) | null, + changeSource: string | null, + canUndoByBackspace: boolean, + additionalData?: ContentChangedData +) => void; + +/** + * Retrieves the rect of the visible viewport of the editor. + * @param core The StandaloneEditorCore object + */ +export type GetVisibleViewport = (core: StandaloneEditorCore) => Rect | null; + +/** + * Change the editor selection to the given range + * @param core The StandaloneEditorCore object + * @param range The range to select + * @param skipSameRange When set to true, do nothing if the given range is the same with current selection + * in editor, otherwise it will always remove current selection range and set to the given one. + * This parameter is always treated as true in Edge to avoid some weird runtime exception. + */ +export type SelectRange = ( + core: StandaloneEditorCore, + range: Range, + skipSameRange?: boolean +) => boolean; + +/** + * Select a table and save data of the selected range + * @param core The StandaloneEditorCore object + * @param image image to select + * @returns true if successful + */ +export type SelectImage = ( + core: StandaloneEditorCore, + image: HTMLImageElement | null +) => ImageSelectionRange | null; + +/** + * Select a table and save data of the selected range + * @param core The StandaloneEditorCore object + * @param table table to select + * @param coordinates first and last cell of the selection, if this parameter is null, instead of + * selecting, will unselect the table. + * @returns true if successful */ -export interface StandaloneCoreApiMap { +export type SelectTable = ( + core: StandaloneEditorCore, + table: HTMLTableElement | null, + coordinates?: TableSelection +) => TableSelectionRange | null; + +/** + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The StandaloneEditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + */ +export type SetContent = ( + core: StandaloneEditorCore, + content: string, + triggerContentChangedEvent: boolean, + metadata?: ContentMetadata +) => void; + +/** + * Get current or cached selection range + * @param core The StandaloneEditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ +export type GetSelectionRange = ( + core: StandaloneEditorCore, + tryGetFromCache: boolean +) => Range | null; + +/** + * Check if the editor has focus now + * @param core The StandaloneEditorCore object + * @returns True if the editor has focus, otherwise false + */ +export type HasFocus = (core: StandaloneEditorCore) => boolean; + +/** + * Focus to editor. If there is a cached selection range, use it as current selection + * @param core The StandaloneEditorCore object + */ +export type Focus = (core: StandaloneEditorCore) => void; + +/** + * Insert a DOM node into editor content + * @param core The StandaloneEditorCore object. No op if null. + * @param option An insert option object to specify how to insert the node + */ +export type InsertNode = ( + core: StandaloneEditorCore, + node: Node, + option: InsertOption | null +) => boolean; + +/** + * Attach a DOM event to the editor content DIV + * @param core The StandaloneEditorCore object + * @param eventMap A map from event name to its handler + */ +export type AttachDomEvent = ( + core: StandaloneEditorCore, + eventMap: Record +) => () => void; + +/** + * Get current editor content as HTML string + * @param core The StandaloneEditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ +export type GetContent = ( + core: StandaloneEditorCore, + mode: GetContentMode | CompatibleGetContentMode +) => string; + +/** + * Get style based format state from current selection, including font name/size and colors + * @param core The StandaloneEditorCore objects + * @param node The node to get style from + */ +export type GetStyleBasedFormatState = ( + core: StandaloneEditorCore, + node: Node | null +) => StyleBasedFormatState; + +/** + * Restore an undo snapshot into editor + * @param core The StandaloneEditorCore object + * @param step Steps to move, can be 0, positive or negative + */ +export type RestoreUndoSnapshot = (core: StandaloneEditorCore, step: number) => void; + +/** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param core The StandaloneEditorCore object. + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + * @param deprecated Deprecated parameter, not used + */ +export type EnsureTypeInContainer = ( + core: StandaloneEditorCore, + position: NodePosition, + keyboardEvent?: KeyboardEvent, + deprecated?: boolean +) => void; + +/** + * Temp interface + * TODO: Port other core API + */ +export interface PortedCoreApiMap { /** * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object @@ -126,14 +378,196 @@ export interface StandaloneCoreApiMap { */ formatContentModel: FormatContentModel; - // TODO: This is copied from legacy editor core, will be ported to use new types later + /** + * Switch the Shadow Edit mode of editor On/Off + * @param core The StandaloneEditorCore 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 + */ + getVisibleViewport: GetVisibleViewport; } +/** + * Temp interface + * TODO: Port these core API + */ +export interface UnportedCoreApiMap { + /** + * Select content according to the given information. + * There are a bunch of allowed combination of parameters. See IEditor.select for more details + * @param core The editor core object + * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path + * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection, or null + * @param arg3 (optional) A Node + * @param arg4 (optional) An offset number, or a PositionType + */ + select: Select; + + /** + * Trigger a plugin event + * @param core The StandaloneEditorCore object + * @param pluginEvent The event object to trigger + * @param broadcast Set to true to skip the shouldHandleEventExclusively check + */ + triggerEvent: TriggerEvent; + + /** + * Get current or cached selection range + * @param core The StandaloneEditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ + getSelectionRangeEx: GetSelectionRangeEx; + + /** + * Edit and transform color of elements between light mode and dark mode + * @param core The StandaloneEditorCore object + * @param rootNode The root HTML element to transform + * @param includeSelf True to transform the root node as well, otherwise false + * @param callback The callback function to invoke before do color transformation + * @param direction To specify the transform direction, light to dark, or dark to light + * @param forceTransform By default this function will only work when editor core is in dark mode. + * Pass true to this value to force do color transformation even editor core is in light mode + * @param fromDarkModel Whether the given content is already in dark mode + */ + transformColor: TransformColor; + + /** + * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. + * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. + * @param core The StandaloneEditorCore object + * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. + * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent + * @param canUndoByBackspace True if this action can be undone when user presses Backspace key (aka Auto Complete). + */ + addUndoSnapshot: AddUndoSnapshot; + + /** + * Change the editor selection to the given range + * @param core The StandaloneEditorCore object + * @param range The range to select + * @param skipSameRange When set to true, do nothing if the given range is the same with current selection + * in editor, otherwise it will always remove current selection range and set to the given one. + * This parameter is always treated as true in Edge to avoid some weird runtime exception. + */ + selectRange: SelectRange; + + /** + * Select a image and save data of the selected range + * @param core The StandaloneEditorCore object + * @param image image to select + * @param imageId the id of the image element + * @returns true if successful + */ + selectImage: SelectImage; + + /** + * Select a table and save data of the selected range + * @param core The StandaloneEditorCore object + * @param table table to select + * @param coordinates first and last cell of the selection, if this parameter is null, instead of + * selecting, will unselect the table. + * @param shouldAddStyles Whether need to update the style elements + * @returns true if successful + */ + selectTable: SelectTable; + + /** + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The StandaloneEditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + */ + setContent: SetContent; + + /** + * Get current or cached selection range + * @param core The StandaloneEditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ + getSelectionRange: GetSelectionRange; + + /** + * Check if the editor has focus now + * @param core The StandaloneEditorCore 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 + */ + focus: Focus; + + /** + * Insert a DOM node into editor content + * @param core The StandaloneEditorCore object. No op if null. + * @param option An insert option object to specify how to insert the node + */ + insertNode: InsertNode; + + /** + * Attach a DOM event to the editor content DIV + * @param core The StandaloneEditorCore 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 + */ + attachDomEvent: AttachDomEvent; + + /** + * Get current editor content as HTML string + * @param core The StandaloneEditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ + getContent: GetContent; + + /** + * Get style based format state from current selection, including font name/size and colors + * @param core The StandaloneEditorCore objects + * @param node The node to get style from + */ + getStyleBasedFormatState: GetStyleBasedFormatState; + + /** + * Restore an undo snapshot into editor + * @param core The editor core object + * @param step Steps to move, can be 0, positive or negative + */ + restoreUndoSnapshot: RestoreUndoSnapshot; + + /** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param core The EditorCore object. + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + * @param deprecated Deprecated parameter, not used + */ + ensureTypeInContainer: EnsureTypeInContainer; +} + +/** + * The interface for the map of core API for Content Model editor. + * Editor can call call API from this map under StandaloneEditorCore object + */ +export interface StandaloneCoreApiMap extends PortedCoreApiMap, UnportedCoreApiMap {} + /** * Represents the core data structure of a Content Model editor */ -export interface StandaloneEditorCore extends ContentModelPluginState { +export interface StandaloneEditorCore + extends StandaloneEditorCorePluginState, + UnportedCorePluginState, + StandaloneEditorDefaultSettings { /** * The content DIV element of this editor */ @@ -149,6 +583,34 @@ export interface StandaloneEditorCore extends ContentModelPluginState { */ readonly originalApi: StandaloneCoreApiMap; + /** + * An array of editor plugins. + */ + readonly plugins: EditorPlugin[]; + + /** + * Editor running environment + */ + readonly environment: EditorEnvironment; + + /** + * Dark model handler for the editor, used for variable-based solution. + * If keep it null, editor will still use original dataset-based dark mode solution. + */ + readonly darkColorHandler: DarkColorHandler; + + /** + * A handler to convert HTML string to a trust HTML string. + * By default it will just return the original HTML string directly. + * To override, pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler + */ + readonly trustedHTMLHandler: TrustedHTMLHandler; +} + +/** + * Default DOM and Content Model conversion settings for an editor + */ +export interface StandaloneEditorDefaultSettings { /** * Default DOM to Content Model options */ @@ -170,9 +632,4 @@ export interface StandaloneEditorCore extends ContentModelPluginState { * will be used for setting content model if there is no other customized options */ defaultModelToDomConfig: ModelToDomSettings; - - /** - * Editor running environment - */ - environment: EditorEnvironment; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts new file mode 100644 index 00000000000..064a00aab53 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -0,0 +1,47 @@ +import type { SelectionPluginState } from '../pluginState/SelectionPluginState'; +import type { EntityPluginState } from '../pluginState/EntityPluginState'; +import type { LifecyclePluginState } from '../pluginState/LifecyclePluginState'; +import type { DOMEventPluginState } from '../pluginState/DOMEventPluginState'; +import type { ContentModelCachePluginState } from '../pluginState/ContentModelCachePluginState'; +import type { ContentModelFormatPluginState } from '../pluginState/ContentModelFormatPluginState'; +import type { CopyPastePluginState, PluginWithState } from 'roosterjs-editor-types'; + +/** + * Core plugins for standalone editor + */ +export interface StandaloneEditorCorePlugins { + /** + * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary + */ + readonly cache: PluginWithState; + + /** + * ContentModelFormat plugins helps editor to do formatting on top of content model. + */ + readonly format: PluginWithState; + + /** + * Copy and paste plugin for handling onCopy and onPaste event + */ + readonly copyPaste: PluginWithState; + + /** + * DomEvent plugin helps handle additional DOM events such as IME composition, cut, drop. + */ + readonly domEvent: PluginWithState; + + /** + * Selection plugin handles selection, including range selection, table selection, and image selection + */ + readonly selection: PluginWithState; + + /** + * Entity Plugin handles all operations related to an entity and generate entity specified events + */ + readonly entity: PluginWithState; + + /** + * Lifecycle plugin handles editor initialization and disposing + */ + readonly lifecycle: PluginWithState; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index 3f17c706d55..6aeb08701ef 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -1,5 +1,9 @@ +import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; +import type { StandaloneCoreApiMap } from './StandaloneEditorCore'; +import type { EditorPlugin, TrustedHTMLHandler } from 'roosterjs-editor-types'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; +import type { ContentModelDocument } from '../group/ContentModelDocument'; /** * Options for Content Model editor @@ -19,4 +23,70 @@ export interface StandaloneEditorOptions { * Reuse existing DOM structure if possible, and update the model when content or selection is changed */ cacheModel?: boolean; + + /** + * List of plugins. + * The order of plugins here determines in what order each event will be dispatched. + * Plugins not appear in this list will not be added to editor, including built-in plugins. + * Default value is empty array. + */ + plugins?: EditorPlugin[]; + + /** + * Default format of editor content. This will be applied to empty content. + * If there is already content inside editor, format of existing content will not be changed. + * Default value is the computed style of editor content DIV + */ + defaultSegmentFormat?: ContentModelSegmentFormat; + + /** + * Allowed custom content type when paste besides text/plain, text/html and images + * Only text types are supported, and do not add "text/" prefix to the type values + */ + allowedCustomPasteType?: string[]; + + /** + * The scroll container to get scroll event from. + * By default, the scroll container will be the same with editor content DIV + */ + scrollContainer?: HTMLElement; + + /** + * Base dark mode color. We will use this color to calculate the dark mode color from a given light mode color + * @default #333333 + */ + baseDarkColor?: string; + + /** + * Customized trusted type handler used for sanitizing HTML string before assign to DOM tree + * This is required when trusted-type Content-Security-Policy (CSP) is enabled. + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types + */ + trustedHTMLHandler?: TrustedHTMLHandler; + + /** + * A function map to override default core API implementation + * Default value is null + */ + coreApiOverride?: Partial; + + /** + * Color of the border of a selectedImage. Default color: '#DB626C' + */ + imageSelectionBorderColor?: string; + + /** + * Initial Content Model + */ + initialModel?: ContentModelDocument; + + /** + * Whether to skip the adjust editor process when for light/dark mode + */ + doNotAdjustEditorColor?: boolean; + + /** + * If the editor is currently in dark mode + */ + inDarkMode?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts index d6a768f4dfc..9c9c9a30989 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts @@ -23,7 +23,12 @@ export type EntityLifecycleOperation = * Notify plugins that a new entity state need to be updated to an entity. * This is normally happened when user undo/redo the content with an entity snapshot added by a plugin that handles entity */ - | 'UpdateEntityState'; + | 'updateEntityState' + + /** + * Notify plugins that user is clicking target to an entity + */ + | 'click'; /** * Define entity removal related operations diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts index 7c425792fcc..5ab4eec9b1f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts @@ -1,3 +1,5 @@ +import type { ContentModelEntity } from '../entity/ContentModelEntity'; +import type { EntityRemovalOperation } from '../enum/EntityOperation'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; import type { @@ -6,6 +8,26 @@ import type { ContentChangedEventData, } from 'roosterjs-editor-types'; +/** + * Represents an entity that has been changed during a content change process + */ +export interface ChangedEntity { + /** + * The changed entity + */ + entity: ContentModelEntity; + + /** + * Operation that causes the change + */ + operation: EntityRemovalOperation | 'newEntity'; + + /** + * @optional Raw DOM event that causes the chagne + */ + rawEvent?: Event; +} + /** * Data of ContentModelContentChangedEvent */ @@ -13,12 +35,17 @@ export interface ContentModelContentChangedEventData extends ContentChangedEvent /** * The content model that is applied which causes this content changed event */ - contentModel?: ContentModelDocument; + readonly contentModel?: ContentModelDocument; /** * Selection range applied to the document */ - selection?: DOMSelection; + readonly selection?: DOMSelection; + + /** + * Entities got changed (added or removed) during the content change process + */ + readonly changedEntities?: ChangedEntity[]; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts index 2df7b0bed03..45bdb0514ae 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts @@ -1,158 +1,14 @@ -/** - * Enum used to control the different types of bullet list - */ -export enum BulletListType { - /** - * Minimum value of the enum - */ - Min = 1, - /** - * Bullet triggered by * - */ - Disc = 1, - /** - * Bullet triggered by - - */ - Dash = 2, - /** - * Bullet triggered by -- - */ - Square = 3, - /** - * Bullet triggered by > - */ - ShortArrow = 4, - /** - * Bullet triggered by -> - */ - LongArrow = 5, - /** - * Bullet triggered by => - */ - UnfilledArrow = 6, - /** - * Bullet triggered by — - */ - Hyphen = 7, - /** - * Bullet triggered by --> - */ - DoubleLongArrow = 8, - /** - * Bullet type circle - */ - Circle = 9, - /** - * Maximum value of the enum - */ - Max = 9, -} - -/** - * Enum used to control the different types of numbering list - */ -export enum NumberingListType { - /** - * Minimum value of the enum - */ - Min = 1, - /** - * Numbering triggered by 1. - */ - Decimal = 1, - /** - * Numbering triggered by 1- - */ - DecimalDash = 2, - /** - * Numbering triggered by 1) - */ - DecimalParenthesis = 3, - /** - * Numbering triggered by (1) - */ - DecimalDoubleParenthesis = 4, - /** - * Numbering triggered by a. - */ - LowerAlpha = 5, - /** - * Numbering triggered by a) - */ - LowerAlphaParenthesis = 6, - /** - * Numbering triggered by (a) - */ - LowerAlphaDoubleParenthesis = 7, - /** - * Numbering triggered by a- - */ - LowerAlphaDash = 8, - /** - * Numbering triggered by A. - */ - UpperAlpha = 9, - /** - * Numbering triggered by A) - */ - UpperAlphaParenthesis = 10, - /** - * Numbering triggered by (A) - */ - UpperAlphaDoubleParenthesis = 11, - /** - * Numbering triggered by A- - */ - UpperAlphaDash = 12, - /** - * Numbering triggered by i. - */ - LowerRoman = 13, - /** - * Numbering triggered by i) - */ - LowerRomanParenthesis = 14, - /** - * Numbering triggered by (i) - */ - LowerRomanDoubleParenthesis = 15, - /** - * Numbering triggered by i- - */ - LowerRomanDash = 16, - /** - * Numbering triggered by I. - */ - UpperRoman = 17, - /** - * Numbering triggered by I) - */ - UpperRomanParenthesis = 18, - /** - * Numbering triggered by (I) - */ - UpperRomanDoubleParenthesis = 19, - /** - * Numbering triggered by I- - */ - UpperRomanDash = 20, - /** - * Maximum value of the enum - */ - Max = 20, -} - /** * Format of list / list item that stored as metadata */ export type ListMetadataFormat = { /** - * Style type for Ordered list + * Style type for Ordered list. Use value of constant NumberingListType as value. */ - orderedStyleType?: NumberingListType; + orderedStyleType?: number; /** - * Style type for Unordered list + * Style type for Unordered list. Use value of constant BulletListType as value. */ - unorderedStyleType?: BulletListType; + unorderedStyleType?: number; }; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/TableMetadataFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/TableMetadataFormat.ts index 79954b0be86..394581cc8f7 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/TableMetadataFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/TableMetadataFormat.ts @@ -1,85 +1,3 @@ -/** - * Table format border - */ -export enum TableBorderFormat { - /** - * All border of the table are displayed - * __ __ __ - * |__|__|__| - * |__|__|__| - * |__|__|__| - */ - DEFAULT, - - /** - * Middle vertical border are not displayed - * __ __ __ - * |__ __ __| - * |__ __ __| - * |__ __ __| - */ - LIST_WITH_SIDE_BORDERS, - - /** - * All borders except header rows borders are displayed - * __ __ __ - * __|__|__ - * __|__|__ - */ - NO_HEADER_BORDERS, - - /** - * The left and right border of the table are not displayed - * __ __ __ - * __|__|__ - * __|__|__ - * __|__|__ - */ - NO_SIDE_BORDERS, - - /** - * Only the borders that divides the header row, first column and externals are displayed - * __ __ __ - * |__ __ __| - * | | | - * |__|__ __| - */ - FIRST_COLUMN_HEADER_EXTERNAL, - - /** - * The header row has no vertical border, except for the first one - * The first column has no horizontal border, except for the first one - * __ __ __ - * |__ __ __ - * | |__|__| - * | |__|__| - */ - ESPECIAL_TYPE_1, - - /** - * The header row has no vertical border, except for the first one - * The only horizontal border of the table is the top and bottom of header row - * __ __ __ - * |__ __ __ - * | | | - * | | | - */ - ESPECIAL_TYPE_2, - - /** - * The only borders are the bottom of header row and the right border of first column - * __ __ __ - * | - * | - */ - ESPECIAL_TYPE_3, - - /** - * No border - */ - CLEAR, -} - /** * Format of table that stored as metadata */ @@ -135,9 +53,9 @@ export type TableMetadataFormat = { bgColorOdd?: string | null; /** - * Table Borders Type + * Table Borders Type. Use value of constant TableBorderFormat as value */ - tableBorderFormat?: TableBorderFormat; + tableBorderFormat?: number; /** * Vertical alignment for each row */ 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 851d8cdd922..066317a05b4 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -50,12 +50,8 @@ export { FloatFormat } from './format/formatParts/FloatFormat'; export { EntityInfoFormat } from './format/formatParts/EntityInfoFormat'; export { DatasetFormat } from './format/metadata/DatasetFormat'; -export { TableMetadataFormat, TableBorderFormat } from './format/metadata/TableMetadataFormat'; -export { - ListMetadataFormat, - NumberingListType, - BulletListType, -} from './format/metadata/ListMetadataFormat'; +export { TableMetadataFormat } from './format/metadata/TableMetadataFormat'; +export { ListMetadataFormat } from './format/metadata/ListMetadataFormat'; export { ImageResizeMetadataFormat, ImageCropMetadataFormat, @@ -208,14 +204,45 @@ export { FormatContentModel, StandaloneCoreApiMap, StandaloneEditorCore, + StandaloneEditorDefaultSettings, + SwitchShadowEdit, + Select, + TriggerEvent, + GetSelectionRangeEx, + TransformColor, + AddUndoSnapshot, + SelectRange, + PortedCoreApiMap, + UnportedCoreApiMap, + SelectImage, + SelectTable, + SetContent, + GetSelectionRange, + HasFocus, + Focus, + InsertNode, + AttachDomEvent, + GetContent, + GetStyleBasedFormatState, + RestoreUndoSnapshot, + EnsureTypeInContainer, + GetVisibleViewport, } from './editor/StandaloneEditorCore'; +export { StandaloneEditorCorePlugins } from './editor/StandaloneEditorCorePlugins'; export { ContentModelCachePluginState } from './pluginState/ContentModelCachePluginState'; -export { ContentModelPluginState } from './pluginState/ContentModelPluginState'; +export { + StandaloneEditorCorePluginState, + UnportedCorePluginState, +} from './pluginState/StandaloneEditorPluginState'; export { ContentModelFormatPluginState, PendingFormat, } from './pluginState/ContentModelFormatPluginState'; +export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; +export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; +export { EntityPluginState, KnownEntityItem } from './pluginState/EntityPluginState'; +export { SelectionPluginState } from './pluginState/SelectionPluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { @@ -246,4 +273,5 @@ export { ContentModelContentChangedEvent, CompatibleContentModelContentChangedEvent, ContentModelContentChangedEventData, + ChangedEntity, } from './event/ContentModelContentChangedEvent'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts index 242c2145bf8..ec554db2bd8 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts @@ -11,4 +11,9 @@ export interface EditorEnvironment { * Whether editor is running on Android */ isAndroid?: boolean; + + /** + * Whether editor is running on Safari browser + */ + isSafari?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts deleted file mode 100644 index b31f0382035..00000000000 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { CopyPastePluginState } from 'roosterjs-editor-types'; -import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; -import type { ContentModelFormatPluginState } from './ContentModelFormatPluginState'; - -/** - * Temporary core plugin state for Content Model editor - * TODO: Create Content Model plugin state from all core plugins once we have standalone Content Model Editor - */ -export interface ContentModelPluginState { - /** - * Plugin state for ContentModelCachePlugin - */ - cache: ContentModelCachePluginState; - - /** - * Plugin state for ContentModelCopyPastePlugin - */ - copyPaste: CopyPastePluginState; - - /** - * Plugin state for ContentModelFormatPlugin - */ - format: ContentModelFormatPluginState; -} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts new file mode 100644 index 00000000000..1c578e0edc5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts @@ -0,0 +1,36 @@ +import type { ContextMenuProvider } from 'roosterjs-editor-types'; + +/** + * The state object for DOMEventPlugin + */ +export interface DOMEventPluginState { + /** + * Whether editor is in IME input sequence + */ + isInIME: boolean; + + /** + * Scroll container of editor + */ + scrollContainer: HTMLElement; + + /** + * Context menu providers, that can provide context menu items + */ + contextMenuProviders: ContextMenuProvider[]; + + /** + * Whether mouse up event handler is added + */ + mouseUpEventListerAdded: boolean; + + /** + * X-coordinate when mouse down happens + */ + mouseDownX: number | null; + + /** + * X-coordinate when mouse down happens + */ + mouseDownY: number | null; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/EntityPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/EntityPluginState.ts new file mode 100644 index 00000000000..e5759c2077e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/EntityPluginState.ts @@ -0,0 +1,29 @@ +/** + * Represents all info of a known entity, including its DOM element, whether it is deleted and if it can be persisted + */ +export interface KnownEntityItem { + /** + * The HTML element of entity wrapper + */ + element: HTMLElement; + + /** + * Whether this entity is deleted. + */ + isDeleted?: boolean; + + /** + * Whether we want to persist this entity element during undo/redo + */ + canPersist?: boolean; +} + +/** + * The state object for EntityPlugin + */ +export interface EntityPluginState { + /** + * Entities cached for undo snapshot + */ + entityMap: Record; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts new file mode 100644 index 00000000000..7deb442085f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts @@ -0,0 +1,28 @@ +import type { DarkColorHandler } from 'roosterjs-editor-types'; + +/** + * The state object for LifecyclePlugin + */ +export interface LifecyclePluginState { + /** + * Whether editor is in dark mode + */ + isDarkMode: boolean; + + /** + * Cached document fragment for original content + */ + shadowEditFragment: DocumentFragment | null; + + /** + * External content transform function to help do color transform for existing content + */ + onExternalContentTransform: + | (( + element: HTMLElement, + fromDarkMode: boolean, + toDarkMode: boolean, + darkColorHandler: DarkColorHandler + ) => void) + | null; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts new file mode 100644 index 00000000000..b58bf24eb37 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -0,0 +1,36 @@ +import type { ImageSelectionRange, TableSelectionRange } from 'roosterjs-editor-types'; + +/** + * The state object for SelectionPlugin + */ +export interface SelectionPluginState { + /** + * Cached selection range + */ + selectionRange: Range | null; + + /** + * Table selection range + */ + tableSelectionRange: TableSelectionRange | null; + + /** + * Image selection range + */ + imageSelectionRange: ImageSelectionRange | null; + + /** + * A style node in current document to help implement image and table selection + */ + selectionStyleNode: HTMLStyleElement | null; + + /** + * When set to true, onFocus event will not trigger reselect cached range + */ + skipReselectOnFocus?: boolean; + + /** + * Color of the border of a selectedImage. Default color: '#DB626C' + */ + imageSelectionBorderColor?: string; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts new file mode 100644 index 00000000000..5198de965ff --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts @@ -0,0 +1,61 @@ +import type { SelectionPluginState } from './SelectionPluginState'; +import type { + CopyPastePluginState, + EditPluginState, + UndoPluginState, +} from 'roosterjs-editor-types'; +import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; +import type { ContentModelFormatPluginState } from './ContentModelFormatPluginState'; +import type { DOMEventPluginState } from './DOMEventPluginState'; +import type { EntityPluginState } from './EntityPluginState'; +import type { LifecyclePluginState } from './LifecyclePluginState'; + +/** + * Temporary core plugin state for Content Model editor (ported part) + * TODO: Create Content Model plugin state from all core plugins once we have standalone Content Model Editor + */ +export interface StandaloneEditorCorePluginState { + /** + * Plugin state for ContentModelCachePlugin + */ + cache: ContentModelCachePluginState; + + /** + * Plugin state for ContentModelCopyPastePlugin + */ + copyPaste: CopyPastePluginState; + + /** + * Plugin state for ContentModelFormatPlugin + */ + format: ContentModelFormatPluginState; + + /** + * Plugin state for DOMEventPlugin + */ + domEvent: DOMEventPluginState; + + /** + * Plugin state for LifecyclePlugin + */ + lifecycle: LifecyclePluginState; + + /** + * Plugin state for EntityPlugin + */ + entity: EntityPluginState; + + /** + * Plugin state for SelectionPlugin + */ + selection: SelectionPluginState; +} + +/** + * Temporary core plugin state for Content Model editor (unported part) + * TODO: Port these plugins + */ +export interface UnportedCorePluginState { + undo: UndoPluginState; + edit: EditPluginState; +} diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index 821d628f9e6..676a8b26397 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,6 +1,9 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; -import { getDarkColor } from 'roosterjs-color-utils'; +import { + ContentModelEditPlugin, + ContentModelPastePlugin, + EntityDelimiterPlugin, +} from 'roosterjs-content-model-plugins'; import type { EditorPlugin } from 'roosterjs-editor-types'; import type { ContentModelEditorOptions, @@ -21,13 +24,16 @@ export function createContentModelEditor( initialContent?: string ): IContentModelEditor { const plugins = additionalPlugins ? [...additionalPlugins] : []; - plugins.push(new ContentModelPastePlugin(), new ContentModelEditPlugin()); + plugins.push( + new ContentModelPastePlugin(), + new ContentModelEditPlugin(), + new EntityDelimiterPlugin() + ); const options: ContentModelEditorOptions = { plugins: plugins, initialContent: initialContent, - getDarkColor: getDarkColor, - defaultFormat: { + defaultSegmentFormat: { fontFamily: 'Calibri,Arial,Helvetica,sans-serif', fontSize: '11pt', textColor: '#000000', diff --git a/packages-content-model/roosterjs-content-model/package.json b/packages-content-model/roosterjs-content-model/package.json index 698c3df6db1..76bfe86013d 100644 --- a/packages-content-model/roosterjs-content-model/package.json +++ b/packages-content-model/roosterjs-content-model/package.json @@ -9,8 +9,7 @@ "roosterjs-content-model-core": "", "roosterjs-content-model-api": "", "roosterjs-content-model-editor": "", - "roosterjs-content-model-plugins": "", - "roosterjs-color-utils": "" + "roosterjs-content-model-plugins": "" }, "version": "0.0.0", "main": "./lib/index.ts" diff --git a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts index 0041b247fc9..153602d4b31 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts @@ -43,7 +43,8 @@ export default class ImageSelection implements EditorPlugin { if ( safeInstanceOf(target, 'HTMLImageElement') && target.isContentEditable && - event.rawEvent.button != mouseMiddleButton + event.rawEvent.button != mouseMiddleButton && + event.isClicking ) { this.editor.select(target); } diff --git a/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts b/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts index 60331218a62..c5ac7e4e940 100644 --- a/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts +++ b/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts @@ -7,6 +7,8 @@ import { ImageSelectionRange, PluginEvent, PluginEventType, + PluginMouseUpEvent, + PluginMouseDownEvent, } from 'roosterjs-editor-types'; export * from 'roosterjs-editor-dom/test/DomTestHelper'; @@ -58,8 +60,8 @@ describe('ImageSelectionPlugin |', () => { editor.setContent(``); const target = document.getElementById(imageId); editorIsFeatureEnabled.and.returnValue(true); - simulateMouseEvent('mousedown', target!, 0); - simulateMouseEvent('mouseup', target!, 0); + imageSelection.onPluginEvent(mouseDown(target!, 0)); + imageSelection.onPluginEvent(mouseup(target!, 0, true)); editor.focus(); const selection = editor.getSelectionRangeEx(); @@ -67,6 +69,19 @@ describe('ImageSelectionPlugin |', () => { expect(selection.areAllCollapsed).toBe(false); }); + it('should not be triggered in mouse up left click', () => { + editor.setContent(``); + const target = document.getElementById(imageId); + editorIsFeatureEnabled.and.returnValue(true); + imageSelection.onPluginEvent(mouseDown(target!, 0)); + imageSelection.onPluginEvent(mouseup(target!, 0, false)); + editor.focus(); + + const selection = editor.getSelectionRangeEx(); + expect(selection.type).toBe(SelectionRangeTypes.Normal); + expect(selection.areAllCollapsed).toBe(true); + }); + it('should handle a ESCAPE KEY in a image', () => { editor.setContent(``); const target = document.getElementById(imageId); @@ -204,17 +219,42 @@ describe('ImageSelectionPlugin |', () => { }; }; - function simulateMouseEvent(mouseEvent: string, target: HTMLElement, keyNumber: number) { + const mouseup = ( + target: HTMLElement, + keyNumber: number, + isClicking: boolean + ): PluginMouseUpEvent => { + const rect = target.getBoundingClientRect(); + return { + eventType: PluginEventType.MouseUp, + rawEvent: { + target: target, + view: window, + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.top, + shiftKey: false, + button: keyNumber, + }, + isClicking, + }; + }; + + const mouseDown = (target: HTMLElement, keyNumber: number): PluginMouseDownEvent => { const rect = target.getBoundingClientRect(); - var event = new MouseEvent(mouseEvent, { - view: window, - bubbles: true, - cancelable: true, - clientX: rect.left, - clientY: rect.top, - shiftKey: false, - button: keyNumber, - }); - target.dispatchEvent(event); - } + return { + eventType: PluginEventType.MouseDown, + rawEvent: { + target: target, + view: window, + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.top, + shiftKey: false, + button: keyNumber, + }, + }; + }; }); diff --git a/packages/roosterjs-editor-plugins/lib/plugins/AutoFormat/AutoFormat.ts b/packages/roosterjs-editor-plugins/lib/plugins/AutoFormat/AutoFormat.ts index a71090d70dc..c1396475f3d 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/AutoFormat/AutoFormat.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/AutoFormat/AutoFormat.ts @@ -59,17 +59,19 @@ export default class AutoFormat implements EditorPlugin { if ( this.lastKeyTyped === '-' && !specialCharacters.test(keyTyped) && - keyTyped !== ' ' && keyTyped !== '-' ) { const searcher = this.editor.getContentSearcherOfCursor(event); const textBeforeCursor = searcher?.getSubStringBefore(3); const dashes = searcher?.getSubStringBefore(2); const isPrecededByADash = textBeforeCursor?.[0] === '-'; - const isPrecededByASpace = textBeforeCursor?.[0] === ' '; + const isSpaced = + (textBeforeCursor == ' --' && keyTyped !== ' ') || + (textBeforeCursor !== ' --' && keyTyped === ' '); + if ( isPrecededByADash || - isPrecededByASpace || + isSpaced || (typeof textBeforeCursor === 'string' && specialCharacters.test(textBeforeCursor[0])) || dashes !== '--' @@ -78,7 +80,10 @@ export default class AutoFormat implements EditorPlugin { } const textRange = searcher?.getRangeFromText(dashes, true /* exactMatch */); - const nodeHyphen = document.createTextNode('—'); + const nodeHyphen = + textBeforeCursor === ' --' && keyTyped === ' ' + ? document.createTextNode('–') + : document.createTextNode('—'); this.editor.addUndoSnapshot( () => { if (textRange) { diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/entityFeatures.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/entityFeatures.ts index f62bafe4763..ba16265f6f6 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/entityFeatures.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/entityFeatures.ts @@ -463,8 +463,8 @@ function cacheGetCheckBefore(event: PluginKeyboardEvent, checkBefore?: boolean): } function getRelatedElements(delimiter: HTMLElement, checkBefore: boolean, editor: IEditor) { - let entity: Element | null = null; - let delimiterPair: Element | null = null; + let entity: HTMLElement | null = null; + let delimiterPair: HTMLElement | null = null; const traverser = getBlockTraverser(editor, delimiter); if (!traverser) { return { delimiterPair, entity }; @@ -486,11 +486,18 @@ function getRelatedElements(delimiter: HTMLElement, checkBefore: boolean, editor entity = entity || getElementFromInline(current, entitySelector); delimiterPair = delimiterPair || getElementFromInline(current, selector); - // If we found the entity but the next inline after the entity is not a delimiter, - // it means that the delimiter pair got removed or is invalid, return null instead. - if (entity && !delimiterPair && !getElementFromInline(current, entitySelector)) { - delimiterPair = null; - break; + if (entity) { + // If we found the entity but the next inline after the entity is not a delimiter, + // it means that the delimiter pair got removed or is invalid, return null instead. + if (!delimiterPair && !getElementFromInline(current, entitySelector)) { + delimiterPair = null; + break; + } + // If the delimiter is not editable keep looking for a editable one, by setting the value as null, + // in case the entity is wrapping another inline readonly entity + if (delimiterPair && !delimiterPair.isContentEditable) { + delimiterPair = null; + } } current = traverseFn(traverser); } diff --git a/packages/roosterjs-editor-plugins/test/AutoFormat/autoFormatTest.ts b/packages/roosterjs-editor-plugins/test/AutoFormat/autoFormatTest.ts index 6218ffc8e33..88c4064114f 100644 --- a/packages/roosterjs-editor-plugins/test/AutoFormat/autoFormatTest.ts +++ b/packages/roosterjs-editor-plugins/test/AutoFormat/autoFormatTest.ts @@ -34,6 +34,9 @@ describe('AutoHyphen |', () => { plugin.onPluginEvent(keyDown(keysTyped[1])); plugin.onPluginEvent(keyDown(keysTyped[2])); plugin.onPluginEvent(keyDown(keysTyped[3])); + plugin.onPluginEvent(keyDown(keysTyped[4])); + plugin.onPluginEvent(keyDown(keysTyped[5])); + plugin.onPluginEvent(keyDown(keysTyped[6])); expect(editor.getContent()).toBe(expectedResult); } @@ -45,6 +48,14 @@ describe('AutoHyphen |', () => { ); }); + it('Should format with space ', () => { + runTestShouldHandleAutoHyphen( + '
t--
', + ['t', ' ', '-', '-', ' ', 'b'], + '
t—
' + ); + }); + it('Should not format| - ', () => { runTestShouldHandleAutoHyphen( '
t—-
', diff --git a/packages/roosterjs-editor-plugins/test/ContentEdit/features/inlineEntityFeatureTest.ts b/packages/roosterjs-editor-plugins/test/ContentEdit/features/inlineEntityFeatureTest.ts index cec8afcfa65..554eb0df6bb 100644 --- a/packages/roosterjs-editor-plugins/test/ContentEdit/features/inlineEntityFeatureTest.ts +++ b/packages/roosterjs-editor-plugins/test/ContentEdit/features/inlineEntityFeatureTest.ts @@ -1,5 +1,6 @@ import * as addDelimiters from 'roosterjs-editor-dom/lib/delimiter/addDelimiters'; import * as getComputedStyles from 'roosterjs-editor-dom/lib/utils/getComputedStyles'; +import { BlockElement, Entity, IEditor, Keys, PluginKeyDownEvent } from 'roosterjs-editor-types'; import { EntityFeatures } from '../../../lib/plugins/ContentEdit/features/entityFeatures'; import { commitEntity, @@ -9,7 +10,6 @@ import { Position, PositionContentSearcher, } from 'roosterjs-editor-dom'; -import { Entity, IEditor, Keys, PluginKeyDownEvent, BlockElement } from 'roosterjs-editor-types'; describe('Content Edit Features |', () => { const { moveBetweenDelimitersFeature, removeEntityBetweenDelimiters } = EntityFeatures; @@ -37,6 +37,7 @@ describe('Content Edit Features |', () => { cleanUp(); defaultEvent = {}; testContainer = document.createElement('div'); + testContainer.setAttribute('contenteditable', 'true'); document.body.appendChild(testContainer); wrapper = document.createElement('span'); @@ -387,10 +388,44 @@ describe('Content Edit Features |', () => { restoreSelection(); }); + it('DelimiterBefore, should handle and handle, nested entity no shiftKey', () => { + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + + event = runTest(delimiterBefore, true /* expected */, event); + + spyOnSelection(); + moveBetweenDelimitersFeature.handleEvent(event, editor); + + expect(select).toHaveBeenCalledWith(new Position(delimiterAfter!, 1)); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(extendSpy).toHaveBeenCalledTimes(0); + + restoreSelection(); + }); + it('DelimiterBefore, should handle and handle, no shiftKey elements wrapped in B', () => { wrapElementInB(delimiterBefore); wrapElementInB(entity.wrapper); wrapElementInB(delimiterAfter); + + event = runTest(delimiterBefore, true /* expected */, event); + + spyOnSelection(); + moveBetweenDelimitersFeature.handleEvent(event, editor); + + expect(select).toHaveBeenCalledWith(new Position(delimiterAfter!, 1)); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(extendSpy).toHaveBeenCalledTimes(0); + + restoreSelection(); + }); + + it('DelimiterBefore, should handle and handle, no shiftKey elements wrapped in B and nestedEntity', () => { + wrapElementInB(delimiterBefore); + wrapElementInB(entity.wrapper); + wrapElementInB(delimiterAfter); + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + event = runTest(delimiterBefore, true /* expected */, event); spyOnSelection(); @@ -424,7 +459,29 @@ describe('Content Edit Features |', () => { restoreSelection(); }); - it('DelimiterBefore, should handle and handle, with shiftKey, elements wrapped in B', () => { + it('DelimiterBefore, should handle and handle, with shiftKey and nested entity', () => { + event = { + ...event, + rawEvent: { + ...event.rawEvent, + shiftKey: true, + }, + }; + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + + event = runTest(delimiterBefore, true /* expected */, event); + + spyOnSelection(); + + moveBetweenDelimitersFeature.handleEvent(event, editor); + + expect(extendSpy).toHaveBeenCalledWith(testContainer, 3); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + + restoreSelection(); + }); + + it('DelimiterBefore, should handle and handle, with shiftKey, elements wrapped in B and nested entity', () => { event = { ...event, rawEvent: { @@ -436,6 +493,8 @@ describe('Content Edit Features |', () => { wrapElementInB(delimiterBefore); wrapElementInB(entity.wrapper); wrapElementInB(delimiterAfter); + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + event = runTest(delimiterBefore, true /* expected */, event); spyOnSelection(); @@ -518,6 +577,25 @@ describe('Content Edit Features |', () => { restoreSelection(); }); + it('DelimiterBefore, shouldHandle and Handle, cursor at end of element before delimiter before and nested entity', () => { + const bold = document.createElement('b'); + bold.append(document.createTextNode('Bold')); + testContainer.insertBefore(bold, delimiterBefore); + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + + event = runTest(new Position(bold.firstChild!, 4), true /* expected */, event); + + spyOnSelection(); + + moveBetweenDelimitersFeature.handleEvent(event, editor); + + expect(select).toHaveBeenCalledWith(new Position(delimiterAfter!, 1)); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(extendSpy).toHaveBeenCalledTimes(0); + + restoreSelection(); + }); + it('DelimiterBefore, should not Handle, cursor is not not at the start of the element after delimiter after', () => { const bold = document.createElement('b'); bold.append(document.createTextNode('Bold')); @@ -547,6 +625,21 @@ describe('Content Edit Features |', () => { runTest(delimiterBefore, true /* expected */, event); }); + it('DelimiterBefore, Inline Readonly Entity with multiple Inline Elements and nested scenario', () => { + const b = document.createElement('b'); + b.appendChild(document.createTextNode('Bold')); + + entity.wrapper.appendChild(b); + entity.wrapper.appendChild(b.cloneNode(true)); + + wrapElementInB(delimiterBefore); + wrapElementInB(entity.wrapper); + wrapElementInB(delimiterAfter); + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + + runTest(delimiterBefore, true /* expected */, event); + }); + it('DelimiterBefore, should not Handle, getBlockElementAtCursor returned inline', () => { const div = document.createElement('div'); div.appendChild(document.createTextNode('New block')); @@ -790,6 +883,22 @@ describe('Content Edit Features |', () => { } }); +function setupNestedEntityScenario( + entity: Entity, + delimiterBefore: Element | null, + delimiterAfter: Element | null +) { + const wrapperClone = entity.wrapper.cloneNode(true /* deep */); + while (entity.wrapper.firstChild) { + entity.wrapper.removeChild(entity.wrapper.firstChild); + } + entity.wrapper.append( + delimiterBefore!.cloneNode(true /* deep */), + wrapperClone, + delimiterAfter!.cloneNode(true) + ); +} + function wrapElementInB(delimiterBefore: Element | null) { const element = delimiterBefore?.insertAdjacentElement( 'beforebegin', @@ -823,6 +932,7 @@ function addEntityBeforeEach(entity: Entity, wrapper: HTMLElement) { type: 'Test', wrapper, }; + wrapper.setAttribute('contenteditable', 'false'); commitEntity(wrapper, 'test', true, 'test'); addDelimiters.default(wrapper); diff --git a/tools/buildTools/normalize.js b/tools/buildTools/normalize.js index 740489870a3..8236c64979c 100644 --- a/tools/buildTools/normalize.js +++ b/tools/buildTools/normalize.js @@ -19,7 +19,7 @@ function normalize() { allPackages.forEach(packageName => { const versionKey = findPackageRoot(packageName); - const version = versions[versionKey]; + const version = versions.overrides?.[packageName] ?? versions[versionKey]; const packageJson = readPackageJson(packageName, true /*readFromSourceFolder*/); Object.keys(packageJson.dependencies).forEach(dep => { @@ -38,12 +38,12 @@ function normalize() { } }); - if (packageJson.version && packageJson.version != '0.0.0') { - knownCustomizedPackages[packageName] = packageJson.version; - } else { + if (!packageJson.version || packageJson.version == '0.0.0') { packageJson.version = version; } + knownCustomizedPackages[packageName] = packageJson.version; + packageJson.typings = './lib/index.d.ts'; packageJson.main = './lib/index.js'; packageJson.module = './lib-mjs/index.js'; diff --git a/versions.json b/versions.json index 1317dbfc02c..23bac8c5609 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,9 @@ -{ - "packages": "8.59.0", - "packages-ui": "8.54.0", - "packages-content-model": "0.20.0" -} +{ + "packages": "8.59.0", + "packages-ui": "8.54.0", + "packages-content-model": "0.21.0", + "overrides": { + "roosterjs-editor-core": "8.59.1", + "roosterjs-editor-plugins": "8.59.1" + } +}