diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 0f70a391e7f..9f3ec7f2316 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import SampleEntityPlugin from '../plugins/SampleEntityPlugin'; import { ApiPlaygroundPlugin } from '../sidePane/apiPlayground/ApiPlaygroundPlugin'; -import { Border, ContentModelDocument, EditorOptions } from 'roosterjs-content-model-types'; -import { Colors, EditorPlugin, IEditor, Snapshots } from 'roosterjs-content-model-types'; import { ContentModelPanePlugin } from '../sidePane/contentModel/ContentModelPanePlugin'; import { createEmojiPlugin } from '../roosterjsReact/emoji'; import { createImageEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createImageEditMenuProvider'; @@ -30,6 +28,7 @@ import { PresetPlugin } from '../sidePane/presets/PresetPlugin'; import { redoButton } from '../roosterjsReact/ribbon/buttons/redoButton'; import { registerWindowForCss, unregisterWindowForCss } from '../../utils/cssMonitor'; import { Rooster } from '../roosterjsReact/rooster'; +import { SamplePickerPlugin } from '../plugins/SamplePickerPlugin'; import { SidePane } from '../sidePane/SidePane'; import { SidePanePlugin } from '../sidePane/SidePanePlugin'; import { SnapshotPlugin } from '../sidePane/snapshot/SnapshotPlugin'; @@ -41,11 +40,18 @@ import { UpdateContentPlugin } from '../plugins/UpdateContentPlugin'; import { WindowProvider } from '@fluentui/react/lib/WindowProvider'; import { zoomButton } from '../demoButtons/zoomButton'; import { - createContextMenuPlugin, - createTableEditMenuProvider, -} from '../roosterjsReact/contextMenu'; + Border, + Colors, + ContentModelDocument, + EditorOptions, + EditorPlugin, + IEditor, + KnownAnnounceStrings, + Snapshots, +} from 'roosterjs-content-model-types'; import { AutoFormatPlugin, + CustomReplacePlugin, EditPlugin, HyperlinkPlugin, MarkdownPlugin, @@ -54,6 +60,10 @@ import { TableEditPlugin, WatermarkPlugin, } from 'roosterjs-content-model-plugins'; +import { + createContextMenuPlugin, + createTableEditMenuProvider, +} from '../roosterjsReact/contextMenu'; const styles = require('./MainPane.scss'); @@ -88,6 +98,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { private ribbonPlugin: RibbonPlugin; private snapshotPlugin: SnapshotPlugin; private formatPainterPlugin: FormatPainterPlugin; + private samplePickerPlugin: SamplePickerPlugin; private snapshots: Snapshots; protected sidePane = React.createRef(); @@ -125,6 +136,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { this.presetPlugin = new PresetPlugin(); this.ribbonPlugin = createRibbonPlugin(); this.formatPainterPlugin = new FormatPainterPlugin(); + this.samplePickerPlugin = new SamplePickerPlugin(); this.state = { showSidePane: window.location.hash != '', @@ -327,6 +339,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { const plugins: EditorPlugin[] = [ this.ribbonPlugin, this.formatPainterPlugin, + this.samplePickerPlugin, ...this.getToggleablePlugins(), this.contentModelPanePlugin.getInnerRibbonPlugin(), this.updateContentPlugin, @@ -356,6 +369,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { dir={this.state.isRtl ? 'rtl' : 'ltr'} knownColors={this.knownColors} disableCache={this.state.initState.disableCache} + announcerStringGetter={getAnnouncingString} /> )} @@ -478,6 +492,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { markdownOptions, autoFormatOptions, linkTitle, + customReplacements, } = this.state.initState; return [ pluginList.autoFormat && new AutoFormatPlugin(autoFormatOptions), @@ -500,10 +515,21 @@ export class MainPane extends React.Component<{}, MainPaneState> { ? url => linkTitle.replace(UrlPlaceholder, url) : linkTitle ), + pluginList.customReplace && new CustomReplacePlugin(customReplacements), ].filter(x => !!x); } } +const AnnounceStringMap: Record = { + announceListItemBullet: 'Auto corrected Bullet', + announceListItemNumbering: 'Auto corrected {0}', + announceOnFocusLastCell: 'Warning, pressing tab here adds an extra row.', +}; + +function getAnnouncingString(key: KnownAnnounceStrings) { + return AnnounceStringMap[key]; +} + export function mount(parent: HTMLElement) { ReactDOM.render(, parent); } diff --git a/demo/scripts/controlsV2/plugins/SamplePickerPlugin.tsx b/demo/scripts/controlsV2/plugins/SamplePickerPlugin.tsx new file mode 100644 index 00000000000..1b0cd13d122 --- /dev/null +++ b/demo/scripts/controlsV2/plugins/SamplePickerPlugin.tsx @@ -0,0 +1,206 @@ +import * as React from 'react'; +import { Callout } from '@fluentui/react/lib/Callout'; +import { DOMInsertPoint } from 'roosterjs-content-model-types'; +import { IContextualMenuItem } from '@fluentui/react/lib/ContextualMenu'; +import { mergeStyles } from '@fluentui/react/lib/Styling'; +import { ReactEditorPlugin, UIUtilities } from '../roosterjsReact/common'; +import { + PickerDirection, + PickerHandler, + PickerHelper, + PickerPlugin, + PickerSelectionChangMode, + getDOMInsertPointRect, +} from 'roosterjs-content-model-plugins'; +import { + createContentModelDocument, + createEntity, + createParagraph, +} from 'roosterjs-content-model-dom'; + +const itemStyle = mergeStyles({ + height: '20px', + margin: '4px', + padding: '4px', + minWidth: '200px', +}); + +const selectedItemStyle = mergeStyles({ + backgroundColor: 'blue', + color: 'white', + fontWeight: 'bold', +}); + +export class SamplePickerPlugin extends PickerPlugin implements ReactEditorPlugin { + private pickerHandler: SamplePickerHandler; + + constructor() { + const pickerHandler = new SamplePickerHandler(); + super('@', pickerHandler); + + this.pickerHandler = pickerHandler; + } + + setUIUtilities(uiUtilities: UIUtilities): void { + this.pickerHandler.setUIUtilities(uiUtilities); + } +} + +class SamplePickerHandler implements PickerHandler { + private uiUtilities: UIUtilities; + private index = 0; + private ref: IPickerMenu | null = null; + private queryString: string; + private items: IContextualMenuItem[] = []; + private onClose: (() => void) | null = null; + private helper: PickerHelper | null = null; + + onInitialize(helper: PickerHelper) { + this.helper = helper; + } + + onDispose() { + this.helper = null; + } + + setUIUtilities(uiUtilities: UIUtilities): void { + this.uiUtilities = uiUtilities; + } + + onTrigger(queryString: string, pos: DOMInsertPoint): PickerDirection | null { + this.index = 0; + this.queryString = queryString; + this.items = buildItems(queryString, this.index); + + const rect = getDOMInsertPointRect(this.helper.editor.getDocument(), pos); + + if (rect) { + this.onClose = this.uiUtilities.renderComponent( + (this.ref = ref)} + items={this.items} + /> + ); + return 'vertical'; + } else { + return null; + } + } + + onClosePicker() { + this.onClose?.(); + this.onClose = null; + } + + onSelectionChanged(mode: PickerSelectionChangMode): void { + switch (mode) { + case 'first': + case 'firstInRow': + case 'previousPage': + this.index = 0; + break; + + case 'last': + case 'lastInRow': + case 'nextPage': + this.index = 4; + break; + + case 'previous': + this.index = this.index - 1; + + if (this.index < 0) { + this.index = 4; + } + + break; + + case 'next': + this.index = (this.index + 1) % 5; + break; + } + + this.items = buildItems(this.queryString, this.index); + this.ref?.setMenuItems(this.items); + } + + onSelect(): void { + const text = this.items[this.index]?.text; + + if (text) { + const span = this.helper.editor.getDocument().createElement('span'); + span.textContent = '@' + text; + span.style.textDecoration = 'underline'; + span.style.color = 'blue'; + + const entity = createEntity(span, true /*isReadonly*/, {}, 'TEST_ENTITY'); + const paragraph = createParagraph(); + const doc = createContentModelDocument(); + + paragraph.segments.push(entity); + doc.blocks.push(paragraph); + + this.helper.replaceQueryString( + doc, + { + changeSource: 'SamplePicker', + }, + true /*canUndoByBackspace*/ + ); + } + + this.onClose?.(); + this.onClose = null; + this.ref = null; + this.helper.closePicker(); + } + + onQueryStringChanged(queryString: string): void { + this.queryString = queryString; + + if (queryString.length > 100 || queryString.split(' ').length > 4) { + // Querystring is too long, so close picker + this.helper.closePicker(); + } else { + this.items = buildItems(this.queryString, this.index); + this.ref?.setMenuItems(this.items); + } + } +} + +function buildItems(queryString: string, index: number): IContextualMenuItem[] { + return [1, 2, 3, 4, 5].map((x, i) => ({ + key: 'item' + i, + text: queryString.substring(1) + ' item ' + x, + checked: i == index, + })); +} + +interface IPickerMenu { + setMenuItems: (items: IContextualMenuItem[]) => void; +} + +const PickerMenu = React.forwardRef( + ( + props: { x: number; y: number; items: IContextualMenuItem[] }, + ref: React.Ref + ) => { + const [items, setItems] = React.useState(props.items); + + React.useImperativeHandle(ref, () => ({ + setMenuItems: setItems, + })); + + return ( + + {items.map(item => ( +
+ {item.text} +
+ ))} +
+ ); + } +); diff --git a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts index 605f8212eab..a4fdd37cc88 100644 --- a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts +++ b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts @@ -1,5 +1,5 @@ -import { Announce, CustomReplace, ImageEdit } from 'roosterjs-editor-plugins'; -import { EditorPlugin as LegacyEditorPlugin, KnownAnnounceStrings } from 'roosterjs-editor-types'; +import { EditorPlugin as LegacyEditorPlugin } from 'roosterjs-editor-types'; +import { ImageEdit } from 'roosterjs-editor-plugins'; import { LegacyPluginList, OptionState } from '../sidePane/editorOptions/OptionState'; export function createLegacyPlugins(initState: OptionState): LegacyEditorPlugin[] { @@ -12,20 +12,7 @@ export function createLegacyPlugins(initState: OptionState): LegacyEditorPlugin[ applyChangesOnMouseUp: initState.applyChangesOnMouseUp, }) : null, - customReplace: pluginList.customReplace ? new CustomReplace() : null, - announce: pluginList.announce ? new Announce(getDefaultStringsMap()) : null, }; return Object.values(plugins).filter(x => !!x); } - -function getDefaultStringsMap(): Map { - return new Map([ - [KnownAnnounceStrings.AnnounceListItemBullet, 'Autocorrected Bullet'], - [KnownAnnounceStrings.AnnounceListItemNumbering, 'Autocorrected {0}'], - [ - KnownAnnounceStrings.AnnounceOnFocusLastCell, - 'Warning, pressing tab here adds an extra row.', - ], - ]); -} diff --git a/demo/scripts/controlsV2/roosterjsReact/pasteOptions/component/showPasteOptionPane.tsx b/demo/scripts/controlsV2/roosterjsReact/pasteOptions/component/showPasteOptionPane.tsx index 9208f185e57..fd381afc379 100644 --- a/demo/scripts/controlsV2/roosterjsReact/pasteOptions/component/showPasteOptionPane.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/pasteOptions/component/showPasteOptionPane.tsx @@ -1,15 +1,16 @@ import * as React from 'react'; import { ButtonKeys, Buttons } from '../utils/buttons'; import { Callout, DirectionalHint } from '@fluentui/react/lib/Callout'; +import { getDOMInsertPointRect } from 'roosterjs-content-model-plugins'; import { getLocalizedString } from '../../common/index'; import { getObjectKeys } from 'roosterjs-content-model-dom'; -import { getPositionRect } from '../utils/getPositionRect'; import { Icon } from '@fluentui/react/lib/Icon'; import { IconButton } from '@fluentui/react/lib/Button'; import { memoizeFunction } from '@fluentui/react/lib/Utilities'; import { mergeStyleSets } from '@fluentui/react/lib/Styling'; import { renderReactComponent } from '../../common/utils/renderReactComponent'; import { useTheme } from '@fluentui/react/lib/Theme'; +import { useWindow } from '@fluentui/react/lib/WindowProvider'; import type { LocalizedStrings, UIUtilities } from '../../common/index'; import type { Theme } from '@fluentui/react/lib/Theme'; import type { PasteOptionButtonKeys, PasteOptionStringKeys } from '../type/PasteOptionStringKeys'; @@ -106,7 +107,7 @@ const PasteOptionComponent = React.forwardRef(function PasteOptionFunc( const classNames = getPasteOptionClassNames(theme); const [selectedKey, setSelectedKey] = React.useState(null); - const rect = getPositionRect(container, offset); + const rect = getDOMInsertPointRect(useWindow().document, { node: container, offset }); const target = rect && { x: props.isRtl ? rect.left : rect.right, y: rect.bottom }; React.useImperativeHandle( diff --git a/demo/scripts/controlsV2/roosterjsReact/rooster/component/Rooster.tsx b/demo/scripts/controlsV2/roosterjsReact/rooster/component/Rooster.tsx index 1efdc590789..134a49a2c04 100644 --- a/demo/scripts/controlsV2/roosterjsReact/rooster/component/Rooster.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/rooster/component/Rooster.tsx @@ -53,7 +53,14 @@ export function Rooster(props: RoosterProps) { }, [inDarkMode]); const divProps = getNativeProps>(props, divProperties); - return
; + return ( +
+ ); } function defaultEditorCreator(div: HTMLDivElement, options: EditorOptions) { diff --git a/demo/scripts/controlsV2/sidePane/contentModel/components/model/ContentModelDocumentView.tsx b/demo/scripts/controlsV2/sidePane/contentModel/components/model/ContentModelDocumentView.tsx index fdaf3574a84..6a7568b93b3 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/components/model/ContentModelDocumentView.tsx +++ b/demo/scripts/controlsV2/sidePane/contentModel/components/model/ContentModelDocumentView.tsx @@ -3,14 +3,41 @@ import { BlockGroupContentView } from './BlockGroupContentView'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelView } from '../ContentModelView'; import { hasSelectionInBlockGroup } from 'roosterjs-content-model-dom'; +import { SegmentFormatView } from '../format/SegmentFormatView'; +import { useProperty } from '../../hooks/useProperty'; const styles = require('./ContentModelDocumentView.scss'); export function ContentModelDocumentView(props: { doc: ContentModelDocument }) { const { doc } = props; + const [isReverted, setIsReverted] = useProperty(!!doc.hasRevertedRangeSelection); + const revertedCheckbox = React.useRef(null); + const onIsRevertedChange = React.useCallback(() => { + const newValue = revertedCheckbox.current.checked; + doc.hasRevertedRangeSelection = newValue; + setIsReverted(newValue); + }, [doc, setIsReverted]); + const getContent = React.useCallback(() => { - return ; - }, [doc]); + return ( + <> +
+ + Reverted range selection +
+ + + ); + }, [doc, isReverted]); + + const getFormat = React.useCallback(() => { + return doc.format ? : null; + }, [doc.format]); return ( ); } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index f49f65aecc2..1bd0329e394 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -1,3 +1,4 @@ +import { emojiReplacements } from './getReplacements'; import { OptionPaneProps, OptionState, UrlPlaceholder } from './OptionState'; import { OptionsPane } from './OptionsPane'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -17,11 +18,10 @@ const initialState: OptionState = { sampleEntity: true, markdown: true, hyperlink: true, + customReplace: true, // Legacy plugins imageEdit: false, - customReplace: false, - announce: false, }, defaultFormat: { fontFamily: 'Calibri', @@ -45,6 +45,8 @@ const initialState: OptionState = { autoNumbering: true, autoUnlink: false, autoHyphen: true, + autoFraction: true, + autoOrdinals: true, }, markdownOptions: { bold: true, @@ -52,6 +54,7 @@ const initialState: OptionState = { strikethrough: true, codeFormat: {}, }, + customReplacements: emojiReplacements, }; export class EditorOptionsPlugin extends SidePanePluginImpl { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index f22214d18d0..57bbd973492 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -1,11 +1,9 @@ -import { AutoFormatOptions, MarkdownOptions } from 'roosterjs-content-model-plugins'; +import { AutoFormatOptions, CustomReplace, MarkdownOptions } from 'roosterjs-content-model-plugins'; import type { SidePaneElementProps } from '../SidePaneElement'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; export interface LegacyPluginList { imageEdit: boolean; - customReplace: boolean; - announce: boolean; } export interface NewPluginList { @@ -21,6 +19,7 @@ export interface NewPluginList { sampleEntity: boolean; markdown: boolean; hyperlink: boolean; + customReplace: boolean; } export interface BuildInPluginList extends LegacyPluginList, NewPluginList {} @@ -36,6 +35,7 @@ export interface OptionState { watermarkText: string; autoFormatOptions: AutoFormatOptions; markdownOptions: MarkdownOptions; + customReplacements: CustomReplace[]; // Legacy plugin options defaultFormat: ContentModelSegmentFormat; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx index 8f9e896fd53..c9cc0e0e7aa 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx @@ -139,6 +139,7 @@ export class OptionsPane extends React.Component { imageMenu: this.state.imageMenu, autoFormatOptions: { ...this.state.autoFormatOptions }, markdownOptions: { ...this.state.markdownOptions }, + customReplacements: this.state.customReplacements, }; if (callback) { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index 06f81255bd1..383000d54cd 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -118,8 +118,6 @@ export class LegacyPlugins extends PluginsBase { (state, value) => (state.forcePreserveRatio = value) ) )} - {this.renderPluginItem('customReplace', 'Custom Replace Plugin (autocomplete)')} - {this.renderPluginItem('announce', 'Announce')} ); @@ -137,6 +135,8 @@ export class Plugins extends PluginsBase { private autoLink = React.createRef(); private autoUnlink = React.createRef(); private autoHyphen = React.createRef(); + private autoFraction = React.createRef(); + private autoOrdinals = React.createRef(); private markdownBold = React.createRef(); private markdownItalic = React.createRef(); private markdownStrikethrough = React.createRef(); @@ -181,6 +181,18 @@ export class Plugins extends PluginsBase { this.props.state.autoFormatOptions.autoHyphen, (state, value) => (state.autoFormatOptions.autoHyphen = value) )} + {this.renderCheckBox( + 'Fraction', + this.autoFraction, + this.props.state.autoFormatOptions.autoFraction, + (state, value) => (state.autoFormatOptions.autoFraction = value) + )} + {this.renderCheckBox( + 'Ordinals', + this.autoOrdinals, + this.props.state.autoFormatOptions.autoOrdinals, + (state, value) => (state.autoFormatOptions.autoOrdinals = value) + )} )} {this.renderPluginItem('edit', 'Edit')} @@ -279,6 +291,7 @@ export class Plugins extends PluginsBase { (state, value) => (state.linkTitle = value) ) )} + {this.renderPluginItem('customReplace', 'Custom Replace')} ); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/AutoFormatCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/AutoFormatCode.ts index ba9f5c37100..2cddd39bc60 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/AutoFormatCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/AutoFormatCode.ts @@ -12,6 +12,9 @@ export class AutoFormatCode extends CodeElement { autoLink: ${this.options.autoLink}, autoNumbering: ${this.options.autoNumbering}, autoUnlink: ${this.options.autoUnlink}, + autoHyphen: ${this.options.autoHyphen}, + autoFraction: ${this.options.autoFraction}, + autoOrdinals: ${this.options.autoOrdinals}, })`; } } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 62d863e2d07..1912151d5e7 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -4,8 +4,8 @@ import { HyperLinkCode } from './HyperLinkCode'; import { MarkdownCode } from './MarkdownCode'; import { OptionState } from '../OptionState'; import { WatermarkCode } from './WatermarkCode'; + import { - CustomReplaceCode, EditPluginCode, ImageEditCode, PastePluginCode, @@ -54,10 +54,7 @@ export class LegacyPluginCode extends PluginsCodeBase { constructor(state: OptionState) { const pluginList = state.pluginList; - const plugins: CodeElement[] = [ - pluginList.imageEdit && new ImageEditCode(), - pluginList.customReplace && new CustomReplaceCode(), - ]; + const plugins: CodeElement[] = [pluginList.imageEdit && new ImageEditCode()]; super(plugins); } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index f9ebac0542e..b910a90da7a 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -39,9 +39,3 @@ export class ImageEditCode extends SimplePluginCode { super('ImageEdit', 'roosterjsLegacy'); } } - -export class CustomReplaceCode extends SimplePluginCode { - constructor() { - super('CustomReplace', 'roosterjsLegacy'); - } -} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/getReplacements.ts b/demo/scripts/controlsV2/sidePane/editorOptions/getReplacements.ts new file mode 100644 index 00000000000..1a9e243b944 --- /dev/null +++ b/demo/scripts/controlsV2/sidePane/editorOptions/getReplacements.ts @@ -0,0 +1,37 @@ +import { ContentModelText } from 'roosterjs-content-model-types'; +import { CustomReplace } from 'roosterjs-content-model-plugins'; + +function replaceEmojis( + previousSegment: ContentModelText, + stringToReplace: string, + replacement: string +) { + const { text } = previousSegment; + const queryString = text.split(' ').pop(); + if (queryString === stringToReplace) { + previousSegment.text = text.replace(stringToReplace, replacement); + return true; + } + return false; +} + +function makeEmojiReplacements(stringToReplace: string, replacement: string) { + return { + stringToReplace, + replacementString: replacement, + replacementHandler: replaceEmojis, + }; +} + +export const emojiReplacements: CustomReplace[] = [ + makeEmojiReplacements(';)', '😉'), + makeEmojiReplacements(';-)', '😉'), + makeEmojiReplacements(';P', '😜'), + makeEmojiReplacements(';-P', '😜'), + makeEmojiReplacements('<3', '❤️'), + makeEmojiReplacements(' { +export class FormatStatePane extends React.Component { constructor(props: FormatStatePaneProps) { super(props); this.state = { diff --git a/demo/scripts/controlsV2/sidePane/formatState/FormatStatePlugin.ts b/demo/scripts/controlsV2/sidePane/formatState/FormatStatePlugin.ts index c4ab238389c..35c01f9408b 100644 --- a/demo/scripts/controlsV2/sidePane/formatState/FormatStatePlugin.ts +++ b/demo/scripts/controlsV2/sidePane/formatState/FormatStatePlugin.ts @@ -1,6 +1,6 @@ -import FormatStatePane, { FormatStatePaneProps, FormatStatePaneState } from './FormatStatePane'; +import { FormatStatePane, FormatStatePaneProps, FormatStatePaneState } from './FormatStatePane'; +import { getDOMInsertPointRect } from 'roosterjs-content-model-plugins'; import { getFormatState } from 'roosterjs-content-model-api'; -import { getPositionRect } from '../../roosterjsReact/pasteOptions/utils/getPositionRect'; import { PluginEvent } from 'roosterjs-content-model-types'; import { SidePaneElementProps } from '../SidePaneElement'; import { SidePanePluginImpl } from '../SidePanePluginImpl'; @@ -50,7 +50,7 @@ export class FormatStatePlugin extends SidePanePluginImpl( model, @@ -80,6 +83,10 @@ export function setModelIndentation( } else { block.levels.pop(); } + + if (block.levels.length > 0 && context) { + context.announceData = getListAnnounceData([block, ...path]); + } } } else if (block) { let currentBlock: ContentModelBlock = block; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts b/packages/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts index 55511429be8..3ad7824f584 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts @@ -1,8 +1,4 @@ -import type { - ContentModelBlockGroup, - ContentModelDocument, - ContentModelListItem, -} from 'roosterjs-content-model-types'; +import type { ContentModelBlockGroup, ContentModelListItem } from 'roosterjs-content-model-types'; /** * @param model The content model @@ -10,12 +6,12 @@ import type { * Search for all list items in the same thread as the current list item */ export function findListItemsInSameThread( - model: ContentModelDocument, + group: ContentModelBlockGroup, currentItem: ContentModelListItem ): ContentModelListItem[] { const items: (ContentModelListItem | null)[] = []; - findListItems(model, items); + findListItems(group, items); return filterListItems(items, currentItem); } @@ -97,7 +93,11 @@ function filterListItems( if (isOrderedList && startNumberOverride) { break; } - } else if (!isOrderedList || startNumberOverride) { + } else if ( + !isOrderedList || + startNumberOverride || + item.levels.length < currentItem.levels.length + ) { break; } } @@ -117,7 +117,11 @@ function filterListItems( if (areListTypesCompatible(items, currentIndex, i) && !startNumberOverride) { result.push(item); - } else if (!isOrderedList || startNumberOverride) { + } else if ( + !isOrderedList || + startNumberOverride || + item.levels.length < currentItem.levels.length + ) { break; } } diff --git a/packages/roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData.ts b/packages/roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData.ts new file mode 100644 index 00000000000..20fc55e3ee1 --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData.ts @@ -0,0 +1,80 @@ +import { findListItemsInSameThread } from './findListItemsInSameThread'; +import { + getAutoListStyleType, + getClosestAncestorBlockGroupIndex, + getOrderedListNumberStr, + updateListMetadata, +} from 'roosterjs-content-model-dom'; +import type { + AnnounceData, + ContentModelBlockGroup, + ContentModelListItem, +} from 'roosterjs-content-model-types'; + +/** + * Get announce data for list item + * @param path Content model path that include the list item + * @returns Announce data of current list item if any, or null + */ +export function getListAnnounceData(path: ContentModelBlockGroup[]): AnnounceData | null { + const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); + + if (index >= 0) { + const listItem = path[index] as ContentModelListItem; + const level = listItem.levels[listItem.levels.length - 1]; + + if (level.format.displayForDummyItem) { + return null; + } else if (level.listType == 'OL') { + const listNumber = getListNumber(path, listItem); + const metadata = updateListMetadata(level); + const listStyle = getAutoListStyleType( + 'OL', + metadata ?? {}, + listItem.levels.length - 1, + level.format.listStyleType + ); + + return listStyle === undefined + ? null + : { + defaultStrings: 'announceListItemNumbering', + formatStrings: [getOrderedListNumberStr(listStyle, listNumber)], + }; + } else { + return { + defaultStrings: 'announceListItemBullet', + }; + } + } else { + return null; + } +} + +function getListNumber(path: ContentModelBlockGroup[], listItem: ContentModelListItem) { + const items = findListItemsInSameThread(path[path.length - 1], listItem); + let listNumber = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (listNumber == 0 && item.levels.length == listItem.levels.length) { + listNumber = item.levels[item.levels.length - 1]?.format.startNumberOverride ?? 1; + } + + if (item == listItem) { + // Found current item, so break and return + break; + } else if (item.levels.length < listItem.levels.length) { + // Found upper level item, reset list number + listNumber = 0; + } else if (item.levels.length > listItem.levels.length) { + // Found deeper level item, skip + continue; + } else if (!item.levels[item.levels.length - 1].format.displayForDummyItem) { + // Save level, and is not dummy, number plus one + listNumber++; + } + } + return listNumber; +} diff --git a/packages/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts b/packages/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts index 675125340a9..add41d2482d 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts @@ -4,6 +4,7 @@ import { addSegment, createContentModelDocument, createImage, + setSelection, } from 'roosterjs-content-model-dom'; import type { IEditor } from 'roosterjs-content-model-types'; @@ -36,6 +37,8 @@ function insertImageWithSrc(editor: IEditor, src: string) { mergeModel(model, doc, context, { mergeFormat: 'mergeAll', }); + image.isSelected = true; + setSelection(model, image); return true; }, diff --git a/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts b/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts index f34f8c42750..21c1db1753d 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts @@ -24,6 +24,8 @@ import { export function editTable(editor: IEditor, operation: TableOperation) { editor.focus(); + fixUpSafariSelection(editor); + formatTableWithContentModel(editor, 'editTable', tableModel => { switch (operation) { case 'alignCellLeft': @@ -88,3 +90,21 @@ export function editTable(editor: IEditor, operation: TableOperation) { } }); } + +// In safari, when open context menu under a table, it may expand the range selection to the beginning of next table cell. +// So we make a workaround here to collapse the selection when need, to avoid unexpected table editing behavior +// (e.g. insert two columns but actually need one only) +function fixUpSafariSelection(editor: IEditor) { + if (editor.getEnvironment().isSafari) { + const selection = editor.getDOMSelection(); + + if (selection?.type == 'range' && !selection.range.collapsed) { + selection.range.collapse(true /*toStart*/); + editor.setDOMSelection({ + type: 'range', + range: selection.range, + isReverted: false, + }); + } + } +} diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts index 25dac400525..8031a24b6d6 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -46,11 +46,12 @@ export function formatSegmentWithContentModel( if (isCollapsedSelection) { const para = segmentAndParagraphs[0][1]; + const path = segmentAndParagraphs[0][2]; segmentAndParagraphs = adjustWordSelection( model, segmentAndParagraphs[0][0] - ).map(x => [x, para]); + ).map(x => [x, para, path]); if (segmentAndParagraphs.length > 1) { isCollapsedSelection = false; diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts index dc6b2fb7282..aaed95fe722 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts @@ -5,6 +5,7 @@ import type { ContentModelSegmentFormat, ContentModelText, FormatContentModelContext, + FormatContentModelOptions, IEditor, } from 'roosterjs-content-model-types'; @@ -12,6 +13,7 @@ import type { * Invoke a callback to format the text segment before the selection marker using Content Model * @param editor The editor object * @param callback The callback to format the text segment. + * @returns True if the segment before cursor is found and callback is called, otherwise false */ export function formatTextSegmentBeforeSelectionMarker( editor: IEditor, @@ -21,8 +23,11 @@ export function formatTextSegmentBeforeSelectionMarker( paragraph: ContentModelParagraph, markerFormat: ContentModelSegmentFormat, context: FormatContentModelContext - ) => boolean -) { + ) => boolean, + options?: FormatContentModelOptions +): boolean { + let result = false; + editor.formatContentModel((model, context) => { const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( model, @@ -36,10 +41,15 @@ export function formatTextSegmentBeforeSelectionMarker( if (marker.segmentType === 'SelectionMarker' && markerIndex > 0) { const previousSegment = paragraph.segments[markerIndex - 1]; if (previousSegment && previousSegment.segmentType === 'Text') { + result = true; + return callback(model, previousSegment, paragraph, marker.format, context); } } } + return false; - }); + }, options); + + return result; } diff --git a/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index 9cad3b4dc88..765ca3526e6 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -1,3 +1,5 @@ +import * as getListAnnounceData from '../../../lib/modelApi/list/getListAnnounceData'; +import { FormatContentModelContext } from 'roosterjs-content-model-types'; import { setModelIndentation } from '../../../lib/modelApi/block/setModelIndentation'; import { createContentModelDocument, @@ -9,30 +11,61 @@ import { } from 'roosterjs-content-model-dom'; describe('indent', () => { + let getListAnnounceDataSpy: jasmine.Spy; + const mockedAnnounceData = 'ANNOUNCE' as any; + + beforeEach(() => { + getListAnnounceDataSpy = spyOn(getListAnnounceData, 'getListAnnounceData').and.returnValue( + mockedAnnounceData + ); + }); + it('Empty group', () => { const group = createContentModelDocument(); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [], }); expect(result).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group without selection', () => { const group = createContentModelDocument(); const para = createParagraph(); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; group.blocks.push(para); - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [para], }); expect(result).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected paragraph', () => { @@ -43,6 +76,11 @@ describe('indent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -53,7 +91,7 @@ describe('indent', () => { text2.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -77,6 +115,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected indented paragraph', () => { @@ -87,6 +131,11 @@ describe('indent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -99,7 +148,7 @@ describe('indent', () => { text2.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -127,6 +176,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected indented paragraph in RTL', () => { @@ -137,13 +192,18 @@ describe('indent', () => { direction: 'rtl', }); const text1 = createText('test1'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); group.blocks.push(para1); text1.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -159,6 +219,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with multiple selected paragraph - 1', () => { @@ -169,6 +235,11 @@ describe('indent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -180,7 +251,7 @@ describe('indent', () => { text2.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -206,6 +277,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with multiple selected paragraph - 2', () => { @@ -216,6 +293,11 @@ describe('indent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -227,7 +309,7 @@ describe('indent', () => { text1.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -253,6 +335,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with paragraph under OL', () => { @@ -264,6 +352,11 @@ describe('indent', () => { const text2 = createText('test2'); const text3 = createText('test3'); const listItem = createListItem([createListLevel('OL')]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -275,7 +368,7 @@ describe('indent', () => { text2.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -299,6 +392,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with paragraph under OL with formats', () => { @@ -320,6 +419,11 @@ describe('indent', () => { } ), ]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -331,7 +435,7 @@ describe('indent', () => { text2.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -361,6 +465,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with paragraph and multiple OL', () => { @@ -370,6 +480,11 @@ describe('indent', () => { const listItem1 = createListItem([createListLevel('OL')]); const listItem2 = createListItem([createListLevel('OL')]); const listItem3 = createListItem([createListLevel('OL')]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para2.segments.push(text2); listItem2.blocks.push(para2); @@ -379,7 +494,7 @@ describe('indent', () => { text2.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -404,6 +519,14 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + announceData: mockedAnnounceData, + }); + expect(getListAnnounceDataSpy).toHaveBeenCalledTimes(1); + expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem2, group]); }); it('Group with multiple selected paragraph and multiple OL', () => { @@ -417,6 +540,11 @@ describe('indent', () => { const listItem1 = createListItem([createListLevel('OL')]); const listItem2 = createListItem([createListLevel('OL')]); const listItem3 = createListItem([createListLevel('OL')]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -432,7 +560,7 @@ describe('indent', () => { text2.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -471,6 +599,15 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + announceData: mockedAnnounceData, + }); + expect(getListAnnounceDataSpy).toHaveBeenCalledTimes(2); + expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem2, group]); + expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem3, group]); }); it('Group with multiple selected paragraph and UL and OL', () => { @@ -484,6 +621,11 @@ describe('indent', () => { const listItem1 = createListItem([createListLevel('OL')]); const listItem2 = createListItem([createListLevel('OL')]); const listItem3 = createListItem([createListLevel('UL')]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -499,7 +641,7 @@ describe('indent', () => { text2.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -539,6 +681,14 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + announceData: mockedAnnounceData, + }); + expect(getListAnnounceDataSpy).toHaveBeenCalledTimes(1); + expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem2, group]); }); it('Mixed with paragraph, list item and quote', () => { @@ -552,6 +702,11 @@ describe('indent', () => { const text3 = createText('test3'); const listItem1 = createListItem([createListLevel('OL')]); const listItem2 = createListItem([createListLevel('UL')]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -568,7 +723,7 @@ describe('indent', () => { text2.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -615,6 +770,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected indented paragraph, outdent with different length', () => { @@ -623,13 +784,18 @@ describe('indent', () => { marginLeft: '60px', }); const text1 = createText('test1'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); group.blocks.push(para1); text1.isSelected = true; - const result = setModelIndentation(group, 'indent', 15); + const result = setModelIndentation(group, 'indent', 15, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -643,6 +809,12 @@ describe('indent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with list with first item selected', () => { @@ -656,6 +828,12 @@ describe('indent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + text1.isSelected = true; text2.isSelected = true; text3.isSelected = true; @@ -669,7 +847,7 @@ describe('indent', () => { group.blocks.push(listItem2); group.blocks.push(listItem3); - const result = setModelIndentation(group, 'indent'); + const result = setModelIndentation(group, 'indent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -714,34 +892,71 @@ describe('indent', () => { }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); }); describe('outdent', () => { + let getListAnnounceDataSpy: jasmine.Spy; + const mockedAnnounceData = 'ANNOUNCE' as any; + + beforeEach(() => { + getListAnnounceDataSpy = spyOn(getListAnnounceData, 'getListAnnounceData').and.returnValue( + mockedAnnounceData + ); + }); + it('Empty group', () => { const group = createContentModelDocument(); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [], }); expect(result).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group without selection', () => { const group = createContentModelDocument(); const para = createParagraph(); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; group.blocks.push(para); - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [para], }); expect(result).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected paragraph that cannot outdent', () => { @@ -752,6 +967,11 @@ describe('outdent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -762,12 +982,18 @@ describe('outdent', () => { text2.isSelected = true; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [para1, para2, para3], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected single indented paragraph', () => { @@ -784,6 +1010,11 @@ describe('outdent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -794,7 +1025,7 @@ describe('outdent', () => { text2.isSelected = true; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -822,6 +1053,12 @@ describe('outdent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected 2 indented paragraph', () => { @@ -834,6 +1071,11 @@ describe('outdent', () => { const text2 = createText('test2'); const text3 = createText('test3'); const text4 = createText('test4'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -847,7 +1089,7 @@ describe('outdent', () => { text2.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -882,6 +1124,12 @@ describe('outdent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected multiple indented paragraph', () => { @@ -892,6 +1140,11 @@ describe('outdent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -903,7 +1156,7 @@ describe('outdent', () => { text1.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -931,6 +1184,12 @@ describe('outdent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected list item', () => { @@ -938,6 +1197,11 @@ describe('outdent', () => { const para1 = createParagraph(); const text1 = createText('test1'); const listItem = createListItem([createListLevel('OL')]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); listItem.blocks.push(para1); @@ -945,7 +1209,7 @@ describe('outdent', () => { text1.isSelected = true; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -956,6 +1220,12 @@ describe('outdent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected multiple level list item', () => { @@ -972,6 +1242,11 @@ describe('outdent', () => { ), createListLevel('UL'), ]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); listItem.blocks.push(para1); @@ -979,7 +1254,7 @@ describe('outdent', () => { text1.isSelected = true; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -1003,6 +1278,14 @@ describe('outdent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + announceData: mockedAnnounceData, + }); + expect(getListAnnounceDataSpy).toHaveBeenCalledTimes(1); + expect(getListAnnounceDataSpy).toHaveBeenCalledWith([listItem, group]); }); it('Group with mixed list item, quote and paragraph', () => { @@ -1014,6 +1297,11 @@ describe('outdent', () => { const text2 = createText('test2'); const text3 = createText('test3'); const listItem = createListItem([createListLevel('UL')]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); para2.segments.push(text2); @@ -1029,7 +1317,7 @@ describe('outdent', () => { text2.isSelected = true; text3.isSelected = true; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -1054,6 +1342,12 @@ describe('outdent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected indented paragraph in RTL', () => { @@ -1064,13 +1358,18 @@ describe('outdent', () => { direction: 'rtl', }); const text1 = createText('test1'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); group.blocks.push(para1); text1.isSelected = true; - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -1086,6 +1385,12 @@ describe('outdent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with selected indented paragraph, outdent with different length', () => { @@ -1094,13 +1399,18 @@ describe('outdent', () => { marginLeft: '60px', }); const text1 = createText('test1'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; para1.segments.push(text1); group.blocks.push(para1); text1.isSelected = true; - const result = setModelIndentation(group, 'outdent', 15); + const result = setModelIndentation(group, 'outdent', 15, context); expect(group).toEqual({ blockGroupType: 'Document', blocks: [ @@ -1114,6 +1424,12 @@ describe('outdent', () => { ], }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Group with list with no indention selected', () => { @@ -1127,6 +1443,12 @@ describe('outdent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + text1.isSelected = true; text2.isSelected = true; text3.isSelected = true; @@ -1140,7 +1462,7 @@ describe('outdent', () => { group.blocks.push(listItem2); group.blocks.push(listItem3); - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -1161,6 +1483,12 @@ describe('outdent', () => { }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Outdent parent format container, ltr', () => { @@ -1172,6 +1500,11 @@ describe('outdent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; text2.isSelected = true; formatContainer.format.marginLeft = '100px'; @@ -1184,7 +1517,7 @@ describe('outdent', () => { formatContainer.blocks.push(para1, para2); group.blocks.push(formatContainer, para3); - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -1223,6 +1556,12 @@ describe('outdent', () => { }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); it('Outdent parent format container, rtl', () => { @@ -1234,6 +1573,11 @@ describe('outdent', () => { const text1 = createText('test1'); const text2 = createText('test2'); const text3 = createText('test3'); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; text1.isSelected = true; text2.isSelected = true; @@ -1248,7 +1592,7 @@ describe('outdent', () => { formatContainer.blocks.push(para1, para2); group.blocks.push(formatContainer, para3); - const result = setModelIndentation(group, 'outdent'); + const result = setModelIndentation(group, 'outdent', undefined, context); expect(group).toEqual({ blockGroupType: 'Document', @@ -1294,5 +1638,11 @@ describe('outdent', () => { }); expect(result).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/roosterjs-content-model-api/test/modelApi/list/findListItemsInSameThreadTest.ts b/packages/roosterjs-content-model-api/test/modelApi/list/findListItemsInSameThreadTest.ts index d1599daadf6..6974519f717 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/list/findListItemsInSameThreadTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/list/findListItemsInSameThreadTest.ts @@ -277,4 +277,27 @@ describe('findListItemsInSameThread', () => { expect(result).toEqual([item1, item2]); }); + + it('Multiple deeper OL under different groups', () => { + const group = createContentModelDocument(); + const item1 = createListItem([createListLevel('OL')]); + const item2 = createListItem([createListLevel('OL'), createListLevel('OL')]); + const item3 = createListItem([createListLevel('OL'), createListLevel('OL')]); + const item4 = createListItem([createListLevel('OL')]); + const item5 = createListItem([createListLevel('OL'), createListLevel('OL')]); + const item6 = createListItem([createListLevel('OL'), createListLevel('OL')]); + + item1.format.lineHeight = '1'; + item1.format.lineHeight = '2'; + item1.format.lineHeight = '3'; + item1.format.lineHeight = '4'; + item1.format.lineHeight = '5'; + item1.format.lineHeight = '6'; + + group.blocks.push(item1, item2, item3, item4, item5, item6); + + const result = findListItemsInSameThread(group, item6); + + expect(result).toEqual([item5, item6]); + }); }); diff --git a/packages/roosterjs-content-model-api/test/modelApi/list/getListAnnounceDataTest.ts b/packages/roosterjs-content-model-api/test/modelApi/list/getListAnnounceDataTest.ts new file mode 100644 index 00000000000..03fd9c715a1 --- /dev/null +++ b/packages/roosterjs-content-model-api/test/modelApi/list/getListAnnounceDataTest.ts @@ -0,0 +1,125 @@ +import * as getAutoListStyleType from 'roosterjs-content-model-dom/lib/modelApi/list/getAutoListStyleType'; +import { getListAnnounceData } from '../../../lib/modelApi/list/getListAnnounceData'; +import { + NumberingListType, + createContentModelDocument, + createListItem, + createListLevel, +} from 'roosterjs-content-model-dom'; + +describe('getListAnnounceData', () => { + let getAutoListStyleTypeSpy: jasmine.Spy; + + beforeEach(() => { + getAutoListStyleTypeSpy = spyOn(getAutoListStyleType, 'getAutoListStyleType'); + }); + it('empty path', () => { + const result = getListAnnounceData([]); + + expect(result).toEqual(null); + expect(getAutoListStyleTypeSpy).not.toHaveBeenCalled(); + }); + + it('no list item', () => { + const doc = createContentModelDocument(); + const result = getListAnnounceData([doc]); + + expect(result).toEqual(null); + expect(getAutoListStyleTypeSpy).not.toHaveBeenCalled(); + }); + + it('path has single list item, no list style', () => { + const doc = createContentModelDocument(); + const listItem = createListItem([createListLevel('OL')]); + + doc.blocks.push(listItem); + + getAutoListStyleTypeSpy.and.returnValue(undefined); + + const result = getListAnnounceData([listItem, doc]); + + expect(result).toEqual(null); + expect(getAutoListStyleTypeSpy).toHaveBeenCalledWith('OL', {}, 0, undefined); + }); + + it('path has single list item, has list style in format', () => { + const doc = createContentModelDocument(); + const listItem = createListItem([createListLevel('OL', { listStyleType: 'decimal' })]); + + doc.blocks.push(listItem); + + getAutoListStyleTypeSpy.and.returnValue(NumberingListType.Decimal); + + const result = getListAnnounceData([listItem, doc]); + + expect(result).toEqual({ + defaultStrings: 'announceListItemNumbering', + formatStrings: ['1'], + }); + expect(getAutoListStyleTypeSpy).toHaveBeenCalledWith('OL', {}, 0, 'decimal'); + }); + + it('path has dummy list item', () => { + const doc = createContentModelDocument(); + const listItem = createListItem([createListLevel('OL', { displayForDummyItem: 'block' })]); + + doc.blocks.push(listItem); + + const result = getListAnnounceData([listItem, doc]); + + expect(result).toEqual(null); + expect(getAutoListStyleTypeSpy).not.toHaveBeenCalled(); + }); + + it('path with bullet list', () => { + const doc = createContentModelDocument(); + const listItem = createListItem([createListLevel('UL')]); + + doc.blocks.push(listItem); + + const result = getListAnnounceData([listItem, doc]); + + expect(result).toEqual({ + defaultStrings: 'announceListItemBullet', + }); + expect(getAutoListStyleTypeSpy).not.toHaveBeenCalled(); + }); + + it('path has deeper list', () => { + const doc = createContentModelDocument(); + const listItem = createListItem([ + createListLevel('UL'), + createListLevel('OL', { listStyleType: 'decimal' }), + ]); + + doc.blocks.push(listItem); + + getAutoListStyleTypeSpy.and.returnValue(NumberingListType.Decimal); + + const result = getListAnnounceData([listItem, doc]); + + expect(result).toEqual({ + defaultStrings: 'announceListItemNumbering', + formatStrings: ['1'], + }); + expect(getAutoListStyleTypeSpy).toHaveBeenCalledWith('OL', {}, 1, 'decimal'); + }); + + it('path has multiple list items', () => { + const doc = createContentModelDocument(); + const listItem1 = createListItem([createListLevel('OL')]); + const listItem2 = createListItem([createListLevel('OL')]); + + doc.blocks.push(listItem1, listItem2); + + getAutoListStyleTypeSpy.and.returnValue(NumberingListType.Decimal); + + const result = getListAnnounceData([listItem2, doc]); + + expect(result).toEqual({ + defaultStrings: 'announceListItemNumbering', + formatStrings: ['2'], + }); + expect(getAutoListStyleTypeSpy).toHaveBeenCalledWith('OL', {}, 0, undefined); + }); +}); diff --git a/packages/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts b/packages/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts index 3731a336e55..807bb876216 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts @@ -1,6 +1,9 @@ +import * as createImage from 'roosterjs-content-model-dom/lib/modelApi/creators/createImage'; import * as readFile from 'roosterjs-content-model-dom/lib/domUtils/readFile'; -import { IEditor } from 'roosterjs-content-model-types'; +import * as setSelection from 'roosterjs-content-model-dom/lib/modelApi/selection/setSelection'; +import { ContentModelImage, IEditor } from 'roosterjs-content-model-types'; import { insertImage } from '../../../lib/publicApi/image/insertImage'; + import { ContentModelDocument, ContentModelFormatter, @@ -23,6 +26,16 @@ describe('insertImage', () => { calledTimes: number ) { let formatResult: boolean | undefined; + const image = { + segmentType: 'Image', + src: testUrl, + format: { + backgroundColor: '', + }, + dataset: {}, + }; + spyOn(createImage, 'createImage').and.returnValue(image); + spyOn(setSelection, 'setSelection').and.callThrough(); const formatContentModel = jasmine .createSpy('formatContentModel') @@ -46,6 +59,9 @@ describe('insertImage', () => { expect(formatContentModel.calls.argsFor(0)[1]).toEqual({ apiName, }); + expect(setSelection.setSelection).toHaveBeenCalledTimes(1); + expect(setSelection.setSelection).toHaveBeenCalledWith(model, image); + expect(image.isSelected).toBeTrue(); expect(formatResult).toBe(calledTimes > 0); } @@ -101,10 +117,7 @@ describe('insertImage', () => { backgroundColor: '', }, dataset: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, + isSelectedAsImageSelection: true, isSelected: true, }, ], @@ -142,10 +155,7 @@ describe('insertImage', () => { backgroundColor: '', }, dataset: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, + isSelectedAsImageSelection: true, isSelected: true, }, ], @@ -189,12 +199,7 @@ describe('insertImage', () => { backgroundColor: '', }, dataset: {}, - }, - { - segmentType: 'SelectionMarker', - format: { - fontSize: '20px', - }, + isSelectedAsImageSelection: true, isSelected: true, }, ], diff --git a/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts b/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts index f78b80cbde1..7e5fe3090dc 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts @@ -18,6 +18,9 @@ describe('editTable', () => { let editor: IEditor; let focusSpy: jasmine.Spy; let formatTableWithContentModelSpy: jasmine.Spy; + let getEnvironmentSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; const mockedTable = 'TABLE' as any; function runTest(operation: TableOperation, expectedSpy: jasmine.Spy, ...parameters: string[]) { @@ -29,10 +32,15 @@ describe('editTable', () => { jasmine.anything() ); expect(expectedSpy).toHaveBeenCalledWith(mockedTable, ...parameters); + expect(getDOMSelectionSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); } beforeEach(() => { focusSpy = jasmine.createSpy('focus'); + getEnvironmentSpy = jasmine.createSpy('getEnvironment').and.returnValue({}); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); formatTableWithContentModelSpy = spyOn( formatTableWithContentModel, 'formatTableWithContentModel' @@ -42,6 +50,9 @@ describe('editTable', () => { editor = { focus: focusSpy, + getEnvironment: getEnvironmentSpy, + getDOMSelection: getDOMSelectionSpy, + setDOMSelection: setDOMSelectionSpy, } as any; }); @@ -240,4 +251,37 @@ describe('editTable', () => { runTest('splitVertically', spy); }); }); + + it('edit in safar', () => { + const spy = spyOn(alignTableCell, 'alignTableCellHorizontally'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + collapsed: false, + collapse: collapseSpy, + }; + + getEnvironmentSpy.and.returnValue({ + isSafari: true, + }); + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: mockedRange, + }); + + editTable(editor, 'alignCellLeft'); + + expect(formatTableWithContentModelSpy).toHaveBeenCalledWith( + editor, + 'editTable', + jasmine.anything() + ); + expect(spy).toHaveBeenCalledWith(mockedTable, 'alignCellLeft'); + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(collapseSpy).toHaveBeenCalledWith(true); + }); }); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts b/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts new file mode 100644 index 00000000000..2f04873238a --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts @@ -0,0 +1,60 @@ +import type { Announce } from 'roosterjs-content-model-types'; + +/** + * @internal + * Announce the given data + * @param core The EditorCore object + * @param announceData Data to announce + */ +export const announce: Announce = (core, announceData) => { + const { text, defaultStrings, formatStrings = [] } = announceData; + const { announcerStringGetter } = core.lifecycle; + const template = defaultStrings && announcerStringGetter?.(defaultStrings); + const textToAnnounce = formatString(template || text, formatStrings); + + if (textToAnnounce) { + let announceContainer = core.lifecycle.announceContainer; + + if (!announceContainer || textToAnnounce == announceContainer.textContent) { + announceContainer?.parentElement?.removeChild(announceContainer); + announceContainer = createAriaLiveElement(core.physicalRoot.ownerDocument); + + core.lifecycle.announceContainer = announceContainer; + } + + if (announceContainer) { + announceContainer.textContent = textToAnnounce; + } + } +}; + +function formatString(text: string | undefined, formatStrings: string[]) { + if (text == undefined) { + return text; + } + + text = text.replace(/\{(\d+)\}/g, (_, sub: string) => { + const index = parseInt(sub); + const replace = formatStrings[index]; + return replace ?? ''; + }); + + return text; +} + +function createAriaLiveElement(document: Document): HTMLDivElement { + const div = document.createElement('div'); + + div.style.clip = 'rect(0px, 0px, 0px, 0px)'; + div.style.clipPath = 'inset(100%)'; + div.style.height = '1px'; + div.style.overflow = 'hidden'; + div.style.position = 'absolute'; + div.style.whiteSpace = 'nowrap'; + div.style.width = '1px'; + div.ariaLive = 'assertive'; + + document.body.appendChild(div); + + return div; +} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/coreApiMap.ts b/packages/roosterjs-content-model-core/lib/coreApi/coreApiMap.ts index feeed264fef..3c63cd0fcbc 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/coreApiMap.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/coreApiMap.ts @@ -1,5 +1,5 @@ -import type { CoreApiMap } from 'roosterjs-content-model-types'; import { addUndoSnapshot } from './addUndoSnapshot/addUndoSnapshot'; +import { announce } from './announce/announce'; import { attachDomEvent } from './attachDomEvent/attachDomEvent'; import { createContentModel } from './createContentModel/createContentModel'; import { createEditorContext } from './createEditorContext/createEditorContext'; @@ -14,6 +14,7 @@ import { setEditorStyle } from './setEditorStyle/setEditorStyle'; import { setLogicalRoot } from './setLogicalRoot/setLogicalRoot'; import { switchShadowEdit } from './switchShadowEdit/switchShadowEdit'; import { triggerEvent } from './triggerEvent/triggerEvent'; +import type { CoreApiMap } from 'roosterjs-content-model-types'; /** * @internal @@ -39,4 +40,6 @@ export const coreApiMap: CoreApiMap = { switchShadowEdit: switchShadowEdit, getVisibleViewport: getVisibleViewport, setEditorStyle: setEditorStyle, + + announce: announce, }; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts index fc29cc407b4..720c0645a71 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts @@ -22,15 +22,9 @@ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { ...getRootComputedStyleForContext(logicalRoot.ownerDocument), }; - checkRootRtl(logicalRoot, context); + if (core.domHelper.isRightToLeft()) { + context.isRootRtl = true; + } return context; }; - -function checkRootRtl(element: HTMLElement, context: EditorContext) { - const style = element?.ownerDocument.defaultView?.getComputedStyle(element); - - if (style?.direction == 'rtl') { - context.isRootRtl = true; - } -} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 4d1c1e5f574..6515ff40b50 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -100,20 +100,20 @@ export const formatContentModel: FormatContentModel = ( handlePendingFormat(core, context, core.api.getDOMSelection(core)); } + + if (context.announceData) { + core.api.announce(core, context.announceData); + } }; function handleImages(core: EditorCore, context: FormatContentModelContext) { if (context.newImages.length > 0) { - const viewport = core.api.getVisibleViewport(core); - - if (viewport) { - const { left, right } = viewport; - const minMaxImageSize = 10; - const maxWidth = Math.max(right - left, minMaxImageSize); - context.newImages.forEach(image => { - image.format.maxWidth = `${maxWidth}px`; - }); - } + const width = core.domHelper.getClientWidth(); + const minMaxImageSize = 10; + const maxWidth = Math.max(width, minMaxImageSize); + context.newImages.forEach(image => { + image.format.maxWidth = `${maxWidth}px`; + }); } } diff --git a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts index 8d88512ca4e..96556c0e296 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts @@ -1,5 +1,6 @@ import { getAllEntityWrappers, + isBlockEntityContainer, isEntityElement, isNodeOfType, parseEntityFormat, @@ -7,8 +8,6 @@ import { } from 'roosterjs-content-model-dom'; import type { Snapshot, EditorCore, KnownEntityItem } from 'roosterjs-content-model-types'; -const BlockEntityContainer = '_E_EBlockEntityContainer'; - /** * @internal */ @@ -85,10 +84,6 @@ function tryGetEntityElement( return result; } -function isBlockEntityContainer(node: HTMLElement) { - return node.classList.contains(BlockEntityContainer); -} - function tryGetEntityFromContainer( element: HTMLElement, entityMap: Record diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index 50b533230cf..5c42d7460ee 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -127,12 +127,18 @@ function reconcileSelection( collapsed, } = newRange; + delete model.hasRevertedRangeSelection; + if (collapsed) { return !!reconcileNodeSelection(startContainer, startOffset); } else if ( startContainer == endContainer && isNodeOfType(startContainer, 'TEXT_NODE') ) { + if (newSelection.isReverted) { + model.hasRevertedRangeSelection = true; + } + return ( isIndexedSegment(startContainer) && !!reconcileTextSelection(startContainer, startOffset, endOffset) @@ -142,6 +148,10 @@ function reconcileSelection( const marker2 = reconcileNodeSelection(endContainer, endOffset); if (marker1 && marker2) { + if (newSelection.isReverted) { + model.hasRevertedRangeSelection = true; + } + setSelection(model, marker1, marker2); return true; } else { diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts index eb5ed83750d..cdc177336a1 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts @@ -160,8 +160,8 @@ class CopyPastePlugin implements PluginWithState { } cleanUpAndRestoreSelection(tempDiv); - this.editor.focus(); this.editor.setDOMSelection(selection); + this.editor.focus(); if (isCut) { this.editor.formatContentModel( diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts new file mode 100644 index 00000000000..b7c1975da3f --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts @@ -0,0 +1,157 @@ +import { + getSelectedSegmentsAndParagraphs, + isBlockEntityContainer, + isEntityDelimiter, + isNodeOfType, +} from 'roosterjs-content-model-dom'; +import type { + ContentModelBlockGroup, + ContentModelEntity, + ContentModelParagraph, + ContentModelSegment, + IEditor, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function adjustSelectionAroundEntity( + editor: IEditor, + key: 'ArrowLeft' | 'ArrowRight', + shiftKey: boolean +) { + const selection = editor.isDisposed() ? null : editor.getDOMSelection(); + + if (!selection || selection.type != 'range') { + return; + } + + const { range, isReverted } = selection; + const anchorNode = isReverted ? range.startContainer : range.endContainer; + const offset = isReverted ? range.startOffset : range.endOffset; + const delimiter = isNodeOfType(anchorNode, 'ELEMENT_NODE') + ? anchorNode + : anchorNode.parentElement; + const isRtl = + delimiter && + editor.getDocument().defaultView?.getComputedStyle(delimiter).direction == 'rtl'; + const movingBefore = (key == 'ArrowLeft') != !!isRtl; + + if ( + delimiter && + ((isEntityDelimiter(delimiter, !movingBefore) && + ((movingBefore && offset == 0) || (!movingBefore && offset == 1))) || + isBlockEntityContainer(delimiter)) + ) { + editor.formatContentModel(model => { + const allSel = getSelectedSegmentsAndParagraphs( + model, + false /*includingFormatHolder*/, + true /*includingEntity*/ + ); + const sel = allSel[isReverted ? 0 : allSel.length - 1]; + const index = sel?.[1]?.segments.indexOf(sel[0]) ?? -1; + + if (sel && sel[1] && index >= 0) { + const [segment, paragraph, path] = sel; + const isShrinking = shiftKey && !range.collapsed && movingBefore != !!isReverted; + const entitySegment = isShrinking + ? segment + : paragraph.segments[movingBefore ? index - 1 : index + 1]; + + const pairedDelimiter = findPairedDelimiter( + entitySegment, + path, + paragraph, + movingBefore + ); + + if (pairedDelimiter) { + const newRange = getNewRange( + range, + isShrinking, + movingBefore, + pairedDelimiter, + shiftKey + ); + + editor.setDOMSelection({ + type: 'range', + range: newRange, + isReverted: newRange.collapsed ? false : isReverted, + }); + } + } + + return false; + }); + } +} + +function getNewRange( + originalRange: Range, + isShrinking: boolean, + movingBefore: boolean, + pairedDelimiter: HTMLElement, + shiftKey: boolean +) { + const newRange = originalRange.cloneRange(); + + if (isShrinking) { + if (movingBefore) { + newRange.setEndBefore(pairedDelimiter); + } else { + newRange.setStartAfter(pairedDelimiter); + } + } else { + if (movingBefore) { + newRange.setStartBefore(pairedDelimiter); + } else { + newRange.setEndAfter(pairedDelimiter); + } + if (!shiftKey) { + if (movingBefore) { + newRange.setEndBefore(pairedDelimiter); + } else { + newRange.setStartAfter(pairedDelimiter); + } + } + } + + return newRange; +} + +function findPairedDelimiter( + entitySegment: ContentModelSegment, + path: ContentModelBlockGroup[], + paragraph: ContentModelParagraph, + movingBefore: boolean +) { + let entity: ContentModelEntity | null = null; + + if (entitySegment?.segmentType == 'Entity') { + // Inline entity + entity = entitySegment; + } else { + // Block entity + const blocks = path[0].blocks; + const paraIndex = blocks.indexOf(paragraph); + const entityBlock = + paraIndex >= 0 ? blocks[movingBefore ? paraIndex - 1 : paraIndex + 1] : null; + + if (entityBlock?.blockType == 'Entity') { + entity = entityBlock; + } + } + + const pairedDelimiter = entity + ? movingBefore + ? entity.wrapper.previousElementSibling + : entity.wrapper.nextElementSibling + : null; + + return isNodeOfType(pairedDelimiter, 'ELEMENT_NODE') && + isEntityDelimiter(pairedDelimiter, movingBefore) + ? pairedDelimiter + : null; +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts index 4e1f1d29bc0..e86692c91a2 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts @@ -1,3 +1,4 @@ +import { adjustSelectionAroundEntity } from './adjustSelectionAroundEntity'; import { normalizePos } from '../selection/normalizePos'; import { addDelimiters, @@ -11,6 +12,7 @@ import { findClosestEntityWrapper, iterateSelections, isCharacterValue, + findClosestBlockEntityContainer, } from 'roosterjs-content-model-dom'; import type { CompositionEndEvent, @@ -29,8 +31,6 @@ const DelimiterSelector = '.' + DelimiterAfter + ',.' + DelimiterBefore; const ZeroWidthSpace = '\u200B'; const EntityInfoName = '_Entity'; const InlineEntitySelector = 'span.' + EntityInfoName; -const BlockEntityContainer = '_E_EBlockEntityContainer'; -const BlockEntityContainerSelector = '.' + BlockEntityContainer; /** * @internal exported only for unit test @@ -195,56 +195,97 @@ export function handleCompositionEndEvent(editor: IEditor, event: CompositionEnd export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent) { const selection = editor.getDOMSelection(); - const { rawEvent } = event; if (!selection || selection.type != 'range') { return; } - const isEnter = rawEvent.key === 'Enter'; - const helper = editor.getDOMHelper(); - if (selection.range.collapsed && (isCharacterValue(rawEvent) || isEnter)) { - const helper = editor.getDOMHelper(); - const node = getFocusedElement(selection); - if (node && isEntityDelimiter(node) && helper.isNodeInEditor(node)) { - const blockEntityContainer = node.closest(BlockEntityContainerSelector); - if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { - const isAfter = node.classList.contains(DelimiterAfter); - - if (isAfter) { - selection.range.setStartAfter(blockEntityContainer); - } else { - selection.range.setStartBefore(blockEntityContainer); - } - selection.range.collapse(true /* toStart */); - if (isEnter) { - event.rawEvent.preventDefault(); - } + const rawEvent = event.rawEvent; + const range = selection.range; + const key = rawEvent.key; - editor.formatContentModel(handleKeyDownInBlockDelimiter, { - selectionOverride: { - type: 'range', - isReverted: false, - range: selection.range, - }, - }); + switch (key) { + case 'Enter': + if (range.collapsed) { + handleInputOnDelimiter(editor, range, getFocusedElement(selection), rawEvent); } else { - if (isEnter) { - event.rawEvent.preventDefault(); - editor.formatContentModel(handleEnterInlineEntity); - } else { - editor.takeSnapshot(); - editor - .getDocument() - .defaultView?.requestAnimationFrame(() => - preventTypeInDelimiter(node, editor) - ); + const helper = editor.getDOMHelper(); + const entity = findClosestEntityWrapper(range.startContainer, helper); + + if ( + entity && + isNodeOfType(entity, 'ELEMENT_NODE') && + helper.isNodeInEditor(entity) + ) { + triggerEntityEventOnEnter(editor, entity, rawEvent); } } - } - } else if (isEnter) { - const entity = findClosestEntityWrapper(selection.range.startContainer, helper); - if (entity && isNodeOfType(entity, 'ELEMENT_NODE') && helper.isNodeInEditor(entity)) { - triggerEntityEventOnEnter(editor, entity, rawEvent); + break; + + case 'ArrowLeft': + case 'ArrowRight': + if (!rawEvent.altKey && !rawEvent.ctrlKey && !rawEvent.metaKey) { + // Handle in async so focus is already moved, this makes us easier to check if we should adjust the selection + editor.getDocument().defaultView?.requestAnimationFrame(() => { + adjustSelectionAroundEntity(editor, key, rawEvent.shiftKey); + }); + } + break; + + default: + if (isCharacterValue(rawEvent) && range.collapsed) { + handleInputOnDelimiter(editor, range, getFocusedElement(selection), rawEvent); + } + + break; + } +} + +function handleInputOnDelimiter( + editor: IEditor, + range: Range, + focusedNode: HTMLElement | null, + rawEvent: KeyboardEvent +) { + const helper = editor.getDOMHelper(); + + if (focusedNode && isEntityDelimiter(focusedNode) && helper.isNodeInEditor(focusedNode)) { + const blockEntityContainer = findClosestBlockEntityContainer(focusedNode, helper); + const isEnter = rawEvent.key === 'Enter'; + + if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { + const isAfter = focusedNode.classList.contains(DelimiterAfter); + + if (isAfter) { + range.setStartAfter(blockEntityContainer); + } else { + range.setStartBefore(blockEntityContainer); + } + + range.collapse(true /* toStart */); + + if (isEnter) { + rawEvent.preventDefault(); + } + + editor.formatContentModel(handleKeyDownInBlockDelimiter, { + selectionOverride: { + type: 'range', + isReverted: false, + range, + }, + }); + } else { + if (isEnter) { + rawEvent.preventDefault(); + editor.formatContentModel(handleEnterInlineEntity); + } else { + editor.takeSnapshot(); + editor + .getDocument() + .defaultView?.requestAnimationFrame(() => + preventTypeInDelimiter(focusedNode, editor) + ); + } } } } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts index 2421bbdb815..5e0cd991614 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts @@ -48,6 +48,7 @@ class LifecyclePlugin implements PluginWithState { isDarkMode: !!options.inDarkMode, shadowEditFragment: null, styleElements: {}, + announcerStringGetter: options.announcerStringGetter, }; } @@ -88,6 +89,13 @@ class LifecyclePlugin implements PluginWithState { delete this.state.styleElements[key]; }); + const announceContainer = this.state.announceContainer; + + if (announceContainer) { + announceContainer.parentElement?.removeChild(announceContainer); + delete this.state.announceContainer; + } + if (this.disposer) { this.disposer(); this.disposer = null; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index 9c75100e40b..7c97bf89568 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -1,672 +1,685 @@ -import { findCoordinate } from './findCoordinate'; -import { findTableCellElement } from '../../coreApi/setDOMSelection/findTableCellElement'; -import { isSingleImageInSelection } from './isSingleImageInSelection'; -import { normalizePos } from './normalizePos'; -import { - isCharacterValue, - isElementOfType, - isModifierKey, - isNodeOfType, - parseTableCells, - toArray, -} from 'roosterjs-content-model-dom'; -import type { - DOMSelection, - IEditor, - PluginEvent, - PluginWithState, - SelectionPluginState, - EditorOptions, - DOMHelper, - MouseUpEvent, - ParsedTable, - TableSelectionInfo, - TableCellCoordinate, - RangeSelection, -} from 'roosterjs-content-model-types'; - -const MouseLeftButton = 0; -const MouseMiddleButton = 1; -const MouseRightButton = 2; -const Up = 'ArrowUp'; -const Down = 'ArrowDown'; -const Left = 'ArrowLeft'; -const Right = 'ArrowRight'; -const Tab = 'Tab'; - -class SelectionPlugin implements PluginWithState { - private editor: IEditor | null = null; - private state: SelectionPluginState; - private disposer: (() => void) | null = null; - private isSafari = false; - private isMac = false; - private scrollTopCache: number = 0; - - constructor(options: EditorOptions) { - this.state = { - selection: null, - tableSelection: null, - imageSelectionBorderColor: options.imageSelectionBorderColor, - }; - } - - getName() { - return 'Selection'; - } - - initialize(editor: IEditor) { - this.editor = editor; - - const env = this.editor.getEnvironment(); - const document = this.editor.getDocument(); - - this.isSafari = !!env.isSafari; - this.isMac = !!env.isMac; - document.addEventListener('selectionchange', this.onSelectionChange); - if (this.isSafari) { - this.disposer = this.editor.attachDomEvent({ - focus: { beforeDispatch: this.onFocus }, - drop: { beforeDispatch: this.onDrop }, - }); - } else { - this.disposer = this.editor.attachDomEvent({ - focus: { beforeDispatch: this.onFocus }, - blur: { beforeDispatch: this.onBlur }, - drop: { beforeDispatch: this.onDrop }, - }); - } - } - - dispose() { - this.editor?.getDocument().removeEventListener('selectionchange', this.onSelectionChange); - - if (this.disposer) { - this.disposer(); - this.disposer = null; - } - - this.detachMouseEvent(); - this.editor = null; - } - - getState(): SelectionPluginState { - return this.state; - } - - onPluginEvent(event: PluginEvent) { - if (!this.editor) { - return; - } - - switch (event.eventType) { - case 'mouseDown': - this.onMouseDown(this.editor, event.rawEvent); - break; - - case 'mouseUp': - this.onMouseUp(event); - break; - - case 'keyDown': - this.onKeyDown(this.editor, event.rawEvent); - break; - - case 'contentChanged': - this.state.tableSelection = null; - break; - - case 'scroll': - if (!this.editor.hasFocus()) { - this.scrollTopCache = event.scrollContainer.scrollTop; - } - break; - } - } - - private onMouseDown(editor: IEditor, rawEvent: MouseEvent) { - const selection = editor.getDOMSelection(); - let image: HTMLImageElement | null; - - // Image selection - if ( - rawEvent.button === MouseRightButton && - (image = - this.getClickingImage(rawEvent) ?? - this.getContainedTargetImage(rawEvent, selection)) && - image.isContentEditable - ) { - this.selectImageWithRange(image, rawEvent); - return; - } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { - this.selectBeforeOrAfterElement(editor, selection.image); - return; - } - - // Table selection - if (selection?.type == 'table' && rawEvent.button == MouseLeftButton) { - this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); - } - - let tableSelection: TableSelectionInfo | null; - const target = rawEvent.target as Node; - - if ( - target && - rawEvent.button == MouseLeftButton && - (tableSelection = this.parseTableSelection(target, target, editor.getDOMHelper())) - ) { - this.state.tableSelection = tableSelection; - - if (rawEvent.detail >= 3) { - const lastCo = findCoordinate( - tableSelection.parsedTable, - rawEvent.target as Node, - editor.getDOMHelper() - ); - - if (lastCo) { - // Triple click, select the current cell - tableSelection.lastCo = lastCo; - this.updateTableSelection(lastCo); - rawEvent.preventDefault(); - } - } - - this.state.mouseDisposer = editor.attachDomEvent({ - mousemove: { - beforeDispatch: this.onMouseMove, - }, - }); - } - } - - private onMouseMove = (event: Event) => { - if (this.editor && this.state.tableSelection) { - const hasTableSelection = !!this.state.tableSelection.lastCo; - const currentNode = event.target as Node; - const domHelper = this.editor.getDOMHelper(); - - const range = this.editor.getDocument().createRange(); - const startNode = this.state.tableSelection.startNode; - const isReverted = - currentNode.compareDocumentPosition(startNode) == Node.DOCUMENT_POSITION_FOLLOWING; - - if (isReverted) { - range.setStart(currentNode, 0); - range.setEnd( - startNode, - isNodeOfType(startNode, 'TEXT_NODE') - ? startNode.nodeValue?.length ?? 0 - : startNode.childNodes.length - ); - } else { - range.setStart(startNode, 0); - range.setEnd(currentNode, 0); - } - - // Use common container of the range to search a common table that covers both start and end node - const tableStart = range.commonAncestorContainer; - const newTableSelection = this.parseTableSelection(tableStart, startNode, domHelper); - - if (newTableSelection) { - const lastCo = findCoordinate( - newTableSelection.parsedTable, - currentNode, - domHelper - ); - - if (newTableSelection.table != this.state.tableSelection.table) { - // Move mouse into another table (nest table scenario) - this.state.tableSelection = newTableSelection; - this.state.tableSelection.lastCo = lastCo ?? undefined; - } - - const updated = lastCo && this.updateTableSelection(lastCo); - - if (hasTableSelection || updated) { - event.preventDefault(); - } - } else if (this.editor.getDOMSelection()?.type == 'table') { - // Move mouse out of table - this.setDOMSelection( - { - type: 'range', - range, - isReverted, - }, - this.state.tableSelection - ); - } - } - }; - - private selectImageWithRange(image: HTMLImageElement, event: Event) { - const range = image.ownerDocument.createRange(); - range.selectNode(image); - - const domSelection = this.editor?.getDOMSelection(); - if (domSelection?.type == 'image' && image == domSelection.image) { - event.preventDefault(); - } else { - this.setDOMSelection( - { - type: 'range', - isReverted: false, - range, - }, - null - ); - } - } - - private onMouseUp(event: MouseUpEvent) { - let image: HTMLImageElement | null; - - if ( - (image = this.getClickingImage(event.rawEvent)) && - image.isContentEditable && - event.rawEvent.button != MouseMiddleButton && - (event.rawEvent.button == - MouseRightButton /* it's not possible to drag using right click */ || - event.isClicking) - ) { - this.selectImageWithRange(image, event.rawEvent); - } - - this.detachMouseEvent(); - } - - private onDrop = () => { - this.detachMouseEvent(); - }; - - private onKeyDown(editor: IEditor, rawEvent: KeyboardEvent) { - const key = rawEvent.key; - const selection = editor.getDOMSelection(); - const win = editor.getDocument().defaultView; - - switch (selection?.type) { - case 'image': - if (!isModifierKey(rawEvent) && !rawEvent.shiftKey && selection.image.parentNode) { - if (key === 'Escape') { - this.selectBeforeOrAfterElement(editor, selection.image); - rawEvent.stopPropagation(); - } else if (key !== 'Delete' && key !== 'Backspace') { - this.selectBeforeOrAfterElement(editor, selection.image); - } - } - break; - - case 'range': - if (key == Up || key == Down || key == Left || key == Right || key == Tab) { - const start = selection.range.startContainer; - this.state.tableSelection = this.parseTableSelection( - start, - start, - editor.getDOMHelper() - ); - - const rangeKey = key == Tab ? this.getTabKey(rawEvent) : key; - - if (this.state.tableSelection) { - win?.requestAnimationFrame(() => this.handleSelectionInTable(rangeKey)); - } - } - break; - - case 'table': - if (this.state.tableSelection?.lastCo) { - const { shiftKey, key } = rawEvent; - - if (shiftKey && (key == Left || key == Right)) { - const isRtl = - win?.getComputedStyle(this.state.tableSelection.table).direction == - 'rtl'; - - this.updateTableSelectionFromKeyboard( - 0, - (key == Left ? -1 : 1) * (isRtl ? -1 : 1) - ); - rawEvent.preventDefault(); - } else if (shiftKey && (key == Up || key == Down)) { - this.updateTableSelectionFromKeyboard(key == Up ? -1 : 1, 0); - rawEvent.preventDefault(); - } else if (key != 'Shift' && !isCharacterValue(rawEvent)) { - if (key == Up || key == Down || key == Left || key == Right) { - this.setDOMSelection(null /*domSelection*/, this.state.tableSelection); - win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); - } - } - } - break; - } - } - - private getTabKey(rawEvent: KeyboardEvent) { - return rawEvent.shiftKey ? 'TabLeft' : 'TabRight'; - } - - private handleSelectionInTable( - key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight' - ) { - if (!this.editor || !this.state.tableSelection) { - return; - } - - const selection = this.editor.getDOMSelection(); - const domHelper = this.editor.getDOMHelper(); - - if (selection?.type == 'range') { - const { - range: { collapsed, startContainer, endContainer, commonAncestorContainer }, - isReverted, - } = selection; - const start = isReverted ? endContainer : startContainer; - const end: Node | null = isReverted ? startContainer : endContainer; - const tableSel = this.parseTableSelection(commonAncestorContainer, start, domHelper); - - if (!tableSel) { - return; - } - - let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); - const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; - - if (lastCo && tableSel.table == table) { - if (lastCo.col != oldCo.col && (key == Up || key == Down)) { - const change = key == Up ? -1 : 1; - const originalTd = findTableCellElement(parsedTable, oldCo)?.cell; - let td: HTMLTableCellElement | null = null; - - lastCo = { row: oldCo.row + change, col: oldCo.col }; - - while (lastCo.row >= 0 && lastCo.row < parsedTable.length) { - td = findTableCellElement(parsedTable, lastCo)?.cell || null; - - if (td == originalTd) { - lastCo.row += change; - } else { - break; - } - } - - if (collapsed && td) { - this.setRangeSelectionInTable( - td, - key == Up ? td.childNodes.length : 0, - this.editor - ); - } - } else if (key == 'TabLeft' || key == 'TabRight') { - const reverse = key == 'TabLeft'; - for ( - let step = reverse ? -1 : 1, - row = lastCo.row ?? 0, - col = (lastCo.col ?? 0) + step; - ; - col += step - ) { - if (col < 0 || col >= parsedTable[row].length) { - row += step; - if (row < 0) { - this.selectBeforeOrAfterElement(this.editor, tableSel.table); - break; - } else if (row >= parsedTable.length) { - this.selectBeforeOrAfterElement( - this.editor, - tableSel.table, - true /*after*/ - ); - break; - } - col = reverse ? parsedTable[row].length - 1 : 0; - } - const cell = parsedTable[row][col]; - if (typeof cell != 'string') { - this.setRangeSelectionInTable(cell, 0, this.editor); - break; - } - } - } else { - this.state.tableSelection = null; - } - } - - if (!collapsed && lastCo) { - this.state.tableSelection = tableSel; - this.updateTableSelection(lastCo); - } - } - } - - private setRangeSelectionInTable(cell: Node, nodeOffset: number, editor: IEditor) { - // Get deepest editable position in the cell - const { node, offset } = normalizePos(cell, nodeOffset); - - const range = editor.getDocument().createRange(); - range.setStart(node, offset); - range.collapse(true /*toStart*/); - - this.setDOMSelection( - { - type: 'range', - range, - isReverted: false, - }, - null /*tableSelection*/ - ); - } - - private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { - if (this.state.tableSelection?.lastCo && this.editor) { - const { lastCo, parsedTable } = this.state.tableSelection; - const row = lastCo.row + rowChange; - const col = lastCo.col + colChange; - - if (row >= 0 && row < parsedTable.length && col >= 0 && col < parsedTable[row].length) { - this.updateTableSelection({ row, col }); - } - } - } - - private selectBeforeOrAfterElement(editor: IEditor, element: HTMLElement, after?: boolean) { - const doc = editor.getDocument(); - const parent = element.parentNode; - const index = parent && toArray(parent.childNodes).indexOf(element); - - if (parent && index !== null && index >= 0) { - const range = doc.createRange(); - range.setStart(parent, index + (after ? 1 : 0)); - range.collapse(); - - this.setDOMSelection( - { - type: 'range', - range: range, - isReverted: false, - }, - null /*tableSelection*/ - ); - } - } - - private getClickingImage(event: UIEvent): HTMLImageElement | null { - const target = event.target as Node; - - return isNodeOfType(target, 'ELEMENT_NODE') && isElementOfType(target, 'img') - ? target - : null; - } - - // MacOS will not create a mouseUp event if contextMenu event is not prevent defaulted. - // Make sure we capture image target even if image is wrapped - private getContainedTargetImage = ( - event: MouseEvent, - previousSelection: DOMSelection | null - ): HTMLImageElement | null => { - if (!this.isMac || !previousSelection || previousSelection.type !== 'image') { - return null; - } - - const target = event.target as Node; - if ( - isNodeOfType(target, 'ELEMENT_NODE') && - isElementOfType(target, 'span') && - target.firstChild === previousSelection.image - ) { - return previousSelection.image; - } - return null; - }; - - private onFocus = () => { - if (!this.state.skipReselectOnFocus && this.state.selection) { - this.setDOMSelection(this.state.selection, this.state.tableSelection); - } - - if (this.state.selection?.type == 'range' && !this.isSafari) { - // Editor is focused, now we can get live selection. So no need to keep a selection if the selection type is range. - this.state.selection = null; - } - - if (this.scrollTopCache && this.editor) { - const sc = this.editor.getScrollContainer(); - sc.scrollTop = this.scrollTopCache; - this.scrollTopCache = 0; - } - }; - - private onBlur = () => { - if (this.editor) { - if (!this.state.selection) { - this.state.selection = this.editor.getDOMSelection(); - } - const sc = this.editor.getScrollContainer(); - this.scrollTopCache = sc.scrollTop; - } - }; - - private onSelectionChange = () => { - if (this.editor?.hasFocus() && !this.editor.isInShadowEdit()) { - const newSelection = this.editor.getDOMSelection(); - - //If am image selection changed to a wider range due a keyboard event, we should update the selection - const selection = this.editor.getDocument().getSelection(); - - if (newSelection?.type == 'image' && selection) { - if (selection && !isSingleImageInSelection(selection)) { - const range = selection.getRangeAt(0); - this.editor.setDOMSelection({ - type: 'range', - range, - isReverted: - selection.focusNode != range.endContainer || - selection.focusOffset != range.endOffset, - }); - } - } - - // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. - // So we always save a selection whenever editor has focus. Then after blur, we can still use this cached selection. - if (newSelection?.type == 'range') { - if (this.isSafari) { - this.state.selection = newSelection; - } - this.trySelectSingleImage(newSelection); - } - } - }; - - private parseTableSelection( - tableStart: Node, - tdStart: Node, - domHelper: DOMHelper - ): TableSelectionInfo | null { - let table: HTMLTableElement | null; - let parsedTable: ParsedTable | null; - let firstCo: TableCellCoordinate | null; - - if ( - (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && - (parsedTable = parseTableCells(table)) && - (firstCo = findCoordinate(parsedTable, tdStart, domHelper)) - ) { - return { table, parsedTable, firstCo, startNode: tdStart }; - } else { - return null; - } - } - - private updateTableSelection(lastCo: TableCellCoordinate) { - if (this.state.tableSelection && this.editor) { - const { - table, - firstCo, - parsedTable, - startNode, - lastCo: oldCo, - } = this.state.tableSelection; - - if (oldCo || firstCo.row != lastCo.row || firstCo.col != lastCo.col) { - this.state.tableSelection.lastCo = lastCo; - - this.setDOMSelection( - { - type: 'table', - table, - firstRow: firstCo.row, - firstColumn: firstCo.col, - lastRow: lastCo.row, - lastColumn: lastCo.col, - }, - { table, firstCo, lastCo, parsedTable, startNode } - ); - - return true; - } - } - - return false; - } - - private setDOMSelection( - selection: DOMSelection | null, - tableSelection: TableSelectionInfo | null - ) { - this.editor?.setDOMSelection(selection); - this.state.tableSelection = tableSelection; - } - - private detachMouseEvent() { - if (this.state.mouseDisposer) { - this.state.mouseDisposer(); - this.state.mouseDisposer = undefined; - } - } - - private trySelectSingleImage(selection: RangeSelection) { - if (!selection.range.collapsed) { - const image = isSingleImageInSelection(selection.range); - if (image) { - this.setDOMSelection( - { - type: 'image', - image: image, - }, - null /*tableSelection*/ - ); - } - } - } -} - -/** - * @internal - * Create a new instance of SelectionPlugin. - * @param option The editor option - */ -export function createSelectionPlugin( - options: EditorOptions -): PluginWithState { - return new SelectionPlugin(options); -} +import { findCoordinate } from './findCoordinate'; +import { findTableCellElement } from '../../coreApi/setDOMSelection/findTableCellElement'; +import { isSingleImageInSelection } from './isSingleImageInSelection'; +import { normalizePos } from './normalizePos'; +import { + isCharacterValue, + isElementOfType, + isModifierKey, + isNodeOfType, + parseTableCells, + toArray, +} from 'roosterjs-content-model-dom'; +import type { + DOMSelection, + IEditor, + PluginEvent, + PluginWithState, + SelectionPluginState, + EditorOptions, + DOMHelper, + MouseUpEvent, + ParsedTable, + TableSelectionInfo, + TableCellCoordinate, + RangeSelection, +} from 'roosterjs-content-model-types'; + +const MouseLeftButton = 0; +const MouseMiddleButton = 1; +const MouseRightButton = 2; +const Up = 'ArrowUp'; +const Down = 'ArrowDown'; +const Left = 'ArrowLeft'; +const Right = 'ArrowRight'; +const Tab = 'Tab'; + +class SelectionPlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: SelectionPluginState; + private disposer: (() => void) | null = null; + private isSafari = false; + private isMac = false; + private scrollTopCache: number = 0; + + constructor(options: EditorOptions) { + this.state = { + selection: null, + tableSelection: null, + imageSelectionBorderColor: options.imageSelectionBorderColor, + }; + } + + getName() { + return 'Selection'; + } + + initialize(editor: IEditor) { + this.editor = editor; + + const env = this.editor.getEnvironment(); + const document = this.editor.getDocument(); + + this.isSafari = !!env.isSafari; + this.isMac = !!env.isMac; + document.addEventListener('selectionchange', this.onSelectionChange); + if (this.isSafari) { + this.disposer = this.editor.attachDomEvent({ + focus: { beforeDispatch: this.onFocus }, + drop: { beforeDispatch: this.onDrop }, + }); + } else { + this.disposer = this.editor.attachDomEvent({ + focus: { beforeDispatch: this.onFocus }, + blur: { beforeDispatch: this.onBlur }, + drop: { beforeDispatch: this.onDrop }, + }); + } + } + + dispose() { + this.editor?.getDocument().removeEventListener('selectionchange', this.onSelectionChange); + + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + + this.detachMouseEvent(); + this.editor = null; + } + + getState(): SelectionPluginState { + return this.state; + } + + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + + switch (event.eventType) { + case 'mouseDown': + this.onMouseDown(this.editor, event.rawEvent); + break; + + case 'mouseUp': + this.onMouseUp(event); + break; + + case 'keyDown': + this.onKeyDown(this.editor, event.rawEvent); + break; + + case 'contentChanged': + this.state.tableSelection = null; + break; + + case 'scroll': + if (!this.editor.hasFocus()) { + this.scrollTopCache = event.scrollContainer.scrollTop; + } + break; + } + } + + private onMouseDown(editor: IEditor, rawEvent: MouseEvent) { + const selection = editor.getDOMSelection(); + let image: HTMLImageElement | null; + + // Image selection + if ( + rawEvent.button === MouseRightButton && + (image = + this.getClickingImage(rawEvent) ?? + this.getContainedTargetImage(rawEvent, selection)) && + image.isContentEditable + ) { + this.selectImageWithRange(image, rawEvent); + return; + } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { + this.selectBeforeOrAfterElement(editor, selection.image); + return; + } + + // Table selection + if (selection?.type == 'table' && rawEvent.button == MouseLeftButton) { + this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); + } + + let tableSelection: TableSelectionInfo | null; + const target = rawEvent.target as Node; + + if ( + target && + rawEvent.button == MouseLeftButton && + (tableSelection = this.parseTableSelection(target, target, editor.getDOMHelper())) + ) { + this.state.tableSelection = tableSelection; + + if (rawEvent.detail >= 3) { + const lastCo = findCoordinate( + tableSelection.parsedTable, + rawEvent.target as Node, + editor.getDOMHelper() + ); + + if (lastCo) { + // Triple click, select the current cell + tableSelection.lastCo = lastCo; + this.updateTableSelection(lastCo); + rawEvent.preventDefault(); + } + } + + this.state.mouseDisposer = editor.attachDomEvent({ + mousemove: { + beforeDispatch: this.onMouseMove, + }, + }); + } + } + + private onMouseMove = (event: Event) => { + if (this.editor && this.state.tableSelection) { + const hasTableSelection = !!this.state.tableSelection.lastCo; + const currentNode = event.target as Node; + const domHelper = this.editor.getDOMHelper(); + + const range = this.editor.getDocument().createRange(); + const startNode = this.state.tableSelection.startNode; + const isReverted = + currentNode.compareDocumentPosition(startNode) == Node.DOCUMENT_POSITION_FOLLOWING; + + if (isReverted) { + range.setStart(currentNode, 0); + range.setEnd( + startNode, + isNodeOfType(startNode, 'TEXT_NODE') + ? startNode.nodeValue?.length ?? 0 + : startNode.childNodes.length + ); + } else { + range.setStart(startNode, 0); + range.setEnd(currentNode, 0); + } + + // Use common container of the range to search a common table that covers both start and end node + const tableStart = range.commonAncestorContainer; + const newTableSelection = this.parseTableSelection(tableStart, startNode, domHelper); + + if (newTableSelection) { + const lastCo = findCoordinate( + newTableSelection.parsedTable, + currentNode, + domHelper + ); + + if (newTableSelection.table != this.state.tableSelection.table) { + // Move mouse into another table (nest table scenario) + this.state.tableSelection = newTableSelection; + this.state.tableSelection.lastCo = lastCo ?? undefined; + } + + const updated = lastCo && this.updateTableSelection(lastCo); + + if (hasTableSelection || updated) { + event.preventDefault(); + } + } else if (this.editor.getDOMSelection()?.type == 'table') { + // Move mouse out of table + this.setDOMSelection( + { + type: 'range', + range, + isReverted, + }, + this.state.tableSelection + ); + } + } + }; + + private selectImageWithRange(image: HTMLImageElement, event: Event) { + const range = image.ownerDocument.createRange(); + range.selectNode(image); + + const domSelection = this.editor?.getDOMSelection(); + if (domSelection?.type == 'image' && image == domSelection.image) { + event.preventDefault(); + } else { + this.setDOMSelection( + { + type: 'range', + isReverted: false, + range, + }, + null + ); + } + } + + private onMouseUp(event: MouseUpEvent) { + let image: HTMLImageElement | null; + + if ( + (image = this.getClickingImage(event.rawEvent)) && + image.isContentEditable && + event.rawEvent.button != MouseMiddleButton && + (event.rawEvent.button == + MouseRightButton /* it's not possible to drag using right click */ || + event.isClicking) + ) { + this.selectImageWithRange(image, event.rawEvent); + } + + this.detachMouseEvent(); + } + + private onDrop = () => { + this.detachMouseEvent(); + }; + + private onKeyDown(editor: IEditor, rawEvent: KeyboardEvent) { + const key = rawEvent.key; + const selection = editor.getDOMSelection(); + const win = editor.getDocument().defaultView; + + switch (selection?.type) { + case 'image': + if (!isModifierKey(rawEvent) && !rawEvent.shiftKey && selection.image.parentNode) { + if (key === 'Escape') { + this.selectBeforeOrAfterElement(editor, selection.image); + rawEvent.stopPropagation(); + } else if (key !== 'Delete' && key !== 'Backspace') { + this.selectBeforeOrAfterElement(editor, selection.image); + } + } + break; + + case 'range': + if (key == Up || key == Down || key == Left || key == Right || key == Tab) { + const start = selection.range.startContainer; + this.state.tableSelection = this.parseTableSelection( + start, + start, + editor.getDOMHelper() + ); + + const rangeKey = key == Tab ? this.getTabKey(rawEvent) : key; + + if (this.state.tableSelection) { + win?.requestAnimationFrame(() => this.handleSelectionInTable(rangeKey)); + } + } + break; + + case 'table': + if (this.state.tableSelection?.lastCo) { + const { shiftKey, key } = rawEvent; + + if (shiftKey && (key == Left || key == Right)) { + const isRtl = + win?.getComputedStyle(this.state.tableSelection.table).direction == + 'rtl'; + + this.updateTableSelectionFromKeyboard( + 0, + (key == Left ? -1 : 1) * (isRtl ? -1 : 1) + ); + rawEvent.preventDefault(); + } else if (shiftKey && (key == Up || key == Down)) { + this.updateTableSelectionFromKeyboard(key == Up ? -1 : 1, 0); + rawEvent.preventDefault(); + } else if (key != 'Shift' && !isCharacterValue(rawEvent)) { + if (key == Up || key == Down || key == Left || key == Right) { + this.setDOMSelection(null /*domSelection*/, this.state.tableSelection); + win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); + } + } + } + break; + } + } + + private getTabKey(rawEvent: KeyboardEvent) { + return rawEvent.shiftKey ? 'TabLeft' : 'TabRight'; + } + + private handleSelectionInTable( + key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight' + ) { + if (!this.editor || !this.state.tableSelection) { + return; + } + + const selection = this.editor.getDOMSelection(); + const domHelper = this.editor.getDOMHelper(); + + if (selection?.type == 'range') { + const { + range: { collapsed, startContainer, endContainer, commonAncestorContainer }, + isReverted, + } = selection; + const start = isReverted ? endContainer : startContainer; + const end: Node | null = isReverted ? startContainer : endContainer; + const tableSel = this.parseTableSelection(commonAncestorContainer, start, domHelper); + + if (!tableSel) { + return; + } + + let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); + const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; + + if (lastCo && tableSel.table == table) { + if (lastCo.col != oldCo.col && (key == Up || key == Down)) { + const change = key == Up ? -1 : 1; + const originalTd = findTableCellElement(parsedTable, oldCo)?.cell; + let td: HTMLTableCellElement | null = null; + + lastCo = { row: oldCo.row + change, col: oldCo.col }; + + while (lastCo.row >= 0 && lastCo.row < parsedTable.length) { + td = findTableCellElement(parsedTable, lastCo)?.cell || null; + + if (td == originalTd) { + lastCo.row += change; + } else { + break; + } + } + + if (collapsed && td) { + this.setRangeSelectionInTable( + td, + key == Up ? td.childNodes.length : 0, + this.editor + ); + } + } else if (key == 'TabLeft' || key == 'TabRight') { + const reverse = key == 'TabLeft'; + for ( + let step = reverse ? -1 : 1, + row = lastCo.row ?? 0, + col = (lastCo.col ?? 0) + step; + ; + col += step + ) { + if (col < 0 || col >= parsedTable[row].length) { + row += step; + if (row < 0) { + this.selectBeforeOrAfterElement(this.editor, tableSel.table); + break; + } else if (row >= parsedTable.length) { + this.selectBeforeOrAfterElement( + this.editor, + tableSel.table, + true /*after*/ + ); + break; + } + col = reverse ? parsedTable[row].length - 1 : 0; + } + const cell = parsedTable[row][col]; + + if (typeof cell != 'string') { + this.setRangeSelectionInTable(cell, 0, this.editor); + lastCo.row = row; + lastCo.col = col; + break; + } + } + } else { + this.state.tableSelection = null; + } + + if ( + collapsed && + (lastCo.col != oldCo.col || lastCo.row != oldCo.row) && + lastCo.row >= 0 && + lastCo.row == parsedTable.length - 1 && + lastCo.col == parsedTable[lastCo.row]?.length - 1 + ) { + this.editor?.announce({ defaultStrings: 'announceOnFocusLastCell' }); + } + } + + if (!collapsed && lastCo) { + this.state.tableSelection = tableSel; + this.updateTableSelection(lastCo); + } + } + } + + private setRangeSelectionInTable(cell: Node, nodeOffset: number, editor: IEditor) { + // Get deepest editable position in the cell + const { node, offset } = normalizePos(cell, nodeOffset); + + const range = editor.getDocument().createRange(); + range.setStart(node, offset); + range.collapse(true /*toStart*/); + + this.setDOMSelection( + { + type: 'range', + range, + isReverted: false, + }, + null /*tableSelection*/ + ); + } + + private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { + if (this.state.tableSelection?.lastCo && this.editor) { + const { lastCo, parsedTable } = this.state.tableSelection; + const row = lastCo.row + rowChange; + const col = lastCo.col + colChange; + + if (row >= 0 && row < parsedTable.length && col >= 0 && col < parsedTable[row].length) { + this.updateTableSelection({ row, col }); + } + } + } + + private selectBeforeOrAfterElement(editor: IEditor, element: HTMLElement, after?: boolean) { + const doc = editor.getDocument(); + const parent = element.parentNode; + const index = parent && toArray(parent.childNodes).indexOf(element); + + if (parent && index !== null && index >= 0) { + const range = doc.createRange(); + range.setStart(parent, index + (after ? 1 : 0)); + range.collapse(); + + this.setDOMSelection( + { + type: 'range', + range: range, + isReverted: false, + }, + null /*tableSelection*/ + ); + } + } + + private getClickingImage(event: UIEvent): HTMLImageElement | null { + const target = event.target as Node; + + return isNodeOfType(target, 'ELEMENT_NODE') && isElementOfType(target, 'img') + ? target + : null; + } + + // MacOS will not create a mouseUp event if contextMenu event is not prevent defaulted. + // Make sure we capture image target even if image is wrapped + private getContainedTargetImage = ( + event: MouseEvent, + previousSelection: DOMSelection | null + ): HTMLImageElement | null => { + if (!this.isMac || !previousSelection || previousSelection.type !== 'image') { + return null; + } + + const target = event.target as Node; + if ( + isNodeOfType(target, 'ELEMENT_NODE') && + isElementOfType(target, 'span') && + target.firstChild === previousSelection.image + ) { + return previousSelection.image; + } + return null; + }; + + private onFocus = () => { + if (!this.state.skipReselectOnFocus && this.state.selection) { + this.setDOMSelection(this.state.selection, this.state.tableSelection); + } + + if (this.state.selection?.type == 'range' && !this.isSafari) { + // Editor is focused, now we can get live selection. So no need to keep a selection if the selection type is range. + this.state.selection = null; + } + + if (this.scrollTopCache && this.editor) { + const sc = this.editor.getScrollContainer(); + sc.scrollTop = this.scrollTopCache; + this.scrollTopCache = 0; + } + }; + + private onBlur = () => { + if (this.editor) { + if (!this.state.selection) { + this.state.selection = this.editor.getDOMSelection(); + } + const sc = this.editor.getScrollContainer(); + this.scrollTopCache = sc.scrollTop; + } + }; + + private onSelectionChange = () => { + if (this.editor?.hasFocus() && !this.editor.isInShadowEdit()) { + const newSelection = this.editor.getDOMSelection(); + + //If am image selection changed to a wider range due a keyboard event, we should update the selection + const selection = this.editor.getDocument().getSelection(); + + if (newSelection?.type == 'image' && selection) { + if (selection && !isSingleImageInSelection(selection)) { + const range = selection.getRangeAt(0); + this.editor.setDOMSelection({ + type: 'range', + range, + isReverted: + selection.focusNode != range.endContainer || + selection.focusOffset != range.endOffset, + }); + } + } + + // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. + // So we always save a selection whenever editor has focus. Then after blur, we can still use this cached selection. + if (newSelection?.type == 'range') { + if (this.isSafari) { + this.state.selection = newSelection; + } + this.trySelectSingleImage(newSelection); + } + } + }; + + private parseTableSelection( + tableStart: Node, + tdStart: Node, + domHelper: DOMHelper + ): TableSelectionInfo | null { + let table: HTMLTableElement | null; + let parsedTable: ParsedTable | null; + let firstCo: TableCellCoordinate | null; + + if ( + (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && + (parsedTable = parseTableCells(table)) && + (firstCo = findCoordinate(parsedTable, tdStart, domHelper)) + ) { + return { table, parsedTable, firstCo, startNode: tdStart }; + } else { + return null; + } + } + + private updateTableSelection(lastCo: TableCellCoordinate) { + if (this.state.tableSelection && this.editor) { + const { + table, + firstCo, + parsedTable, + startNode, + lastCo: oldCo, + } = this.state.tableSelection; + + if (oldCo || firstCo.row != lastCo.row || firstCo.col != lastCo.col) { + this.state.tableSelection.lastCo = lastCo; + + this.setDOMSelection( + { + type: 'table', + table, + firstRow: firstCo.row, + firstColumn: firstCo.col, + lastRow: lastCo.row, + lastColumn: lastCo.col, + }, + { table, firstCo, lastCo, parsedTable, startNode } + ); + + return true; + } + } + + return false; + } + + private setDOMSelection( + selection: DOMSelection | null, + tableSelection: TableSelectionInfo | null + ) { + this.editor?.setDOMSelection(selection); + this.state.tableSelection = tableSelection; + } + + private detachMouseEvent() { + if (this.state.mouseDisposer) { + this.state.mouseDisposer(); + this.state.mouseDisposer = undefined; + } + } + + private trySelectSingleImage(selection: RangeSelection) { + if (!selection.range.collapsed) { + const image = isSingleImageInSelection(selection.range); + if (image) { + this.setDOMSelection( + { + type: 'image', + image: image, + }, + null /*tableSelection*/ + ); + } + } + } +} + +/** + * @internal + * Create a new instance of SelectionPlugin. + * @param option The editor option + */ +export function createSelectionPlugin( + options: EditorOptions +): PluginWithState { + return new SelectionPlugin(options); +} diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 9e1eb76f173..220e76736dd 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -31,6 +31,7 @@ import type { EntityState, CachedElementHandler, DomToModelOptionForCreateModel, + AnnounceData, } from 'roosterjs-content-model-types'; /** @@ -402,6 +403,16 @@ export class Editor implements IEditor { core.api.setEditorStyle(core, key, cssRule, subSelectors); } + /** + * Announce the given data + * @param announceData Data to announce + */ + announce(announceData: AnnounceData): void { + const core = this.getCore(); + + core.api.announce(core, announceData); + } + /** * @returns the current EditorCore object * @throws a standard Error if there's no core object diff --git a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts index ec959e18188..a638dc10027 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts @@ -1,4 +1,4 @@ -import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import { isNodeOfType, parseValueWithUnit, toArray } from 'roosterjs-content-model-dom'; import type { DOMHelper } from 'roosterjs-content-model-types'; class DOMHelperImpl implements DOMHelper { @@ -60,6 +60,27 @@ class DOMHelperImpl implements DOMHelper { const activeElement = this.contentDiv.ownerDocument.activeElement; return !!(activeElement && this.contentDiv.contains(activeElement)); } + + /** + * Check if the root element is in RTL mode + */ + isRightToLeft(): boolean { + const contentDiv = this.contentDiv; + const style = contentDiv.ownerDocument.defaultView?.getComputedStyle(contentDiv); + + return style?.direction == 'rtl'; + } + + /** + * Get the width of the editable area of the editor content div + */ + getClientWidth(): number { + const contentDiv = this.contentDiv; + const style = contentDiv.ownerDocument.defaultView?.getComputedStyle(contentDiv); + const paddingLeft = parseValueWithUnit(style?.paddingLeft); + const paddingRight = parseValueWithUnit(style?.paddingRight); + return this.contentDiv.clientWidth - (paddingLeft + paddingRight); + } } /** diff --git a/packages/roosterjs-content-model-core/lib/override/listMetadataApplier.ts b/packages/roosterjs-content-model-core/lib/override/listMetadataApplier.ts index 21436361700..cc278974aa6 100644 --- a/packages/roosterjs-content-model-core/lib/override/listMetadataApplier.ts +++ b/packages/roosterjs-content-model-core/lib/override/listMetadataApplier.ts @@ -1,8 +1,9 @@ import { - getObjectKeys, ListMetadataDefinition, OrderedListStyleMap, UnorderedListStyleMap, + getAutoListStyleType, + getOrderedListNumberStr, } from 'roosterjs-content-model-dom'; import type { ContentModelListItemFormat, @@ -11,91 +12,30 @@ import type { MetadataApplier, } from 'roosterjs-content-model-types'; -const DefaultOrderedListStyles = ['decimal', 'lower-alpha', 'lower-roman']; -const DefaultUnorderedListStyles = ['disc', 'circle', 'square']; const OrderedMapPlaceholderRegex = /\$\{(\w+)\}/; -const CharCodeOfA = 65; -const RomanValues: Record = { - M: 1000, - CM: 900, - D: 500, - CD: 400, - C: 100, - XC: 90, - L: 50, - XL: 40, - X: 10, - IX: 9, - V: 5, - IV: 4, - I: 1, -}; -function getOrderedListStyleValue( - template: string | undefined, - listNumber: number +function getListStyleValue( + listType: 'OL' | 'UL', + listStyleType: number, + listNumber?: number ): string | undefined { - return template - ? template.replace(OrderedMapPlaceholderRegex, (_, subStr) => { - switch (subStr) { - case 'Number': - return listNumber + ''; - case 'LowerAlpha': - return convertDecimalsToAlpha(listNumber, true /*isLowerCase*/); - case 'UpperAlpha': - return convertDecimalsToAlpha(listNumber, false /*isLowerCase*/); - case 'LowerRoman': - return convertDecimalsToRoman(listNumber, true /*isLowerCase*/); - case 'UpperRoman': - return convertDecimalsToRoman(listNumber, false /*isLowerCase*/); - } - - return ''; - }) - : undefined; -} - -function convertDecimalsToAlpha(decimal: number, isLowerCase?: boolean): string { - let alpha = ''; - decimal--; - - while (decimal >= 0) { - alpha = String.fromCharCode((decimal % 26) + CharCodeOfA) + alpha; - decimal = Math.floor(decimal / 26) - 1; - } - return isLowerCase ? alpha.toLowerCase() : alpha; -} - -function convertDecimalsToRoman(decimal: number, isLowerCase?: boolean) { - let romanValue = ''; + if (listType == 'OL') { + const numberStr = getOrderedListNumberStr(listStyleType, listNumber ?? 1); + const template = OrderedListStyleMap[listStyleType]; - for (const i of getObjectKeys(RomanValues)) { - const timesRomanCharAppear = Math.floor(decimal / RomanValues[i]); - decimal = decimal - timesRomanCharAppear * RomanValues[i]; - romanValue = romanValue + i.repeat(timesRomanCharAppear); + return template ? template.replace(OrderedMapPlaceholderRegex, numberStr) : undefined; + } else { + return UnorderedListStyleMap[listStyleType]; } - return isLowerCase ? romanValue.toLocaleLowerCase() : romanValue; } -function shouldApplyToItem(listStyleType: string) { - return listStyleType.indexOf('"') >= 0; -} +function shouldApplyToItem(listStyleType: number, listType: 'OL' | 'UL') { + const style = + listType == 'OL' + ? OrderedListStyleMap[listStyleType] + : UnorderedListStyleMap[listStyleType]; -function getRawListStyleType(listType: 'OL' | 'UL', metadata: ListMetadataFormat, depth: number) { - const { orderedStyleType, unorderedStyleType, applyListStyleFromLevel } = metadata; - if (listType == 'OL') { - return typeof orderedStyleType == 'number' - ? OrderedListStyleMap[orderedStyleType] - : applyListStyleFromLevel - ? DefaultOrderedListStyles[depth % DefaultOrderedListStyles.length] - : undefined; - } else { - return typeof unorderedStyleType == 'number' - ? UnorderedListStyleMap[unorderedStyleType] - : applyListStyleFromLevel - ? DefaultUnorderedListStyles[depth % DefaultUnorderedListStyles.length] - : undefined; - } + return style?.indexOf('"') >= 0; } /** @@ -111,17 +51,15 @@ export const listItemMetadataApplier: MetadataApplier< if (depth >= 0) { const listType = context.listFormat.nodeStack[depth + 1].listType ?? 'OL'; - const listStyleType = getRawListStyleType(listType, metadata ?? {}, depth); + const listStyleType = getAutoListStyleType(listType, metadata ?? {}, depth); - if (listStyleType) { - if (shouldApplyToItem(listStyleType)) { - format.listStyleType = - listType == 'OL' - ? getOrderedListStyleValue( - listStyleType, - context.listFormat.threadItemCounts[depth] - ) - : listStyleType; + if (listStyleType !== undefined) { + if (shouldApplyToItem(listStyleType, listType)) { + format.listStyleType = getListStyleValue( + listType, + listStyleType, + context.listFormat.threadItemCounts[depth] + ); } else { delete format.listStyleType; } @@ -143,11 +81,19 @@ export const listLevelMetadataApplier: MetadataApplier< if (depth >= 0) { const listType = context.listFormat.nodeStack[depth + 1].listType ?? 'OL'; - const listStyleType = getRawListStyleType(listType, metadata ?? {}, depth); + const listStyleType = getAutoListStyleType(listType, metadata ?? {}, depth); + + if (listStyleType !== undefined) { + if (!shouldApplyToItem(listStyleType, listType)) { + const listStyleTypeFormat = getListStyleValue( + listType, + listStyleType, + context.listFormat.threadItemCounts[depth] + ); - if (listStyleType) { - if (!shouldApplyToItem(listStyleType)) { - format.listStyleType = listStyleType; + if (listStyleTypeFormat) { + format.listStyleType = listStyleTypeFormat; + } } else { delete format.listStyleType; } diff --git a/packages/roosterjs-content-model-core/test/coreApi/announce/announceTest.ts b/packages/roosterjs-content-model-core/test/coreApi/announce/announceTest.ts new file mode 100644 index 00000000000..f614b638623 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/coreApi/announce/announceTest.ts @@ -0,0 +1,218 @@ +import { announce } from '../../../lib/coreApi/announce/announce'; +import { EditorCore } from 'roosterjs-content-model-types'; + +describe('announce', () => { + let core: EditorCore; + let createElementSpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let getterSpy: jasmine.Spy; + + beforeEach(() => { + createElementSpy = jasmine.createSpy('createElement'); + appendChildSpy = jasmine.createSpy('appendChild'); + getterSpy = jasmine.createSpy('getter'); + + core = { + lifecycle: { + announcerStringGetter: getterSpy, + }, + physicalRoot: { + ownerDocument: { + createElement: createElementSpy, + body: { + appendChild: appendChildSpy, + }, + }, + }, + } as any; + }); + + it('announce empty string', () => { + announce(core, {}); + expect(createElementSpy).not.toHaveBeenCalled(); + expect(appendChildSpy).not.toHaveBeenCalled(); + }); + + it('announce a given string', () => { + const mockedDiv = { + style: {}, + } as any; + + createElementSpy.and.returnValue(mockedDiv); + announce(core, { + text: 'test', + }); + + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedDiv); + expect(mockedDiv).toEqual({ + style: { + clip: 'rect(0px, 0px, 0px, 0px)', + clipPath: 'inset(100%)', + height: '1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', + }, + ariaLive: 'assertive', + textContent: 'test', + }); + }); + + it('announce a default string', () => { + const mockedDiv = { + style: {}, + } as any; + + createElementSpy.and.returnValue(mockedDiv); + getterSpy.and.returnValue('test'); + + announce(core, { + defaultStrings: 'announceListItemBullet', + }); + + expect(getterSpy).toHaveBeenCalledWith('announceListItemBullet'); + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedDiv); + expect(mockedDiv).toEqual({ + style: { + clip: 'rect(0px, 0px, 0px, 0px)', + clipPath: 'inset(100%)', + height: '1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', + }, + ariaLive: 'assertive', + textContent: 'test', + }); + }); + + it('announce a default string with format', () => { + const mockedDiv = { + style: {}, + } as any; + + createElementSpy.and.returnValue(mockedDiv); + getterSpy.and.returnValue('test1 {0} test2'); + + announce(core, { + defaultStrings: 'announceListItemBullet', + formatStrings: ['replace1', 'replace2'], + }); + + expect(getterSpy).toHaveBeenCalledWith('announceListItemBullet'); + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedDiv); + expect(mockedDiv).toEqual({ + style: { + clip: 'rect(0px, 0px, 0px, 0px)', + clipPath: 'inset(100%)', + height: '1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', + }, + ariaLive: 'assertive', + textContent: 'test1 replace1 test2', + }); + }); + + it('announce a default string with complex format', () => { + const mockedDiv = { + style: {}, + } as any; + + createElementSpy.and.returnValue(mockedDiv); + getterSpy.and.returnValue('test1 {0} test2 {1} {0}'); + + announce(core, { + defaultStrings: 'announceListItemBullet', + formatStrings: ['replace1', 'replace2'], + }); + + expect(getterSpy).toHaveBeenCalledWith('announceListItemBullet'); + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedDiv); + expect(mockedDiv).toEqual({ + style: { + clip: 'rect(0px, 0px, 0px, 0px)', + clipPath: 'inset(100%)', + height: '1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', + }, + ariaLive: 'assertive', + textContent: 'test1 replace1 test2 replace2 replace1', + }); + }); + + it('already has div with different text', () => { + const removeChildSpy = jasmine.createSpy('removeChild'); + const mockedDiv = { + textContent: '', + parentElement: { + removeChild: removeChildSpy, + }, + } as any; + + core.lifecycle.announceContainer = mockedDiv; + + createElementSpy.and.returnValue(mockedDiv); + announce(core, { + text: 'test', + }); + + expect(removeChildSpy).not.toHaveBeenCalled(); + expect(createElementSpy).not.toHaveBeenCalled(); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(mockedDiv).toEqual({ + textContent: 'test', + parentElement: { + removeChild: removeChildSpy, + }, + }); + }); + + it('already has div with same text', () => { + const removeChildSpy = jasmine.createSpy('removeChild'); + const mockedDiv = { + textContent: 'test', + parentElement: { + removeChild: removeChildSpy, + }, + } as any; + const mockedDiv2 = { + style: {}, + } as any; + + core.lifecycle.announceContainer = mockedDiv; + createElementSpy.and.returnValue(mockedDiv2); + + announce(core, { + text: 'test', + }); + + expect(removeChildSpy).toHaveBeenCalledWith(mockedDiv); + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedDiv2); + expect(mockedDiv2).toEqual({ + style: { + clip: 'rect(0px, 0px, 0px, 0px)', + clipPath: 'inset(100%)', + height: '1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', + }, + ariaLive: 'assertive', + textContent: 'test', + }); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts index 45d97afccd8..595081af099 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts @@ -6,16 +6,12 @@ describe('createEditorContext', () => { const isDarkMode = 'DARKMODE' as any; const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; - const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); const domIndexer = 'DOMINDEXER' as any; + const isRtlSpy = jasmine.createSpy('isRtl'); const div = { - ownerDocument: { - defaultView: { - getComputedStyle: getComputedStyleSpy, - }, - }, + ownerDocument: {}, }; const core = ({ @@ -33,6 +29,7 @@ describe('createEditorContext', () => { }, domHelper: { calculateZoomScale: calculateZoomScaleSpy, + isRightToLeft: isRtlSpy, }, } as any) as EditorCore; @@ -55,16 +52,12 @@ describe('createEditorContext', () => { const isDarkMode = 'DARKMODE' as any; const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; - const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); + const isRtlSpy = jasmine.createSpy('isRtl'); const domIndexer = 'DOMINDEXER' as any; const div = { - ownerDocument: { - defaultView: { - getComputedStyle: getComputedStyleSpy, - }, - }, + ownerDocument: {}, }; const core = ({ @@ -82,6 +75,7 @@ describe('createEditorContext', () => { }, domHelper: { calculateZoomScale: calculateZoomScaleSpy, + isRightToLeft: isRtlSpy, }, } as any) as EditorCore; @@ -105,15 +99,10 @@ describe('createEditorContext', () => { const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; const mockedPendingFormat = 'PENDINGFORMAT' as any; - const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); const div = { - ownerDocument: { - defaultView: { - getComputedStyle: getComputedStyleSpy, - }, - }, + ownerDocument: {}, }; const core = ({ @@ -130,6 +119,7 @@ describe('createEditorContext', () => { cache: {}, domHelper: { calculateZoomScale: calculateZoomScaleSpy, + isRightToLeft: jasmine.createSpy('isRtl'), }, } as any) as EditorCore; @@ -153,15 +143,11 @@ describe('createEditorContext', () => { const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; const mockedPendingFormat = 'PENDINGFORMAT' as any; - const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); + const isRtlSpy = jasmine.createSpy('isRtl'); const div = { - ownerDocument: { - defaultView: { - getComputedStyle: getComputedStyleSpy, - }, - }, + ownerDocument: {}, }; const core = ({ @@ -179,6 +165,7 @@ describe('createEditorContext', () => { cache: {}, domHelper: { calculateZoomScale: calculateZoomScaleSpy, + isRightToLeft: isRtlSpy, }, } as any) as EditorCore; @@ -201,22 +188,18 @@ describe('createEditorContext', () => { describe('createEditorContext - checkZoomScale', () => { let core: EditorCore; let div: any; - let getComputedStyleSpy: jasmine.Spy; let calculateZoomScaleSpy: jasmine.Spy; + let isRtlSpy: jasmine.Spy; const isDarkMode = 'DARKMODE' as any; const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; beforeEach(() => { - getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale'); + isRtlSpy = jasmine.createSpy('isRtl'); div = { - ownerDocument: { - defaultView: { - getComputedStyle: getComputedStyleSpy, - }, - }, + ownerDocument: {}, }; core = ({ physicalRoot: div, @@ -231,6 +214,7 @@ describe('createEditorContext - checkZoomScale', () => { cache: {}, domHelper: { calculateZoomScale: calculateZoomScaleSpy, + isRightToLeft: isRtlSpy, }, } as any) as EditorCore; }); @@ -257,21 +241,17 @@ describe('createEditorContext - checkZoomScale', () => { describe('createEditorContext - checkRootDir', () => { let core: EditorCore; let div: any; - let getComputedStyleSpy: jasmine.Spy; let calculateZoomScaleSpy: jasmine.Spy; + let isRtlSpy: jasmine.Spy; const isDarkMode = 'DARKMODE' as any; const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; beforeEach(() => { - getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); + isRtlSpy = jasmine.createSpy('isRtl'); div = { - ownerDocument: { - defaultView: { - getComputedStyle: getComputedStyleSpy, - }, - }, + ownerDocument: {}, }; core = ({ physicalRoot: div, @@ -286,15 +266,13 @@ describe('createEditorContext - checkRootDir', () => { cache: {}, domHelper: { calculateZoomScale: calculateZoomScaleSpy, + isRightToLeft: isRtlSpy, }, } as any) as EditorCore; }); it('LTR CSS', () => { - getComputedStyleSpy.and.returnValue({ - direction: 'ltr', - }); - + isRtlSpy.and.returnValue(false); const context = createEditorContext(core, false); expect(context).toEqual({ @@ -311,10 +289,7 @@ describe('createEditorContext - checkRootDir', () => { }); it('RTL', () => { - getComputedStyleSpy.and.returnValue({ - direction: 'rtl', - }); - + isRtlSpy.and.returnValue(true); const context = createEditorContext(core, false); expect(context).toEqual({ diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 17078241df9..dc1a0afae0c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -19,6 +19,8 @@ describe('formatContentModel', () => { let triggerEvent: jasmine.Spy; let getDOMSelection: jasmine.Spy; let hasFocus: jasmine.Spy; + let getClientWidth: jasmine.Spy; + let announce: jasmine.Spy; const apiName = 'mockedApi'; const mockedContainer = 'C' as any; @@ -38,6 +40,8 @@ describe('formatContentModel', () => { triggerEvent = jasmine.createSpy('triggerEvent'); getDOMSelection = jasmine.createSpy('getDOMSelection').and.returnValue(null); hasFocus = jasmine.createSpy('hasFocus'); + getClientWidth = jasmine.createSpy('getClientWidth'); + announce = jasmine.createSpy('announce'); core = ({ api: { @@ -48,6 +52,7 @@ describe('formatContentModel', () => { getFocusedPosition, triggerEvent, getDOMSelection, + announce, }, lifecycle: {}, cache: {}, @@ -56,6 +61,7 @@ describe('formatContentModel', () => { }, domHelper: { hasFocus, + getClientWidth, }, } as any) as EditorCore; }); @@ -80,6 +86,7 @@ describe('formatContentModel', () => { expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); expect(triggerEvent).not.toHaveBeenCalled(); + expect(announce).not.toHaveBeenCalled(); }); it('Callback return true', () => { @@ -112,6 +119,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Skip undo snapshot', () => { @@ -147,6 +155,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Customize change source', () => { @@ -178,6 +187,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Customize change source, getChangeData and skip undo snapshot', () => { @@ -218,6 +228,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Has onNodeCreated', () => { @@ -255,6 +266,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Has entity got deleted', () => { @@ -317,6 +329,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Has new entity in dark mode', () => { @@ -378,6 +391,7 @@ describe('formatContentModel', () => { true ); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(announce).not.toHaveBeenCalled(); }); it('With selectionOverride', () => { @@ -406,6 +420,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('With domToModelOptions', () => { @@ -438,15 +453,12 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Has image', () => { const image = createImage('test'); const rawEvent = 'RawEvent' as any; - const getVisibleViewportSpy = jasmine - .createSpy('getVisibleViewport') - .and.returnValue({ top: 100, bottom: 200, left: 100, right: 200 }); - core.api.getVisibleViewport = getVisibleViewportSpy; formatContentModel( core, @@ -460,7 +472,7 @@ describe('formatContentModel', () => { } ); - expect(getVisibleViewportSpy).toHaveBeenCalledTimes(1); + expect(getClientWidth).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); @@ -478,6 +490,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Has shouldClearCachedModel', () => { @@ -509,6 +522,7 @@ describe('formatContentModel', () => { }, true ); + expect(announce).not.toHaveBeenCalled(); }); it('Has shouldClearCachedModel, and callback return false', () => { @@ -533,6 +547,7 @@ describe('formatContentModel', () => { cachedModel: undefined, cachedSelection: undefined, }); + expect(announce).not.toHaveBeenCalled(); }); }); @@ -891,4 +906,40 @@ describe('formatContentModel', () => { } as any); }); }); + + describe('Has announce data', () => { + it('callback returns false', () => { + const mockedData = 'ANNOUNCE' as any; + const callback = jasmine + .createSpy('callback') + .and.callFake((model: ContentModelDocument, context: FormatContentModelContext) => { + context.announceData = mockedData; + return false; + }); + + formatContentModel(core, callback, { apiName }); + + expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(setContentModel).not.toHaveBeenCalled(); + expect(triggerEvent).not.toHaveBeenCalled(); + expect(announce).toHaveBeenCalledWith(core, mockedData); + }); + + it('callback returns true', () => { + const mockedData = 'ANNOUNCE' as any; + const callback = jasmine + .createSpy('callback') + .and.callFake((model: ContentModelDocument, context: FormatContentModelContext) => { + context.announceData = mockedData; + return true; + }); + + formatContentModel(core, callback, { apiName }); + + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalled(); + expect(triggerEvent).toHaveBeenCalled(); + expect(announce).toHaveBeenCalledWith(core, mockedData); + }); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index d0b8ebdcb9b..efe35870517 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -194,6 +194,7 @@ describe('domIndexerImpl.reconcileSelection', () => { expect(result).toBeFalse(); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, normal range on non-indexed text, collapsed', () => { @@ -208,6 +209,7 @@ describe('domIndexerImpl.reconcileSelection', () => { expect(result).toBeFalse(); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, normal range on indexed text, collapsed', () => { @@ -255,6 +257,7 @@ describe('domIndexerImpl.reconcileSelection', () => { ], }); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, normal range on indexed text, expanded on same node', () => { @@ -300,6 +303,53 @@ describe('domIndexerImpl.reconcileSelection', () => { segments: [segment1, segment2, segment3], }); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); + }); + + it('no old range, normal range on indexed text, expanded on same node, reverted', () => { + const node = document.createTextNode('test') as any; + const newRangeEx: DOMSelection = { + type: 'range', + range: createRange(node, 1, node, 3), + isReverted: true, + }; + const paragraph = createParagraph(); + const segment = createText(''); + + paragraph.segments.push(segment); + domIndexerImpl.onSegment(node, paragraph, [segment]); + + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); + + const segment1: ContentModelSegment = { + segmentType: 'Text', + text: 't', + format: {}, + }; + const segment2: ContentModelSegment = { + segmentType: 'Text', + text: 'es', + format: {}, + isSelected: true, + }; + const segment3: ContentModelSegment = { + segmentType: 'Text', + text: 't', + format: {}, + }; + + expect(result).toBeTrue(); + expect(node.__roosterjsContentModel).toEqual({ + paragraph, + segments: [segment1, segment2, segment3], + }); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [segment1, segment2, segment3], + }); + expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeTrue(); }); it('no old range, normal range on indexed text, expanded on different node', () => { @@ -370,6 +420,7 @@ describe('domIndexerImpl.reconcileSelection', () => { blockGroupType: 'Document', blocks: [paragraph], }); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, normal range on indexed text, expanded on other type of node', () => { @@ -430,6 +481,7 @@ describe('domIndexerImpl.reconcileSelection', () => { blockGroupType: 'Document', blocks: [paragraph], }); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, image range on indexed text', () => { @@ -472,6 +524,7 @@ describe('domIndexerImpl.reconcileSelection', () => { format: {}, dataset: {}, }); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, table range on indexed text', () => { @@ -516,6 +569,7 @@ describe('domIndexerImpl.reconcileSelection', () => { blockGroupType: 'Document', blocks: [tableModel], }); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, collapsed range after last node', () => { @@ -548,6 +602,7 @@ describe('domIndexerImpl.reconcileSelection', () => { segments: [segment, createSelectionMarker({ fontFamily: 'Arial' })], }); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('has old range - collapsed, expanded new range', () => { @@ -606,6 +661,7 @@ describe('domIndexerImpl.reconcileSelection', () => { segments: [segment1, segment2, segment3], }); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('has old range - expanded, expanded new range', () => { @@ -664,5 +720,6 @@ describe('domIndexerImpl.reconcileSelection', () => { segments: [segment1, createSelectionMarker(), segment2], }); expect(setSelectionSpy).toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/entity/adjustSelectionAroundEntityTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/adjustSelectionAroundEntityTest.ts new file mode 100644 index 00000000000..9c9d8705537 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/adjustSelectionAroundEntityTest.ts @@ -0,0 +1,970 @@ +import { adjustSelectionAroundEntity } from '../../../lib/corePlugin/entity/adjustSelectionAroundEntity'; +import { ContentModelDocument, DOMSelection, IEditor } from 'roosterjs-content-model-types'; +import { + createContentModelDocument, + createDomToModelContext, + createParagraph, + createSelectionMarker, + domToContentModel, +} from 'roosterjs-content-model-dom'; + +describe('adjustSelectionAroundEntity', () => { + let editor: IEditor; + let formatContentModelSpy: jasmine.Spy; + let isDisposedSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let getComputedStyleSpy: jasmine.Spy; + let setStartBeforeSpy: jasmine.Spy; + let setEndBeforeSpy: jasmine.Spy; + let setStartAfterSpy: jasmine.Spy; + let setEndAfterSpy: jasmine.Spy; + let cloneRangeSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let mockedRange: Range; + + beforeEach(() => { + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + isDisposedSpy = jasmine.createSpy('isDisposed'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + getComputedStyleSpy = jasmine.createSpy('getComputedStyle').and.returnValue({ + direction: 'ltr', + }); + + setStartBeforeSpy = jasmine.createSpy('setStartBefore'); + setStartAfterSpy = jasmine.createSpy('setStartAfter'); + setEndBeforeSpy = jasmine.createSpy('setEndBefore'); + setEndAfterSpy = jasmine.createSpy('setEndAfter'); + + mockedRange = { + setStartBefore: setStartBeforeSpy, + setStartAfter: setStartAfterSpy, + setEndBefore: setEndBeforeSpy, + setEndAfter: setEndAfterSpy, + } as any; + + cloneRangeSpy = jasmine.createSpy('cloneRange').and.returnValue(mockedRange); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + + editor = { + formatContentModel: formatContentModelSpy, + isDisposed: isDisposedSpy, + getDOMSelection: getDOMSelectionSpy, + getDocument: () => ({ + defaultView: { + getComputedStyle: getComputedStyleSpy, + }, + }), + setDOMSelection: setDOMSelectionSpy, + } as any; + }); + + function runTest( + model: ContentModelDocument, + key: 'ArrowLeft' | 'ArrowRight', + shiftKey: boolean, + selection: DOMSelection | null, + formatCalled: boolean + ) { + formatContentModelSpy.and.callFake((callback: Function) => { + const result = callback(model); + + expect(result).toBeFalse(); + }); + getDOMSelectionSpy.and.returnValue(selection); + + adjustSelectionAroundEntity(editor, key, shiftKey); + + if (formatCalled) { + expect(formatContentModelSpy).toHaveBeenCalled(); + } else { + expect(formatContentModelSpy).not.toHaveBeenCalled(); + } + } + + it('Editor is disposed', () => { + isDisposedSpy.and.returnValue(true); + + runTest(null!, 'ArrowLeft', false, null, false); + }); + + it('Empty model, no selection, Left, no shift', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [], + }, + 'ArrowLeft', + false, + null, + false + ); + }); + + it('Model has no entity, no selection, Left, no shift', () => { + const marker = createSelectionMarker(); + const para = createParagraph(); + const doc = createContentModelDocument(); + + para.segments.push(marker); + doc.blocks.push(para); + + runTest(doc, 'ArrowLeft', false, null, false); + }); + + it('Model has inline entity, selection is before delimiter, Right, no shift', () => { + const doc = createContentModelDocument(); + + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + addEntity(root, false); + + runTest( + doc, + 'ArrowRight', + false, + { + type: 'range', + range: { + endContainer: text, + endOffset: 2, + } as any, + isReverted: false, + }, + false + ); + }); + + it('Model has inline entity, selection is after delimiter, Left, no shift', () => { + const doc = createContentModelDocument(); + + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + addEntity(root, false); + root.appendChild(text); + + runTest( + doc, + 'ArrowLeft', + false, + { + type: 'range', + range: { + endContainer: text, + endOffset: 2, + } as any, + isReverted: false, + }, + false + ); + }); + + it('Model has inline entity, selection is on delimiter 1, Right, no shift', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter1.firstChild, + endOffset: 1, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', false, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on delimiter 2, Left, no shift', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', false, selection, true); + + expect(setStartBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on delimiter 1, Shift+Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter1.firstChild, + endOffset: 1, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on delimiter 1, Shift+Right, reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 1, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: true, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on delimiter 2, Shift+Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter2 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on delimiter 2, Shift+Left, reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter2.firstChild, + startOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, true); + + expect(setStartBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: true, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on entity, Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest( + doc, + 'ArrowLeft', + false, + { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }, + true + ); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Left, reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest( + doc, + 'ArrowLeft', + false, + { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }, + true + ); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest( + doc, + 'ArrowRight', + false, + { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }, + false + ); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Right, reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest( + doc, + 'ArrowRight', + false, + { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }, + false + ); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Shift+Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on entity, Shift+Left, Reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Shift+Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Shift+Right, Reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on text and entity, Shift+Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: text, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on text and entity, Shift+Left, Reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 1, + endContainer: text, + endOffset: 4, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on text and entity, Shift+Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: text, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on text and entity, Shift+Right, Reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 1, + endContainer: text, + endOffset: 4, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: true, + }); + }); + + it('Model has block entity, selection is on delimiter 1, Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, true); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter1.firstChild, + endOffset: 1, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', false, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); + + it('Model has block entity, selection is on delimiter 2, Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, true); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', false, selection, true); + + expect(setStartBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setEndAfterSpy).not.toHaveBeenCalledWith(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); +}); + +function addEntity( + parent: HTMLElement, + isBlock: boolean +): { delimiter1: HTMLElement; delimiter2: HTMLElement; container: HTMLElement | null } { + function addDelimiter(parent: HTMLElement, isBefore: boolean) { + const span = document.createElement('span'); + span.className = isBefore ? 'entityDelimiterBefore' : 'entityDelimiterAfter'; + span.textContent = '\u200B'; + parent.appendChild(span); + + return span; + } + + const wrapper = document.createElement(isBlock ? 'div' : 'span'); + wrapper.className = '_Entity _EType_A _EReadonly_1'; + + if (isBlock) { + wrapper.style.display = 'inline-block'; + wrapper.style.width = '100%'; + + const container = document.createElement('div'); + + container.className = '_E_EBlockEntityContainer'; + parent.appendChild(container); + + const delimiter1 = addDelimiter(container, true); + + container.appendChild(wrapper); + + const delimiter2 = addDelimiter(container, false); + + return { delimiter1, delimiter2, container }; + } else { + const delimiter1 = addDelimiter(parent, true); + + parent.appendChild(wrapper); + + const delimiter2 = addDelimiter(parent, false); + + return { delimiter1, delimiter2, container: null }; + } +} diff --git a/packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts similarity index 93% rename from packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts index 4424b8a44d1..aa40486771e 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts @@ -1,3 +1,4 @@ +import * as adjustSelectionAroundEntity from '../../../lib/corePlugin/entity/adjustSelectionAroundEntity'; import * as DelimiterFile from '../../../lib/corePlugin/entity/entityDelimiterUtils'; import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; import * as isNodeOfType from 'roosterjs-content-model-dom/lib/domUtils/isNodeOfType'; @@ -19,7 +20,10 @@ describe('EntityDelimiterUtils |', () => { let queryElementsSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let mockedEditor: any; + beforeEach(() => { + queryElementsSpy = jasmine.createSpy('queryElement'); + mockedEditor = ({ getDOMHelper: () => ({ queryElements: queryElementsSpy, @@ -59,9 +63,7 @@ describe('EntityDelimiterUtils |', () => { addDelimiterForEntity: true, }) ); - queryElementsSpy = jasmine - .createSpy('queryElement') - .and.callFake(sel => div.querySelectorAll(sel)); + queryElementsSpy.and.callFake(sel => div.querySelectorAll(sel)); entityWrapper.remove(); @@ -98,9 +100,7 @@ describe('EntityDelimiterUtils |', () => { }, createModelToDomContext({}) ); - queryElementsSpy = jasmine - .createSpy('queryElement') - .and.callFake(sel => div.querySelectorAll(sel)); + queryElementsSpy.and.callFake(sel => div.querySelectorAll(sel)); handleDelimiterContentChangedEvent(mockedEditor); @@ -137,9 +137,7 @@ describe('EntityDelimiterUtils |', () => { addDelimiterForEntity: true, }) ); - queryElementsSpy = jasmine - .createSpy('queryElement') - .and.callFake(sel => div.querySelectorAll(sel)); + queryElementsSpy.and.callFake(sel => div.querySelectorAll(sel)); const invalidDelimiter = entityWrapper.previousElementSibling; invalidDelimiter?.appendChild(document.createTextNode('_')); @@ -159,6 +157,7 @@ describe('EntityDelimiterUtils |', () => { let rafSpy: jasmine.Spy; let takeSnapshotSpy: jasmine.Spy; let triggerEventSpy: jasmine.Spy; + let findClosestElementAncestorSpy: jasmine.Spy; beforeEach(() => { mockedSelection = undefined!; @@ -166,6 +165,9 @@ describe('EntityDelimiterUtils |', () => { formatContentModelSpy = jasmine.createSpy('formatContentModel'); takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); triggerEventSpy = jasmine.createSpy('triggerEvent'); + findClosestElementAncestorSpy = jasmine + .createSpy('findClosestElementAncestor') + .and.callFake((node: HTMLElement, selector: string) => node.closest(selector)); mockedEditor = ({ getDOMSelection: () => mockedSelection, @@ -179,6 +181,7 @@ describe('EntityDelimiterUtils |', () => { getDOMHelper: () => ({ queryElements: queryElementsSpy, isNodeInEditor: () => true, + findClosestElementAncestor: findClosestElementAncestorSpy, }), triggerEvent: triggerEventSpy, takeSnapshot: takeSnapshotSpy, @@ -631,6 +634,80 @@ describe('EntityDelimiterUtils |', () => { rawEvent: mockedEvent, }); }); + + it('Handle, range selection | ArrowLeft Key', () => { + mockedSelection = { + type: 'range', + range: { + collapsed: true, + }, + isReverted: false, + }; + spyOn(mockedEditor, 'getDOMSelection').and.returnValue({ + type: 'range', + range: mockedSelection.range, + }); + const adjustSelectionAroundEntitySpy = spyOn( + adjustSelectionAroundEntity, + 'adjustSelectionAroundEntity' + ); + + const mockedEvent = { + ctrlKey: false, + altKey: false, + metaKey: false, + shiftKey: false, + key: 'ArrowLeft', + }; + + rafSpy.and.callFake((callback: Function) => callback()); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: mockedEvent, + }); + + expect(adjustSelectionAroundEntitySpy).toHaveBeenCalledWith( + mockedEditor, + 'ArrowLeft', + false + ); + }); + + it('Do not Handle, range selection | Ctrl+ArrowLeft Key', () => { + mockedSelection = { + type: 'range', + range: { + collapsed: true, + }, + isReverted: false, + }; + spyOn(mockedEditor, 'getDOMSelection').and.returnValue({ + type: 'range', + range: mockedSelection.range, + }); + const adjustSelectionAroundEntitySpy = spyOn( + adjustSelectionAroundEntity, + 'adjustSelectionAroundEntity' + ); + + const mockedEvent = { + ctrlKey: true, + altKey: false, + metaKey: false, + shiftKey: false, + key: 'ArrowLeft', + }; + + rafSpy.and.callFake((callback: Function) => callback()); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: mockedEvent, + }); + + expect(adjustSelectionAroundEntitySpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts index c71bd1463be..f687a441fa5 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts @@ -21,6 +21,7 @@ describe('LifecyclePlugin', () => { isDarkMode: false, shadowEditFragment: null, styleElements: {}, + announcerStringGetter: undefined, }); expect(div.isContentEditable).toBeTrue(); @@ -35,6 +36,7 @@ describe('LifecyclePlugin', () => { it('init with options', () => { const mockedModel = 'MODEL' as any; + const mockedAnnouncerStringGetter = 'ANNOUNCE' as any; const div = document.createElement('div'); const plugin = createLifecyclePlugin( { @@ -42,6 +44,7 @@ describe('LifecyclePlugin', () => { fontFamily: 'arial', }, initialModel: mockedModel, + announcerStringGetter: mockedAnnouncerStringGetter, }, div ); @@ -59,6 +62,7 @@ describe('LifecyclePlugin', () => { isDarkMode: false, shadowEditFragment: null, styleElements: {}, + announcerStringGetter: mockedAnnouncerStringGetter, }); expect(div.isContentEditable).toBeTrue(); @@ -134,6 +138,7 @@ describe('LifecyclePlugin', () => { isDarkMode: false, shadowEditFragment: null, styleElements: {}, + announcerStringGetter: undefined, }); plugin.onPluginEvent({ @@ -165,6 +170,7 @@ describe('LifecyclePlugin', () => { isDarkMode: false, shadowEditFragment: null, styleElements: {}, + announcerStringGetter: undefined, }); const mockedIsDarkColor = 'Dark' as any; @@ -218,6 +224,7 @@ describe('LifecyclePlugin', () => { isDarkMode: false, shadowEditFragment: null, styleElements: {}, + announcerStringGetter: undefined, }); const mockedIsDarkColor = 'Dark' as any; @@ -249,16 +256,29 @@ describe('LifecyclePlugin', () => { }, } as any; + const removeChildSpy2 = jasmine.createSpy('removeChild'); + const mockedContainer = { + parentElement: { + removeChild: removeChildSpy2, + }, + } as any; + state.styleElements.a = style; + state.announceContainer = mockedContainer; plugin.dispose(); expect(removeChildSpy).toHaveBeenCalledTimes(1); expect(removeChildSpy).toHaveBeenCalledWith(style); + + expect(removeChildSpy2).toHaveBeenCalledTimes(1); + expect(removeChildSpy2).toHaveBeenCalledWith(mockedContainer); + expect(state).toEqual({ styleElements: {}, isDarkMode: false, shadowEditFragment: null, + announcerStringGetter: undefined, }); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 4b3501d1093..cc1adf1e727 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -698,6 +698,7 @@ describe('SelectionPlugin handle table selection', () => { let requestAnimationFrameSpy: jasmine.Spy; let getComputedStyleSpy: jasmine.Spy; let addEventListenerSpy: jasmine.Spy; + let announceSpy: jasmine.Spy; beforeEach(() => { contentDiv = document.createElement('div'); @@ -707,6 +708,7 @@ describe('SelectionPlugin handle table selection', () => { requestAnimationFrameSpy = jasmine.createSpy('requestAnimationFrame'); getComputedStyleSpy = jasmine.createSpy('getComputedStyle'); addEventListenerSpy = jasmine.createSpy('addEventListener'); + announceSpy = jasmine.createSpy('announce'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ createRange: createRangeSpy, defaultView: { @@ -735,6 +737,7 @@ describe('SelectionPlugin handle table selection', () => { return focusDisposer; } }, + announce: announceSpy, } as any; plugin = createSelectionPlugin({}); plugin.initialize(editor); @@ -1205,6 +1208,7 @@ describe('SelectionPlugin handle table selection', () => { imageSelectionBorderColor: undefined, }); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, Press Right', () => { @@ -1245,6 +1249,7 @@ describe('SelectionPlugin handle table selection', () => { imageSelectionBorderColor: undefined, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, Press Tab', () => { @@ -1306,6 +1311,7 @@ describe('SelectionPlugin handle table selection', () => { isReverted: false, }); expect(setStartSpy).toHaveBeenCalledWith(td2, 0); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, Press Shift+Tab', () => { @@ -1368,6 +1374,7 @@ describe('SelectionPlugin handle table selection', () => { isReverted: false, }); expect(setStartSpy).toHaveBeenCalledWith(td1, 0); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, Press Tab - Next Row', () => { @@ -1429,6 +1436,7 @@ describe('SelectionPlugin handle table selection', () => { isReverted: false, }); expect(setStartSpy).toHaveBeenCalledWith(td3, 0); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, First cell - Press Shift+Tab', () => { @@ -1491,6 +1499,7 @@ describe('SelectionPlugin handle table selection', () => { isReverted: false, }); expect(setStartSpy).toHaveBeenCalledWith(table.parentNode, 0); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, Last cell - Press Tab', () => { @@ -1552,6 +1561,7 @@ describe('SelectionPlugin handle table selection', () => { isReverted: false, }); expect(setStartSpy).toHaveBeenCalledWith(table.parentNode, 1); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, Press Down', () => { @@ -1613,6 +1623,9 @@ describe('SelectionPlugin handle table selection', () => { isReverted: false, }); expect(setStartSpy).toHaveBeenCalledWith(td4, 0); + expect(announceSpy).toHaveBeenCalledWith({ + defaultStrings: 'announceOnFocusLastCell', + }); }); it('From Range, Press Shift+Up', () => { @@ -1677,6 +1690,7 @@ describe('SelectionPlugin handle table selection', () => { lastRow: 0, lastColumn: 1, }); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, Press Shift+Down', () => { @@ -1741,6 +1755,7 @@ describe('SelectionPlugin handle table selection', () => { lastRow: 1, lastColumn: 1, }); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Range, Press Shift+Down to ouside of table', () => { @@ -1796,6 +1811,7 @@ describe('SelectionPlugin handle table selection', () => { imageSelectionBorderColor: undefined, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Table, Press A', () => { @@ -1840,6 +1856,7 @@ describe('SelectionPlugin handle table selection', () => { }); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Table, Press Left', () => { @@ -1893,6 +1910,7 @@ describe('SelectionPlugin handle table selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Table, Press Shift+Left', () => { @@ -1948,6 +1966,7 @@ describe('SelectionPlugin handle table selection', () => { lastColumn: 0, }); expect(preventDefaultSpy).toHaveBeenCalled(); + expect(announceSpy).not.toHaveBeenCalled(); }); it('From Table, Press Shift+Up', () => { @@ -2003,6 +2022,7 @@ describe('SelectionPlugin handle table selection', () => { lastColumn: 1, }); expect(preventDefaultSpy).toHaveBeenCalled(); + expect(announceSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index e2bfafff8ed..928afd110d7 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -1086,6 +1086,37 @@ describe('Editor', () => { editor.dispose(); expect(resetSpy).toHaveBeenCalledWith(); - expect(() => editor.getVisibleViewport()).toThrow(); + expect(() => editor.setEditorStyle('key', 'rule', ['rule1', 'rule2'])).toThrow(); + }); + + it('announce', () => { + const div = document.createElement('div'); + const resetSpy = jasmine.createSpy('reset'); + const announceSpy = jasmine.createSpy('announce'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { + setContentModel: setContentModelSpy, + announce: announceSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new Editor(div); + + const mockedData = 'ANNOUNCE' as any; + + editor.announce(mockedData); + + expect(announceSpy).toHaveBeenCalledWith(mockedCore, mockedData); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.announce(mockedData)).toThrow(); }); }); diff --git a/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts index 7b370d4cfad..2bc51a43872 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts @@ -239,4 +239,84 @@ describe('DOMHelperImpl', () => { expect(result).toBe(false); }); }); + + describe('isRightToLeft', () => { + let div: HTMLDivElement; + let getComputedStyleSpy: jasmine.Spy; + + beforeEach(() => { + getComputedStyleSpy = jasmine.createSpy('getComputedStyle'); + + div = { + ownerDocument: { + defaultView: { + getComputedStyle: getComputedStyleSpy, + }, + }, + } as any; + }); + + it('LTR', () => { + const domHelper = createDOMHelper(div); + + getComputedStyleSpy.and.returnValue({ + direction: 'ltr', + }); + + const result = domHelper.isRightToLeft(); + + expect(getComputedStyleSpy).toHaveBeenCalledWith(div); + expect(result).toBeFalse(); + }); + + it('RTL', () => { + const domHelper = createDOMHelper(div); + + getComputedStyleSpy.and.returnValue({ + direction: 'rtl', + }); + + const result = domHelper.isRightToLeft(); + + expect(getComputedStyleSpy).toHaveBeenCalledWith(div); + expect(result).toBeTrue(); + }); + }); + + describe('getClientWidth', () => { + let div: HTMLDivElement; + let getComputedStyleSpy: jasmine.Spy; + + beforeEach(() => { + getComputedStyleSpy = jasmine.createSpy('getComputedStyle'); + + div = { + ownerDocument: { + defaultView: { + getComputedStyle: getComputedStyleSpy, + }, + }, + clientWidth: 1000, + } as any; + }); + + it('getClientWidth', () => { + const domHelper = createDOMHelper(div); + + getComputedStyleSpy.and.returnValue({ + paddingLeft: '10px', + paddingRight: '10px', + }); + + expect(domHelper.getClientWidth()).toBe(980); + }); + + it('getClientWidth', () => { + const domHelper = createDOMHelper(div); + + getComputedStyleSpy.and.returnValue({}); + + expect(domHelper.getClientWidth()).toBe(1000); + }); + }); }); diff --git a/packages/roosterjs-content-model-dom/lib/constants/ChangeSource.ts b/packages/roosterjs-content-model-dom/lib/constants/ChangeSource.ts index 7744e04c4f4..1b6436f913d 100644 --- a/packages/roosterjs-content-model-dom/lib/constants/ChangeSource.ts +++ b/packages/roosterjs-content-model-dom/lib/constants/ChangeSource.ts @@ -56,4 +56,9 @@ export const ChangeSource = { * Data of this event will be the key code number */ Keyboard: 'Keyboard', + + /** + * Content changed by auto format + */ + AutoFormat: 'AutoFormat', }; diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts b/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts index 7ccaddfb2b7..eacb390f507 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts @@ -14,6 +14,10 @@ export function domToContentModel( ): ContentModelDocument { const model = createContentModelDocument(context.defaultFormat); + if (context.selection?.type == 'range' && context.selection.isReverted) { + model.hasRevertedRangeSelection = true; + } + context.elementProcessors.child(model, root, context); normalizeContentModel(model); diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts b/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts index 2ad9159da11..bca216c8496 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -10,12 +10,15 @@ import type { } from 'roosterjs-content-model-types'; const ENTITY_INFO_NAME = '_Entity'; +const ENTITY_INFO_SELECTOR = '.' + ENTITY_INFO_NAME; const ENTITY_TYPE_PREFIX = '_EType_'; const ENTITY_ID_PREFIX = '_EId_'; const ENTITY_READONLY_PREFIX = '_EReadonly_'; const ZERO_WIDTH_SPACE = '\u200B'; const DELIMITER_BEFORE = 'entityDelimiterBefore'; const DELIMITER_AFTER = 'entityDelimiterAfter'; +const BLOCK_ENTITY_CONTAINER = '_E_EBlockEntityContainer'; +const BLOCK_ENTITY_CONTAINER_SELECTOR = '.' + BLOCK_ENTITY_CONTAINER; /** * Check if the given DOM Node is an entity wrapper element @@ -33,7 +36,20 @@ export function findClosestEntityWrapper( startNode: Node, domHelper: DOMHelper ): HTMLElement | null { - return domHelper.findClosestElementAncestor(startNode, `.${ENTITY_INFO_NAME}`); + return domHelper.findClosestElementAncestor(startNode, ENTITY_INFO_SELECTOR); +} + +/** + * Find the closest block entity wrapper element from a given DOM node + * @param node The node to start looking for entity container + * @param domHelper The DOM helper + * @returns + */ +export function findClosestBlockEntityContainer( + node: Node, + domHelper: DOMHelper +): HTMLElement | null { + return domHelper.findClosestElementAncestor(node, BLOCK_ENTITY_CONTAINER_SELECTOR); } /** @@ -102,17 +118,30 @@ export function generateEntityClassNames(format: ContentModelEntityFormat): stri /** * Checks whether the node provided is a Entity delimiter * @param node the node to check + * @param isBefore True to match delimiter before entity only, false to match delimiter after entity, or undefined means match both * @return true if it is a delimiter */ -export function isEntityDelimiter(element: HTMLElement): boolean { +export function isEntityDelimiter(element: HTMLElement, isBefore?: boolean): boolean { + const matchBefore = isBefore === undefined || isBefore; + const matchAfter = isBefore === undefined || !isBefore; + return ( isElementOfType(element, 'span') && - (element.classList.contains(DELIMITER_AFTER) || - element.classList.contains(DELIMITER_BEFORE)) && + ((matchAfter && element.classList.contains(DELIMITER_AFTER)) || + (matchBefore && element.classList.contains(DELIMITER_BEFORE))) && element.textContent === ZERO_WIDTH_SPACE ); } +/** + * Check if the given element is a container element of block entity + * @param element The element to check + * @returns True if the element is a block entity container, otherwise false + */ +export function isBlockEntityContainer(element: HTMLElement): boolean { + return isElementOfType(element, 'div') && element.classList.contains(BLOCK_ENTITY_CONTAINER); +} + /** * Adds delimiters to the element provided. If the delimiters already exists, will not be added * @param element the node to add the delimiters diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index b35a5aa5ac0..d2f8789135a 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -30,6 +30,8 @@ export { generateEntityClassNames, addDelimiters, isEntityDelimiter, + isBlockEntityContainer, + findClosestBlockEntityContainer, } from './domUtils/entityUtils'; export { reuseCachedElement } from './domUtils/reuseCachedElement'; export { isWhiteSpacePreserved } from './domUtils/isWhiteSpacePreserved'; @@ -67,6 +69,8 @@ export { isEmpty } from './modelApi/common/isEmpty'; export { normalizeSingleSegment } from './modelApi/common/normalizeSegment'; export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplicit'; +export { getOrderedListNumberStr } from './modelApi/list/getOrderedListNumberStr'; +export { getAutoListStyleType } from './modelApi/list/getAutoListStyleType'; export { parseValueWithUnit } from './formatHandlers/utils/parseValueWithUnit'; export { BorderKeys } from './formatHandlers/common/borderFormatHandler'; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/list/getAutoListStyleType.ts b/packages/roosterjs-content-model-dom/lib/modelApi/list/getAutoListStyleType.ts new file mode 100644 index 00000000000..74d867520d2 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelApi/list/getAutoListStyleType.ts @@ -0,0 +1,67 @@ +import { BulletListType } from '../../constants/BulletListType'; +import { NumberingListType } from '../../constants/NumberingListType'; +import type { ListMetadataFormat } from 'roosterjs-content-model-types'; + +const DefaultOrderedListStyles = [ + NumberingListType.Decimal, + NumberingListType.LowerAlpha, + NumberingListType.LowerRoman, +]; +const DefaultUnorderedListStyles = [ + BulletListType.Disc, + BulletListType.Circle, + BulletListType.Square, +]; +const OrderedListStyleRevertMap: Record = { + 'lower-alpha': NumberingListType.LowerAlpha, + 'lower-latin': NumberingListType.LowerAlpha, + 'upper-alpha': NumberingListType.UpperAlpha, + 'upper-latin': NumberingListType.UpperAlpha, + 'lower-roman': NumberingListType.LowerRoman, + 'upper-roman': NumberingListType.UpperRoman, +}; +const UnorderedListStyleRevertMap: Record = { + disc: BulletListType.Disc, + circle: BulletListType.Circle, + square: BulletListType.Square, +}; + +/** + * Get automatic list style of a list item according to its lis type and metadata. + * @param listType The list type, either OL or UL + * @param metadata Metadata of this list item from list item model + * @param depth Depth of list level, start from 0 + * @param existingStyleType Existing list style type in format, if any + * @returns A number to represent list style type. + * This will be the value of either NumberingListType (when listType is OL) or BulletListType (when listType is UL). + * When there is a specified list style in its metadata, return this value, otherwise + * When specified "applyListStyleFromLevel" in metadata, calculate auto list type from its depth, otherwise + * When there is already listStyleType in list level format, find a related style type index, otherwise + * return undefined + */ +export function getAutoListStyleType( + listType: 'OL' | 'UL', + metadata: ListMetadataFormat, + depth: number, + existingStyleType?: string +): number | undefined { + const { orderedStyleType, unorderedStyleType, applyListStyleFromLevel } = metadata; + + if (listType == 'OL') { + return typeof orderedStyleType == 'number' + ? orderedStyleType + : applyListStyleFromLevel + ? DefaultOrderedListStyles[depth % DefaultOrderedListStyles.length] + : existingStyleType + ? OrderedListStyleRevertMap[existingStyleType] + : undefined; + } else { + return typeof unorderedStyleType == 'number' + ? unorderedStyleType + : applyListStyleFromLevel + ? DefaultUnorderedListStyles[depth % DefaultUnorderedListStyles.length] + : existingStyleType + ? UnorderedListStyleRevertMap[existingStyleType] + : undefined; + } +} diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/list/getOrderedListNumberStr.ts b/packages/roosterjs-content-model-dom/lib/modelApi/list/getOrderedListNumberStr.ts new file mode 100644 index 00000000000..f8a6778b224 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelApi/list/getOrderedListNumberStr.ts @@ -0,0 +1,78 @@ +import { getObjectKeys } from '../../domUtils/getObjectKeys'; +import { NumberingListType } from '../../constants/NumberingListType'; + +const CharCodeOfA = 65; +const RomanValues: Record = { + M: 1000, + CM: 900, + D: 500, + CD: 400, + C: 100, + XC: 90, + L: 50, + XL: 40, + X: 10, + IX: 9, + V: 5, + IV: 4, + I: 1, +}; + +/** + * Get the list number for a list item according to list style type and its index number + * @param styleType The list style number, should be a value of NumberingListType type + * @param listNumber List number, start from 1 + * @returns A string for this list item. For example, when pass in NumberingListType.LowerAlpha and 2, it returns "b" + */ +export function getOrderedListNumberStr(styleType: number, listNumber: number): string { + switch (styleType) { + case NumberingListType.LowerAlpha: + case NumberingListType.LowerAlphaDash: + case NumberingListType.LowerAlphaDoubleParenthesis: + case NumberingListType.LowerAlphaParenthesis: + return convertDecimalsToAlpha(listNumber, true /*isLowerCase*/); + + case NumberingListType.UpperAlpha: + case NumberingListType.UpperAlphaDash: + case NumberingListType.UpperAlphaDoubleParenthesis: + case NumberingListType.UpperAlphaParenthesis: + return convertDecimalsToAlpha(listNumber, false /*isLowerCase*/); + + case NumberingListType.LowerRoman: + case NumberingListType.LowerRomanDash: + case NumberingListType.LowerRomanDoubleParenthesis: + case NumberingListType.LowerRomanParenthesis: + return convertDecimalsToRoman(listNumber, true /*isLowerCase*/); + + case NumberingListType.UpperRoman: + case NumberingListType.UpperRomanDash: + case NumberingListType.UpperRomanDoubleParenthesis: + case NumberingListType.UpperRomanParenthesis: + return convertDecimalsToRoman(listNumber, false /*isLowerCase*/); + + default: + return listNumber + ''; + } +} + +function convertDecimalsToAlpha(decimal: number, isLowerCase?: boolean): string { + let alpha = ''; + decimal--; + + while (decimal >= 0) { + alpha = String.fromCharCode((decimal % 26) + CharCodeOfA) + alpha; + decimal = Math.floor(decimal / 26) - 1; + } + return isLowerCase ? alpha.toLowerCase() : alpha; +} + +function convertDecimalsToRoman(decimal: number, isLowerCase?: boolean) { + let romanValue = ''; + + for (const i of getObjectKeys(RomanValues)) { + const timesRomanCharAppear = Math.floor(decimal / RomanValues[i]); + decimal = decimal - timesRomanCharAppear * RomanValues[i]; + romanValue = romanValue + i.repeat(timesRomanCharAppear); + } + return isLowerCase ? romanValue.toLocaleLowerCase() : romanValue; +} diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateListMetadata.ts b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateListMetadata.ts index 7bfc8639de2..622990f48dc 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateListMetadata.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/metadata/updateListMetadata.ts @@ -6,12 +6,18 @@ import { createNumberDefinition, createObjectDefinition, } from './definitionCreators'; -import type { ContentModelWithDataset, ListMetadataFormat } from 'roosterjs-content-model-types'; +import type { + ContentModelWithDataset, + ListMetadataFormat, + ObjectDefinition, +} from 'roosterjs-content-model-types'; /** * Metadata definition for List */ -export const ListMetadataDefinition = createObjectDefinition( +export const ListMetadataDefinition: ObjectDefinition = createObjectDefinition< + ListMetadataFormat +>( { orderedStyleType: createNumberDefinition( true /** isOptional */, diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts index 91b4d2b307a..eeb023bb2c1 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts @@ -23,18 +23,27 @@ import type { */ export function getSelectedSegmentsAndParagraphs( model: ContentModelDocument, - includingFormatHolder: boolean -): [ContentModelSegment, ContentModelParagraph | null][] { + includingFormatHolder: boolean, + includingEntity?: boolean +): [ContentModelSegment, ContentModelParagraph | null, ContentModelBlockGroup[]][] { const selections = collectSelections(model, { includeListFormatHolder: includingFormatHolder ? 'allSegments' : 'never', }); - const result: [ContentModelSegment, ContentModelParagraph | null][] = []; + const result: [ + ContentModelSegment, + ContentModelParagraph | null, + ContentModelBlockGroup[] + ][] = []; - selections.forEach(({ segments, block }) => { + selections.forEach(({ segments, block, path }) => { if (segments && ((includingFormatHolder && !block) || block?.blockType == 'Paragraph')) { segments.forEach(segment => { - if (segment.segmentType != 'Entity' || !segment.entityFormat.isReadonly) { - result.push([segment, block?.blockType == 'Paragraph' ? block : null]); + if ( + includingEntity || + segment.segmentType != 'Entity' || + !segment.entityFormat.isReadonly + ) { + result.push([segment, block?.blockType == 'Paragraph' ? block : null, path]); } }); } @@ -76,20 +85,20 @@ export function getSelectedParagraphs(model: ContentModelDocument): ContentModel /** * Get an array of block group - block pair that is of the expected block group type from selection - * @param model The Content Model to get selection from + * @param group The root block group to search * @param blockGroupTypes The expected block group types * @param stopTypes Block group types that will stop searching when hit * @param deepFirst True means search in deep first, otherwise wide first */ export function getOperationalBlocks( - model: ContentModelDocument, + group: ContentModelBlockGroup, blockGroupTypes: TypeOfBlockGroup[], stopTypes: ContentModelBlockGroupType[], deepFirst?: boolean ): OperationalBlocks[] { const result: OperationalBlocks[] = []; const findSequence = deepFirst ? blockGroupTypes.map(type => [type]) : [blockGroupTypes]; - const selections = collectSelections(model, { + const selections = collectSelections(group, { includeListFormatHolder: 'never', contentUnderSelectedTableCell: 'ignoreForTable', // When whole table is selected, we treat the table as a single block }); @@ -182,13 +191,13 @@ interface SelectionInfo { } function collectSelections( - model: ContentModelDocument, + group: ContentModelBlockGroup, option?: IterateSelectionsOption ): SelectionInfo[] { const selections: SelectionInfo[] = []; iterateSelections( - model, + group, (path, tableContext, block, segments) => { selections.push({ path, diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 2a5e836f6bb..54bb520d0c7 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -27,6 +27,10 @@ export function contentModelToDom( const range = extractSelectionRange(doc, context); + if (model.hasRevertedRangeSelection && range?.type == 'range') { + range.isReverted = true; + } + root.normalize(); return range; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts index 399a2e138c5..bfb14652f51 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts @@ -1,6 +1,6 @@ import * as normalizeContentModel from '../../lib/modelApi/common/normalizeContentModel'; -import { domToContentModel } from '../../lib/domToModel/domToContentModel'; import { ContentModelDocument, DomToModelContext } from 'roosterjs-content-model-types'; +import { domToContentModel } from '../../lib/domToModel/domToContentModel'; describe('domToContentModel', () => { it('Not include root', () => { @@ -38,4 +38,47 @@ describe('domToContentModel', () => { expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledTimes(1); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(result); }); + + it('With reverted selection', () => { + const elementProcessor = jasmine.createSpy('elementProcessor'); + const childProcessor = jasmine.createSpy('childProcessor'); + const mockedRange = 'RANGE' as any; + const mockContext: DomToModelContext = { + elementProcessors: { + element: elementProcessor, + child: childProcessor, + }, + defaultStyles: {}, + segmentFormat: {}, + isDarkMode: false, + defaultFormat: { + fontSize: '10pt', + }, + selection: { + type: 'range', + range: mockedRange, + isReverted: true, + }, + } as any; + + spyOn(normalizeContentModel, 'normalizeContentModel'); + + const rootElement = document.createElement('div'); + const model = domToContentModel(rootElement, mockContext); + const result: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [], + format: { + fontSize: '10pt', + }, + hasRevertedRangeSelection: true, + }; + + expect(model).toEqual(result); + expect(elementProcessor).not.toHaveBeenCalled(); + expect(childProcessor).toHaveBeenCalledTimes(1); + expect(childProcessor).toHaveBeenCalledWith(result, rootElement, mockContext); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledTimes(1); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(result); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts index 22a8d40d02c..c7d0c335acf 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts @@ -1,9 +1,12 @@ import { ContentModelEntityFormat } from 'roosterjs-content-model-types'; +import { createDOMHelper } from 'roosterjs-content-model-core/lib/editor/core/DOMHelperImpl'; import { addDelimiters, + findClosestBlockEntityContainer, findClosestEntityWrapper, generateEntityClassNames, getAllEntityWrappers, + isBlockEntityContainer, isEntityDelimiter, isEntityElement, parseEntityFormat, @@ -287,9 +290,7 @@ describe('findClosestEntityWrapper', () => { div.appendChild(span); - const result = findClosestEntityWrapper(span, { - findClosestElementAncestor: (): null => null, - } as any); + const result = findClosestEntityWrapper(span, createDOMHelper(div)); expect(result).toBeNull(); }); @@ -299,13 +300,71 @@ describe('findClosestEntityWrapper', () => { const span = document.createElement('span'); const wrapper = document.createElement('div'); + wrapper.className = '_Entity'; + div.appendChild(wrapper); wrapper.appendChild(span); - const result = findClosestEntityWrapper(span, { - findClosestElementAncestor: (): HTMLElement => wrapper, - } as any); + const result = findClosestEntityWrapper(span, createDOMHelper(div)); expect(result).toBe(wrapper); }); }); + +describe('findClosestBlockEntityContainer', () => { + it('no container', () => { + const div = document.createElement('div'); + const span = document.createElement('span'); + + div.appendChild(span); + + const result = findClosestBlockEntityContainer(span, createDOMHelper(div)); + + expect(result).toBeNull(); + }); + + it('has container', () => { + const div = document.createElement('div'); + const container = document.createElement('div'); + const wrapper = document.createElement('div'); + + container.className = '_E_EBlockEntityContainer'; + + div.appendChild(container); + container.appendChild(wrapper); + + const result = findClosestBlockEntityContainer(wrapper, createDOMHelper(div)); + + expect(result).toBe(container); + }); +}); + +describe('isBlockEntityContainer', () => { + it('DIV without container class', () => { + const div = document.createElement('div'); + + const result = isBlockEntityContainer(div); + + expect(result).toBeFalse(); + }); + + it('SPAN with container class', () => { + const span = document.createElement('span'); + + span.className = '_E_EBlockEntityContainer'; + + const result = isBlockEntityContainer(span); + + expect(result).toBeFalse(); + }); + + it('DIV with container class', () => { + const div = document.createElement('div'); + + div.className = '_E_EBlockEntityContainer'; + + const result = isBlockEntityContainer(div); + + expect(result).toBeTrue(); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/list/getAutoListStyleTypeTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/list/getAutoListStyleTypeTest.ts new file mode 100644 index 00000000000..1be23a7c0bf --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/modelApi/list/getAutoListStyleTypeTest.ts @@ -0,0 +1,117 @@ +import { BulletListType } from '../../../lib/constants/BulletListType'; +import { getAutoListStyleType } from '../../../lib/modelApi/list/getAutoListStyleType'; +import { NumberingListType } from '../../../lib/constants/NumberingListType'; + +describe('getAutoListStyleType', () => { + it('ul, no styleType, no auto apply, no existing style', () => { + expect(getAutoListStyleType('UL', {}, 0)).toBe(undefined); + expect(getAutoListStyleType('UL', {}, 1)).toBe(undefined); + expect(getAutoListStyleType('UL', {}, 2)).toBe(undefined); + }); + + it('ul, no styleType, no auto apply, has existing style', () => { + expect(getAutoListStyleType('UL', {}, 0, 'disc')).toBe(BulletListType.Disc); + expect(getAutoListStyleType('UL', {}, 0, 'circle')).toBe(BulletListType.Circle); + expect(getAutoListStyleType('UL', {}, 0, 'square')).toBe(BulletListType.Square); + expect(getAutoListStyleType('UL', {}, 0, 'other')).toBe(undefined); + }); + + it('ul, no styleType, has auto apply', () => { + expect(getAutoListStyleType('UL', { applyListStyleFromLevel: true }, 0)).toBe( + BulletListType.Disc + ); + expect(getAutoListStyleType('UL', { applyListStyleFromLevel: true }, 1)).toBe( + BulletListType.Circle + ); + expect(getAutoListStyleType('UL', { applyListStyleFromLevel: true }, 2)).toBe( + BulletListType.Square + ); + expect(getAutoListStyleType('UL', { applyListStyleFromLevel: true }, 3)).toBe( + BulletListType.Disc + ); + expect(getAutoListStyleType('UL', { applyListStyleFromLevel: true }, 2, 'other')).toBe( + BulletListType.Square + ); + }); + + it('ul, has styleType', () => { + expect(getAutoListStyleType('UL', { unorderedStyleType: BulletListType.Circle }, 0)).toBe( + BulletListType.Circle + ); + expect(getAutoListStyleType('UL', { unorderedStyleType: BulletListType.Dash }, 0)).toBe( + BulletListType.Dash + ); + expect( + getAutoListStyleType('UL', { unorderedStyleType: BulletListType.LongArrow }, 0) + ).toBe(BulletListType.LongArrow); + + expect( + getAutoListStyleType( + 'UL', + { unorderedStyleType: BulletListType.LongArrow, applyListStyleFromLevel: true }, + 2 + ) + ).toBe(BulletListType.LongArrow); + }); + + it('ol, no styleType, no auto apply, no existing style', () => { + expect(getAutoListStyleType('OL', {}, 0)).toBe(undefined); + expect(getAutoListStyleType('OL', {}, 1)).toBe(undefined); + expect(getAutoListStyleType('OL', {}, 2)).toBe(undefined); + }); + + it('ol, no styleType, no auto apply, has existing style', () => { + expect(getAutoListStyleType('OL', {}, 0, 'lower-alpha')).toBe(NumberingListType.LowerAlpha); + expect(getAutoListStyleType('OL', {}, 0, 'lower-latin')).toBe(NumberingListType.LowerAlpha); + expect(getAutoListStyleType('OL', {}, 0, 'upper-alpha')).toBe(NumberingListType.UpperAlpha); + expect(getAutoListStyleType('OL', {}, 0, 'upper-latin')).toBe(NumberingListType.UpperAlpha); + expect(getAutoListStyleType('OL', {}, 0, 'lower-roman')).toBe(NumberingListType.LowerRoman); + expect(getAutoListStyleType('OL', {}, 0, 'upper-roman')).toBe(NumberingListType.UpperRoman); + expect(getAutoListStyleType('OL', {}, 0, 'other')).toBe(undefined); + }); + + it('ol, no styleType, has auto apply', () => { + expect(getAutoListStyleType('OL', { applyListStyleFromLevel: true }, 0)).toBe( + NumberingListType.Decimal + ); + expect(getAutoListStyleType('OL', { applyListStyleFromLevel: true }, 1)).toBe( + NumberingListType.LowerAlpha + ); + expect(getAutoListStyleType('OL', { applyListStyleFromLevel: true }, 2)).toBe( + NumberingListType.LowerRoman + ); + expect(getAutoListStyleType('OL', { applyListStyleFromLevel: true }, 3)).toBe( + NumberingListType.Decimal + ); + expect(getAutoListStyleType('OL', { applyListStyleFromLevel: true }, 2, 'other')).toBe( + NumberingListType.LowerRoman + ); + }); + + it('ol, has styleType', () => { + expect( + getAutoListStyleType('OL', { orderedStyleType: NumberingListType.LowerAlphaDash }, 0) + ).toBe(NumberingListType.LowerAlphaDash); + expect( + getAutoListStyleType('OL', { orderedStyleType: NumberingListType.LowerAlpha }, 0) + ).toBe(NumberingListType.LowerAlpha); + expect( + getAutoListStyleType( + 'OL', + { orderedStyleType: NumberingListType.LowerRomanParenthesis }, + 0 + ) + ).toBe(NumberingListType.LowerRomanParenthesis); + + expect( + getAutoListStyleType( + 'OL', + { + orderedStyleType: NumberingListType.LowerRomanParenthesis, + applyListStyleFromLevel: true, + }, + 2 + ) + ).toBe(NumberingListType.LowerRomanParenthesis); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/list/getOrderedListNumberStrTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/list/getOrderedListNumberStrTest.ts new file mode 100644 index 00000000000..cc6bdf512c2 --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/modelApi/list/getOrderedListNumberStrTest.ts @@ -0,0 +1,281 @@ +import { getOrderedListNumberStr } from '../../../lib/modelApi/list/getOrderedListNumberStr'; +import { NumberingListType } from '../../../lib/constants/NumberingListType'; + +describe('getOrderedListNumberStr', () => { + it('Decimal', () => { + expect(getOrderedListNumberStr(NumberingListType.Decimal, 1)).toBe('1'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 2)).toBe('2'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 5)).toBe('5'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 10)).toBe('10'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 20)).toBe('20'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 50)).toBe('50'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 100)).toBe('100'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 1000)).toBe('1000'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 10000)).toBe('10000'); + expect(getOrderedListNumberStr(NumberingListType.Decimal, 0)).toBe('0'); + }); + + it('LowerAlpha', () => { + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 1)).toBe('a'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 2)).toBe('b'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 5)).toBe('e'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 10)).toBe('j'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 20)).toBe('t'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 50)).toBe('ax'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 100)).toBe('cv'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 1000)).toBe('all'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 10000)).toBe('ntp'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlpha, 0)).toBe(''); + }); + + it('LowerAlphaDash', () => { + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 1)).toBe('a'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 2)).toBe('b'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 5)).toBe('e'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 10)).toBe('j'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 20)).toBe('t'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 50)).toBe('ax'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 100)).toBe('cv'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 1000)).toBe('all'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 10000)).toBe('ntp'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDash, 0)).toBe(''); + }); + + it('LowerAlphaDoubleParenthesis', () => { + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 1)).toBe('a'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 2)).toBe('b'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 5)).toBe('e'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 10)).toBe( + 'j' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 20)).toBe( + 't' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 50)).toBe( + 'ax' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 100)).toBe( + 'cv' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 1000)).toBe( + 'all' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 10000)).toBe( + 'ntp' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaDoubleParenthesis, 0)).toBe(''); + }); + + it('LowerAlphaParenthesis', () => { + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 1)).toBe('a'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 2)).toBe('b'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 5)).toBe('e'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 10)).toBe('j'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 20)).toBe('t'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 50)).toBe('ax'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 100)).toBe('cv'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 1000)).toBe('all'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 10000)).toBe('ntp'); + expect(getOrderedListNumberStr(NumberingListType.LowerAlphaParenthesis, 0)).toBe(''); + }); + + it('UpperAlpha', () => { + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 1)).toBe('A'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 2)).toBe('B'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 5)).toBe('E'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 10)).toBe('J'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 20)).toBe('T'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 50)).toBe('AX'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 100)).toBe('CV'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 1000)).toBe('ALL'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 10000)).toBe('NTP'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlpha, 0)).toBe(''); + }); + + it('UpperAlphaDash', () => { + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 1)).toBe('A'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 2)).toBe('B'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 5)).toBe('E'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 10)).toBe('J'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 20)).toBe('T'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 50)).toBe('AX'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 100)).toBe('CV'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 1000)).toBe('ALL'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 10000)).toBe('NTP'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDash, 0)).toBe(''); + }); + + it('UpperAlphaDoubleParenthesis', () => { + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 1)).toBe('A'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 2)).toBe('B'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 5)).toBe('E'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 10)).toBe( + 'J' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 20)).toBe( + 'T' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 50)).toBe( + 'AX' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 100)).toBe( + 'CV' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 1000)).toBe( + 'ALL' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 10000)).toBe( + 'NTP' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaDoubleParenthesis, 0)).toBe(''); + }); + + it('UpperAlphaParenthesis', () => { + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 1)).toBe('A'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 2)).toBe('B'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 5)).toBe('E'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 10)).toBe('J'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 20)).toBe('T'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 50)).toBe('AX'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 100)).toBe('CV'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 1000)).toBe('ALL'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 10000)).toBe('NTP'); + expect(getOrderedListNumberStr(NumberingListType.UpperAlphaParenthesis, 0)).toBe(''); + }); + + it('LowerRoman', () => { + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 1)).toBe('i'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 2)).toBe('ii'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 5)).toBe('v'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 10)).toBe('x'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 20)).toBe('xx'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 50)).toBe('l'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 100)).toBe('c'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 1000)).toBe('m'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 10000)).toBe('mmmmmmmmmm'); + expect(getOrderedListNumberStr(NumberingListType.LowerRoman, 0)).toBe(''); + }); + + it('LowerRomanDash', () => { + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 1)).toBe('i'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 2)).toBe('ii'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 5)).toBe('v'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 10)).toBe('x'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 20)).toBe('xx'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 50)).toBe('l'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 100)).toBe('c'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 1000)).toBe('m'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 10000)).toBe('mmmmmmmmmm'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDash, 0)).toBe(''); + }); + + it('LowerRomanDoubleParenthesis', () => { + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 1)).toBe('i'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 2)).toBe( + 'ii' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 5)).toBe('v'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 10)).toBe( + 'x' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 20)).toBe( + 'xx' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 50)).toBe( + 'l' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 100)).toBe( + 'c' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 1000)).toBe( + 'm' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 10000)).toBe( + 'mmmmmmmmmm' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanDoubleParenthesis, 0)).toBe(''); + }); + + it('LowerRomanParenthesis', () => { + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 1)).toBe('i'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 2)).toBe('ii'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 5)).toBe('v'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 10)).toBe('x'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 20)).toBe('xx'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 50)).toBe('l'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 100)).toBe('c'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 1000)).toBe('m'); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 10000)).toBe( + 'mmmmmmmmmm' + ); + expect(getOrderedListNumberStr(NumberingListType.LowerRomanParenthesis, 0)).toBe(''); + }); + + it('UpperRoman', () => { + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 1)).toBe('I'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 2)).toBe('II'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 5)).toBe('V'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 10)).toBe('X'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 20)).toBe('XX'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 50)).toBe('L'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 100)).toBe('C'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 1000)).toBe('M'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 10000)).toBe('MMMMMMMMMM'); + expect(getOrderedListNumberStr(NumberingListType.UpperRoman, 0)).toBe(''); + }); + + it('UpperRomanDash', () => { + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 1)).toBe('I'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 2)).toBe('II'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 5)).toBe('V'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 10)).toBe('X'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 20)).toBe('XX'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 50)).toBe('L'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 100)).toBe('C'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 1000)).toBe('M'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 10000)).toBe('MMMMMMMMMM'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDash, 0)).toBe(''); + }); + + it('UpperRomanDoubleParenthesis', () => { + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 1)).toBe('I'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 2)).toBe( + 'II' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 5)).toBe('V'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 10)).toBe( + 'X' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 20)).toBe( + 'XX' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 50)).toBe( + 'L' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 100)).toBe( + 'C' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 1000)).toBe( + 'M' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 10000)).toBe( + 'MMMMMMMMMM' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanDoubleParenthesis, 0)).toBe(''); + }); + + it('UpperRomanParenthesis', () => { + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 1)).toBe('I'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 2)).toBe('II'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 5)).toBe('V'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 10)).toBe('X'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 20)).toBe('XX'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 50)).toBe('L'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 100)).toBe('C'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 1000)).toBe('M'); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 10000)).toBe( + 'MMMMMMMMMM' + ); + expect(getOrderedListNumberStr(NumberingListType.UpperRomanParenthesis, 0)).toBe(''); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts index 798e6688f70..1a3b5c23b47 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts @@ -41,7 +41,12 @@ describe('getSelectedSegmentsAndParagraphs', () => { function runTest( selections: SelectionInfo[], includingFormatHolder: boolean, - expectedResult: [ContentModelSegment, ContentModelParagraph | null][] + includingEntity: boolean, + expectedResult: [ + ContentModelSegment, + ContentModelParagraph | null, + ContentModelBlockGroup[] + ][] ) { spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { selections.forEach(({ path, tableContext, block, segments }) => { @@ -51,13 +56,17 @@ describe('getSelectedSegmentsAndParagraphs', () => { return false; }); - const result = getSelectedSegmentsAndParagraphs(null!, includingFormatHolder); + const result = getSelectedSegmentsAndParagraphs( + null!, + includingFormatHolder, + includingEntity + ); expect(result).toEqual(expectedResult); } it('Empty result', () => { - runTest([], false, []); + runTest([], false, false, []); }); it('Add segments', () => { @@ -82,11 +91,12 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], false, + false, [ - [s1, p1], - [s2, p1], - [s3, p2], - [s4, p2], + [s1, p1, []], + [s2, p1, []], + [s3, p2, []], + [s4, p2, []], ] ); }); @@ -111,6 +121,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], false, + false, [] ); }); @@ -135,9 +146,10 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], true, + false, [ - [s3, null], - [s4, null], + [s3, null, []], + [s4, null, []], ] ); }); @@ -176,11 +188,12 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], true, + false, [ - [m1, p1], - [s2, p2], - [s3, p2], - [m2, p3], + [m1, p1, []], + [s2, p2, []], + [s3, p2, []], + [m2, p3, []], ] ); }); @@ -201,7 +214,32 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], false, - [[e2, p1]] + false, + [[e2, p1, []]] + ); + }); + + it('Include entity', () => { + const e1 = createEntity(null!); + const e2 = createEntity(null!, false); + const p1 = createParagraph(); + + p1.segments.push(e1, e2); + + runTest( + [ + { + path: [], + block: p1, + segments: [e1, e2], + }, + ], + false, + true, + [ + [e1, p1, []], + [e2, p1, []], + ] ); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts index 3593c544292..871c1a22baf 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts @@ -66,6 +66,7 @@ describe('contentModelToDom', () => { expect((range as RangeSelection).range.startOffset).toBe(0); expect((range as RangeSelection).range.endContainer).toBe(parent.firstChild as HTMLElement); expect((range as RangeSelection).range.endOffset).toBe(0); + expect((range as RangeSelection).isReverted).toBe(false); expect(parent.innerHTML).toBe('

'); }); @@ -93,13 +94,14 @@ describe('contentModelToDom', () => { segment: br, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(div); expect((range as RangeSelection).range.startOffset).toBe(1); expect((range as RangeSelection).range.endContainer).toBe(div); expect((range as RangeSelection).range.endOffset).toBe(1); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - normal collapsed range with empty text', () => { @@ -128,13 +130,14 @@ describe('contentModelToDom', () => { segment: txt, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(div); expect((range as RangeSelection).range.startOffset).toBe(0); expect((range as RangeSelection).range.endContainer).toBe(div); expect((range as RangeSelection).range.endOffset).toBe(0); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - normal collapsed range in side text', () => { @@ -163,13 +166,14 @@ describe('contentModelToDom', () => { segment: txt1, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(txt1); expect((range as RangeSelection).range.startOffset).toBe(5); expect((range as RangeSelection).range.endContainer).toBe(txt1); expect((range as RangeSelection).range.endOffset).toBe(5); + expect((range as RangeSelection).isReverted).toBe(false); expect(txt1.nodeValue).toBe('test1test2'); }); @@ -197,7 +201,7 @@ describe('contentModelToDom', () => { segment: txt1, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range).toBeNull(); }); @@ -226,13 +230,14 @@ describe('contentModelToDom', () => { segment: null, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(div); expect((range as RangeSelection).range.startOffset).toBe(0); expect((range as RangeSelection).range.endContainer).toBe(div); expect((range as RangeSelection).range.endOffset).toBe(0); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - no end', () => { @@ -255,7 +260,7 @@ describe('contentModelToDom', () => { segment: txt1, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range).toBeNull(); }); @@ -282,13 +287,14 @@ describe('contentModelToDom', () => { segment: txt1, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(txt1); expect((range as RangeSelection).range.startOffset).toBe(5); expect((range as RangeSelection).range.endContainer).toBe(txt1); expect((range as RangeSelection).range.endOffset).toBe(5); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - root is fragment - 2', () => { @@ -315,13 +321,14 @@ describe('contentModelToDom', () => { segment: span, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(span); expect((range as RangeSelection).range.startOffset).toBe(1); expect((range as RangeSelection).range.endContainer).toBe(span); expect((range as RangeSelection).range.endOffset).toBe(1); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - expanded range', () => { @@ -352,7 +359,7 @@ describe('contentModelToDom', () => { segment: txt2, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(txt1); @@ -360,6 +367,51 @@ describe('contentModelToDom', () => { expect((range as RangeSelection).range.endContainer).toBe(txt1); expect((range as RangeSelection).range.endOffset).toBe(10); expect(txt1.nodeValue).toEqual('test1test2test3'); + expect((range as RangeSelection).isReverted).toBe(false); + }); + + it('Extract selection range - reverted expanded range', () => { + const mockedHandler = jasmine.createSpy('blockGroupChildren'); + const context = createModelToDomContext(undefined, { + modelHandlerOverride: { + blockGroupChildren: mockedHandler, + }, + }); + + const root = document.createElement('div'); + const span = document.createElement('span'); + const txt1 = document.createTextNode('test1'); + const txt2 = document.createTextNode('test2'); + const txt3 = document.createTextNode('test3'); + + root.appendChild(span); + span.appendChild(txt1); + span.appendChild(txt2); + span.appendChild(txt3); + + context.regularSelection.start = { + block: span, + segment: txt1, + }; + context.regularSelection.end = { + block: span, + segment: txt2, + }; + + const range = contentModelToDom( + document, + root, + { hasRevertedRangeSelection: true } as any, + context + ); + + expect(range!.type).toBe('range'); + expect((range as RangeSelection).range.startContainer).toBe(txt1); + expect((range as RangeSelection).range.startOffset).toBe(5); + expect((range as RangeSelection).range.endContainer).toBe(txt1); + expect((range as RangeSelection).range.endOffset).toBe(10); + expect((range as RangeSelection).isReverted).toBe(true); + expect(txt1.nodeValue).toEqual('test1test2test3'); }); it('Extract selection range - image range', () => { @@ -378,7 +430,7 @@ describe('contentModelToDom', () => { image: image, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('image'); expect((range as ImageSelection).image).toBe(image); @@ -397,7 +449,7 @@ describe('contentModelToDom', () => { context.tableSelection = mockedSelection; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range).toBe(mockedSelection); }); diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index 9e68553e61a..b0936bdf511 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -1,13 +1,17 @@ +import { ChangeSource } from 'roosterjs-content-model-dom'; import { createLink } from './link/createLink'; import { createLinkAfterSpace } from './link/createLinkAfterSpace'; import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; import { keyboardListTrigger } from './list/keyboardListTrigger'; +import { transformFraction } from './numbers/transformFraction'; import { transformHyphen } from './hyphen/transformHyphen'; +import { transformOrdinals } from './numbers/transformOrdinals'; import { unlink } from './link/unlink'; import type { ContentChangedEvent, EditorInputEvent, EditorPlugin, + FormatContentModelOptions, IEditor, KeyDownEvent, PluginEvent, @@ -20,38 +24,50 @@ export type AutoFormatOptions = { /** * When true, after type *, ->, -, --, => , —, > and space key a type of bullet list will be triggered. @default true */ - autoBullet: boolean; + autoBullet?: boolean; /** * When true, after type 1, A, a, i, I followed by ., ), - or between () and space key a type of numbering list will be triggered. @default true */ - autoNumbering: boolean; + autoNumbering?: boolean; /** * When press backspace before a link, remove the hyperlink */ - autoUnlink: boolean; + autoUnlink?: boolean; /** * When paste content, create hyperlink for the pasted link */ - autoLink: boolean; + autoLink?: boolean; /** * Transform -- into hyphen, if typed between two words */ - autoHyphen: boolean; + autoHyphen?: boolean; + + /** + * Transform 1/2, 1/4, 3/4 into fraction character + */ + autoFraction?: boolean; + + /** + * Transform ordinal numbers into superscript + */ + autoOrdinals?: boolean; }; /** * @internal */ -const DefaultOptions: Required = { +const DefaultOptions: Partial = { autoBullet: false, autoNumbering: false, autoUnlink: false, autoLink: false, autoHyphen: false, + autoFraction: false, + autoOrdinals: false, }; /** @@ -67,6 +83,8 @@ export class AutoFormatPlugin implements EditorPlugin { * - autoLink: A boolean that enables or disables automatic hyperlink creation when pasting or typing content. Defaults to false. * - autoUnlink: A boolean that enables or disables automatic hyperlink removal when pressing backspace. Defaults to false. * - autoHyphen: A boolean that enables or disables automatic hyphen transformation. Defaults to false. + * - autoFraction: A boolean that enables or disables automatic fraction transformation. Defaults to false. + * - autoOrdinals: A boolean that enables or disables automatic ordinal number transformation. Defaults to false. */ constructor(private options: AutoFormatOptions = DefaultOptions) {} @@ -129,6 +147,10 @@ export class AutoFormatPlugin implements EditorPlugin { ) { switch (rawEvent.data) { case ' ': + const formatOptions: FormatContentModelOptions = { + changeSource: '', + apiName: '', + }; formatTextSegmentBeforeSelectionMarker( editor, (model, previousSegment, paragraph, _markerFormat, context) => { @@ -137,9 +159,24 @@ export class AutoFormatPlugin implements EditorPlugin { autoNumbering, autoLink, autoHyphen, + autoFraction, + autoOrdinals, } = this.options; let shouldHyphen = false; let shouldLink = false; + let shouldList = false; + let shouldFraction = false; + let shouldOrdinals = false; + + if (autoBullet || autoNumbering) { + shouldList = keyboardListTrigger( + model, + paragraph, + context, + autoBullet, + autoNumbering + ); + } if (autoLink) { shouldLink = createLinkAfterSpace( @@ -153,19 +190,40 @@ export class AutoFormatPlugin implements EditorPlugin { shouldHyphen = transformHyphen(previousSegment, paragraph, context); } - return ( - keyboardListTrigger( - model, + if (autoFraction) { + shouldFraction = transformFraction( + previousSegment, paragraph, - context, - autoBullet, - autoNumbering - ) || - shouldHyphen || + context + ); + } + + if (autoOrdinals) { + shouldOrdinals = transformOrdinals( + previousSegment, + paragraph, + context + ); + } + + formatOptions.apiName = getApiName(shouldList, shouldHyphen); + formatOptions.changeSource = getChangeSource( + shouldList, + shouldHyphen, shouldLink ); - } + + return ( + shouldList || + shouldHyphen || + shouldLink || + shouldFraction || + shouldOrdinals + ); + }, + formatOptions ); + break; } } @@ -191,3 +249,15 @@ export class AutoFormatPlugin implements EditorPlugin { } } } + +const getApiName = (shouldList: boolean, shouldHyphen: boolean) => { + return shouldList ? 'autoToggleList' : shouldHyphen ? 'autoHyphen' : ''; +}; + +const getChangeSource = (shouldList: boolean, shouldHyphen: boolean, shouldLink: boolean) => { + return shouldList || shouldHyphen + ? ChangeSource.AutoFormat + : shouldLink + ? ChangeSource.AutoLink + : ''; +}; diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts index 22d975f44cb..7d997c765fc 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts @@ -1,4 +1,4 @@ -import { addLink } from 'roosterjs-content-model-dom'; +import { addLink, ChangeSource } from 'roosterjs-content-model-dom'; import { formatTextSegmentBeforeSelectionMarker, matchLink } from 'roosterjs-content-model-api'; import type { IEditor, LinkData } from 'roosterjs-content-model-types'; @@ -6,18 +6,24 @@ import type { IEditor, LinkData } from 'roosterjs-content-model-types'; * @internal */ export function createLink(editor: IEditor) { - formatTextSegmentBeforeSelectionMarker(editor, (_model, linkSegment, _paragraph) => { - let linkData: LinkData | null = null; - if (!linkSegment.link && (linkData = matchLink(linkSegment.text))) { - addLink(linkSegment, { - format: { - href: linkData.normalizedUrl, - underline: true, - }, - dataset: {}, - }); - return true; + formatTextSegmentBeforeSelectionMarker( + editor, + (_model, linkSegment, _paragraph) => { + let linkData: LinkData | null = null; + if (!linkSegment.link && (linkData = matchLink(linkSegment.text))) { + addLink(linkSegment, { + format: { + href: linkData.normalizedUrl, + underline: true, + }, + dataset: {}, + }); + return true; + } + return false; + }, + { + changeSource: ChangeSource.AutoLink, } - return false; - }); + ); } diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts index 12d8d632375..ef85ec409e8 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts @@ -20,20 +20,14 @@ export function keyboardListTrigger( shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ) { - if (shouldSearchForBullet || shouldSearchForNumbering) { - const listStyleType = getListTypeStyle( - model, - shouldSearchForBullet, - shouldSearchForNumbering - ); - if (listStyleType) { - paragraph.segments.splice(0, 1); - const { listType, styleType, index } = listStyleType; - triggerList(model, listType, styleType, index); - context.canUndoByBackspace = true; + const listStyleType = getListTypeStyle(model, shouldSearchForBullet, shouldSearchForNumbering); + if (listStyleType) { + paragraph.segments.splice(0, 1); + const { listType, styleType, index } = listStyleType; + triggerList(model, listType, styleType, index); + context.canUndoByBackspace = true; - return true; - } + return true; } return false; } diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts new file mode 100644 index 00000000000..1c42add3ea7 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformFraction.ts @@ -0,0 +1,34 @@ +import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import type { + ContentModelParagraph, + ContentModelText, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; + +const FRACTIONS: Record = { + '1/2': '½', + '1/4': '¼', + '3/4': '¾', +}; + +/** + * @internal + */ +export function transformFraction( + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext +): boolean { + const fraction = previousSegment.text.split(' ').pop()?.trim(); + if (fraction && FRACTIONS[fraction]) { + const textLength = previousSegment.text.length - 1; + const textIndex = textLength - fraction.length; + const textSegment = splitTextSegment(previousSegment, paragraph, textIndex, textLength); + textSegment.text = FRACTIONS[fraction]; + + context.canUndoByBackspace = true; + return true; + } + + return false; +} diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts new file mode 100644 index 00000000000..235a9424551 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/numbers/transformOrdinals.ts @@ -0,0 +1,43 @@ +import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import type { + ContentModelParagraph, + ContentModelText, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; + +const getOrdinal = (value: number) => { + const ORDINALS: Record = { + 1: 'st', + 2: 'nd', + 3: 'rd', + }; + return ORDINALS[value] || 'th'; +}; + +/** + * @internal + */ +export function transformOrdinals( + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext +): boolean { + const value = previousSegment.text.split(' ').pop()?.trim(); + if (value) { + const ordinal = value.substring(value.length - 2); + const ordinalValue = parseInt(value); + if (ordinalValue && getOrdinal(ordinalValue) === ordinal) { + const ordinalSegment = splitTextSegment( + previousSegment, + paragraph, + previousSegment.text.length - 3, + previousSegment.text.length - 1 + ); + + ordinalSegment.format.superOrSubScriptSequence = 'super'; + context.canUndoByBackspace = true; + return true; + } + } + return false; +} diff --git a/packages/roosterjs-content-model-plugins/lib/customReplace/CustomReplacePlugin.ts b/packages/roosterjs-content-model-plugins/lib/customReplace/CustomReplacePlugin.ts new file mode 100644 index 00000000000..bedeefd966d --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/customReplace/CustomReplacePlugin.ts @@ -0,0 +1,134 @@ +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; +import type { + ContentModelParagraph, + ContentModelText, + EditorInputEvent, + EditorPlugin, + IEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; + +/** + * The CustomReplace interface defines a custom replacement that can be used in CustomReplacePlugin. + */ +export interface CustomReplace { + /** + * The string to replace in the editor. + */ + stringToReplace: string; + + /** + * The string to replace with. + */ + replacementString: string; + + /** + * The handler to replace the string. + * @param previousSegment The text segment to replace. + * @param stringToReplace The string to replace. + * @param replacementString The string to replace with. + * @param paragraph The paragraph that contains the text segment. + * @returns True if the string is replaced successfully, otherwise false. + */ + replacementHandler: ( + previousSegment: ContentModelText, + stringToReplace: string, + replacementString: string, + paragraph?: ContentModelParagraph + ) => boolean; +} + +/** + * CustomReplacePlugin is a plugin that allows you to replace a string with another string in the editor. + */ +export class CustomReplacePlugin implements EditorPlugin { + private editor: IEditor | null = null; + private triggerKeys: string[] = []; + + /** + * @param customReplacements Custom replacement rules. + * Ex: [{ stringToReplace: ':)', replacementString: '🙂', replacementHandler: replaceEmojis }] + */ + constructor(private customReplacements: CustomReplace[]) {} + + /** + * Get name of this plugin + */ + getName() { + return 'CustomReplace'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor; + this.triggerKeys = this.customReplacements.map(replacement => + replacement.stringToReplace.slice(-1) + ); + } + + /** + * 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 'input': + this.handleEditorInputEvent(this.editor, event); + break; + } + } + } + + private handleEditorInputEvent(editor: IEditor, event: EditorInputEvent) { + const rawEvent = event.rawEvent; + const selection = editor.getDOMSelection(); + const key = rawEvent.data; + if ( + this.customReplacements.length > 0 && + rawEvent.inputType === 'insertText' && + selection && + selection.type === 'range' && + selection.range.collapsed && + key && + this.triggerKeys.indexOf(key) > -1 + ) { + formatTextSegmentBeforeSelectionMarker( + editor, + (_model, previousSegment, paragraph, _markerFormat, context) => { + const replaced = this.customReplacements.some( + ({ stringToReplace, replacementString, replacementHandler }) => { + return replacementHandler( + previousSegment, + stringToReplace, + replacementString, + paragraph + ); + } + ); + if (replaced) { + context.canUndoByBackspace = true; + return true; + } + return false; + } + ); + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 73a994c473b..59cfe12ccc8 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -80,12 +80,20 @@ export class EditPlugin implements EditorPlugin { if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { switch (rawEvent.key) { case 'Backspace': - case 'Delete': // Use our API to handle BACKSPACE/DELETE key. // No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache keyboardDelete(editor, rawEvent); break; + case 'Delete': + // Use our API to handle BACKSPACE/DELETE key. + // No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache + // And leave it to browser when shift key is pressed so that browser will trigger cut event + if (!event.rawEvent.shiftKey) { + keyboardDelete(editor, rawEvent); + } + break; + case 'Tab': keyboardTab(editor, rawEvent); break; @@ -141,6 +149,16 @@ export class EditPlugin implements EditorPlugin { if (handled) { rawEvent.preventDefault(); + + // Restore the selection to avoid the cursor jump issue + // See: https://issues.chromium.org/issues/330596261 + const selection = editor.getDOMSelection(); + const doc = this.editor?.getDocument(); + doc?.defaultView?.requestAnimationFrame(() => { + if (this.editor) { + this.editor.setDOMSelection(selection); + } + }); } } } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index 46d08fc71da..869d20ac8f5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -1,3 +1,4 @@ +import { getListAnnounceData } from 'roosterjs-content-model-api'; import { createBr, createListItem, @@ -39,9 +40,12 @@ export const handleEnterOnList: DeleteSelectionStep = context => { if (listItem && listItem.blockGroupType === 'ListItem' && listParent) { const listIndex = listParent.blocks.indexOf(listItem); const nextBlock = listParent.blocks[listIndex + 1]; + if (deleteResult == 'range' && nextBlock) { normalizeContentModel(listParent); + const nextListItem = listParent.blocks[listIndex + 1]; + if ( isBlockGroupOfType(nextListItem, 'ListItem') && nextListItem.levels[0] @@ -52,8 +56,10 @@ export const handleEnterOnList: DeleteSelectionStep = context => { ? listItem.levels[index].dataset : {}; }); + const lastParagraph = listItem.blocks[listItem.blocks.length - 1]; const nextParagraph = nextListItem.blocks[0]; + if ( nextParagraph.blockType === 'Paragraph' && lastParagraph.blockType === 'Paragraph' && @@ -66,15 +72,24 @@ export const handleEnterOnList: DeleteSelectionStep = context => { createSelectionMarker(insertPoint.marker.format) ); } + context.lastParagraph = undefined; } } else if (deleteResult !== 'range') { if (isEmptyListItem(listItem)) { listItem.levels.pop(); } else { - createNewListItem(context, listItem, listParent); + const newListItem = createNewListItem(context, listItem, listParent); + + if (context.formatContext) { + context.formatContext.announceData = getListAnnounceData([ + newListItem, + ...path.slice(index + 1), + ]); + } } } + rawEvent?.preventDefault(); context.deleteResult = 'range'; } @@ -106,6 +121,8 @@ const createNewListItem = ( insertPoint.paragraph = newParagraph; context.lastParagraph = newParagraph; listParent.blocks.splice(listIndex + 1, 0, newListItem); + + return newListItem; }; const createNewListLevel = (listItem: ContentModelListItem) => { diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index 0fb3dd7e327..52b1b8ff5df 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -8,6 +8,7 @@ import type { ContentModelDocument, ContentModelListItem, ContentModelTableCell, + FormatContentModelContext, IEditor, } from 'roosterjs-content-model-types'; @@ -20,8 +21,8 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { switch (selection?.type) { case 'range': editor.formatContentModel( - model => { - return handleTab(model, rawEvent); + (model, context) => { + return handleTab(model, rawEvent, context); }, { apiName: 'handleTabKey', @@ -49,13 +50,18 @@ export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { * - If it is a paragraph, call handleTabOnParagraph to handle the tab key. * - If it is a list item, call handleTabOnList to handle the tab key. */ -function handleTab(model: ContentModelDocument, rawEvent: KeyboardEvent) { +function handleTab( + model: ContentModelDocument, + rawEvent: KeyboardEvent, + context: FormatContentModelContext +) { const blocks = getOperationalBlocks( model, ['ListItem', 'TableCell'], [] ); const block = blocks.length > 0 ? blocks[0].block : undefined; + if (blocks.length > 1) { setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); rawEvent.preventDefault(); @@ -63,9 +69,9 @@ function handleTab(model: ContentModelDocument, rawEvent: KeyboardEvent) { } else if (isBlockGroupOfType(block, 'TableCell')) { return handleTabOnTableCell(model, block, rawEvent); } else if (block?.blockType === 'Paragraph') { - return handleTabOnParagraph(model, block, rawEvent); + return handleTabOnParagraph(model, block, rawEvent, context); } else if (isBlockGroupOfType(block, 'ListItem')) { - return handleTabOnList(model, block, rawEvent); + return handleTabOnList(model, block, rawEvent, context); } return false; } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts index 1f2cbf130ff..2cec2fefc77 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnList.ts @@ -1,6 +1,10 @@ import { handleTabOnParagraph } from './handleTabOnParagraph'; import { setModelIndentation } from 'roosterjs-content-model-api'; -import type { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; +import type { + ContentModelDocument, + ContentModelListItem, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; /** * 1. When the selection is collapsed and the cursor is at start of a list item, call setModelIndentation. @@ -10,7 +14,8 @@ import type { ContentModelDocument, ContentModelListItem } from 'roosterjs-conte export function handleTabOnList( model: ContentModelDocument, listItem: ContentModelListItem, - rawEvent: KeyboardEvent + rawEvent: KeyboardEvent, + context?: FormatContentModelContext ) { const selectedParagraph = findSelectedParagraph(listItem); if ( @@ -18,9 +23,14 @@ export function handleTabOnList( selectedParagraph.length == 1 && selectedParagraph[0].blockType === 'Paragraph' ) { - return handleTabOnParagraph(model, selectedParagraph[0], rawEvent); + return handleTabOnParagraph(model, selectedParagraph[0], rawEvent, context); } else { - setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + setModelIndentation( + model, + rawEvent.shiftKey ? 'outdent' : 'indent', + undefined /*length*/, + context + ); rawEvent.preventDefault(); return true; } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts index 91a8bfc359d..0a3c89bbcaa 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnParagraph.ts @@ -1,6 +1,10 @@ import { createSelectionMarker, createText } from 'roosterjs-content-model-dom'; import { setModelIndentation } from 'roosterjs-content-model-api'; -import type { ContentModelDocument, ContentModelParagraph } from 'roosterjs-content-model-types'; +import type { + ContentModelDocument, + ContentModelParagraph, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; const tabSpaces = '    '; const space = ' '; @@ -23,7 +27,8 @@ const space = ' '; export function handleTabOnParagraph( model: ContentModelDocument, paragraph: ContentModelParagraph, - rawEvent: KeyboardEvent + rawEvent: KeyboardEvent, + context?: FormatContentModelContext ) { const selectedSegments = paragraph.segments.filter(segment => segment.isSelected); const isCollapsed = @@ -39,7 +44,12 @@ export function handleTabOnParagraph( ) { return false; } - setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + setModelIndentation( + model, + rawEvent.shiftKey ? 'outdent' : 'indent', + undefined /*length*/, + context + ); } else { if (!isCollapsed) { let firstSelectedSegmentIndex: number | undefined = undefined; diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index b0de73404cc..f1dc01e3060 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -1,4 +1,5 @@ export { TableEditPlugin } from './tableEdit/TableEditPlugin'; +export { OnTableEditorCreatedCallback } from './tableEdit/OnTableEditorCreatedCallback'; export { PastePlugin } from './paste/PastePlugin'; export { EditPlugin } from './edit/EditPlugin'; export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; @@ -11,6 +12,7 @@ export { ShortcutUndo, ShortcutUndo2, ShortcutRedo, + ShortcutRedoAlt, ShortcutRedoMacOS, ShortcutBullet, ShortcutNumbering, @@ -27,3 +29,9 @@ export { WatermarkFormat } from './watermark/WatermarkFormat'; export { MarkdownPlugin, MarkdownOptions } from './markdown/MarkdownPlugin'; export { HyperlinkPlugin } from './hyperlink/HyperlinkPlugin'; export { HyperlinkToolTip } from './hyperlink/HyperlinkToolTip'; +export { PickerPlugin } from './picker/PickerPlugin'; +export { PickerHelper } from './picker/PickerHelper'; +export { PickerSelectionChangMode, PickerDirection, PickerHandler } from './picker/PickerHandler'; +export { CustomReplacePlugin, CustomReplace } from './customReplace/CustomReplacePlugin'; + +export { getDOMInsertPointRect } from './pluginUtils/Rect/getDOMInsertPointRect'; diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts index 8596e1b92b4..9d03d41447c 100644 --- a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts +++ b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts @@ -32,27 +32,28 @@ export function setFormat( const firstCharIndex = previousSegment.text .substring(0, lastCharIndex - 1) .lastIndexOf(character); + if (lastCharIndex - firstCharIndex > 2) { + const formattedText = splitTextSegment( + previousSegment, + paragraph, + firstCharIndex, + lastCharIndex + ); - const formattedText = splitTextSegment( - previousSegment, - paragraph, - firstCharIndex, - lastCharIndex - ); - - formattedText.text = formattedText.text.replace(character, '').slice(0, -1); - formattedText.format = { - ...formattedText.format, - ...format, - }; - if (codeFormat) { - formattedText.code = { - format: codeFormat, + formattedText.text = formattedText.text.replace(character, '').slice(0, -1); + formattedText.format = { + ...formattedText.format, + ...format, }; - } + if (codeFormat) { + formattedText.code = { + format: codeFormat, + }; + } - context.canUndoByBackspace = true; - return true; + context.canUndoByBackspace = true; + return true; + } } } return false; diff --git a/packages/roosterjs-content-model-plugins/lib/picker/PickerHandler.ts b/packages/roosterjs-content-model-plugins/lib/picker/PickerHandler.ts new file mode 100644 index 00000000000..b7a25cbe0b6 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/picker/PickerHandler.ts @@ -0,0 +1,131 @@ +import type { PickerHelper } from './PickerHelper'; +import type { DOMInsertPoint } from 'roosterjs-content-model-types'; + +/** + * Direction option for picker + */ +export type PickerDirection = + /** + * Show options horizontally + */ + | 'horizontal' + /** + * Show options vertically + */ + | 'vertical' + + /** + * Show options in both direction (2-D picker) + */ + | 'both'; + +/** + * Change mode that PickerPlugin will pass to child class + */ +export type PickerSelectionChangMode = + /** + * When user press Right ("horizontal" mode or "both" mode) (Left in RTL) or Down ("vertical" mode), + * select the next option + */ + | 'next' + + /** + * When user press Left ("horizontal" mode or "both" mode) (Right in RTL) or Up ("vertical" mode), + * select the previous option + */ + | 'previous' + + /** + * When user press Down ("both" mode), + * select the next row + */ + | 'nextRow' + + /** + * When user press Up ("both" mode), + * select the previous row + */ + | 'previousRow' + + /** + * When user press PageDown, + * switch to next page + */ + | 'nextPage' + + /** + * When user press PageUp, + * switch to previous page + */ + | 'previousPage' + + /** + * When user press Home, + * Select the first item in current row + */ + | 'firstInRow' + + /** + * When user press End, + * Select the last item in current row + */ + | 'lastInRow' + + /** + * When user press CTRL (or META on Mac) + Home, + * Select the very first item + */ + | 'first' + + /** + * When user press CTRL (or META on Mac) + End, + * Select the very last item + */ + | 'last'; + +/** + * Represents the interface a handler for picker plugin. Developer need to implement this interface to create a new type of picker + */ +export interface PickerHandler { + /** + * Initialize the picker handler, pass in editor and PickerPlugin instance so that the handler can save them + * @param editor The editor instance + * @param pickerPlugin The PickerPlugin instance + */ + onInitialize: (helper: PickerHelper) => void; + + /** + * Dispose the picker handler + */ + onDispose: () => void; + + /** + * Notify the picker handler that user has typed trigger character so handler should show picker now + * @param queryString Current query string + * @param insertPoint Insert point where user is typing, can be used for calculating picker position + * @returns A picker direction to let picker plugin know what kind of picker is opened. Picker plugin will use this value + * to decide how to handle keyboard event to change selection. Return null means picker is not actually opened + */ + onTrigger: (queryString: string, insertPoint: DOMInsertPoint) => PickerDirection | null; + + /** + * Notify the picker handler that picker should be closed now + */ + onClosePicker?(): void; + + /** + * Notify the picker handler that user has changed current typed query string + */ + onQueryStringChanged?(queryString: string): void; + + /** + * Notify the picker handler that user has decide to select the current option in picker + */ + onSelect?(): void; + + /** + * Notify the picker handler that user is using keyboard to change current selection + * @param mode The moving mode. Handler code can use this value to decide which item need to be selected + */ + onSelectionChanged?(mode: PickerSelectionChangMode): void; +} diff --git a/packages/roosterjs-content-model-plugins/lib/picker/PickerHelper.ts b/packages/roosterjs-content-model-plugins/lib/picker/PickerHelper.ts new file mode 100644 index 00000000000..3d1d0e93724 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/picker/PickerHelper.ts @@ -0,0 +1,33 @@ +import type { + ContentModelDocument, + FormatContentModelOptions, + IEditor, +} from 'roosterjs-content-model-types'; + +/** + * Represents the interface of picker plugin, provides necessary utility functions for pickers + */ +export interface PickerHelper { + /** + * The editor instance + */ + readonly editor: IEditor; + + /** + * Replace the query string with a given Content Model. + * This is used for commit a change from picker and insert the committed content into editor. + * @param model The Content Model to insert + * @param options Options for formatting content model + * @param canUndoByBackspace Whether this change can be undone using Backspace key + */ + replaceQueryString: ( + model: ContentModelDocument, + options?: FormatContentModelOptions, + canUndoByBackspace?: boolean + ) => void; + + /** + * Notify Picker Plugin that picker is closed from the handler code, so picker plugin can quit the suggesting state + */ + closePicker: () => void; +} diff --git a/packages/roosterjs-content-model-plugins/lib/picker/PickerHelperImpl.ts b/packages/roosterjs-content-model-plugins/lib/picker/PickerHelperImpl.ts new file mode 100644 index 00000000000..67f5aaad628 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/picker/PickerHelperImpl.ts @@ -0,0 +1,72 @@ +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; +import { getQueryString } from './getQueryString'; +import { mergeModel } from 'roosterjs-content-model-dom'; +import type { + ContentModelDocument, + ContentModelText, + FormatContentModelOptions, + IEditor, +} from 'roosterjs-content-model-types'; +import type { PickerDirection, PickerHandler } from './PickerHandler'; +import type { PickerHelper } from './PickerHelper'; + +/** + * @internal + */ +export class PickerHelperImpl implements PickerHelper { + public direction: PickerDirection | null = null; + + constructor( + public readonly editor: IEditor, + public readonly handler: PickerHandler, + private triggerCharacter: string + ) {} + + /** + * Replace the query string with a given Content Model. + * This is used for commit a change from picker and insert the committed content into editor. + * @param model The Content Model to insert + * @param options Options for formatting content model + * @param canUndoByBackspace Whether this change can be undone using Backspace key + */ + replaceQueryString( + model: ContentModelDocument, + options?: FormatContentModelOptions, + canUndoByBackspace?: boolean + ): void { + this.editor.focus(); + + formatTextSegmentBeforeSelectionMarker( + this.editor, + (target, previousSegment, paragraph, _, context) => { + const potentialSegments: ContentModelText[] = []; + const queryString = getQueryString( + this.triggerCharacter, + paragraph, + previousSegment, + potentialSegments + ); + + if (queryString) { + potentialSegments.forEach(x => (x.isSelected = true)); + mergeModel(target, model, context); + context.canUndoByBackspace = canUndoByBackspace; + return true; + } else { + return false; + } + }, + options + ); + } + + /** + * Notify Picker Plugin that picker is closed from the handler code, so picker plugin can quit the suggesting state + */ + closePicker() { + if (this.direction) { + this.direction = null; + this.handler.onClosePicker?.(); + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/picker/PickerPlugin.ts b/packages/roosterjs-content-model-plugins/lib/picker/PickerPlugin.ts new file mode 100644 index 00000000000..a1d4e3ea0a2 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/picker/PickerPlugin.ts @@ -0,0 +1,253 @@ +import { ChangeSource, isCursorMovingKey, isPunctuation } from 'roosterjs-content-model-dom'; +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; +import { getQueryString } from './getQueryString'; +import { PickerHelperImpl } from './PickerHelperImpl'; +import type { PickerHandler } from './PickerHandler'; +import type { + DOMInsertPoint, + EditorPlugin, + IEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; + +/** + * PickerPlugin represents a plugin of editor which can handle picker related behaviors, including + * - Show picker when special trigger key is pressed + * - Hide picker + * - Change selection in picker by Up/Down/Left/Right + * - Apply selected item in picker + * + * PickerPlugin doesn't provide any UI, it just wraps related DOM events and invoke callback functions. + */ +export class PickerPlugin implements EditorPlugin { + private isMac: boolean = false; + private lastQueryString = ''; + private helper: PickerHelperImpl | null = null; + + /** + * Construct a new instance of PickerPlugin class + * @param triggerCharacter The character to trigger a picker to be shown + * @param handler Picker handler for receiving picker state change events + */ + constructor(private triggerCharacter: string, private readonly handler: PickerHandler) {} + + /** + * Get a friendly name + */ + getName() { + return 'Picker'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.isMac = !!editor.getEnvironment().isMac; + this.helper = new PickerHelperImpl(editor, this.handler, this.triggerCharacter); + this.handler.onInitialize(this.helper); + } + + /** + * Dispose this plugin + */ + dispose() { + this.handler.onDispose(); + this.helper = null; + } + + /** + * Check if the plugin should handle the given event exclusively. + * Handle an event exclusively means other plugin will not receive this event in + * onPluginEvent method. + * If two plugins will return true in willHandleEventExclusively() for the same event, + * the final result depends on the order of the plugins are added into editor + * @param event The event to check + */ + willHandleEventExclusively(event: PluginEvent) { + return ( + !!this.helper?.direction && + event.eventType == 'keyDown' && + (isCursorMovingKey(event.rawEvent) || + event.rawEvent.key == 'Enter' || + event.rawEvent.key == 'Tab' || + event.rawEvent.key == 'Escape') + ); + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if (!this.helper) { + return; + } + + switch (event.eventType) { + case 'contentChanged': + if (this.helper.direction) { + if (event.source == ChangeSource.SetContent) { + this.helper.closePicker(); + } else { + this.onSuggestingInput(this.helper); + } + } + break; + + case 'keyDown': + if (this.helper.direction) { + this.onSuggestingKeyDown(this.helper, event.rawEvent); + } + break; + + case 'input': + if (this.helper.direction) { + this.onSuggestingInput(this.helper); + } else { + this.onInput(this.helper, event.rawEvent); + } + break; + + case 'mouseUp': + if (this.helper.direction) { + this.helper.closePicker(); + } + break; + } + } + + private onSuggestingKeyDown(helper: PickerHelperImpl, event: KeyboardEvent) { + switch (event.key) { + case 'ArrowLeft': + case 'ArrowRight': + if (helper.direction == 'horizontal' || helper.direction == 'both') { + let isIncrement = event.key == 'ArrowRight'; + + if (helper.editor.getDOMHelper().isRightToLeft()) { + isIncrement = !isIncrement; + } + + this.handler.onSelectionChanged?.(isIncrement ? 'next' : 'previous'); + } + + event.preventDefault(); + break; + case 'ArrowUp': + case 'ArrowDown': + { + const isIncrement = event.key == 'ArrowDown'; + + if (helper.direction != 'horizontal') { + this.handler.onSelectionChanged?.( + helper.direction == 'both' + ? isIncrement + ? 'nextRow' + : 'previousRow' + : isIncrement + ? 'next' + : 'previous' + ); + } + } + + event.preventDefault(); + break; + case 'PageUp': + case 'PageDown': + this.handler.onSelectionChanged?.( + event.key == 'PageDown' ? 'nextPage' : 'previousPage' + ); + + event.preventDefault(); + break; + case 'Home': + case 'End': + const hasCtrl = this.isMac ? event.metaKey : event.ctrlKey; + this.handler.onSelectionChanged?.( + event.key == 'Home' + ? hasCtrl + ? 'first' + : 'firstInRow' + : hasCtrl + ? 'last' + : 'lastInRow' + ); + + event.preventDefault(); + break; + case 'Escape': + helper.closePicker(); + event.preventDefault(); + break; + + case 'Enter': + case 'Tab': + this.handler.onSelect?.(); + event.preventDefault(); + break; + } + } + + private onSuggestingInput(helper: PickerHelperImpl) { + if ( + !formatTextSegmentBeforeSelectionMarker(helper.editor, (_, segment, paragraph) => { + const newQueryString = getQueryString( + this.triggerCharacter, + paragraph, + segment + ).replace(/[\u0020\u00A0]/g, ' '); + const oldQueryString = this.lastQueryString; + + if ( + newQueryString && + ((newQueryString.length >= oldQueryString.length && + newQueryString.indexOf(oldQueryString) == 0) || + (newQueryString.length < oldQueryString.length && + oldQueryString.indexOf(newQueryString) == 0)) + ) { + this.lastQueryString = newQueryString; + this.handler.onQueryStringChanged?.(newQueryString); + } else { + helper.closePicker(); + } + + return false; + }) + ) { + helper.closePicker(); + } + } + + private onInput(helper: PickerHelperImpl, event: InputEvent) { + if (event.inputType == 'insertText' && event.data == this.triggerCharacter) { + formatTextSegmentBeforeSelectionMarker(helper.editor, (_, segment) => { + if (segment.text.endsWith(this.triggerCharacter)) { + const charBeforeTrigger = segment.text[segment.text.length - 2]; + + if ( + !charBeforeTrigger || + !charBeforeTrigger.trim() || + isPunctuation(charBeforeTrigger) + ) { + const selection = helper.editor.getDOMSelection(); + const pos: DOMInsertPoint | null = + selection?.type == 'range' && selection.range.collapsed + ? { + node: selection.range.startContainer, + offset: selection.range.startOffset, + } + : null; + + if (pos) { + this.lastQueryString = this.triggerCharacter; + helper.direction = this.handler.onTrigger(this.lastQueryString, pos); + } + } + } + + return false; + }); + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts b/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts new file mode 100644 index 00000000000..e5a581bb1e2 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/picker/getQueryString.ts @@ -0,0 +1,48 @@ +import { splitTextSegment } from '../pluginUtils/splitTextSegment'; +import type { ContentModelParagraph, ContentModelText } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function getQueryString( + triggerCharacter: string, + paragraph: ContentModelParagraph, + previousSegment: ContentModelText, + splittedSegmentResult?: ContentModelText[] +): string { + let result = ''; + let i = paragraph.segments.indexOf(previousSegment); + + for (; i >= 0; i--) { + const segment = paragraph.segments[i]; + + if (segment.segmentType != 'Text') { + result = ''; + break; + } + + const index = segment.text.lastIndexOf(triggerCharacter); + + if (index >= 0) { + result = segment.text.substring(index) + result; + + splittedSegmentResult?.unshift( + index > 0 + ? splitTextSegment(segment, paragraph, index, segment.text.length) + : segment + ); + + break; + } else { + result = segment.text + result; + + splittedSegmentResult?.unshift(segment); + } + } + + if (i < 0) { + result = ''; + } + + return result; +} diff --git a/demo/scripts/controlsV2/roosterjsReact/pasteOptions/utils/getPositionRect.ts b/packages/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getDOMInsertPointRect.ts similarity index 52% rename from demo/scripts/controlsV2/roosterjsReact/pasteOptions/utils/getPositionRect.ts rename to packages/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getDOMInsertPointRect.ts index 7b0cab07551..5085a9f1950 100644 --- a/demo/scripts/controlsV2/roosterjsReact/pasteOptions/utils/getPositionRect.ts +++ b/packages/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getDOMInsertPointRect.ts @@ -1,14 +1,16 @@ import { isNodeOfType, normalizeRect } from 'roosterjs-content-model-dom'; -import { Rect } from 'roosterjs-content-model-types'; +import type { DOMInsertPoint, Rect } from 'roosterjs-content-model-types'; /** - * Get bounding rect of this position - * @param position The position to get rect from + * Get bounding rect of the given DOM insert point + * @param doc The document object + * @param pos The input DOM insert point */ -export function getPositionRect(container: Node, offset: number): Rect | null { - let range = container.ownerDocument.createRange(); +export function getDOMInsertPointRect(doc: Document, pos: DOMInsertPoint): Rect | null { + let { node, offset } = pos; + const range = doc.createRange(); - range.setStart(container, offset); + range.setStart(node, offset); // 1) try to get rect using range.getBoundingClientRect() let rect = normalizeRect(range.getBoundingClientRect()); @@ -18,12 +20,12 @@ export function getPositionRect(container: Node, offset: number): Rect | null { } // 2) try to get rect using range.getClientRects - while (container.lastChild) { - if (offset == container.childNodes.length) { - container = container.lastChild; - offset = container.childNodes.length; + while (node.lastChild) { + if (offset == node.childNodes.length) { + node = node.lastChild; + offset = node.childNodes.length; } else { - container = container.childNodes[offset]; + node = node.childNodes[offset]; offset = 0; } } @@ -35,13 +37,13 @@ export function getPositionRect(container: Node, offset: number): Rect | null { } // 3) if node is text node, try inserting a SPAN and get the rect of SPAN for others - if (isNodeOfType(container, 'TEXT_NODE')) { - const span = container.ownerDocument.createElement('span'); + if (isNodeOfType(node, 'TEXT_NODE')) { + const span = node.ownerDocument.createElement('span'); span.textContent = '\u200b'; range.insertNode(span); rect = normalizeRect(span.getBoundingClientRect()); - span.parentNode.removeChild(span); + span.parentNode?.removeChild(span); if (rect) { return rect; @@ -49,8 +51,8 @@ export function getPositionRect(container: Node, offset: number): Rect | null { } // 4) try getBoundingClientRect on element - if (isNodeOfType(container, 'ELEMENT_NODE') && container.getBoundingClientRect) { - rect = normalizeRect(container.getBoundingClientRect()); + if (isNodeOfType(node, 'ELEMENT_NODE') && node.getBoundingClientRect) { + rect = normalizeRect(node.getBoundingClientRect()); if (rect) { return rect; diff --git a/packages/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts b/packages/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts index 8ab2984a874..96987379992 100644 --- a/packages/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/shortcut/ShortcutPlugin.ts @@ -11,6 +11,7 @@ import { ShortcutNumbering, ShortcutOutdentList, ShortcutRedo, + ShortcutRedoAlt, ShortcutRedoMacOS, ShortcutUnderline, ShortcutUndo, @@ -31,6 +32,7 @@ const defaultShortcuts: ShortcutCommand[] = [ ShortcutUndo, ShortcutUndo2, ShortcutRedo, + ShortcutRedoAlt, ShortcutRedoMacOS, ShortcutBullet, ShortcutNumbering, diff --git a/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts b/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts index edfecf5c90f..ffb004af755 100644 --- a/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts +++ b/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts @@ -128,6 +128,7 @@ export const ShortcutRedo: ShortcutCommand = { }; /** + * @deprecated * Shortcut command for Redo 2 * Windows: N/A * MacOS: Meta + Shift + Z @@ -142,6 +143,20 @@ export const ShortcutRedoMacOS: ShortcutCommand = { environment: 'mac', }; +/** + * Shortcut command for Redo 3 + * Windows: Ctrl + Shift + Z + * MacOS: Meta + Shift + Z + */ +export const ShortcutRedoAlt: ShortcutCommand = { + shortcutKey: { + modifierKey: 'ctrl', + shiftKey: true, + which: Keys.Z, + }, + onClick: editor => redo(editor), +}; + /** * Shortcut command for Bullet List * Windows: Ctrl + . (Period) diff --git a/packages/roosterjs-content-model-plugins/lib/shortcut/utils/setShortcutIndentationCommand.ts b/packages/roosterjs-content-model-plugins/lib/shortcut/utils/setShortcutIndentationCommand.ts index 7c6934f06a4..5b38b9393ea 100644 --- a/packages/roosterjs-content-model-plugins/lib/shortcut/utils/setShortcutIndentationCommand.ts +++ b/packages/roosterjs-content-model-plugins/lib/shortcut/utils/setShortcutIndentationCommand.ts @@ -6,7 +6,7 @@ import type { IEditor } from 'roosterjs-content-model-types'; * @internal */ export function setShortcutIndentationCommand(editor: IEditor, operation: 'indent' | 'outdent') { - editor.formatContentModel(model => { + editor.formatContentModel((model, context) => { const listItem = getFirstSelectedListItem(model); if ( @@ -14,7 +14,7 @@ export function setShortcutIndentationCommand(editor: IEditor, operation: 'inden listItem.blocks[0].blockType == 'Paragraph' && listItem.blocks[0].segments[0].segmentType == 'SelectionMarker' ) { - setModelIndentation(model, operation); + setModelIndentation(model, operation, undefined /*length*/, context); return true; } return false; diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/OnTableEditorCreatedCallback.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/OnTableEditorCreatedCallback.ts new file mode 100644 index 00000000000..6019bea2658 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/OnTableEditorCreatedCallback.ts @@ -0,0 +1,7 @@ +/** + * Optional callback when creating a TableEditPlugin, allows to customize the Selectors element as required. + */ +export type OnTableEditorCreatedCallback = ( + editorType: 'HorizontalTableInserter' | 'VerticalTableInserter' | 'TableMover' | 'TableResizer', + element: HTMLElement +) => () => void; diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts index 8c81918e290..386f3091453 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts @@ -1,5 +1,6 @@ import { isNodeOfType, normalizeRect } from 'roosterjs-content-model-dom'; import { TableEditor } from './editors/TableEditor'; +import type { OnTableEditorCreatedCallback } from './OnTableEditorCreatedCallback'; import type { EditorPlugin, IEditor, PluginEvent, Rect } from 'roosterjs-content-model-types'; const TABLE_RESIZER_LENGTH = 12; @@ -18,8 +19,12 @@ export class TableEditPlugin implements EditorPlugin { * @param anchorContainerSelector An optional selector string to specify the container to host the plugin. * The container must not be affected by transform: scale(), otherwise the position calculation will be wrong. * If not specified, the plugin will be inserted in document.body + * @param onTableEditorCreated An optional callback to customize the Table Editors elements when created. */ - constructor(private anchorContainerSelector?: string) {} + constructor( + private anchorContainerSelector?: string, + private onTableEditorCreated?: OnTableEditorCreatedCallback + ) {} /** * Get a friendly name of this plugin @@ -66,6 +71,7 @@ export class TableEditPlugin implements EditorPlugin { this.disposeTableEditor(); this.editor = null; this.onMouseMoveDisposer = null; + this.onTableEditorCreated = undefined; } /** @@ -140,7 +146,8 @@ export class TableEditPlugin implements EditorPlugin { table, this.invalidateTableRects, isNodeOfType(container, 'ELEMENT_NODE') ? container : undefined, - event?.currentTarget + event?.currentTarget, + this.onTableEditorCreated ); } } diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts index 179f4dbd4c6..ef6007dc572 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts @@ -3,7 +3,8 @@ import { createTableInserter } from './features/TableInserter'; import { createTableMover } from './features/TableMover'; import { createTableResizer } from './features/TableResizer'; import { disposeTableEditFeature } from './features/TableEditFeature'; -import { isNodeOfType, normalizeRect } from 'roosterjs-content-model-dom'; +import { isNodeOfType, normalizeRect, parseTableCells } from 'roosterjs-content-model-dom'; +import type { OnTableEditorCreatedCallback } from '../OnTableEditorCreatedCallback'; import type { TableEditFeature } from './features/TableEditFeature'; import type { IEditor, TableSelection } from 'roosterjs-content-model-types'; @@ -65,7 +66,8 @@ export class TableEditor { public readonly table: HTMLTableElement, private onChanged: () => void, private anchorContainer?: HTMLElement, - private contentDiv?: EventTarget | null + private contentDiv?: EventTarget | null, + private onTableEditorCreated?: OnTableEditorCreatedCallback ) { this.isRTL = editor.getDocument().defaultView?.getComputedStyle(table).direction == 'rtl'; this.setEditorFeatures(); @@ -100,7 +102,6 @@ export class TableEditor { // Get whole table rect const tableRect = normalizeRect(this.table.getBoundingClientRect()); - //console.log('>>>tableRect', tableRect); if (!tableRect) { return; } @@ -193,9 +194,9 @@ export class TableEditor { this.editor, this.isRTL, this.onSelect, - this.getOnMouseOut, this.contentDiv, - this.anchorContainer + this.anchorContainer, + this.onEditorCreated ); } @@ -207,11 +208,34 @@ export class TableEditor { this.onStartTableResize, this.onFinishEditing, this.contentDiv, - this.anchorContainer + this.anchorContainer, + this.onTableEditorCreated ); } } + private onEditorCreated = ( + editorType: + | 'HorizontalTableInserter' + | 'VerticalTableInserter' + | 'TableMover' + | 'TableResizer', + element: HTMLElement + ) => { + const disposer = this.onTableEditorCreated?.(editorType, element); + const onMouseOut = element && this.getOnMouseOut(element); + if (onMouseOut) { + element.addEventListener('mouseout', onMouseOut); + } + + return () => { + disposer?.(); + if (onMouseOut) { + element.removeEventListener('mouseout', onMouseOut); + } + }; + }; + private setResizingTd(td: HTMLTableCellElement) { if (this.horizontalResizer && this.horizontalResizer.node != td) { this.disposeCellResizers(); @@ -259,8 +283,8 @@ export class TableEditor { this.isRTL, !!isHorizontal, this.onInserted, - this.getOnMouseOut, - this.anchorContainer + this.anchorContainer, + this.onEditorCreated ); if (isHorizontal) { this.horizontalInserter = newInserter; @@ -355,12 +379,13 @@ export class TableEditor { this.editor.focus(); if (table) { + const parsedTable = parseTableCells(table); const selection: TableSelection = { table: table, firstRow: 0, firstColumn: 0, - lastRow: table.rows.length - 1, - lastColumn: table.rows[table.rows.length - 1].cells.length - 1, + lastRow: parsedTable.length - 1, + lastColumn: (parsedTable[0]?.length ?? 0) - 1, type: 'table', }; diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditFeature.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditFeature.ts index 769b8dd54f3..b1bea610c57 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditFeature.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditFeature.ts @@ -14,9 +14,9 @@ export interface TableEditFeature { */ export function disposeTableEditFeature(resizer: TableEditFeature | null) { if (resizer) { - resizer.div?.parentNode?.removeChild(resizer.div); - resizer.div = null; resizer.featureHandler?.dispose(); resizer.featureHandler = null; + resizer.div?.parentNode?.removeChild(resizer.div); + resizer.div = null; } } diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts index 48ea79d805c..bf2798aa398 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts @@ -1,6 +1,7 @@ import { createElement } from '../../../pluginUtils/CreateElement/createElement'; import { getIntersectedRect } from '../../../pluginUtils/Rect/getIntersectedRect'; import { isElementOfType, normalizeRect } from 'roosterjs-content-model-dom'; +import type { OnTableEditorCreatedCallback } from '../../OnTableEditorCreatedCallback'; import { formatTableWithContentModel, insertTableColumn, @@ -26,8 +27,8 @@ export function createTableInserter( isRTL: boolean, isHorizontal: boolean, onInsert: () => void, - getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, - anchorContainer?: HTMLElement + anchorContainer?: HTMLElement, + onTableEditorCreated?: OnTableEditorCreatedCallback ): TableEditFeature | null { const tdRect = normalizeRect(td.getBoundingClientRect()); const viewPort = editor.getVisibleViewport(); @@ -74,7 +75,7 @@ export function createTableInserter( isHorizontal, editor, onInsert, - getOnMouseOut + onTableEditorCreated ); return { div, featureHandler: handler, node: td }; @@ -84,7 +85,7 @@ export function createTableInserter( } class TableInsertHandler implements Disposable { - private onMouseOutEvent: null | ((ev: MouseEvent) => void); + private disposer: undefined | (() => void); constructor( private div: HTMLDivElement, private td: HTMLTableCellElement, @@ -92,21 +93,19 @@ class TableInsertHandler implements Disposable { private isHorizontal: boolean, private editor: IEditor, private onInsert: () => void, - getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void + onTableEditorCreated?: OnTableEditorCreatedCallback ) { this.div.addEventListener('click', this.insertTd); - this.onMouseOutEvent = getOnMouseOut(div); - this.div.addEventListener('mouseout', this.onMouseOutEvent); + this.disposer = onTableEditorCreated?.( + isHorizontal ? 'HorizontalTableInserter' : 'VerticalTableInserter', + div + ); } dispose() { this.div.removeEventListener('click', this.insertTd); - - if (this.onMouseOutEvent) { - this.div.removeEventListener('mouseout', this.onMouseOutEvent); - } - - this.onMouseOutEvent = null; + this.disposer?.(); + this.disposer = undefined; } private insertTd = () => { diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts index 7cf29617a27..44832b80f9c 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts @@ -1,6 +1,7 @@ import { createElement } from '../../../pluginUtils/CreateElement/createElement'; import { DragAndDropHelper } from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; import { isNodeOfType, normalizeRect } from 'roosterjs-content-model-dom'; +import type { OnTableEditorCreatedCallback } from '../../OnTableEditorCreatedCallback'; import type { DragAndDropHandler } from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; import type { IEditor, Rect } from 'roosterjs-content-model-types'; import type { TableEditFeature } from './TableEditFeature'; @@ -18,9 +19,9 @@ export function createTableMover( editor: IEditor, isRTL: boolean, onFinishDragging: (table: HTMLTableElement) => void, - getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, contentDiv?: EventTarget | null, - anchorContainer?: HTMLElement + anchorContainer?: HTMLElement, + onTableEditorCreated?: OnTableEditorCreatedCallback ): TableEditFeature | null { const rect = normalizeRect(table.getBoundingClientRect()); @@ -67,7 +68,7 @@ export function createTableMover( onDragEnd, }, context.zoomScale, - getOnMouseOut + onTableEditorCreated ); return { div, featureHandler, node: table }; @@ -85,10 +86,10 @@ interface TableMoverInitValue { } class TableMoverFeature extends DragAndDropHelper { - private onMouseOut: ((ev: MouseEvent) => void) | null; + private disposer: undefined | (() => void); constructor( - private div: HTMLElement, + div: HTMLElement, context: TableMoverContext, onSubmit: ( context: TableMoverContext, @@ -97,21 +98,16 @@ class TableMoverFeature extends DragAndDropHelper void, handler: DragAndDropHandler, zoomScale: number, - getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, - forceMobile?: boolean | undefined, - container?: HTMLElement + onTableEditorCreated?: OnTableEditorCreatedCallback ) { - super(div, context, onSubmit, handler, zoomScale, forceMobile); - this.onMouseOut = getOnMouseOut(div); - div.addEventListener('mouseout', this.onMouseOut); + super(div, context, onSubmit, handler, zoomScale); + this.disposer = onTableEditorCreated?.('TableMover', div); } dispose(): void { + this.disposer?.(); + this.disposer = undefined; super.dispose(); - if (this.onMouseOut) { - this.div.removeEventListener('mouseout', this.onMouseOut); - } - this.onMouseOut = null; } } diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts index 9ada78d4dd6..a7dbc0b8b4b 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts @@ -1,5 +1,7 @@ import { createElement } from '../../../pluginUtils/CreateElement/createElement'; import { DragAndDropHelper } from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; + +import type { OnTableEditorCreatedCallback } from '../../OnTableEditorCreatedCallback'; import { getFirstSelectedTable, isNodeOfType, @@ -8,6 +10,7 @@ import { } from 'roosterjs-content-model-dom'; import type { ContentModelTable, IEditor, Rect } from 'roosterjs-content-model-types'; import type { TableEditFeature } from './TableEditFeature'; +import type { DragAndDropHandler } from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; const TABLE_RESIZER_LENGTH = 12; const TABLE_RESIZER_ID = '_Table_Resizer'; @@ -22,7 +25,8 @@ export function createTableResizer( onStart: () => void, onEnd: () => false, contentDiv?: EventTarget | null, - anchorContainer?: HTMLElement + anchorContainer?: HTMLElement, + onTableEditorCreated?: OnTableEditorCreatedCallback ): TableEditFeature | null { const rect = normalizeRect(table.getBoundingClientRect()); @@ -60,7 +64,7 @@ export function createTableResizer( setDivPosition(context, div); - const featureHandler = new DragAndDropHelper( + const featureHandler = new TableResizer( div, context, hideResizer, // Resizer is hidden while dragging only @@ -70,12 +74,36 @@ export function createTableResizer( onDragEnd, }, zoomScale, - editor.getEnvironment().isMobileOrTablet + editor.getEnvironment().isMobileOrTablet, + onTableEditorCreated ); return { node: table, div, featureHandler }; } +class TableResizer extends DragAndDropHelper { + private disposer: undefined | (() => void); + + constructor( + trigger: HTMLElement, + context: DragAndDropContext, + onSubmit: (context: DragAndDropContext, trigger: HTMLElement) => void, + handler: DragAndDropHandler, + zoomScale: number, + forceMobile?: boolean, + onTableEditorCreated?: OnTableEditorCreatedCallback + ) { + super(trigger, context, onSubmit, handler, zoomScale, forceMobile); + this.disposer = onTableEditorCreated?.('TableResizer', trigger); + } + + dispose(): void { + this.disposer?.(); + this.disposer = undefined; + super.dispose(); + } +} + interface DragAndDropContext { table: HTMLTableElement; isRTL: boolean; diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 726cb514e71..9203fa27645 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -2,9 +2,12 @@ import * as createLink from '../../lib/autoFormat/link/createLink'; import * as formatTextSegmentBeforeSelectionMarker from 'roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker'; import * as unlink from '../../lib/autoFormat/link/unlink'; import { AutoFormatOptions, AutoFormatPlugin } from '../../lib/autoFormat/AutoFormatPlugin'; +import { ChangeSource } from '../../../roosterjs-content-model-dom/lib/constants/ChangeSource'; import { createLinkAfterSpace } from '../../lib/autoFormat/link/createLinkAfterSpace'; import { keyboardListTrigger } from '../../lib/autoFormat/list/keyboardListTrigger'; +import { transformFraction } from '../../lib/autoFormat/numbers/transformFraction'; import { transformHyphen } from '../../lib/autoFormat/hyphen/transformHyphen'; +import { transformOrdinals } from '../../lib/autoFormat/numbers/transformOrdinals'; import { ContentChangedEvent, ContentModelDocument, @@ -19,12 +22,14 @@ import { describe('Content Model Auto Format Plugin Test', () => { let editor: IEditor; let formatTextSegmentBeforeSelectionMarkerSpy: jasmine.Spy; + let triggerEventSpy: jasmine.Spy; beforeEach(() => { formatTextSegmentBeforeSelectionMarkerSpy = spyOn( formatTextSegmentBeforeSelectionMarker, 'formatTextSegmentBeforeSelectionMarker' ); + triggerEventSpy = jasmine.createSpy('triggerEvent'); editor = ({ focus: () => {}, getDOMSelection: () => @@ -35,40 +40,74 @@ describe('Content Model Auto Format Plugin Test', () => { }, } as any), // Force return invalid range to go through content model code formatContentModel: () => {}, + triggerEvent: triggerEventSpy, } as any) as IEditor; }); describe('onPluginEvent - keyboardListTrigger', () => { function runTest( event: EditorInputEvent, - options?: { - autoBullet: boolean; - autoNumbering: boolean; - } + testBullet: boolean, + expectResult: boolean, + options?: AutoFormatOptions ) { - const plugin = new AutoFormatPlugin(options as AutoFormatOptions); + const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); plugin.onPluginEvent(event); - formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback) => { + const formatOptions = { + apiName: '', + }; + + const inputModel = (bullet: boolean): ContentModelDocument => ({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: bullet ? '*' : '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { expect(callback).toBe( editor, ( - model: ContentModelDocument, + _model: ContentModelDocument, _previousSegment: ContentModelText, paragraph: ContentModelParagraph, context: FormatContentModelContext ) => { - return keyboardListTrigger( - model, + const result = keyboardListTrigger( + inputModel(testBullet), paragraph, context, options!.autoBullet, options!.autoNumbering ); + expect(result).toBe(expectResult); + formatOptions.apiName = result ? 'autoToggleList' : ''; + return result; } ); + expect(options).toEqual({ + changeSource: 'AutoFormat', + apiName: formatOptions.apiName, + }); }); } @@ -77,7 +116,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, { + runTest(event, true, true, { autoBullet: true, autoNumbering: true, }); @@ -88,7 +127,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: '*', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, { + runTest(event, true, false, { autoBullet: true, autoNumbering: true, }); @@ -99,7 +138,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, { autoBullet: false, autoNumbering: false } as AutoFormatOptions); + runTest(event, false, false, { autoBullet: false, autoNumbering: false }); }); it('should trigger keyboardListTrigger with auto bullet only', () => { @@ -107,7 +146,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, { autoBullet: true, autoNumbering: false } as AutoFormatOptions); + runTest(event, true, false, { autoBullet: true, autoNumbering: false }); }); it('should trigger keyboardListTrigger with auto numbering only', () => { @@ -115,7 +154,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, { autoBullet: false, autoNumbering: true } as AutoFormatOptions); + runTest(event, false, true, { autoBullet: false, autoNumbering: true }); }); it('should not trigger keyboardListTrigger if the input type is different from insertText', () => { @@ -123,7 +162,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { key: ' ', defaultPrevented: false, inputType: 'test' } as any, }; - runTest(event, { autoBullet: true, autoNumbering: true } as AutoFormatOptions); + runTest(event, true, false, { autoBullet: true, autoNumbering: true }); }); }); @@ -240,28 +279,40 @@ describe('Content Model Auto Format Plugin Test', () => { describe('onPluginEvent - createLinkAfterSpace', () => { function runTest( event: EditorInputEvent, - options?: { - autoLink: boolean; - } + expectResult: boolean, + options: AutoFormatOptions ) { const plugin = new AutoFormatPlugin(options as AutoFormatOptions); plugin.initialize(editor); + const segment: ContentModelText = { + segmentType: 'Text', + text: 'www.test.com', + format: {}, + }; + const formatOptions = { + changeSource: '', + }; + plugin.onPluginEvent(event); formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback) => { expect(callback).toBe( editor, ( _model: ContentModelDocument, - previousSegment: ContentModelText, + _previousSegment: ContentModelText, paragraph: ContentModelParagraph, context: FormatContentModelContext ) => { - return ( + const result = options && options.autoLink && - createLinkAfterSpace(previousSegment, paragraph, context) - ); + createLinkAfterSpace(segment, paragraph, context); + + expect(result).toBe(expectResult); + + formatOptions.changeSource = result ? ChangeSource.AutoLink : ''; + return result; } ); }); @@ -272,7 +323,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, { + runTest(event, true, { autoLink: true, }); }); @@ -282,7 +333,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, { + runTest(event, false, { autoLink: false, }); }); @@ -296,7 +347,7 @@ describe('Content Model Auto Format Plugin Test', () => { inputType: 'insertText', } as any, }; - runTest(event, { + runTest(event, false, { autoLink: true, }); }); @@ -305,30 +356,43 @@ describe('Content Model Auto Format Plugin Test', () => { describe('onPluginEvent - transformHyphen', () => { function runTest( event: EditorInputEvent, - options?: { - autoHyphen: boolean; - } + expectedResult: boolean, + options?: AutoFormatOptions ) { - const plugin = new AutoFormatPlugin(options as AutoFormatOptions); + const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); - plugin.onPluginEvent(event); - formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback) => { + const formatOption = { + apiName: '', + }; + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test--test', + format: {}, + }; + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { expect(callback).toBe( editor, ( _model: ContentModelDocument, - previousSegment: ContentModelText, + _previousSegment: ContentModelText, paragraph: ContentModelParagraph, context: FormatContentModelContext ) => { - return ( - options && - options.autoHyphen && - transformHyphen(previousSegment, paragraph, context) - ); + let result = false; + + if (options && options.autoHyphen) { + result = transformHyphen(segment, paragraph, context); + } + expect(result).toBe(expectedResult); + formatOption.apiName = result ? 'autoHyphen' : ''; + return result; } ); + expect(options).toEqual({ + changeSource: 'AutoFormat', + apiName: formatOption.apiName, + }); }); } @@ -337,7 +401,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, { + runTest(event, true, { autoHyphen: true, }); }); @@ -347,7 +411,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, { + runTest(event, false, { autoHyphen: false, }); }); @@ -361,9 +425,141 @@ describe('Content Model Auto Format Plugin Test', () => { inputType: 'insertText', } as any, }; - runTest(event, { + runTest(event, false, { autoHyphen: true, }); }); }); + + describe('onPluginEvent - transformFraction', () => { + function runTest( + event: EditorInputEvent, + expectResult: boolean, + options?: AutoFormatOptions + ) { + const plugin = new AutoFormatPlugin(options); + plugin.initialize(editor); + plugin.onPluginEvent(event); + const formatOption = { + apiName: '', + }; + + const segment: ContentModelText = { + segmentType: 'Text', + text: '1/2', + format: {}, + }; + + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { + expect(callback).toBe( + editor, + ( + _model: ContentModelDocument, + _previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext + ) => { + let result = false; + + if (options && options.autoHyphen) { + result = transformFraction(segment, paragraph, context); + } + expect(result).toBe(expectResult); + formatOption.apiName = ''; + return result; + } + ); + expect(options).toEqual({ + changeSource: 'AutoFormat', + apiName: formatOption.apiName, + }); + }); + } + + it('should call transformFraction', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, + }; + runTest(event, true, { + autoFraction: true, + }); + }); + + it('should not call transformHyphen - disable options', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, + }; + runTest(event, false, { + autoFraction: false, + }); + }); + }); + + describe('onPluginEvent - transformOrdinals', () => { + function runTest( + event: EditorInputEvent, + expectResult: boolean, + options?: AutoFormatOptions + ) { + const plugin = new AutoFormatPlugin(options); + plugin.initialize(editor); + plugin.onPluginEvent(event); + const formatOption = { + apiName: '', + }; + + const segment: ContentModelText = { + segmentType: 'Text', + text: '1st', + format: {}, + }; + + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback, options) => { + expect(callback).toBe( + editor, + ( + _model: ContentModelDocument, + _previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext + ) => { + let result = false; + + if (options && options.autoHyphen) { + result = transformOrdinals(segment, paragraph, context); + } + expect(result).toBe(expectResult); + formatOption.apiName = ''; + return result; + } + ); + expect(options).toEqual({ + changeSource: 'AutoFormat', + apiName: formatOption.apiName, + }); + }); + } + + it('should call transformOrdinals', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, + }; + runTest(event, true, { + autoOrdinals: true, + }); + }); + + it('should not call transformOrdinals - disable options', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, + }; + runTest(event, false, { + autoOrdinals: false, + }); + }); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts index 038ce610a2f..e3496c40d77 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts @@ -1,3 +1,4 @@ +import { ChangeSource } from '../../../../roosterjs-content-model-dom/lib/constants/ChangeSource'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createLink } from '../../../lib/autoFormat/link/createLink'; @@ -16,6 +17,7 @@ describe('createLink', () => { newImages: [], }); expect(result).toBe(expectedResult); + expect(options.changeSource).toBe(ChangeSource.AutoLink); }); createLink({ diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformFractionTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformFractionTest.ts new file mode 100644 index 00000000000..e1d384b4af2 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformFractionTest.ts @@ -0,0 +1,88 @@ +import { transformFraction } from '../../../lib/autoFormat/numbers/transformFraction'; +import { + ContentModelParagraph, + ContentModelText, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; + +describe('transformFraction', () => { + function runTest( + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext, + expectedResult: boolean + ) { + const result = transformFraction(previousSegment, paragraph, context); + expect(result).toBe(expectedResult); + } + + it('with no fraction', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); + + it('with fraction', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '1/2', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with fraction 1/4', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '1/4', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with fraction 3/4', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '3/4', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with fraction invalid fraction 1/3', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '1/3', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformOrdinalsTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformOrdinalsTest.ts new file mode 100644 index 00000000000..74ffb14f773 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/numbers/transformOrdinalsTest.ts @@ -0,0 +1,130 @@ +import { transformOrdinals } from '../../../lib/autoFormat/numbers/transformOrdinals'; +import { + ContentModelParagraph, + ContentModelText, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; + +describe('transformOrdinals', () => { + function runTest( + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext, + expectedResult: boolean + ) { + const result = transformOrdinals(previousSegment, paragraph, context); + expect(result).toBe(expectedResult); + } + + it('with no numbers', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); + + it('with 1st', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '1st', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with 2nd', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '2nd', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with 3rd', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '3rd', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with 4th', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '4th', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with 21th', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '21th', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with 2th', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: '2th', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); + + it('with first', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'first', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/customReplace/CustomReplacePluginTest.ts b/packages/roosterjs-content-model-plugins/test/customReplace/CustomReplacePluginTest.ts new file mode 100644 index 00000000000..5918b9e1a2d --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/customReplace/CustomReplacePluginTest.ts @@ -0,0 +1,126 @@ +import * as formatTextSegmentBeforeSelectionMarker from 'roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker'; +import { CustomReplace, CustomReplacePlugin } from '../../lib/customReplace/CustomReplacePlugin'; +import { + ContentModelDocument, + ContentModelParagraph, + ContentModelSegmentFormat, + ContentModelText, + FormatContentModelContext, + IEditor, +} from 'roosterjs-content-model-types'; + +function replaceEmojis( + previousSegment: ContentModelText, + stringToReplace: string, + replacement: string +) { + const { text } = previousSegment; + if (text === stringToReplace) { + previousSegment.text = text.replace(stringToReplace, replacement); + return true; + } + return false; +} + +describe('Content Model Custom Replace Plugin Test', () => { + let editor: IEditor; + let formatTextSegmentBeforeSelectionMarkerSpy: jasmine.Spy; + let customReplacePlugin: CustomReplacePlugin; + const customReplacements: CustomReplace[] = [ + { + stringToReplace: ':)', + replacementString: '😀', + replacementHandler: replaceEmojis, + }, + { + stringToReplace: 'B)', + replacementString: '😎', + replacementHandler: replaceEmojis, + }, + ]; + + beforeEach(() => { + formatTextSegmentBeforeSelectionMarkerSpy = spyOn( + formatTextSegmentBeforeSelectionMarker, + 'formatTextSegmentBeforeSelectionMarker' + ); + + editor = ({ + focus: () => {}, + getDOMSelection: () => + ({ + type: 'range', + range: { + collapsed: true, + }, + } as any), // Force return invalid range to go through content model code + formatContentModel: () => {}, + } as any) as IEditor; + + customReplacePlugin = new CustomReplacePlugin(customReplacements); + + customReplacePlugin.initialize(editor); + }); + + afterEach(() => { + customReplacePlugin.dispose(); + }); + + it('replaceEmojis should replace the text with emoji', () => { + customReplacePlugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: ':', + } as any, + }); + + customReplacePlugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: ')', + } as any, + }); + + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback) => { + expect(callback).toBe( + editor, + ( + _model: ContentModelDocument, + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + _markerFormat: ContentModelSegmentFormat, + context: FormatContentModelContext + ) => { + const replaced = customReplacements.some( + ({ stringToReplace, replacementString, replacementHandler }) => { + return replacementHandler( + previousSegment, + stringToReplace, + replacementString, + paragraph + ); + } + ); + if (replaced) { + context.canUndoByBackspace = true; + return true; + } + return false; + } + ); + }); + }); + + it('replaceEmojis not should replace the text with emoji', () => { + customReplacePlugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '(', + } as any, + }); + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 0ccc3ad7e6a..4ca88a12128 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -77,6 +77,21 @@ describe('EditPlugin', () => { expect(keyboardInputSpy).not.toHaveBeenCalled(); }); + it('Shift+Delete', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Delete', shiftKey: true } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + }); + it('Tab', () => { plugin = new EditPlugin(); const rawEvent = { key: 'Tab' } as any; diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 3edb4b8fff5..f1d394a1c40 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -1,20 +1,58 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import * as getListAnnounceData from 'roosterjs-content-model-api/lib/modelApi/list/getListAnnounceData'; import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom'; import { editingTestCommon } from '../editingTestCommon'; import { handleEnterOnList } from '../../../lib/edit/inputSteps/handleEnterOnList'; import { keyboardInput } from '../../../lib/edit/keyboardInput'; +import { + ContentModelDocument, + ContentModelListItem, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; describe('handleEnterOnList', () => { + let getListAnnounceDataSpy: jasmine.Spy; + const mockedAnnounceData = 'ANNOUNCE' as any; + + beforeEach(() => { + getListAnnounceDataSpy = spyOn(getListAnnounceData, 'getListAnnounceData').and.returnValue( + mockedAnnounceData + ); + }); + function runTest( model: ContentModelDocument, expectedModel: ContentModelDocument, - expectedResult: 'notDeleted' | 'range' + expectedResult: 'notDeleted' | 'range', + expectedListItem: ContentModelListItem | null ) { - const result = deleteSelection(model, [handleEnterOnList]); + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + const result = deleteSelection(model, [handleEnterOnList], context); normalizeContentModel(model); expect(model).toEqual(expectedModel); expect(result.deleteResult).toBe(expectedResult); + + if (expectedListItem) { + expect(getListAnnounceDataSpy).toHaveBeenCalledTimes(1); + expect(getListAnnounceDataSpy).toHaveBeenCalledWith([expectedListItem, model]); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + announceData: mockedAnnounceData, + }); + } else { + expect(getListAnnounceDataSpy).not.toHaveBeenCalled(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + } } it('no list item', () => { @@ -34,7 +72,7 @@ describe('handleEnterOnList', () => { }, ], }; - runTest(model, model, 'notDeleted'); + runTest(model, model, 'notDeleted', null); }); it('empty list item', () => { @@ -177,7 +215,7 @@ describe('handleEnterOnList', () => { ], format: {}, }; - runTest(model, expectedModel, 'range'); + runTest(model, expectedModel, 'range', null); }); it('enter on middle list item', () => { @@ -233,45 +271,52 @@ describe('handleEnterOnList', () => { ], format: {}, }; - const expectedModel: ContentModelDocument = { - blockGroupType: 'Document', + const expectedListItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', blocks: [ { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'te', - format: {}, - }, - ], + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, - ], - levels: [ { - listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - listStyleType: 'decimal', - }, - dataset: { - editingInfo: '{"orderedStyleType":1}', - }, + segmentType: 'Text', + text: 'st', + format: {}, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, format: {}, }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -279,14 +324,9 @@ describe('handleEnterOnList', () => { { blockType: 'Paragraph', segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, { segmentType: 'Text', - text: 'st', + text: 'te', format: {}, }, ], @@ -300,8 +340,6 @@ describe('handleEnterOnList', () => { marginTop: '0px', marginBottom: '0px', listStyleType: 'decimal', - startNumberOverride: undefined, - displayForDummyItem: undefined, }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -315,10 +353,11 @@ describe('handleEnterOnList', () => { }, format: {}, }, + expectedListItem, ], format: {}, }; - runTest(model, expectedModel, 'range'); + runTest(model, expectedModel, 'range', expectedListItem); }); it('enter on last list item', () => { @@ -369,46 +408,52 @@ describe('handleEnterOnList', () => { ], format: {}, }; - - const expectedModel: ContentModelDocument = { - blockGroupType: 'Document', + const expectedListItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', blocks: [ { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, - ], - levels: [ { - listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - listStyleType: 'decimal', - }, - dataset: { - editingInfo: '{"orderedStyleType":1}', - }, + segmentType: 'Br', + format: {}, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, format: {}, }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -417,12 +462,8 @@ describe('handleEnterOnList', () => { blockType: 'Paragraph', segments: [ { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', + segmentType: 'Text', + text: 'test', format: {}, }, ], @@ -436,8 +477,6 @@ describe('handleEnterOnList', () => { marginTop: '0px', marginBottom: '0px', listStyleType: 'decimal', - startNumberOverride: undefined, - displayForDummyItem: undefined, }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -451,11 +490,12 @@ describe('handleEnterOnList', () => { }, format: {}, }, + expectedListItem, ], format: {}, }; - runTest(model, expectedModel, 'range'); + runTest(model, expectedModel, 'range', expectedListItem); }); it('enter on last list item of second list', () => { @@ -629,6 +669,47 @@ describe('handleEnterOnList', () => { ], format: {}, }; + const expectedListItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }; const expectedModel: ContentModelDocument = { blockGroupType: 'Document', @@ -792,52 +873,12 @@ describe('handleEnterOnList', () => { listStyleType: '"B) "', }, }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - startNumberOverride: undefined, - displayForDummyItem: undefined, - }, - dataset: { - editingInfo: '{"orderedStyleType":10}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, - format: {}, - }, + expectedListItem, ], format: {}, }; - runTest(model, expectedModel, 'range'); + runTest(model, expectedModel, 'range', expectedListItem); }); it('enter on list item with selected text', () => { @@ -1003,7 +1044,7 @@ describe('handleEnterOnList', () => { ], format: {}, }; - runTest(model, expectedModel, 'range'); + runTest(model, expectedModel, 'range', null); }); it('enter on multiple list items with selected text', () => { @@ -1288,7 +1329,7 @@ describe('handleEnterOnList', () => { ], format: {}, }; - runTest(model, expectedModel, 'range'); + runTest(model, expectedModel, 'range', null); }); it('expanded range mixed list with paragraph', () => { @@ -1479,7 +1520,7 @@ describe('handleEnterOnList', () => { ], format: {}, }; - runTest(model, expectedModel, 'range'); + runTest(model, expectedModel, 'range', null); }); it('expanded range with mixed list with paragraph | different styles', () => { @@ -1672,7 +1713,7 @@ describe('handleEnterOnList', () => { ], format: {}, }; - runTest(model, expectedModel, 'range'); + runTest(model, expectedModel, 'range', null); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts index 9fb4f67f241..18eef96162e 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -1,5 +1,5 @@ import * as setModelIndentation from '../../../roosterjs-content-model-api/lib/modelApi/block/setModelIndentation'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelDocument, FormatContentModelContext } from 'roosterjs-content-model-types'; import { editingTestCommon } from './editingTestCommon'; import { keyboardTab } from '../../lib/edit/keyboardTab'; @@ -18,14 +18,15 @@ describe('keyboardTab', () => { shiftKey: boolean, expectedResult: boolean ) { + const context: FormatContentModelContext = { + newEntities: [], + deletedEntities: [], + newImages: [], + }; const formatWithContentModelSpy = jasmine .createSpy('formatWithContentModel') .and.callFake((callback, options) => { - const result = callback(input, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); + const result = callback(input, context); expect(result).toBe(expectedResult); }); @@ -54,7 +55,12 @@ describe('keyboardTab', () => { expect(formatWithContentModelSpy).toHaveBeenCalled(); if (indent) { - expect(setModelIndentationSpy).toHaveBeenCalledWith(input as any, indent); + expect(setModelIndentationSpy).toHaveBeenCalledWith( + input as any, + indent, + undefined, + context + ); } else { expect(setModelIndentationSpy).not.toHaveBeenCalled(); } diff --git a/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts b/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts index 346133c8567..fd7ed0add02 100644 --- a/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts +++ b/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts @@ -465,4 +465,31 @@ describe('setFormat', () => { runTest(input, '*', { fontWeight: 'bold' }, input, false); }); + + it('should not set bold - **', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '**', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + runTest(input, '*', { fontWeight: 'bold' }, input, false); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index e4377fa949c..5d194543fd3 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -45,6 +45,7 @@ describe(ID, () => { paste(editor, clipboardData, 'asImage'); const model = editor.getContentModelCopy('connected'); + const width = editor.getDOMHelper().getClientWidth(); expect(model).toEqual({ blockGroupType: 'Document', @@ -56,7 +57,7 @@ describe(ID, () => { segmentType: 'Image', src: 'https://github.com/microsoft/roosterjs', format: { - maxWidth: '100px', + maxWidth: `${width}px`, }, dataset: {}, }, diff --git a/packages/roosterjs-content-model-plugins/test/picker/PickerHelperImplTest.ts b/packages/roosterjs-content-model-plugins/test/picker/PickerHelperImplTest.ts new file mode 100644 index 00000000000..daf9f61e0a1 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/picker/PickerHelperImplTest.ts @@ -0,0 +1,177 @@ +import * as formatTextSegmentBeforeSelectionMarker from 'roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker'; +import * as mergeModel from 'roosterjs-content-model-dom/lib/modelApi/editing/mergeModel'; +import { PickerHandler } from '../../lib/picker/PickerHandler'; +import { PickerHelperImpl } from '../../lib/picker/PickerHelperImpl'; +import { + ContentModelDocument, + ContentModelParagraph, + ContentModelSegment, + ContentModelText, + FormatContentModelContext, + IEditor, +} from 'roosterjs-content-model-types'; + +describe('PickerHelperImpl.replaceQueryString', () => { + let formatTextSegmentBeforeSelectionMarkerSpy: jasmine.Spy; + let editor: IEditor; + let focusSpy: jasmine.Spy; + let mergeModelSpy: jasmine.Spy; + + beforeEach(() => { + focusSpy = jasmine.createSpy('focus'); + mergeModelSpy = spyOn(mergeModel, 'mergeModel'); + + formatTextSegmentBeforeSelectionMarkerSpy = spyOn( + formatTextSegmentBeforeSelectionMarker, + 'formatTextSegmentBeforeSelectionMarker' + ); + editor = { + focus: focusSpy, + } as any; + }); + + function runTest( + target: ContentModelDocument, + prev: ContentModelSegment, + para: ContentModelParagraph, + expectedCalledModel: ContentModelDocument | null + ) { + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + let callbackFunc: any; + + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake( + (editor: IEditor, callback: Function) => { + callbackFunc = callback; + const result = callback(target, prev, para, {}, context); + + expect(result).toBe(!!expectedCalledModel); + + return result; + } + ); + + const mockedModel = 'MODEL' as any; + const options = 'OPTIONS' as any; + const helper = new PickerHelperImpl(editor, null!, '@'); + + helper.replaceQueryString(mockedModel, options, true); + + expect(focusSpy).toHaveBeenCalledWith(); + expect(formatTextSegmentBeforeSelectionMarkerSpy).toHaveBeenCalledWith( + editor, + callbackFunc, + options + ); + + if (expectedCalledModel) { + expect(context.canUndoByBackspace).toBe(true); + expect(mergeModelSpy).toHaveBeenCalledWith(expectedCalledModel, mockedModel, context); + } else { + expect(context.canUndoByBackspace).toBeFalsy(); + expect(mergeModelSpy).not.toHaveBeenCalled(); + } + } + + it('No trigger character', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'test', + format: {}, + }; + const para: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [text], + }; + const doc: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [para], + }; + runTest(doc, text, para, null); + }); + + it('Has trigger character', () => { + const text: ContentModelText = { + segmentType: 'Text', + text: 'te@st', + format: {}, + }; + const para: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [text], + }; + const doc: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [para], + }; + runTest(doc, text, para, { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: '@st', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); +}); + +describe('PickerHelperImpl.closePicker', () => { + it('picker was closed', () => { + const onClosePickerSpy = jasmine.createSpy('onClosePicker'); + const handler: PickerHandler = { + onClosePicker: onClosePickerSpy, + } as any; + const helper = new PickerHelperImpl(null!, handler, '@'); + + helper.closePicker(); + + expect(helper.direction).toBeFalsy(); + expect(onClosePickerSpy).not.toHaveBeenCalled(); + }); + + it('picker was open', () => { + const onClosePickerSpy = jasmine.createSpy('onClosePicker'); + const handler: PickerHandler = { + onClosePicker: onClosePickerSpy, + } as any; + const helper = new PickerHelperImpl(null!, handler, '@'); + + helper.direction = 'both'; + + helper.closePicker(); + + expect(helper.direction).toBeNull(); + expect(onClosePickerSpy).toHaveBeenCalled(); + }); + + it('no onClosePicker callback', () => { + const handler: PickerHandler = {} as any; + const helper = new PickerHelperImpl(null!, handler, '@'); + + helper.direction = 'both'; + + helper.closePicker(); + + expect(helper.direction).toBeNull(); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/picker/PickerPluginTest.ts b/packages/roosterjs-content-model-plugins/test/picker/PickerPluginTest.ts new file mode 100644 index 00000000000..af28935eb75 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/picker/PickerPluginTest.ts @@ -0,0 +1,454 @@ +import * as formatTextSegmentBeforeSelectionMarker from 'roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker'; +import { PickerPlugin } from '../../lib/picker/PickerPlugin'; +import { + PickerDirection, + PickerHandler, + PickerSelectionChangMode, +} from '../../lib/picker/PickerHandler'; +import { + ContentModelSegment, + IEditor, + KeyDownEvent, + ContentModelParagraph, +} from 'roosterjs-content-model-types'; + +describe('PickerPlugin', () => { + let mockedHandler: PickerHandler; + let onInitializeSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let onTriggerSpy: jasmine.Spy; + let onClosePickerSpy: jasmine.Spy; + let onQueryStringChangedSpy: jasmine.Spy; + let isRightToLeftSpy: jasmine.Spy; + let mockedEditor: IEditor; + let mockedSegment: ContentModelSegment; + let mockedParagraph: ContentModelParagraph; + + beforeEach(() => { + onInitializeSpy = jasmine.createSpy('onInitialize'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection').and.returnValue({ + type: 'range', + range: document.createRange(), + }); + onTriggerSpy = jasmine.createSpy('onTrigger'); + onQueryStringChangedSpy = jasmine.createSpy('onQueryStringChanged'); + onClosePickerSpy = jasmine.createSpy('onClosePicker'); + isRightToLeftSpy = jasmine.createSpy('isRightToLeft').and.returnValue(false); + + spyOn( + formatTextSegmentBeforeSelectionMarker, + 'formatTextSegmentBeforeSelectionMarker' + ).and.callFake((_: IEditor, callback: Function) => { + const result = callback(null!, mockedSegment, mockedParagraph); + + expect(result).toBeFalse(); + + return true; + }); + + mockedHandler = { + onInitialize: onInitializeSpy, + onTrigger: onTriggerSpy, + onQueryStringChanged: onQueryStringChangedSpy, + onClosePicker: onClosePickerSpy, + } as any; + + mockedEditor = { + getEnvironment: () => ({}), + getDOMSelection: getDOMSelectionSpy, + getDOMHelper: () => ({ + isRightToLeft: isRightToLeftSpy, + }), + } as any; + mockedSegment = { + segmentType: 'Text', + text: '@', + format: {}, + }; + mockedParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [mockedSegment], + }; + }); + + it('willHandleEventExclusively', () => { + const plugin = new PickerPlugin('@', mockedHandler); + + plugin.initialize(mockedEditor); + + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { + key: 'ArrowUp', + } as any, + }; + + expect(plugin.willHandleEventExclusively(event)).toBeFalse(); + + onTriggerSpy.and.returnValue('both'); + + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '@', + } as any, + }); + + expect(plugin.willHandleEventExclusively(event)).toBeTrue(); + expect( + plugin.willHandleEventExclusively({ + eventType: 'keyDown', + rawEvent: { + key: 'a', + } as any, + }) + ).toBeFalse(); + }); + + it('content changed event', () => { + const plugin = new PickerPlugin('@', mockedHandler); + + plugin.initialize(mockedEditor); + + onTriggerSpy.and.returnValue('both'); + + // Not suggesting + plugin.onPluginEvent({ + eventType: 'contentChanged', + source: 'Test', + }); + + expect(onQueryStringChangedSpy).toHaveBeenCalledTimes(0); + + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '@', + } as any, + }); + + // Suggesting + plugin.onPluginEvent({ + eventType: 'contentChanged', + source: 'Test', + }); + + expect(onQueryStringChangedSpy).toHaveBeenCalledTimes(1); + expect(onQueryStringChangedSpy).toHaveBeenCalledWith('@'); + expect(onClosePickerSpy).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ + eventType: 'contentChanged', + source: 'SetContent', + }); + + expect(onQueryStringChangedSpy).toHaveBeenCalledTimes(1); + expect(onClosePickerSpy).toHaveBeenCalledTimes(1); + }); + + describe('keyDown', () => { + it('not suggesting', () => { + const plugin = new PickerPlugin('@', mockedHandler); + + plugin.initialize(mockedEditor); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const onSelectionChangedSpy = jasmine.createSpy('onSelectionChanged'); + + mockedHandler.onSelectionChanged = onSelectionChangedSpy; + + // Not suggesting + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'PageUp', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(onSelectionChangedSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + function runTest( + dir: PickerDirection, + ctrl: boolean, + key: string, + expectedMode: PickerSelectionChangMode | null + ) { + const plugin = new PickerPlugin('@', mockedHandler); + const onSelectionChangedSpy = jasmine.createSpy('onSelectionChanged'); + const onSelectSpy = jasmine.createSpy('onSelect'); + + mockedHandler.onSelectionChanged = onSelectionChangedSpy; + mockedHandler.onSelect = onSelectSpy; + + plugin.initialize(mockedEditor); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + onTriggerSpy.and.returnValue(dir); + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '@', + } as any, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key, + preventDefault: preventDefaultSpy, + ctrlKey: ctrl, + } as any, + }); + + if (expectedMode) { + expect(onSelectionChangedSpy).toHaveBeenCalledTimes(1); + expect(onSelectionChangedSpy).toHaveBeenCalledWith(expectedMode); + } else { + expect(onSelectionChangedSpy).not.toHaveBeenCalled(); + } + + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(onSelectSpy).not.toHaveBeenCalled(); + } + + it('Suggesting: horizontal, LTR', () => { + runTest('horizontal', false, 'ArrowLeft', 'previous'); + runTest('horizontal', false, 'ArrowRight', 'next'); + runTest('horizontal', false, 'ArrowUp', null); + runTest('horizontal', false, 'ArrowDown', null); + runTest('horizontal', false, 'PageUp', 'previousPage'); + runTest('horizontal', false, 'PageDown', 'nextPage'); + runTest('horizontal', false, 'Home', 'firstInRow'); + runTest('horizontal', false, 'End', 'lastInRow'); + runTest('horizontal', true, 'Home', 'first'); + runTest('horizontal', true, 'End', 'last'); + }); + + it('Suggesting: horizontal, RTL', () => { + isRightToLeftSpy.and.returnValue(true); + + runTest('horizontal', false, 'ArrowLeft', 'next'); + runTest('horizontal', false, 'ArrowRight', 'previous'); + runTest('horizontal', false, 'ArrowUp', null); + runTest('horizontal', false, 'ArrowDown', null); + runTest('horizontal', false, 'PageUp', 'previousPage'); + runTest('horizontal', false, 'PageDown', 'nextPage'); + runTest('horizontal', false, 'Home', 'firstInRow'); + runTest('horizontal', false, 'End', 'lastInRow'); + runTest('horizontal', true, 'Home', 'first'); + runTest('horizontal', true, 'End', 'last'); + }); + + it('Suggesting: both, LTR', () => { + runTest('both', false, 'ArrowLeft', 'previous'); + runTest('both', false, 'ArrowRight', 'next'); + runTest('both', false, 'ArrowUp', 'previousRow'); + runTest('both', false, 'ArrowDown', 'nextRow'); + runTest('both', false, 'PageUp', 'previousPage'); + runTest('both', false, 'PageDown', 'nextPage'); + runTest('both', false, 'Home', 'firstInRow'); + runTest('both', false, 'End', 'lastInRow'); + runTest('both', true, 'Home', 'first'); + runTest('both', true, 'End', 'last'); + }); + + it('Suggesting: both, RTL', () => { + isRightToLeftSpy.and.returnValue(true); + + runTest('both', false, 'ArrowLeft', 'next'); + runTest('both', false, 'ArrowRight', 'previous'); + runTest('both', false, 'ArrowUp', 'previousRow'); + runTest('both', false, 'ArrowDown', 'nextRow'); + runTest('both', false, 'PageUp', 'previousPage'); + runTest('both', false, 'PageDown', 'nextPage'); + runTest('both', false, 'Home', 'firstInRow'); + runTest('both', false, 'End', 'lastInRow'); + runTest('both', true, 'Home', 'first'); + runTest('both', true, 'End', 'last'); + }); + + it('Suggesting: vertical', () => { + runTest('vertical', false, 'ArrowLeft', null); + runTest('vertical', false, 'ArrowRight', null); + runTest('vertical', false, 'ArrowUp', 'previous'); + runTest('vertical', false, 'ArrowDown', 'next'); + runTest('vertical', false, 'PageUp', 'previousPage'); + runTest('vertical', false, 'PageDown', 'nextPage'); + runTest('vertical', false, 'Home', 'firstInRow'); + runTest('vertical', false, 'End', 'lastInRow'); + runTest('vertical', true, 'Home', 'first'); + runTest('vertical', true, 'End', 'last'); + }); + + it('Suggesting: ESCAPE', () => { + const plugin = new PickerPlugin('@', mockedHandler); + const onSelectionChangedSpy = jasmine.createSpy('onSelectionChanged'); + + mockedHandler.onSelectionChanged = onSelectionChangedSpy; + + plugin.initialize(mockedEditor); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + onTriggerSpy.and.returnValue('both'); + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '@', + } as any, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'Escape', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(onSelectionChangedSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(onClosePickerSpy).toHaveBeenCalledTimes(1); + }); + + it('Suggesting: ENTER', () => { + const plugin = new PickerPlugin('@', mockedHandler); + const onSelectionChangedSpy = jasmine.createSpy('onSelectionChanged'); + const onSelectSpy = jasmine.createSpy('onSelect'); + + mockedHandler.onSelectionChanged = onSelectionChangedSpy; + mockedHandler.onSelect = onSelectSpy; + + plugin.initialize(mockedEditor); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + onTriggerSpy.and.returnValue('both'); + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '@', + } as any, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'Enter', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(onSelectionChangedSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(onClosePickerSpy).toHaveBeenCalledTimes(0); + expect(onSelectSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('input', () => { + it('Not suggesting', () => { + const plugin = new PickerPlugin('@', mockedHandler); + + plugin.initialize(mockedEditor); + + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '@', + } as any, + }); + + expect(onQueryStringChangedSpy).toHaveBeenCalledTimes(0); + expect(onClosePickerSpy).not.toHaveBeenCalled(); + expect(onTriggerSpy).toHaveBeenCalledWith('@', jasmine.anything()); + }); + + it('Suggesting', () => { + const plugin = new PickerPlugin('@', mockedHandler); + + plugin.initialize(mockedEditor); + + onTriggerSpy.and.returnValue('both'); + + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '@', + } as any, + }); + + // Suggesting + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: {} as any, + }); + + expect(onQueryStringChangedSpy).toHaveBeenCalledTimes(1); + expect(onQueryStringChangedSpy).toHaveBeenCalledWith('@'); + expect(onClosePickerSpy).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ + eventType: 'contentChanged', + source: 'SetContent', + }); + + expect(onQueryStringChangedSpy).toHaveBeenCalledTimes(1); + expect(onClosePickerSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('mouseup', () => { + it('Not suggesting', () => { + const plugin = new PickerPlugin('@', mockedHandler); + + plugin.initialize(mockedEditor); + + // Suggesting + plugin.onPluginEvent({ + eventType: 'mouseUp', + rawEvent: {} as any, + }); + + expect(onQueryStringChangedSpy).toHaveBeenCalledTimes(0); + expect(onClosePickerSpy).not.toHaveBeenCalled(); + }); + + it('Suggesting', () => { + const plugin = new PickerPlugin('@', mockedHandler); + + plugin.initialize(mockedEditor); + + onTriggerSpy.and.returnValue('both'); + + plugin.onPluginEvent({ + eventType: 'input', + rawEvent: { + inputType: 'insertText', + data: '@', + } as any, + }); + + // Suggesting + plugin.onPluginEvent({ + eventType: 'mouseUp', + rawEvent: {} as any, + }); + + expect(onQueryStringChangedSpy).toHaveBeenCalledTimes(0); + expect(onClosePickerSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/picker/getQueryStringTest.ts b/packages/roosterjs-content-model-plugins/test/picker/getQueryStringTest.ts new file mode 100644 index 00000000000..f5a1ffd9dea --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/picker/getQueryStringTest.ts @@ -0,0 +1,153 @@ +import { getQueryString } from '../../lib/picker/getQueryString'; +import type { + ContentModelBr, + ContentModelParagraph, + ContentModelText, +} from 'roosterjs-content-model-types'; + +describe('getQueryString', () => { + it('paragraph with empty text segment', () => { + const segment: ContentModelText = { + segmentType: 'Text', + format: {}, + text: '', + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [segment], + }; + const splittedResult: ContentModelText[] = []; + + const result = getQueryString('@', paragraph, segment, splittedResult); + + expect(result).toBe(''); + expect(splittedResult).toEqual([segment]); + }); + + it('paragraph with text segment, no trigger char', () => { + const segment: ContentModelText = { + segmentType: 'Text', + format: {}, + text: 'test', + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [segment], + }; + const splittedResult: ContentModelText[] = []; + + const result = getQueryString('@', paragraph, segment, splittedResult); + + expect(result).toBe(''); + expect(splittedResult).toEqual([segment]); + }); + + it('paragraph with multiple text segments, no trigger char', () => { + const segment1: ContentModelText = { + segmentType: 'Text', + format: {}, + text: 'test1', + }; + const segment2: ContentModelText = { + segmentType: 'Text', + format: {}, + text: 'test2', + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [segment1, segment2], + }; + const splittedResult: ContentModelText[] = []; + + const result = getQueryString('@', paragraph, segment2, splittedResult); + + expect(result).toBe(''); + expect(splittedResult).toEqual([segment1, segment2]); + }); + + it('paragraph with multiple text segments, has trigger char', () => { + const segment1: ContentModelText = { + segmentType: 'Text', + format: {}, + text: 'te@st1', + }; + const segment2: ContentModelText = { + segmentType: 'Text', + format: {}, + text: 'test2', + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [segment1, segment2], + }; + const splittedResult: ContentModelText[] = []; + + const result = getQueryString('@', paragraph, segment2, splittedResult); + + expect(result).toBe('@st1test2'); + expect(splittedResult).toEqual([ + { + segmentType: 'Text', + format: {}, + text: '@st1', + isSelected: undefined, + }, + segment2, + ]); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'te', + isSelected: undefined, + }, + { + segmentType: 'Text', + format: {}, + text: '@st1', + isSelected: undefined, + }, + { + segmentType: 'Text', + format: {}, + text: 'test2', + }, + ], + }); + }); + + it('paragraph with multiple text segments, has other type of segment after trigger', () => { + const segment1: ContentModelText = { + segmentType: 'Text', + format: {}, + text: 'te@st1', + }; + const segment2: ContentModelText = { + segmentType: 'Text', + format: {}, + text: 'test2', + }; + const br: ContentModelBr = { + segmentType: 'Br', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [segment1, br, segment2], + }; + const splittedResult: ContentModelText[] = []; + + const result = getQueryString('@', paragraph, segment2, splittedResult); + + expect(result).toBe(''); + expect(splittedResult).toEqual([segment2]); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts b/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts index 1afe60d8740..bcb173ed278 100644 --- a/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts @@ -219,7 +219,7 @@ describe('ShortcutPlugin', () => { expect(apiSpy).toHaveBeenCalledWith(mockedEditor); }); - it('redo 2', () => { + it('redo 3', () => { const apiSpy = spyOn(redo, 'redo'); const plugin = new ShortcutPlugin(); const event: PluginEvent = { @@ -231,12 +231,12 @@ describe('ShortcutPlugin', () => { const exclusively = plugin.willHandleEventExclusively(event); - expect(exclusively).toBeFalse(); - expect(event.eventDataCache!.__ShortcutCommandCache).toBeUndefined(); + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); plugin.onPluginEvent(event); - expect(apiSpy).not.toHaveBeenCalled(); + expect(apiSpy).toHaveBeenCalledWith(mockedEditor); }); it('bullet list', () => { diff --git a/packages/roosterjs-content-model-plugins/test/shortcut/utils/setShortcutIndentationCommandTest.ts b/packages/roosterjs-content-model-plugins/test/shortcut/utils/setShortcutIndentationCommandTest.ts index f9562243de8..c9f412eb65b 100644 --- a/packages/roosterjs-content-model-plugins/test/shortcut/utils/setShortcutIndentationCommandTest.ts +++ b/packages/roosterjs-content-model-plugins/test/shortcut/utils/setShortcutIndentationCommandTest.ts @@ -49,7 +49,12 @@ describe('setShortcutIndentationCommand', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(1); if (shouldIndent) { expect(setModelIndentationSpy).toHaveBeenCalledTimes(1); - expect(setModelIndentationSpy).toHaveBeenCalledWith(model, operation); + expect(setModelIndentationSpy).toHaveBeenCalledWith( + model, + operation, + undefined, + context + ); } else { expect(setModelIndentationSpy).not.toHaveBeenCalled(); } diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts index 17901bace20..fb6a2421c0e 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts @@ -1,3 +1,5 @@ +import * as getIntersectedRect from '../../lib/pluginUtils/Rect/getIntersectedRect'; +import { createTableInserter } from '../../lib/tableEdit/editors/features/TableInserter'; import { DOMEventHandlerFunction, IEditor } from 'roosterjs-content-model-types'; import { getMergedFirstColumnTable, getMergedTopRowTable, getModelTable } from './tableData'; import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; @@ -112,4 +114,112 @@ describe('Table Inserter tests', () => { }); expect(inserterFound).toBe('not clickable'); }); + + it('Customize table inserter', () => { + spyOn(getIntersectedRect, 'getIntersectedRect').and.returnValue({ + bottom: 10, + left: 10, + right: 10, + top: 10, + }); + + const disposer = jasmine.createSpy('disposer'); + const changeCb = jasmine.createSpy('changeCb'); + //Act + const result = createTableInserter( + editor, + ({ + getBoundingClientRect: () => { + return { + bottom: 10, + height: 10, + left: 10, + right: 10, + top: 10, + }; + }, + ownerDocument: document, + }), + { + getBoundingClientRect: () => { + return { + bottom: 10, + height: 10, + left: 10, + right: 10, + }; + ownerDocument: document; + }, + }, + false, + false, + () => {}, + undefined, + (editorType, element) => { + if (element && editorType == 'VerticalTableInserter') { + changeCb(); + } + return () => disposer(); + } + ); + + result?.featureHandler?.dispose(); + + expect(disposer).toHaveBeenCalled(); + expect(changeCb).toHaveBeenCalled(); + }); + + it('Customize table inserter, do not customize editortype is not in the cb', () => { + spyOn(getIntersectedRect, 'getIntersectedRect').and.returnValue({ + bottom: 10, + left: 10, + right: 10, + top: 10, + }); + + const disposer = jasmine.createSpy('disposer'); + const changeCb = jasmine.createSpy('changeCb'); + //Act + const result = createTableInserter( + editor, + ({ + getBoundingClientRect: () => { + return { + bottom: 10, + height: 10, + left: 10, + right: 10, + top: 10, + }; + }, + ownerDocument: document, + }), + { + getBoundingClientRect: () => { + return { + bottom: 10, + height: 10, + left: 10, + right: 10, + }; + ownerDocument: document; + }, + }, + false, + false, + () => {}, + undefined, + (editorType, element) => { + if (element && editorType == 'TableMover') { + changeCb(); + } + return () => disposer(); + } + ); + + result?.featureHandler?.dispose(); + + expect(disposer).toHaveBeenCalled(); + expect(changeCb).not.toHaveBeenCalled(); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts index 3c16dc0284d..f79c7fec44e 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts @@ -1,6 +1,7 @@ import { createTableMover } from '../../lib/tableEdit/editors/features/TableMover'; import { Editor } from 'roosterjs-content-model-core'; import { EditorOptions, IEditor } from 'roosterjs-content-model-types'; +import { OnTableEditorCreatedCallback } from '../../lib/tableEdit/OnTableEditorCreatedCallback'; import { TableEditor } from '../../lib/tableEdit/editors/TableEditor'; import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; @@ -181,6 +182,52 @@ describe('Table Mover Tests', () => { runTest(0, true); }); + it('Customize component with callback', () => { + //Arrange + const scrollContainer = document.createElement('div'); + scrollContainer.innerHTML = '
'; + document.body.insertBefore(scrollContainer, document.body.childNodes[0]); + scrollContainer.append(node); + spyOn(editor, 'getScrollContainer').and.returnValue(scrollContainer); + + const disposer = jasmine.createSpy('disposer'); + const changeCb = jasmine.createSpy('disposer'); + const mover = runTest(0, true, (editorType, element) => { + if (element && editorType == 'TableMover') { + changeCb(); + } + return () => disposer(); + }); + + mover?.featureHandler?.dispose(); + + expect(disposer).toHaveBeenCalled(); + expect(changeCb).toHaveBeenCalled(); + }); + + it('Dont customize component with callback, editor type not in callback', () => { + //Arrange + const scrollContainer = document.createElement('div'); + scrollContainer.innerHTML = '
'; + document.body.insertBefore(scrollContainer, document.body.childNodes[0]); + scrollContainer.append(node); + spyOn(editor, 'getScrollContainer').and.returnValue(scrollContainer); + + const disposer = jasmine.createSpy('disposer'); + const changeCb = jasmine.createSpy('disposer'); + const mover = runTest(0, true, (editorType, element) => { + if (element && editorType == 'TableResizer') { + changeCb(); + } + return () => disposer(); + }); + + mover?.featureHandler?.dispose(); + + expect(disposer).toHaveBeenCalled(); + expect(changeCb).not.toHaveBeenCalled(); + }); + it('On click event', () => { const table = document.getElementById(targetId) as HTMLTableElement; @@ -202,7 +249,11 @@ describe('Table Mover Tests', () => { } }); - function runTest(scrollTop: number, isNotNull: boolean | null) { + function runTest( + scrollTop: number, + isNotNull: boolean | null, + onTableEditorCreatedCallback?: OnTableEditorCreatedCallback + ) { //Arrange node.style.height = '10px'; node.style.overflowX = 'auto'; @@ -216,8 +267,9 @@ describe('Table Mover Tests', () => { editor, false, () => {}, - () => () => {}, - node + node, + undefined, + onTableEditorCreatedCallback ); //Assert @@ -226,5 +278,7 @@ describe('Table Mover Tests', () => { } else { expect(result).toBeDefined(); } + + return result; } }); diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts index a1a410397c5..7acb5c00941 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts @@ -1,5 +1,8 @@ +import * as getIntersectedRect from '../../lib/pluginUtils/Rect/getIntersectedRect'; +import { createTableResizer } from '../../lib/tableEdit/editors/features/TableResizer'; import { getModelTable } from './tableData'; import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; + import { ContentModelTable, DOMEventHandlerFunction, @@ -194,4 +197,90 @@ xdescribe('Table Resizer tests', () => { it('decreases the width and height of the table', () => { resizeWholeTableTest(getModelTable(), -1, 'both'); }); + + it('Customize table inserter', () => { + spyOn(getIntersectedRect, 'getIntersectedRect').and.returnValue({ + bottom: 10, + left: 10, + right: 10, + top: 10, + }); + + const disposer = jasmine.createSpy('disposer'); + const changeCb = jasmine.createSpy('changeCb'); + //Act + const result = createTableResizer( + { + getBoundingClientRect: () => { + return { + bottom: 10, + height: 10, + left: 10, + right: 10, + }; + ownerDocument: document; + }, + }, + editor, + false, + () => {}, + () => false, + null, + undefined, + (editorType, element) => { + if (element && editorType == 'TableResizer') { + changeCb(); + } + return () => disposer(); + } + ); + + result?.featureHandler?.dispose(); + + expect(disposer).toHaveBeenCalled(); + expect(changeCb).toHaveBeenCalled(); + }); + + it('Customize table inserter, do not customize wrong editor type', () => { + spyOn(getIntersectedRect, 'getIntersectedRect').and.returnValue({ + bottom: 10, + left: 10, + right: 10, + top: 10, + }); + + const disposer = jasmine.createSpy('disposer'); + const changeCb = jasmine.createSpy('changeCb'); + //Act + const result = createTableResizer( + { + getBoundingClientRect: () => { + return { + bottom: 10, + height: 10, + left: 10, + right: 10, + }; + ownerDocument: document; + }, + }, + editor, + false, + () => {}, + () => false, + null, + undefined, + (editorType, element) => { + if (element && editorType == 'TableMover') { + changeCb(); + } + return () => disposer(); + } + ); + + result?.featureHandler?.dispose(); + + expect(disposer).toHaveBeenCalled(); + expect(changeCb).not.toHaveBeenCalled(); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 0a570bb91db..d292bb2e714 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -1,3 +1,4 @@ +import type { AnnounceData } from '../parameter/AnnounceData'; import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEvent } from '../event/PluginEvent'; import type { PluginState } from '../pluginState/PluginState'; @@ -171,6 +172,13 @@ export type SetEditorStyle = ( maxRuleLength?: number ) => void; +/** + * Announce the given data + * @param core The EditorCore object + * @param announceData Data to announce + */ +export type Announce = (core: EditorCore, announceData: AnnounceData) => void; + /** * The interface for the map of core API for Editor. * Editor can call call API from this map under EditorCore object @@ -292,6 +300,13 @@ export interface CoreApiMap { * combined with root selector together to build a separate rule. */ setEditorStyle: SetEditorStyle; + + /** + * Announce the given data + * @param core The EditorCore object + * @param announceData Data to announce + */ + announce: Announce; } /** diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts index 0c08db6a0be..ac038940178 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts @@ -1,3 +1,4 @@ +import type { KnownAnnounceStrings } from '../parameter/AnnounceData'; import type { PasteType } from '../enum/PasteType'; import type { Colors, ColorTransformFunction } from '../context/DarkColorHandler'; import type { EditorPlugin } from './EditorPlugin'; @@ -117,4 +118,11 @@ export interface EditorOptions { * Default paste type. By default will use the normal (as-is) paste type. */ defaultPasteType?: PasteType; + + /** + * A callback to help get string template to announce, used for accessibility + * @param key The key of known announce data + * @returns A template string to announce, use placeholder such as "{0}" for variables if necessary + */ + announcerStringGetter?: (key: KnownAnnounceStrings) => string; } diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index 826d2d27ab9..f32b2ae797a 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -1,3 +1,4 @@ +import type { AnnounceData } from '../parameter/AnnounceData'; import type { DomToModelOptionForCreateModel } from '../context/DomToModelOption'; import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEventData, PluginEventFromType } from '../event/PluginEventData'; @@ -218,4 +219,10 @@ export interface IEditor { cssRule: string | null, subSelectors?: 'before' | 'after' | string[] ): void; + + /** + * Announce the given data + * @param announceData Data to announce + */ + announce(announceData: AnnounceData): void; } diff --git a/packages/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts b/packages/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts index 45e52c946f6..1f1de8bccc3 100644 --- a/packages/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts +++ b/packages/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts @@ -66,7 +66,7 @@ export interface ContentChangedEvent extends BasePluginEvent<'contentChanged'> { readonly formatApiName?: string; /** - * @optional Announce data from this content changed event. + * @deprecated Call editor.announce(announceData) directly insteaad */ readonly announceData?: AnnounceData; } diff --git a/packages/roosterjs-content-model-types/lib/group/ContentModelDocument.ts b/packages/roosterjs-content-model-types/lib/group/ContentModelDocument.ts index 0d9126a3ae0..7f1eac6188c 100644 --- a/packages/roosterjs-content-model-types/lib/group/ContentModelDocument.ts +++ b/packages/roosterjs-content-model-types/lib/group/ContentModelDocument.ts @@ -7,4 +7,9 @@ import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; */ export interface ContentModelDocument extends ContentModelBlockGroupBase<'Document'>, - Partial> {} + Partial> { + /** + * Whether the selection in model (if any) is a revert selection (end is before start) + */ + hasRevertedRangeSelection?: boolean; +} diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index c0c22b2d616..df428bb8d0a 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -226,6 +226,7 @@ export { RestoreUndoSnapshot, GetVisibleViewport, SetEditorStyle, + Announce, } from './editor/EditorCore'; export { EditorCorePlugins } from './editor/EditorCorePlugins'; export { EditorPlugin } from './editor/EditorPlugin'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 82b6443e17d..9bb886a6ac8 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -81,4 +81,14 @@ export interface DOMHelper { * @returns True if the editor has focus, otherwise false */ hasFocus(): boolean; + + /** + * Check if the root element is in RTL mode + */ + isRightToLeft(): boolean; + + /** + * Get the width of the editable area of the editor content div + */ + getClientWidth(): number; } diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts index 3c895186c3c..a993e3302ef 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts @@ -1,3 +1,4 @@ +import type { AnnounceData } from './AnnounceData'; import type { ContentModelEntity } from '../entity/ContentModelEntity'; import type { ContentModelImage } from '../segment/ContentModelImage'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; @@ -97,4 +98,9 @@ export interface FormatContentModelContext { * @optional Set to true if this action can be undone when user press Backspace key (aka Auto Complete). */ canUndoByBackspace?: boolean; + + /** + * @optional Set this value to tell AnnouncePlugin to announce the given information + */ + announceData?: AnnounceData | null; } diff --git a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts index fb7d1f07f24..ce69c682d00 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts @@ -1,3 +1,5 @@ +import type { KnownAnnounceStrings } from '../parameter/AnnounceData'; + /** * The state object for LifecyclePlugin */ @@ -12,6 +14,18 @@ export interface LifecyclePluginState { */ shadowEditFragment: DocumentFragment | null; + /** + * The HTML container for announced string + */ + announceContainer?: HTMLElement; + + /** + * A callback to help get string template to announce, used for accessibility + * @param key The key of known announce data + * @returns A template string to announce, use placeholder such as "{0}" for variables if necessary + */ + readonly announcerStringGetter?: (key: KnownAnnounceStrings) => string; + /** * Style elements used for adding CSS rules for editor */ diff --git a/versions.json b/versions.json index 618ac735efe..01511d6a32f 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ -{ - "legacy": "8.62.0", - "react": "8.56.0", - "main": "9.1.0", - "legacyAdapter": "8.62.0" -} +{ + "legacy": "8.62.0", + "react": "8.56.0", + "main": "9.2.0", + "legacyAdapter": "8.62.0" +}