diff --git a/demo/scripts/controlsV2/demoButtons/exportContentButton.ts b/demo/scripts/controlsV2/demoButtons/exportContentButton.ts index 8cf6371219a..84057a5e470 100644 --- a/demo/scripts/controlsV2/demoButtons/exportContentButton.ts +++ b/demo/scripts/controlsV2/demoButtons/exportContentButton.ts @@ -4,7 +4,10 @@ import type { RibbonButton } from '../roosterjsReact/ribbon'; /** * Key of localized strings of Zoom button */ -export type ExportButtonStringKey = 'buttonNameExport'; +export type ExportButtonStringKey = + | 'buttonNameExport' + | 'menuNameExportHTML' + | 'menuNameExportText'; /** * "Export content" button on the format ribbon @@ -14,9 +17,22 @@ export const exportContentButton: RibbonButton = { unlocalizedText: 'Export', iconName: 'Export', flipWhenRtl: true, - onClick: editor => { + dropDownMenu: { + items: { + menuNameExportHTML: 'as HTML', + menuNameExportText: 'as Plain Text', + }, + }, + onClick: (editor, key) => { const win = editor.getDocument().defaultView.open(); - const html = exportContent(editor); + let html = ''; + + if (key == 'menuNameExportHTML') { + html = exportContent(editor); + } else if (key == 'menuNameExportText') { + html = `
${exportContent(editor, 'PlainText')}
`; + } + win.document.write(editor.getTrustedHTMLHandler()(html)); }, commandBarProperties: { diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 8ef4939beee..0f70a391e7f 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -24,7 +24,7 @@ import { getDarkColor } from 'roosterjs-color-utils'; import { getPresetModelById } from '../sidePane/presets/allPresets/allPresets'; import { getTabs, tabNames } from '../tabs/getTabs'; import { getTheme } from '../theme/themes'; -import { OptionState } from '../sidePane/editorOptions/OptionState'; +import { OptionState, UrlPlaceholder } from '../sidePane/editorOptions/OptionState'; import { popoutButton } from '../demoButtons/popoutButton'; import { PresetPlugin } from '../sidePane/presets/PresetPlugin'; import { redoButton } from '../roosterjsReact/ribbon/buttons/redoButton'; @@ -47,6 +47,7 @@ import { import { AutoFormatPlugin, EditPlugin, + HyperlinkPlugin, MarkdownPlugin, PastePlugin, ShortcutPlugin, @@ -474,26 +475,18 @@ export class MainPane extends React.Component<{}, MainPaneState> { tableMenu, imageMenu, watermarkText, + markdownOptions, + autoFormatOptions, + linkTitle, } = this.state.initState; return [ - pluginList.autoFormat && - new AutoFormatPlugin({ - autoBullet: true, - autoNumbering: true, - autoUnlink: true, - autoLink: true, - }), + pluginList.autoFormat && new AutoFormatPlugin(autoFormatOptions), pluginList.edit && new EditPlugin(), pluginList.paste && new PastePlugin(allowExcelNoBorderTable), pluginList.shortcut && new ShortcutPlugin(), pluginList.tableEdit && new TableEditPlugin(), pluginList.watermark && new WatermarkPlugin(watermarkText), - pluginList.markdown && - new MarkdownPlugin({ - bold: true, - italic: true, - strikethrough: true, - }), + pluginList.markdown && new MarkdownPlugin(markdownOptions), pluginList.emoji && createEmojiPlugin(), pluginList.pasteOption && createPasteOptionPlugin(), pluginList.sampleEntity && new SampleEntityPlugin(), @@ -501,6 +494,12 @@ export class MainPane extends React.Component<{}, MainPaneState> { pluginList.contextMenu && listMenu && createListEditMenuProvider(), pluginList.contextMenu && tableMenu && createTableEditMenuProvider(), pluginList.contextMenu && imageMenu && createImageEditMenuProvider(), + pluginList.hyperlink && + new HyperlinkPlugin( + linkTitle?.indexOf(UrlPlaceholder) >= 0 + ? url => linkTitle.replace(UrlPlaceholder, url) + : linkTitle + ), ].filter(x => !!x); } } diff --git a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts index 0e6e1c53229..605f8212eab 100644 --- a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts +++ b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts @@ -1,31 +1,11 @@ +import { Announce, CustomReplace, ImageEdit } from 'roosterjs-editor-plugins'; import { EditorPlugin as LegacyEditorPlugin, KnownAnnounceStrings } from 'roosterjs-editor-types'; -import { - Announce, - ContentEdit, - CustomReplace, - HyperLink, - ImageEdit, -} from 'roosterjs-editor-plugins'; -import { - LegacyPluginList, - OptionState, - UrlPlaceholder, -} from '../sidePane/editorOptions/OptionState'; +import { LegacyPluginList, OptionState } from '../sidePane/editorOptions/OptionState'; export function createLegacyPlugins(initState: OptionState): LegacyEditorPlugin[] { - const { pluginList, linkTitle } = initState; + const { pluginList } = initState; const plugins: Record = { - contentEdit: pluginList.contentEdit ? new ContentEdit(initState.contentEditFeatures) : null, - hyperlink: pluginList.hyperlink - ? new HyperLink( - linkTitle?.indexOf(UrlPlaceholder) >= 0 - ? url => linkTitle.replace(UrlPlaceholder, url) - : linkTitle - ? () => linkTitle - : null - ) - : null, imageEdit: pluginList.imageEdit ? new ImageEdit({ preserveRatio: initState.forcePreserveRatio, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx deleted file mode 100644 index e96cbfdb11f..00000000000 --- a/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from 'react'; -import { ContentEditFeatureSettings } from 'roosterjs-editor-types'; -import { getAllFeatures } from 'roosterjs-editor-plugins/lib/ContentEdit'; -import { getObjectKeys } from 'roosterjs-content-model-dom'; -import { OptionState } from './OptionState'; - -type ContentEditItemId = keyof ContentEditFeatureSettings; - -const styles = require('./OptionsPane.scss'); -const EditFeatureDescriptionMap: Record = { - autoBullet: 'Auto Bullet / Numbering', - indentWhenTab: 'Indent list when Tab', - outdentWhenShiftTab: 'Outdent list when Shift + Tab', - outdentWhenBackspaceOnEmptyFirstLine: 'Outdent list when Backspace on empty first Line', - outdentWhenEnterOnEmptyLine: 'Outdent list when Enter on empty line', - mergeInNewLineWhenBackspaceOnFirstChar: - 'Merge in new line when Backspace on first char in list', - maintainListChain: 'Maintain the continued list numbers', - unquoteWhenBackspaceOnEmptyFirstLine: 'Unquote when Backspace on empty first line', - unquoteWhenEnterOnEmptyLine: 'Unquote when Enter on empty line', - tabInTable: 'Tab to jump cell in table', - upDownInTable: 'Up / Down to jump cell in table', - insertLineBeforeStructuredNodeFeature: - 'Enter to create new line before table/list at beginning of editor content', - autoLink: 'Auto link', - unlinkWhenBackspaceAfterLink: 'Auto unlink when backspace right after a hyperlink', - defaultShortcut: 'Default Shortcuts', - noCycleCursorMove: 'Avoid moving cycle moving cursor when Ctrl+Left/Right', - clickOnEntity: 'Fire an event when click on a readonly entity', - escapeFromEntity: 'Fire an event when Escape from a readonly entity', - enterBeforeReadonlyEntity: 'Start a new line when Enter before an event', - backspaceAfterEntity: 'Fire an event when Backspace after an entity', - deleteBeforeEntity: 'Fire an event when Delete before an event', - markdownBold: 'Markdown style Bolding', - markdownItalic: 'Markdown style Italics', - markdownStrikethru: 'Markdown style Strikethrough', - markdownInlineCode: 'Markdown style Code blocks', - maintainListChainWhenDelete: - 'Maintain the list of number in the right order after press delete before the first item', - indentTableOnTab: 'Indent the table if it is all cells are selected.', - indentWhenTabText: - 'On Tab indent the selection or add Tab, requires TabKeyFeatures Experimental Feature', - outdentWhenTabText: - 'On Shift + Tab outdent the selection, requires TabKeyFeatures Experimental Feature', - autoHyphen: 'Automatically transform -- into hyphen, if typed between two words.', - autoBulletList: - 'When press space after *, -, --, ->, -->, >, => in an empty line, toggle bullet', - autoNumberingList: - 'When press space after an number, a letter or roman number followed by ), ., -, or between parenthesis in an empty line, toggle numbering', - mergeListOnBackspaceAfterList: 'When backspacing between lists, merge the lists', - deleteTableWithBackspace: 'Delete table with backspace key with whole table is selected', - moveBetweenDelimitersFeature: - 'Content edit feature to move the cursor from Delimiters around Entities when using Right or Left Arrow Keys', - removeEntityBetweenDelimiters: - 'When using BACKSPACE or DELETE in a Readonly inline entity delimeter, trigger a Entity Operation', - removeCodeWhenEnterOnEmptyLine: 'Remove code line when enter on empty line', - removeCodeWhenBackspaceOnEmptyFirstLine: 'Remove code line when backspace on empty first line', - indentWhenAltShiftRight: 'Indent list item using Alt + Shift + Right', - outdentWhenAltShiftLeft: 'Outdent list item using Alt + Shift + Left', -}; - -export interface ContentEditFeaturessProps { - state: ContentEditFeatureSettings; - resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void; -} - -export default class ContentEditFeatures extends React.Component { - render() { - const features = getAllFeatures(); - return ( - - - {getObjectKeys(features).map(key => - this.renderContentEditItem(key, EditFeatureDescriptionMap[key]) - )} - -
- ); - } - - private renderContentEditItem( - id: ContentEditItemId, - text: string, - moreOptions?: JSX.Element - ): JSX.Element { - const checked = this.props.state[id]; - - return ( - - - this.onContentEditClick(id)} - /> - - -
- -
- {checked && moreOptions} - - - ); - } - - private onContentEditClick = (id: ContentEditItemId) => { - this.props.resetState(state => { - let checkbox = document.getElementById(id) as HTMLInputElement; - state.contentEditFeatures[id] = checkbox.checked; - }, true); - }; -} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/DefaultFormatPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/DefaultFormatPane.tsx index 4756df115f0..302d4480cb5 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/DefaultFormatPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/DefaultFormatPane.tsx @@ -39,6 +39,7 @@ export class DefaultFormatPane extends React.Component { [NOT_SET]: 'Not Set', '8pt': '8', '10pt': '10', + '11pt': '11', '12pt': '12', '16pt': '16', '20pt': '20', diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index d464731df1a..f49f65aecc2 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -1,4 +1,3 @@ -import { getDefaultContentEditFeatureSettings } from './getDefaultContentEditFeatureSettings'; import { OptionPaneProps, OptionState, UrlPlaceholder } from './OptionState'; import { OptionsPane } from './OptionsPane'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -17,16 +16,18 @@ const initialState: OptionState = { pasteOption: true, sampleEntity: true, markdown: true, + hyperlink: true, // Legacy plugins - contentEdit: false, - hyperlink: false, imageEdit: false, customReplace: false, announce: false, }, - contentEditFeatures: getDefaultContentEditFeatureSettings(), - defaultFormat: {}, + defaultFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, linkTitle: 'Ctrl+Click to follow the link:' + UrlPlaceholder, watermarkText: 'Type content here ...', forcePreserveRatio: false, @@ -43,6 +44,13 @@ const initialState: OptionState = { autoLink: true, autoNumbering: true, autoUnlink: false, + autoHyphen: true, + }, + markdownOptions: { + bold: true, + italic: true, + strikethrough: true, + codeFormat: {}, }, }; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 6b86804b261..f22214d18d0 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -1,11 +1,8 @@ -import { AutoFormatOptions } from 'roosterjs-content-model-plugins'; -import type { ContentEditFeatureSettings } from 'roosterjs-editor-types'; +import { AutoFormatOptions, MarkdownOptions } from 'roosterjs-content-model-plugins'; import type { SidePaneElementProps } from '../SidePaneElement'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; export interface LegacyPluginList { - contentEdit: boolean; - hyperlink: boolean; imageEdit: boolean; customReplace: boolean; announce: boolean; @@ -23,6 +20,7 @@ export interface NewPluginList { pasteOption: boolean; sampleEntity: boolean; markdown: boolean; + hyperlink: boolean; } export interface BuildInPluginList extends LegacyPluginList, NewPluginList {} @@ -37,9 +35,9 @@ export interface OptionState { imageMenu: boolean; watermarkText: string; autoFormatOptions: AutoFormatOptions; + markdownOptions: MarkdownOptions; // Legacy plugin options - contentEditFeatures: ContentEditFeatureSettings; defaultFormat: ContentModelSegmentFormat; linkTitle: string; forcePreserveRatio: boolean; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx index 0d8c956efca..8f9e896fd53 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx @@ -127,7 +127,6 @@ export class OptionsPane extends React.Component { linkTitle: this.state.linkTitle, watermarkText: this.state.watermarkText, pluginList: { ...this.state.pluginList }, - contentEditFeatures: { ...this.state.contentEditFeatures }, defaultFormat: { ...this.state.defaultFormat }, forcePreserveRatio: this.state.forcePreserveRatio, applyChangesOnMouseUp: this.state.applyChangesOnMouseUp, @@ -139,6 +138,7 @@ export class OptionsPane extends React.Component { tableMenu: this.state.tableMenu, imageMenu: this.state.imageMenu, autoFormatOptions: { ...this.state.autoFormatOptions }, + markdownOptions: { ...this.state.markdownOptions }, }; if (callback) { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index edf89de3182..06f81255bd1 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import ContentEditFeatures from './ContentEditFeatures'; import { UrlPlaceholder } from './OptionState'; import type { BuildInPluginList, @@ -103,32 +102,12 @@ abstract class PluginsBase extends Re } export class LegacyPlugins extends PluginsBase { - private linkTitle = React.createRef(); private forcePreserveRatio = React.createRef(); render() { return ( - {this.renderPluginItem( - 'contentEdit', - 'Content Edit', - - )} - {this.renderPluginItem( - 'hyperlink', - 'Hyperlink Plugin', - this.renderInputBox( - 'Label title: ', - this.linkTitle, - this.props.state.linkTitle, - 'Use "' + UrlPlaceholder + '" for the url string', - (state, value) => (state.linkTitle = value) - ) - )} {this.renderPluginItem( 'imageEdit', 'Image Edit Plugin', @@ -153,12 +132,57 @@ export class Plugins extends PluginsBase { private tableMenu = React.createRef(); private imageMenu = React.createRef(); private watermarkText = React.createRef(); + private autoBullet = React.createRef(); + private autoNumbering = React.createRef(); + private autoLink = React.createRef(); + private autoUnlink = React.createRef(); + private autoHyphen = React.createRef(); + private markdownBold = React.createRef(); + private markdownItalic = React.createRef(); + private markdownStrikethrough = React.createRef(); + private markdownCode = React.createRef(); + private linkTitle = React.createRef(); render(): JSX.Element { return (
- {this.renderPluginItem('autoFormat', 'AutoFormat')} + {this.renderPluginItem( + 'autoFormat', + 'AutoFormat', + <> + {this.renderCheckBox( + 'Bullet', + this.autoBullet, + this.props.state.autoFormatOptions.autoBullet, + (state, value) => (state.autoFormatOptions.autoBullet = value) + )} + {this.renderCheckBox( + 'Numbering', + this.autoNumbering, + this.props.state.autoFormatOptions.autoNumbering, + (state, value) => (state.autoFormatOptions.autoNumbering = value) + )} + {this.renderCheckBox( + 'Link', + this.autoLink, + this.props.state.autoFormatOptions.autoLink, + (state, value) => (state.autoFormatOptions.autoLink = value) + )} + {this.renderCheckBox( + 'Unlink', + this.autoUnlink, + this.props.state.autoFormatOptions.autoUnlink, + (state, value) => (state.autoFormatOptions.autoUnlink = value) + )} + {this.renderCheckBox( + 'Hyphen', + this.autoHyphen, + this.props.state.autoFormatOptions.autoHyphen, + (state, value) => (state.autoFormatOptions.autoHyphen = value) + )} + + )} {this.renderPluginItem('edit', 'Edit')} {this.renderPluginItem( 'paste', @@ -210,6 +234,51 @@ export class Plugins extends PluginsBase { {this.renderPluginItem('emoji', 'Emoji')} {this.renderPluginItem('pasteOption', 'PasteOptions')} {this.renderPluginItem('sampleEntity', 'SampleEntity')} + {this.renderPluginItem( + 'markdown', + 'Markdown', + <> + {this.renderCheckBox( + 'Bold', + this.markdownBold, + this.props.state.markdownOptions.bold, + (state, value) => (state.markdownOptions.bold = value) + )} + {this.renderCheckBox( + 'Italic', + this.markdownItalic, + this.props.state.markdownOptions.italic, + (state, value) => (state.markdownOptions.italic = value) + )} + {this.renderCheckBox( + 'Strikethrough', + this.markdownStrikethrough, + this.props.state.markdownOptions.strikethrough, + (state, value) => (state.markdownOptions.strikethrough = value) + )} + + {this.renderCheckBox( + 'Code', + this.markdownCode, + this.props.state.markdownOptions.codeFormat !== undefined, + (state, value) => + value + ? (state.markdownOptions.codeFormat = {}) + : (state.markdownOptions.codeFormat = undefined) + )} + + )} + {this.renderPluginItem( + 'hyperlink', + 'Hyperlink Plugin', + this.renderInputBox( + 'Label title: ', + this.linkTitle, + this.props.state.linkTitle, + 'Use "' + UrlPlaceholder + '" for the url string', + (state, value) => (state.linkTitle = value) + ) + )}
); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/HyperLinkCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/HyperLinkCode.ts index 8f36f804cc6..f4ad8991bd5 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/HyperLinkCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/HyperLinkCode.ts @@ -7,7 +7,7 @@ export class HyperLinkCode extends CodeElement { } getCode() { - return 'new roosterjsLegacy.HyperLink(' + this.getLinkCallback() + ')'; + return 'new roosterjs.HyperlinkPlugin(' + this.getLinkCallback() + ')'; } private getLinkCallback() { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownCode.ts new file mode 100644 index 00000000000..f0898881a1e --- /dev/null +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/MarkdownCode.ts @@ -0,0 +1,17 @@ +import { CodeElement } from './CodeElement'; +import { MarkdownOptions } from 'roosterjs-content-model-plugins'; + +export class MarkdownCode extends CodeElement { + constructor(private markdownOptions: MarkdownOptions) { + super(); + } + + getCode() { + return `new roosterjs.MarkdownPlugin({ + bold: ${this.markdownOptions.bold}, + italic: ${this.markdownOptions.italic}, + strikethrough: ${this.markdownOptions.strikethrough}, + codeFormat: ${JSON.stringify(this.markdownOptions.codeFormat)}, + })`; + } +} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 869bd1d0e8a..62d863e2d07 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -1,7 +1,7 @@ import { AutoFormatCode } from './AutoFormatCode'; import { CodeElement } from './CodeElement'; -import { ContentEditCode } from './ContentEditCode'; import { HyperLinkCode } from './HyperLinkCode'; +import { MarkdownCode } from './MarkdownCode'; import { OptionState } from '../OptionState'; import { WatermarkCode } from './WatermarkCode'; import { @@ -11,7 +11,6 @@ import { PastePluginCode, TableEditPluginCode, ShortcutPluginCode, - MarkdownPluginCode, } from './SimplePluginCode'; export class PluginsCodeBase extends CodeElement { @@ -45,7 +44,8 @@ export class PluginsCode extends PluginsCodeBase { pluginList.tableEdit && new TableEditPluginCode(), pluginList.shortcut && new ShortcutPluginCode(), pluginList.watermark && new WatermarkCode(state.watermarkText), - pluginList.markdown && new MarkdownPluginCode(), + pluginList.markdown && new MarkdownCode(state.markdownOptions), + pluginList.hyperlink && new HyperLinkCode(state.linkTitle), ]); } } @@ -55,8 +55,6 @@ export class LegacyPluginCode extends PluginsCodeBase { const pluginList = state.pluginList; const plugins: CodeElement[] = [ - pluginList.contentEdit && new ContentEditCode(state.contentEditFeatures), - pluginList.hyperlink && new HyperLinkCode(state.linkTitle), pluginList.imageEdit && new ImageEditCode(), pluginList.customReplace && new CustomReplaceCode(), ]; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index 40e42027410..f9ebac0542e 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -45,9 +45,3 @@ export class CustomReplaceCode extends SimplePluginCode { super('CustomReplace', 'roosterjsLegacy'); } } - -export class MarkdownPluginCode extends SimplePluginCode { - constructor() { - super('MarkdownPlugin'); - } -} diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index be7a34fbf42..7259a8b3f3d 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -42,11 +42,13 @@ export { toggleCode } from './publicApi/segment/toggleCode'; export { insertEntity } from './publicApi/entity/insertEntity'; export { insertTableRow } from './modelApi/table/insertTableRow'; export { insertTableColumn } from './modelApi/table/insertTableColumn'; +export { clearSelectedCells } from './modelApi/table/clearSelectedCells'; export { formatTableWithContentModel } from './publicApi/utils/formatTableWithContentModel'; export { formatImageWithContentModel } from './publicApi/utils/formatImageWithContentModel'; export { formatParagraphWithContentModel } from './publicApi/utils/formatParagraphWithContentModel'; export { formatSegmentWithContentModel } from './publicApi/utils/formatSegmentWithContentModel'; +export { formatTextSegmentBeforeSelectionMarker } from './publicApi/utils/formatTextSegmentBeforeSelectionMarker'; export { setListType } from './modelApi/list/setListType'; export { setModelListStyle } from './modelApi/list/setModelListStyle'; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts index ee2bab2d081..57eb8886bac 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts @@ -3,7 +3,6 @@ import type { ContentModelTable, TableSelectionCoordinates } from 'roosterjs-con /** * Clear selection of a table. - * @internal * @param table The table model where the selection is to be cleared * @param sel The selection coordinates to be cleared */ diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts new file mode 100644 index 00000000000..dc6b2fb7282 --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker.ts @@ -0,0 +1,45 @@ +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; +import type { + ContentModelDocument, + ContentModelParagraph, + ContentModelSegmentFormat, + ContentModelText, + FormatContentModelContext, + IEditor, +} from 'roosterjs-content-model-types'; + +/** + * 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. + */ +export function formatTextSegmentBeforeSelectionMarker( + editor: IEditor, + callback: ( + model: ContentModelDocument, + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + markerFormat: ContentModelSegmentFormat, + context: FormatContentModelContext + ) => boolean +) { + editor.formatContentModel((model, context) => { + const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( + model, + false /*includeFormatHolder*/ + ); + + if (selectedSegmentsAndParagraphs.length > 0 && selectedSegmentsAndParagraphs[0][1]) { + const marker = selectedSegmentsAndParagraphs[0][0]; + const paragraph = selectedSegmentsAndParagraphs[0][1]; + const markerIndex = paragraph.segments.indexOf(marker); + if (marker.segmentType === 'SelectionMarker' && markerIndex > 0) { + const previousSegment = paragraph.segments[markerIndex - 1]; + if (previousSegment && previousSegment.segmentType === 'Text') { + return callback(model, previousSegment, paragraph, marker.format, context); + } + } + } + return false; + }); +} diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkSegmentTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts similarity index 51% rename from packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkSegmentTest.ts rename to packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts index 5bb356c56f3..5c58f674525 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/getLinkSegmentTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatTextSegmentBeforeSelectionMarkerTest.ts @@ -1,14 +1,51 @@ -import { ContentModelDocument, ContentModelText } from 'roosterjs-content-model-types'; -import { getLinkSegment } from '../../../lib/autoFormat/link/getLinkSegment'; +import { formatTextSegmentBeforeSelectionMarker } from '../../../lib/publicApi/utils/formatTextSegmentBeforeSelectionMarker'; +import { + ContentModelDocument, + ContentModelParagraph, + ContentModelSegmentFormat, + ContentModelText, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; -describe('getLinkSegment', () => { - function runTest(model: ContentModelDocument, link: ContentModelText | undefined) { - const result = getLinkSegment(model); - expect(result).toEqual(link); +describe('formatTextSegmentBeforeSelectionMarker', () => { + function runTest( + input: ContentModelDocument, + callback: ( + model: ContentModelDocument, + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + markerFormat: ContentModelSegmentFormat, + context: FormatContentModelContext + ) => boolean, + expectedModel: ContentModelDocument, + expectedResult: boolean + ) { + const formatWithContentModelSpy = jasmine + .createSpy('formatWithContentModel') + .and.callFake((callback, options) => { + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + canUndoByBackspace: true, + }); + expect(result).toBe(expectedResult); + }); + + formatTextSegmentBeforeSelectionMarker( + { + focus: () => {}, + formatContentModel: formatWithContentModelSpy, + } as any, + callback + ); + + expect(formatWithContentModelSpy).toHaveBeenCalled(); + expect(input).toEqual(expectedModel); } - it('no selected segments', () => { - const model: ContentModelDocument = { + it('no selection marker', () => { + const input: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { @@ -23,23 +60,17 @@ describe('getLinkSegment', () => { format: {}, }, ], - format: {}, }; - runTest(model, undefined); + runTest(input, () => true, input, false); }); - it('no link segment', () => { - const model: ContentModelDocument = { + it('no previous segment', () => { + const input: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { blockType: 'Paragraph', segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, { segmentType: 'SelectionMarker', isSelected: true, @@ -49,22 +80,22 @@ describe('getLinkSegment', () => { format: {}, }, ], - format: {}, }; - runTest(model, undefined); + runTest(input, () => true, input, false); }); - it('link segment starting with WWW', () => { - const model: ContentModelDocument = { + it('previous segment is not text', () => { + const input: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { blockType: 'Paragraph', segments: [ { - segmentType: 'Text', - text: 'www.bing.com', + segmentType: 'Image', + src: 'test', format: {}, + dataset: {}, }, { segmentType: 'SelectionMarker', @@ -75,17 +106,12 @@ describe('getLinkSegment', () => { format: {}, }, ], - format: {}, }; - runTest(model, { - segmentType: 'Text', - text: 'www.bing.com', - format: {}, - }); + runTest(input, () => true, input, false); }); - it('link segment starting with hyperlink', () => { - const model: ContentModelDocument = { + it('format segment', () => { + const input: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { @@ -93,15 +119,13 @@ describe('getLinkSegment', () => { segments: [ { segmentType: 'Text', - text: 'www.bing.com', + text: 'first', + format: {}, + }, + { + segmentType: 'Text', + text: 'second', format: {}, - link: { - format: { - href: 'www.bing.com', - underline: true, - }, - dataset: {}, - }, }, { segmentType: 'SelectionMarker', @@ -112,24 +136,8 @@ describe('getLinkSegment', () => { format: {}, }, ], - format: {}, }; - runTest(model, { - segmentType: 'Text', - text: 'www.bing.com', - format: {}, - link: { - format: { - href: 'www.bing.com', - underline: true, - }, - dataset: {}, - }, - }); - }); - - it('link segment starting with text and hyperlink', () => { - const model: ContentModelDocument = { + const expectedModel: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { @@ -137,14 +145,14 @@ describe('getLinkSegment', () => { segments: [ { segmentType: 'Text', - text: 'bing', + text: 'first', format: {}, - link: { - format: { - href: 'www.bing.com', - underline: true, - }, - dataset: {}, + }, + { + segmentType: 'Text', + text: 'second', + format: { + textColor: 'red', }, }, { @@ -156,19 +164,15 @@ describe('getLinkSegment', () => { format: {}, }, ], - format: {}, }; - runTest(model, { - segmentType: 'Text', - text: 'bing', - format: {}, - link: { - format: { - href: 'www.bing.com', - underline: true, - }, - dataset: {}, + runTest( + input, + (_model, previousSegment) => { + previousSegment.format = { textColor: 'red' }; + return true; }, - }); + expectedModel, + true + ); }); }); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts index bb128dd0a46..9eea7ba1a28 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts @@ -5,6 +5,16 @@ export function addRangeToSelection(doc: Document, range: Range, isReverted: boo const selection = doc.defaultView?.getSelection(); if (selection) { + const currentRange = selection.rangeCount > 0 && selection.getRangeAt(0); + if ( + currentRange && + currentRange.startContainer == range.startContainer && + currentRange.endContainer == range.endContainer && + currentRange.startOffset == range.startOffset && + currentRange.endOffset == range.endOffset + ) { + return; + } selection.removeAllRanges(); if (!isReverted) { diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index d8ddd82d6ea..2d199b1889d 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -12,11 +12,14 @@ import type { const DOM_SELECTION_CSS_KEY = '_DOMSelection'; const HIDE_CURSOR_CSS_KEY = '_DOMSelectionHideCursor'; +const HIDE_SELECTION_CSS_KEY = '_DOMSelectionHideSelection'; const IMAGE_ID = 'image'; const TABLE_ID = 'table'; const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; const TABLE_CSS_RULE = 'background-color:#C6C6C6!important;'; const CARET_CSS_RULE = 'caret-color: transparent'; +const TRANSPARENT_SELECTION_CSS_RULE = 'background-color: transparent !important'; +const SELECTION_SELECTOR = '*::selection'; /** * @internal @@ -31,6 +34,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC core.selection.skipReselectOnFocus = true; core.api.setEditorStyle(core, DOM_SELECTION_CSS_KEY, null /*cssRule*/); core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, null /*cssRule*/); + core.api.setEditorStyle(core, HIDE_SELECTION_CSS_KEY, null /*cssRule*/); try { switch (selection?.type) { @@ -46,9 +50,14 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC }!important;`, [`#${ensureUniqueId(image, IMAGE_ID)}`] ); - core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); + core.api.setEditorStyle( + core, + HIDE_SELECTION_CSS_KEY, + TRANSPARENT_SELECTION_CSS_RULE, + [SELECTION_SELECTOR] + ); - setRangeSelection(doc, image); + setRangeSelection(doc, image, false /* collapse */); break; case 'table': const { table, firstColumn, firstRow, lastColumn, lastRow } = selection; @@ -105,7 +114,11 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC const nodeToSelect = firstCell.cell?.firstElementChild || firstCell.cell; if (nodeToSelect) { - setRangeSelection(doc, (nodeToSelect as HTMLElement) || undefined); + setRangeSelection( + doc, + (nodeToSelect as HTMLElement) || undefined, + true /* collapse */ + ); } break; @@ -197,13 +210,24 @@ function handleTableSelected( return selectors; } -function setRangeSelection(doc: Document, element: HTMLElement | undefined) { +function setRangeSelection(doc: Document, element: HTMLElement | undefined, collapse: boolean) { if (element && doc.contains(element)) { const range = doc.createRange(); + let isReverted: boolean | undefined = undefined; range.selectNode(element); - range.collapse(); + if (collapse) { + range.collapse(); + } else { + const selection = doc.defaultView?.getSelection(); + const range = selection && selection.rangeCount > 0 && selection.getRangeAt(0); + if (selection && range) { + isReverted = + selection.focusNode != range.endContainer || + selection.focusOffset != range.endOffset; + } + } - addRangeToSelection(doc, range); + addRangeToSelection(doc, range, isReverted); } } 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 1ae8a8e47bf..9c75100e40b 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -1,5 +1,6 @@ import { findCoordinate } from './findCoordinate'; import { findTableCellElement } from '../../coreApi/setDOMSelection/findTableCellElement'; +import { isSingleImageInSelection } from './isSingleImageInSelection'; import { normalizePos } from './normalizePos'; import { isCharacterValue, @@ -21,6 +22,7 @@ import type { ParsedTable, TableSelectionInfo, TableCellCoordinate, + RangeSelection, } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; @@ -30,6 +32,7 @@ const Up = 'ArrowUp'; const Down = 'ArrowDown'; const Left = 'ArrowLeft'; const Right = 'ArrowRight'; +const Tab = 'Tab'; class SelectionPlugin implements PluginWithState { private editor: IEditor | null = null; @@ -37,6 +40,7 @@ class SelectionPlugin implements PluginWithState { private disposer: (() => void) | null = null; private isSafari = false; private isMac = false; + private scrollTopCache: number = 0; constructor(options: EditorOptions) { this.state = { @@ -58,9 +62,8 @@ class SelectionPlugin implements PluginWithState { this.isSafari = !!env.isSafari; this.isMac = !!env.isMac; - + document.addEventListener('selectionchange', this.onSelectionChange); if (this.isSafari) { - document.addEventListener('selectionchange', this.onSelectionChangeSafari); this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus }, drop: { beforeDispatch: this.onDrop }, @@ -75,9 +78,7 @@ class SelectionPlugin implements PluginWithState { } dispose() { - this.editor - ?.getDocument() - .removeEventListener('selectionchange', this.onSelectionChangeSafari); + this.editor?.getDocument().removeEventListener('selectionchange', this.onSelectionChange); if (this.disposer) { this.disposer(); @@ -113,6 +114,12 @@ class SelectionPlugin implements PluginWithState { case 'contentChanged': this.state.tableSelection = null; break; + + case 'scroll': + if (!this.editor.hasFocus()) { + this.scrollTopCache = event.scrollContainer.scrollTop; + } + break; } } @@ -128,12 +135,10 @@ class SelectionPlugin implements PluginWithState { this.getContainedTargetImage(rawEvent, selection)) && image.isContentEditable ) { - this.selectImage(image); - + this.selectImageWithRange(image, rawEvent); return; } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { - this.selectBeforeImage(editor, selection.image); - + this.selectBeforeOrAfterElement(editor, selection.image); return; } @@ -235,6 +240,25 @@ class SelectionPlugin implements PluginWithState { } }; + 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; @@ -246,7 +270,7 @@ class SelectionPlugin implements PluginWithState { MouseRightButton /* it's not possible to drag using right click */ || event.isClicking) ) { - this.selectImage(image); + this.selectImageWithRange(image, event.rawEvent); } this.detachMouseEvent(); @@ -265,16 +289,16 @@ class SelectionPlugin implements PluginWithState { case 'image': if (!isModifierKey(rawEvent) && !rawEvent.shiftKey && selection.image.parentNode) { if (key === 'Escape') { - this.selectBeforeImage(editor, selection.image); + this.selectBeforeOrAfterElement(editor, selection.image); rawEvent.stopPropagation(); } else if (key !== 'Delete' && key !== 'Backspace') { - this.selectBeforeImage(editor, selection.image); + this.selectBeforeOrAfterElement(editor, selection.image); } } break; case 'range': - if (key == Up || key == Down || key == Left || key == Right) { + if (key == Up || key == Down || key == Left || key == Right || key == Tab) { const start = selection.range.startContainer; this.state.tableSelection = this.parseTableSelection( start, @@ -282,8 +306,10 @@ class SelectionPlugin implements PluginWithState { editor.getDOMHelper() ); + const rangeKey = key == Tab ? this.getTabKey(rawEvent) : key; + if (this.state.tableSelection) { - win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); + win?.requestAnimationFrame(() => this.handleSelectionInTable(rangeKey)); } } break; @@ -316,7 +342,13 @@ class SelectionPlugin implements PluginWithState { } } - private handleSelectionInTable(key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') { + 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; } @@ -340,8 +372,8 @@ class SelectionPlugin implements PluginWithState { let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; - if (lastCo && tableSel.table == table && lastCo.col != oldCo.col) { - if (key == Up || key == Down) { + 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; @@ -359,24 +391,42 @@ class SelectionPlugin implements PluginWithState { } if (collapsed && td) { - const { node, offset } = normalizePos( + this.setRangeSelectionInTable( td, - key == Up ? td.childNodes.length : 0 - ); - const range = this.editor.getDocument().createRange(); - - range.setStart(node, offset); - range.collapse(true /*toStart*/); - - this.setDOMSelection( - { - type: 'range', - range, - isReverted: false, - }, - null /*tableSelection*/ + 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; } @@ -389,6 +439,24 @@ class SelectionPlugin implements PluginWithState { } } + 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; @@ -401,24 +469,14 @@ class SelectionPlugin implements PluginWithState { } } - private selectImage(image: HTMLImageElement) { - this.setDOMSelection( - { - type: 'image', - image: image, - }, - null /*tableSelection*/ - ); - } - - private selectBeforeImage(editor: IEditor, image: HTMLImageElement) { + private selectBeforeOrAfterElement(editor: IEditor, element: HTMLElement, after?: boolean) { const doc = editor.getDocument(); - const parent = image.parentNode; - const index = parent && toArray(parent.childNodes).indexOf(image); + 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); + range.setStart(parent, index + (after ? 1 : 0)); range.collapse(); this.setDOMSelection( @@ -470,22 +528,51 @@ class SelectionPlugin implements PluginWithState { // 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.state.selection && this.editor) { - this.state.selection = this.editor.getDOMSelection(); + if (this.editor) { + if (!this.state.selection) { + this.state.selection = this.editor.getDOMSelection(); + } + const sc = this.editor.getScrollContainer(); + this.scrollTopCache = sc.scrollTop; } }; - private onSelectionChangeSafari = () => { + private onSelectionChange = () => { if (this.editor?.hasFocus() && !this.editor.isInShadowEdit()) { - // 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. 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') { - this.state.selection = newSelection; + if (this.isSafari) { + this.state.selection = newSelection; + } + this.trySelectSingleImage(newSelection); } } }; @@ -556,6 +643,21 @@ class SelectionPlugin implements PluginWithState { 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*/ + ); + } + } + } } /** diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/isSingleImageInSelection.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/isSingleImageInSelection.ts new file mode 100644 index 00000000000..a63d9e80f91 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/isSingleImageInSelection.ts @@ -0,0 +1,42 @@ +import { isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; + +/** + * @internal + */ +export function isSingleImageInSelection(selection: Selection | Range): HTMLImageElement | null { + const { startNode, endNode, startOffset, endOffset } = getProps(selection); + + const max = Math.max(startOffset, endOffset); + const min = Math.min(startOffset, endOffset); + + if (startNode && endNode && startNode == endNode && max - min == 1) { + const node = startNode?.childNodes.item(min); + if (isNodeOfType(node, 'ELEMENT_NODE') && isElementOfType(node, 'img')) { + return node; + } + } + return null; +} +function getProps( + selection: Selection | Range +): { startNode: Node | null; endNode: Node | null; startOffset: number; endOffset: number } { + if (isSelection(selection)) { + return { + startNode: selection.anchorNode, + endNode: selection.focusNode, + startOffset: selection.anchorOffset, + endOffset: selection.focusOffset, + }; + } else { + return { + startNode: selection.startContainer, + endNode: selection.endContainer, + startOffset: selection.startOffset, + endOffset: selection.endOffset, + }; + } +} + +function isSelection(selection: Selection | Range): selection is Selection { + return !!(selection as Selection).getRangeAt; +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts index 794b2c9ea36..5d95321d4f7 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts @@ -1,6 +1,31 @@ import { isNodeOfType } from 'roosterjs-content-model-dom'; import type { DOMInsertPoint } from 'roosterjs-content-model-types'; +/** + * HTML void elements + * Per https://www.w3.org/TR/html/syntax.html#syntax-elements, cannot have child nodes + * This regex is used when we move focus to very begin of editor. We should avoid putting focus inside + * void elements so users don't accidentally create child nodes in them + */ +const HTML_VOID_ELEMENTS = [ + 'AREA', + 'BASE', + 'BR', + 'COL', + 'COMMAND', + 'EMBED', + 'HR', + 'IMG', + 'INPUT', + 'KEYGEN', + 'LINK', + 'META', + 'PARAM', + 'SOURCE', + 'TRACK', + 'WBR', +]; + /** * @internal */ @@ -17,8 +42,17 @@ export function normalizePos(node: Node, offset: number): DOMInsertPoint { ? node.nodeValue?.length ?? 0 : node.childNodes.length; } else { - node = node.childNodes[offset]; - offset = 0; + const nextNode = node.childNodes[offset]; + + if ( + isNodeOfType(nextNode, 'ELEMENT_NODE') && + HTML_VOID_ELEMENTS.indexOf(nextNode.tagName) >= 0 + ) { + break; + } else { + node = node.childNodes[offset]; + offset = 0; + } } } diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index 51f72c581a6..a2dd2548a3c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -74,9 +74,14 @@ describe('setDOMSelection', () => { true ); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); } it('From null selection', () => { @@ -127,9 +132,14 @@ describe('setDOMSelection', () => { skipReselectOnFocus: undefined, selection: null, } as any); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(triggerEventSpy).toHaveBeenCalledWith( core, { @@ -163,9 +173,14 @@ describe('setDOMSelection', () => { } as any); expect(triggerEventSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); }); it('range selection, editor id is unique, editor does not have focus', () => { @@ -193,9 +208,14 @@ describe('setDOMSelection', () => { true ); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); }); }); @@ -240,22 +260,21 @@ describe('setDOMSelection', () => { true ); expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); - expect(collapseSpy).toHaveBeenCalledWith(); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(collapseSpy).not.toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); expect(mockedImage.id).toBe('image_0'); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); - expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelection', - 'outline-style:auto!important; outline-color:#DB626C!important;', - ['#image_0'] + '_DOMSelectionHideSelection', + null ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' + '_DOMSelection', + 'outline-style:auto!important; outline-color:#DB626C!important;', + ['#image_0'] ); }); @@ -294,11 +313,15 @@ describe('setDOMSelection', () => { true ); expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); - expect(collapseSpy).toHaveBeenCalledWith(); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(collapseSpy).not.toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); - expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', @@ -307,8 +330,9 @@ describe('setDOMSelection', () => { ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' + '_DOMSelectionHideSelection', + 'background-color: transparent !important', + ['*::selection'] ); }); @@ -347,11 +371,16 @@ describe('setDOMSelection', () => { true ); expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); - expect(collapseSpy).toHaveBeenCalledWith(); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(collapseSpy).not.toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', @@ -360,8 +389,9 @@ describe('setDOMSelection', () => { ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' + '_DOMSelectionHideSelection', + 'background-color: transparent !important', + ['*::selection'] ); }); @@ -402,7 +432,7 @@ describe('setDOMSelection', () => { expect(collapseSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); expect(mockedImage.id).toBe('image_0'); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); expect(setEditorStyleSpy).toHaveBeenCalledWith( @@ -413,8 +443,9 @@ describe('setDOMSelection', () => { ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' + '_DOMSelectionHideSelection', + 'background-color: transparent !important', + ['*::selection'] ); }); }); @@ -458,9 +489,14 @@ describe('setDOMSelection', () => { expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); expect(mockedTable.id).toBeUndefined(); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(3); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); }); function runTest( @@ -506,9 +542,14 @@ describe('setDOMSelection', () => { true ); expect(mockedTable.id).toBe('table_0'); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', @@ -641,9 +682,14 @@ describe('setDOMSelection', () => { true ); expect(table.id).toBe('table_0'); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(5); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideSelection', + null + ); expect(setEditorStyleSpy).toHaveBeenCalledWith( core, '_DOMSelection', 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 68e83fe8f95..4b3501d1093 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1,7 +1,9 @@ +import * as isSingleImageInSelection from '../../../lib/corePlugin/selection/isSingleImageInSelection'; import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; import { createSelectionPlugin } from '../../../lib/corePlugin/selection/SelectionPlugin'; import { DOMEventRecord, + DOMSelection, EditorPlugin, IEditor, PluginWithState, @@ -14,8 +16,10 @@ describe('SelectionPlugin', () => { const disposer = jasmine.createSpy('disposer'); const attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + const addEventListenerSpy = jasmine.createSpy('addEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ removeEventListener: removeEventListenerSpy, + addEventListener: addEventListenerSpy, }); const state = plugin.getState(); const editor = ({ @@ -46,13 +50,14 @@ describe('SelectionPlugin', () => { imageSelectionBorderColor: 'red', }); const state = plugin.getState(); - + const addEventListenerSpy = jasmine.createSpy('addEventListener'); const attachDomEvent = jasmine .createSpy('attachDomEvent') .and.returnValue(jasmine.createSpy('disposer')); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ removeEventListener: removeEventListenerSpy, + addEventListener: addEventListenerSpy, }); plugin.initialize(({ @@ -81,6 +86,8 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { let getDocumentSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; + let getScrollContainerSpy: jasmine.Spy; let editor: IEditor; @@ -88,10 +95,13 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { triggerEvent = jasmine.createSpy('triggerEvent'); getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ removeEventListener: removeEventListenerSpy, + addEventListener: addEventListenerSpy, }); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + getScrollContainerSpy = jasmine.createSpy('getScrollContainer'); plugin = createSelectionPlugin({}); @@ -105,6 +115,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { }, getElementAtCursor: getElementAtCursorSpy, setDOMSelection: setDOMSelectionSpy, + getScrollContainer: getScrollContainerSpy, }); plugin.initialize(editor); }); @@ -144,6 +155,109 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { tableSelection: null, }); }); + + it('Trigger onFocusEvent, use cached scrollTop', () => { + const scMock: any = {}; + const scrollTop = 5; + getScrollContainerSpy.and.returnValue(scMock); + (plugin as any).scrollTopCache = scrollTop; + + eventMap.focus.beforeDispatch(); + + expect(scMock.scrollTop).toEqual(scrollTop); + expect((plugin as any).scrollTopCache).toEqual(0); + }); + + it('onBlur cache scrollTop', () => { + const scrollTop = 5; + const scMock: any = { scrollTop }; + getScrollContainerSpy.and.returnValue(scMock); + plugin.getState().selection = true; + + eventMap.blur.beforeDispatch(); + + expect((plugin as any).scrollTopCache).toEqual(scrollTop); + }); +}); + +describe('SelectionPlugin scroll event ', () => { + let plugin: PluginWithState; + let triggerEvent: jasmine.Spy; + let getElementAtCursorSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let removeEventListenerSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; + let getScrollContainerSpy: jasmine.Spy; + let hasFocusSpy: jasmine.Spy; + + let editor: IEditor; + + beforeEach(() => { + triggerEvent = jasmine.createSpy('triggerEvent'); + getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); + removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + removeEventListener: removeEventListenerSpy, + addEventListener: addEventListenerSpy, + }); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + getScrollContainerSpy = jasmine.createSpy('getScrollContainer'); + hasFocusSpy = jasmine.createSpy('hasFocus'); + + plugin = createSelectionPlugin({}); + + editor = ({ + getDocument: getDocumentSpy, + triggerEvent, + getEnvironment: () => ({}), + attachDomEvent: () => { + return jasmine.createSpy('disposer'); + }, + getElementAtCursor: getElementAtCursorSpy, + setDOMSelection: setDOMSelectionSpy, + getScrollContainer: getScrollContainerSpy, + hasFocus: hasFocusSpy, + }); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Cache scrollTop', () => { + hasFocusSpy.and.returnValue(false); + const scrollTop = 5; + const scMock: any = { scrollTop }; + getScrollContainerSpy.and.returnValue(scMock); + (plugin as any).scrollTopCache = undefined; + + plugin.onPluginEvent?.({ + eventType: 'scroll', + rawEvent: {}, + scrollContainer: scMock, + }); + + expect((plugin as any).scrollTopCache).toEqual(scrollTop); + }); + + it('Do not cache scrollTop', () => { + hasFocusSpy.and.returnValue(true); + const scrollTop = 5; + const scMock: any = { scrollTop }; + getScrollContainerSpy.and.returnValue(scMock); + (plugin as any).scrollTopCache = undefined; + + plugin.onPluginEvent?.({ + eventType: 'scroll', + rawEvent: {}, + scrollContainer: scMock, + }); + + expect((plugin as any).scrollTopCache).toEqual(undefined); + }); }); describe('SelectionPlugin handle image selection', () => { @@ -153,16 +267,27 @@ describe('SelectionPlugin handle image selection', () => { let setDOMSelectionSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; let createRangeSpy: jasmine.Spy; + let domHelperSpy: jasmine.Spy; + let requestAnimationFrameSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; beforeEach(() => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); createRangeSpy = jasmine.createSpy('createRange'); + requestAnimationFrameSpy = jasmine.createSpy('requestAnimationFrame'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ createRange: createRangeSpy, + addEventListener: addEventListenerSpy, + defaultView: { + requestAnimationFrame: requestAnimationFrameSpy, + }, }); + domHelperSpy = jasmine.createSpy('domHelperSpy'); editor = { + getDOMHelper: domHelperSpy, getDOMSelection: getDOMSelectionSpy, setDOMSelection: setDOMSelectionSpy, getDocument: getDocumentSpy, @@ -269,7 +394,13 @@ describe('SelectionPlugin handle image selection', () => { }); it('Image selection, mouse down to same image right click', () => { + const parent = document.createElement('div'); const mockedImage = document.createElement('img'); + parent.appendChild(mockedImage); + const range = document.createRange(); + range.selectNode(mockedImage); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); mockedImage.contentEditable = 'true'; @@ -280,17 +411,21 @@ describe('SelectionPlugin handle image selection', () => { plugin.onPluginEvent!({ eventType: 'mouseDown', - rawEvent: { + rawEvent: (>{ target: mockedImage, button: 2, - } as any, + preventDefault: preventDefaultSpy, + }) as any, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalled(); }); it('Image selection, mouse down to image right click', () => { + const parent = document.createElement('div'); const mockedImage = document.createElement('img'); + parent.appendChild(mockedImage); mockedImage.contentEditable = 'true'; plugin.onPluginEvent!({ @@ -319,7 +454,11 @@ describe('SelectionPlugin handle image selection', () => { }); it('no selection, mouse up to image, is clicking, isEditable', () => { + const parent = document.createElement('div'); const mockedImage = document.createElement('img'); + parent.appendChild(mockedImage); + const range = document.createRange(); + range.selectNode(mockedImage); mockedImage.contentEditable = 'true'; @@ -333,8 +472,9 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'image', - image: mockedImage, + type: 'range', + range, + isReverted: false, }); }); @@ -557,6 +697,7 @@ describe('SelectionPlugin handle table selection', () => { let mouseMoveDisposer: jasmine.Spy; let requestAnimationFrameSpy: jasmine.Spy; let getComputedStyleSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; beforeEach(() => { contentDiv = document.createElement('div'); @@ -565,12 +706,14 @@ describe('SelectionPlugin handle table selection', () => { createRangeSpy = jasmine.createSpy('createRange'); requestAnimationFrameSpy = jasmine.createSpy('requestAnimationFrame'); getComputedStyleSpy = jasmine.createSpy('getComputedStyle'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ createRange: createRangeSpy, defaultView: { requestAnimationFrame: requestAnimationFrameSpy, getComputedStyle: getComputedStyleSpy, }, + addEventListener: addEventListenerSpy, }); focusDisposer = jasmine.createSpy('focus'); mouseMoveDisposer = jasmine.createSpy('mouseMove'); @@ -1104,13 +1247,13 @@ describe('SelectionPlugin handle table selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); }); - it('From Range, Press Down', () => { + it('From Range, Press Tab', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: { - startContainer: td2, + startContainer: td1, startOffset: 0, - endContainer: td2, + endContainer: td1, endOffset: 0, commonAncestorContainer: tr1, }, @@ -1121,11 +1264,11 @@ describe('SelectionPlugin handle table selection', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: { - startContainer: td3, + startContainer: td1, startOffset: 0, - endContainer: td3, + endContainer: td1, endOffset: 0, - commonAncestorContainer: tr2, + commonAncestorContainer: tr1, collapsed: true, }, isReverted: false, @@ -1146,7 +1289,7 @@ describe('SelectionPlugin handle table selection', () => { plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { - key: 'ArrowDown', + key: 'Tab', } as any, }); @@ -1162,18 +1305,18 @@ describe('SelectionPlugin handle table selection', () => { range: mockedRange, isReverted: false, }); - expect(setStartSpy).toHaveBeenCalledWith(td4, 0); + expect(setStartSpy).toHaveBeenCalledWith(td2, 0); }); - it('From Range, Press Shift+Up', () => { + it('From Range, Press Shift+Tab', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: { - startContainer: td4, + startContainer: td2, startOffset: 0, - endContainer: td4, + endContainer: td2, endOffset: 0, - commonAncestorContainer: tr2, + commonAncestorContainer: tr1, }, isReverted: false, }); @@ -1182,23 +1325,32 @@ describe('SelectionPlugin handle table selection', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: { - startContainer: td3, + startContainer: td2, startOffset: 0, - endContainer: td4, + endContainer: td2, endOffset: 0, - commonAncestorContainer: tr2, - collapsed: false, + commonAncestorContainer: tr1, + collapsed: true, }, - isReverted: true, + isReverted: false, }); func(); }); + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { - key: 'ArrowUp', + key: 'Tab', shiftKey: true, } as any, }); @@ -1206,30 +1358,19 @@ describe('SelectionPlugin handle table selection', () => { expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); expect(plugin.getState()).toEqual({ selection: null, - tableSelection: { - table, - parsedTable: [ - [td1, td2], - [td3, td4], - ], - firstCo: { row: 1, col: 1 }, - lastCo: { row: 0, col: 1 }, - startNode: td4, - }, + tableSelection: null, imageSelectionBorderColor: undefined, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'table', - table, - firstRow: 1, - firstColumn: 1, - lastRow: 0, - lastColumn: 1, + type: 'range', + range: mockedRange, + isReverted: false, }); + expect(setStartSpy).toHaveBeenCalledWith(td1, 0); }); - it('From Range, Press Shift+Down', () => { + it('From Range, Press Tab - Next Row', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: { @@ -1248,10 +1389,10 @@ describe('SelectionPlugin handle table selection', () => { range: { startContainer: td2, startOffset: 0, - endContainer: td3, + endContainer: td2, endOffset: 0, - commonAncestorContainer: table, - collapsed: false, + commonAncestorContainer: tr1, + collapsed: true, }, isReverted: false, }); @@ -1259,47 +1400,44 @@ describe('SelectionPlugin handle table selection', () => { func(); }); + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { - key: 'ArrowDown', - shiftKey: true, + key: 'Tab', } as any, }); expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); expect(plugin.getState()).toEqual({ selection: null, - tableSelection: { - table, - parsedTable: [ - [td1, td2], - [td3, td4], - ], - firstCo: { row: 0, col: 1 }, - lastCo: { row: 1, col: 1 }, - startNode: td2, - }, + tableSelection: null, imageSelectionBorderColor: undefined, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledWith({ - type: 'table', - table, - firstRow: 0, - firstColumn: 1, - lastRow: 1, - lastColumn: 1, + type: 'range', + range: mockedRange, + isReverted: false, }); + expect(setStartSpy).toHaveBeenCalledWith(td3, 0); }); - it('From Range, Press Shift+Down to ouside of table', () => { + it('From Range, First cell - Press Shift+Tab', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: { - startContainer: td2, + startContainer: td1, startOffset: 0, - endContainer: td2, + endContainer: td1, endOffset: 0, commonAncestorContainer: tr1, }, @@ -1310,12 +1448,12 @@ describe('SelectionPlugin handle table selection', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: { - startContainer: td2, + startContainer: td1, startOffset: 0, - endContainer: div, + endContainer: td1, endOffset: 0, - commonAncestorContainer: contentDiv, - collapsed: false, + commonAncestorContainer: tr1, + collapsed: true, }, isReverted: false, }); @@ -1323,10 +1461,19 @@ describe('SelectionPlugin handle table selection', () => { func(); }); + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { - key: 'ArrowDown', + key: 'Tab', shiftKey: true, } as any, }); @@ -1334,47 +1481,350 @@ describe('SelectionPlugin handle table selection', () => { expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); expect(plugin.getState()).toEqual({ selection: null, - tableSelection: { - table, - parsedTable: [ - [td1, td2], - [td3, td4], - ], - firstCo: { row: 0, col: 1 }, - startNode: td2, - }, + tableSelection: null, imageSelectionBorderColor: undefined, }); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(table.parentNode, 0); }); - it('From Table, Press A', () => { + it('From Range, Last cell - Press Tab', () => { getDOMSelectionSpy.and.returnValue({ - type: 'table', - }); - plugin.getState().tableSelection = { - table, - parsedTable: [ - [td1, td2], - [td3, td4], - ], - firstCo: { row: 0, col: 1 }, - lastCo: { row: 1, col: 1 }, - startNode: td2, - }; - - const preventDefaultSpy = jasmine.createSpy('preventDefault'); - - plugin.onPluginEvent!({ - eventType: 'keyDown', - rawEvent: { - key: 'a', - preventDefault: preventDefaultSpy, - } as any, + type: 'range', + range: { + startContainer: td4, + startOffset: 0, + endContainer: td4, + endOffset: 0, + commonAncestorContainer: tr2, + }, + isReverted: false, }); - expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); - expect(plugin.getState()).toEqual({ + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td4, + startOffset: 0, + endContainer: td4, + endOffset: 0, + commonAncestorContainer: tr2, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(table.parentNode, 1); + }); + + it('From Range, Press Down', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td3, + startOffset: 0, + endContainer: td3, + endOffset: 0, + commonAncestorContainer: tr2, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(td4, 0); + }); + + it('From Range, Press Shift+Up', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td4, + startOffset: 0, + endContainer: td4, + endOffset: 0, + commonAncestorContainer: tr2, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td3, + startOffset: 0, + endContainer: td4, + endOffset: 0, + commonAncestorContainer: tr2, + collapsed: false, + }, + isReverted: true, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowUp', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 1, col: 1 }, + lastCo: { row: 0, col: 1 }, + startNode: td4, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 1, + firstColumn: 1, + lastRow: 0, + lastColumn: 1, + }); + }); + + it('From Range, Press Shift+Down', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td3, + endOffset: 0, + commonAncestorContainer: table, + collapsed: false, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 0, + firstColumn: 1, + lastRow: 1, + lastColumn: 1, + }); + }); + + it('From Range, Press Shift+Down to ouside of table', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: div, + endOffset: 0, + commonAncestorContainer: contentDiv, + collapsed: false, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('From Table, Press A', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'a', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + expect(plugin.getState()).toEqual({ selection: null, tableSelection: { table, @@ -1437,16 +1887,7 @@ describe('SelectionPlugin handle table selection', () => { expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); expect(plugin.getState()).toEqual({ selection: null, - tableSelection: { - table, - parsedTable: [ - [td1, td2], - [td3, td4], - ], - firstCo: { row: 0, col: 1 }, - lastCo: { row: 1, col: 1 }, - startNode: td2, - }, + tableSelection: null, imageSelectionBorderColor: undefined, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -1577,6 +2018,7 @@ describe('SelectionPlugin on Safari', () => { let isInShadowEditSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; let editor: IEditor; + let getSelectionSpy: jasmine.Spy; beforeEach(() => { disposer = jasmine.createSpy('disposer'); @@ -1584,12 +2026,14 @@ describe('SelectionPlugin on Safari', () => { attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); addEventListenerSpy = jasmine.createSpy('addEventListener'); + getSelectionSpy = jasmine.createSpy('getSelection'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ head: { appendChild: appendChildSpy, }, addEventListener: addEventListenerSpy, removeEventListener: removeEventListenerSpy, + getSelection: getSelectionSpy, }); hasFocusSpy = jasmine.createSpy('hasFocus'); isInShadowEditSpy = jasmine.createSpy('isInShadowEdit'); @@ -1668,6 +2112,9 @@ describe('SelectionPlugin on Safari', () => { const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; const mockedNewSelection = { type: 'range', + range: >{ + collapsed: true, + }, } as any; hasFocusSpy.and.returnValue(true); @@ -1795,4 +2242,191 @@ describe('SelectionPlugin on Safari', () => { }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); }); + + it('', () => {}); +}); + +describe('SelectionPlugin selectionChange on image selected', () => { + let disposer: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let attachDomEvent: jasmine.Spy; + let removeEventListenerSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let hasFocusSpy: jasmine.Spy; + let isInShadowEditSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let editor: IEditor; + let setDOMSelectionSpy: jasmine.Spy; + let getRangeAtSpy: jasmine.Spy; + let getSelectionSpy: jasmine.Spy; + + beforeEach(() => { + disposer = jasmine.createSpy('disposer'); + appendChildSpy = jasmine.createSpy('appendChild'); + attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); + removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); + getRangeAtSpy = jasmine.createSpy('getRangeAt'); + getSelectionSpy = jasmine.createSpy('getSelection').and.returnValue({ + getRangeAt: getRangeAtSpy, + }); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + head: { + appendChild: appendChildSpy, + }, + addEventListener: addEventListenerSpy, + removeEventListener: removeEventListenerSpy, + getSelection: getSelectionSpy, + }); + hasFocusSpy = jasmine.createSpy('hasFocus'); + isInShadowEditSpy = jasmine.createSpy('isInShadowEdit'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + + editor = ({ + getDocument: getDocumentSpy, + attachDomEvent, + getEnvironment: () => ({ + isSafari: true, + }), + hasFocus: hasFocusSpy, + isInShadowEdit: isInShadowEditSpy, + getDOMSelection: getDOMSelectionSpy, + setDOMSelection: setDOMSelectionSpy, + } as any) as IEditor; + }); + + it('onSelectionChange on image | 1', () => { + spyOn(isSingleImageInSelection, 'isSingleImageInSelection').and.returnValue(null); + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = { + type: 'image', + image: {} as any, + } as DOMSelection; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'image', + image: {} as any, + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + getRangeAtSpy.and.returnValue({ startContainer: {} }); + + onSelectionChange(); + + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: { startContainer: {} } as Range, + isReverted: false, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange on image | 2', () => { + const image = document.createElement('img'); + spyOn(isSingleImageInSelection, 'isSingleImageInSelection').and.returnValue(image); + + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = { + type: 'image', + image: {} as any, + } as DOMSelection; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'image', + image: {} as any, + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + getRangeAtSpy.and.returnValue({ startContainer: {} }); + + onSelectionChange(); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange on image | 3', () => { + spyOn(isSingleImageInSelection, 'isSingleImageInSelection').and.returnValue(null); + + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = { + type: 'image', + image: {} as any, + } as DOMSelection; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + range: {} as any, + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + getRangeAtSpy.and.returnValue({ startContainer: {} }); + + onSelectionChange(); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange on image | 4', () => { + const image = document.createElement('img'); + spyOn(isSingleImageInSelection, 'isSingleImageInSelection').and.returnValue(image); + + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = { + type: 'image', + image: {} as any, + } as DOMSelection; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + range: {} as any, + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + getRangeAtSpy.and.returnValue({ startContainer: {} }); + + onSelectionChange(); + + expect(setDOMSelectionSpy).toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'image', + image, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/isSingleImageInSelectionTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/isSingleImageInSelectionTest.ts new file mode 100644 index 00000000000..5e413d867a4 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/isSingleImageInSelectionTest.ts @@ -0,0 +1,145 @@ +import { isSingleImageInSelection } from '../../../lib/corePlugin/selection/isSingleImageInSelection'; + +describe('isSingleImageInSelection |', () => { + describe('With selection', () => { + it('Is not single image in Selection: Selection offsets substraction is not equal to 0', () => { + const focusNode: any = {}; + const selection: any = >{ + focusNode, + anchorNode: focusNode, + focusOffset: 0, + anchorOffset: 2, + getRangeAt: () => {}, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is not single image in Selection: Containers are not the same', () => { + const focusNode: any = {}; + const anchorNode: any = { test: '' }; + const selection: any = >{ + focusNode, + anchorNode, + focusOffset: 0, + anchorOffset: 1, + getRangeAt: () => {}, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is not single image in Selection: Element is not image', () => { + const mockedElement = document.createElement('div'); + const focusNode: any = >{ + childNodes: { + item: () => mockedElement, + }, + }; + const selection: any = >{ + focusNode, + anchorNode: focusNode, + focusOffset: 0, + anchorOffset: 1, + getRangeAt: () => {}, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is single image in selection', () => { + const mockedElement = document.createElement('img'); + const focusNode: any = >{ + childNodes: { + item: () => mockedElement, + }, + }; + const selection: any = >{ + focusNode, + anchorNode: focusNode, + focusOffset: 0, + anchorOffset: 1, + getRangeAt: () => {}, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBe(mockedElement); + }); + }); + + describe('With Range', () => { + it('Is not single image in Selection: Selection offsets substraction is not equal to 0', () => { + const endContainer: any = {}; + const selection: any = >{ + endContainer, + startContainer: endContainer, + endOffset: 0, + startOffset: 2, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is not single image in Selection: Containers are not the same', () => { + const endContainer: any = {}; + const startContainer: any = { test: '' }; + const selection: any = >{ + endContainer, + startContainer, + endOffset: 0, + startOffset: 1, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is not single image in Selection: Element is not image', () => { + const mockedElement = document.createElement('div'); + const endContainer: any = >{ + childNodes: { + item: () => mockedElement, + }, + }; + const selection: any = >{ + endContainer, + startContainer: endContainer, + endOffset: 0, + startOffset: 1, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBeNull(); + }); + + it('Is single image in selection', () => { + const mockedElement = document.createElement('img'); + const endContainer: any = >{ + childNodes: { + item: () => mockedElement, + }, + }; + const selection: any = >{ + endContainer, + startContainer: endContainer, + endOffset: 0, + startOffset: 1, + }; + + const result = isSingleImageInSelection(selection); + + expect(result).toBe(mockedElement); + }); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/normalizePosTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/normalizePosTest.ts index e589c7542a7..db80f530d7f 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/normalizePosTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/normalizePosTest.ts @@ -99,4 +99,17 @@ describe('normalizePos()', () => { it('VOID - With offset out of range', () => { runTest('test1test3', () => document.getElementById('id1'), 2, '', 0); }); + it('VOID - from parent', () => { + const div = document.createElement('div'); + const span = document.createElement('span'); + const br = document.createElement('br'); + + span.appendChild(br); + div.appendChild(span); + + const { node, offset } = normalizePos(div, 0); + + expect(node).toBe(span); + expect(offset).toBe(0); + }); }); diff --git a/packages/roosterjs-content-model-dom/lib/config/defaultContentModelFormatMap.ts b/packages/roosterjs-content-model-dom/lib/config/defaultContentModelFormatMap.ts index 9cf028d32b0..4576d1bc708 100644 --- a/packages/roosterjs-content-model-dom/lib/config/defaultContentModelFormatMap.ts +++ b/packages/roosterjs-content-model-dom/lib/config/defaultContentModelFormatMap.ts @@ -7,6 +7,7 @@ import type { DefaultImplicitFormatMap } from 'roosterjs-content-model-types'; export const defaultContentModelFormatMap: DefaultImplicitFormatMap = { a: { underline: true, + textColor: undefined, // Set to undefined to force override color from parent element so we can write correct link color if any, because browser will assign a default color for link if it doesn't have one }, blockquote: { marginTop: '1em', diff --git a/packages/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts b/packages/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts index a119bc70079..75b8a10a7e6 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts @@ -1,5 +1,7 @@ import type { ContentModelBlockGroup, ContentModelDocument } from 'roosterjs-content-model-types'; +const TextForHR = '________________________________________'; + /** * Convert Content Model to plain text * @param model The source Content Model @@ -46,10 +48,16 @@ function contentModelToTextArray(group: ContentModelBlockGroup, textArray: strin break; } }); - textArray.push(text); + + if (text) { + textArray.push(text); + } + break; case 'Divider': + textArray.push(block.tagName == 'hr' ? TextForHR : ''); + break; case 'Entity': textArray.push(''); break; diff --git a/packages/roosterjs-content-model-dom/test/endToEndTest.ts b/packages/roosterjs-content-model-dom/test/endToEndTest.ts index ac93f89d664..0e48b670b52 100644 --- a/packages/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages/roosterjs-content-model-dom/test/endToEndTest.ts @@ -2105,4 +2105,45 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { '
    1. test
' ); }); + + it('link with color', () => { + runTest( + '', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'www.bing.com', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(245, 212, 39)', + }, + link: { + format: { + underline: true, + href: 'http://www.bing.com', + textColor: 'rgb(245, 212, 39)', + }, + dataset: {}, + }, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(245, 212, 39)', + }, + }, + ], + }, + 'www.bing.com', + '' + ); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/utils/stackFormatTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/utils/stackFormatTest.ts index b6fc3dd3843..4ae723f80e9 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/utils/stackFormatTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/utils/stackFormatTest.ts @@ -26,6 +26,7 @@ describe('stackFormat', () => { const callback = jasmine.createSpy().and.callFake(() => { expect(context.implicitFormat).toEqual({ underline: true, + textColor: undefined, }); context.implicitFormat.fontSize = '10px'; }); @@ -41,6 +42,7 @@ describe('stackFormat', () => { const callback = jasmine.createSpy().and.callFake(() => { expect(context.implicitFormat).toEqual({ underline: true, + textColor: undefined, }); context.implicitFormat.fontSize = '10px'; throw new Error('test'); diff --git a/packages/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts b/packages/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts index d7490edbae5..8400b40cc49 100644 --- a/packages/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts @@ -63,6 +63,21 @@ describe('modelToText', () => { expect(text).toBe('text1\r\ntext2'); }); + it('model with empty lines (BR only in paragraph)', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(createBr()); + para2.segments.push(createBr()); + + model.blocks.push(para1, para2); + + const text = contentModelToText(model); + + expect(text).toBe('\r\n'); + }); + it('model with paragraph and image', () => { const model = createContentModelDocument(); const para1 = createParagraph(); @@ -76,7 +91,7 @@ describe('modelToText', () => { expect(text).toBe('text1 text2'); }); - it('model with divider', () => { + it('model with divider (DIV)', () => { const model = createContentModelDocument(); const para1 = createParagraph(); const para2 = createParagraph(); @@ -91,6 +106,21 @@ describe('modelToText', () => { expect(text).toBe('text1\r\n\r\ntext2'); }); + it('model with divider (HR)', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + model.blocks.push(para1, createDivider('hr'), para2); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\n________________________________________\r\ntext2'); + }); + it('model with list', () => { const model = createContentModelDocument(); const para1 = createParagraph(); diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index b10e68a94ce..9e68553e61a 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -1,6 +1,8 @@ import { createLink } from './link/createLink'; import { createLinkAfterSpace } from './link/createLinkAfterSpace'; +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; import { keyboardListTrigger } from './list/keyboardListTrigger'; +import { transformHyphen } from './hyphen/transformHyphen'; import { unlink } from './link/unlink'; import type { ContentChangedEvent, @@ -34,6 +36,11 @@ export type AutoFormatOptions = { * When paste content, create hyperlink for the pasted link */ autoLink: boolean; + + /** + * Transform -- into hyphen, if typed between two words + */ + autoHyphen: boolean; }; /** @@ -44,6 +51,7 @@ const DefaultOptions: Required = { autoNumbering: false, autoUnlink: false, autoLink: false, + autoHyphen: false, }; /** @@ -52,13 +60,13 @@ const DefaultOptions: Required = { */ export class AutoFormatPlugin implements EditorPlugin { private editor: IEditor | null = null; - /** * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to false. * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to false. * - 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. */ constructor(private options: AutoFormatOptions = DefaultOptions) {} @@ -112,14 +120,52 @@ export class AutoFormatPlugin implements EditorPlugin { private handleEditorInputEvent(editor: IEditor, event: EditorInputEvent) { const rawEvent = event.rawEvent; - if (rawEvent.inputType === 'insertText') { + const selection = editor.getDOMSelection(); + if ( + rawEvent.inputType === 'insertText' && + selection && + selection.type === 'range' && + selection.range.collapsed + ) { switch (rawEvent.data) { case ' ': - const { autoBullet, autoNumbering, autoLink } = this.options; - keyboardListTrigger(editor, autoBullet, autoNumbering); - if (autoLink) { - createLinkAfterSpace(editor); - } + formatTextSegmentBeforeSelectionMarker( + editor, + (model, previousSegment, paragraph, _markerFormat, context) => { + const { + autoBullet, + autoNumbering, + autoLink, + autoHyphen, + } = this.options; + let shouldHyphen = false; + let shouldLink = false; + + if (autoLink) { + shouldLink = createLinkAfterSpace( + previousSegment, + paragraph, + context + ); + } + + if (autoHyphen) { + shouldHyphen = transformHyphen(previousSegment, paragraph, context); + } + + return ( + keyboardListTrigger( + model, + paragraph, + context, + autoBullet, + autoNumbering + ) || + shouldHyphen || + shouldLink + ); + } + ); break; } } diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts new file mode 100644 index 00000000000..95a865d734e --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/hyphen/transformHyphen.ts @@ -0,0 +1,46 @@ +import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; +import type { + ContentModelParagraph, + ContentModelText, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function transformHyphen( + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext +): boolean { + const segments = previousSegment.text.split(' '); + const dashes = segments[segments.length - 2]; + if (dashes === '--') { + const textIndex = previousSegment.text.lastIndexOf('--'); + const textSegment = splitTextSegment(previousSegment, paragraph, textIndex, textIndex + 2); + + textSegment.text = textSegment.text.replace('--', '—'); + context.canUndoByBackspace = true; + return true; + } else { + const text = segments.pop(); + const hasDashes = text && text?.indexOf('--') > -1; + if (hasDashes && text.trim() !== '--') { + const textIndex = previousSegment.text.indexOf(text); + const textSegment = splitTextSegment( + previousSegment, + paragraph, + textIndex, + textIndex + text.length - 1 + ); + + const textLength = textSegment.text.length; + if (textSegment.text[0] !== '-' && textSegment.text[textLength - 1] !== '-') { + textSegment.text = textSegment.text.replace('--', '—'); + context.canUndoByBackspace = true; + return true; + } + } + } + return false; +} 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 baaa7a00108..22d975f44cb 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLink.ts @@ -1,17 +1,17 @@ import { addLink } from 'roosterjs-content-model-dom'; -import { getLinkSegment } from './getLinkSegment'; -import type { IEditor } from 'roosterjs-content-model-types'; +import { formatTextSegmentBeforeSelectionMarker, matchLink } from 'roosterjs-content-model-api'; +import type { IEditor, LinkData } from 'roosterjs-content-model-types'; /** * @internal */ export function createLink(editor: IEditor) { - editor.formatContentModel(model => { - const link = getLinkSegment(model); - if (link && !link.link) { - addLink(link, { + formatTextSegmentBeforeSelectionMarker(editor, (_model, linkSegment, _paragraph) => { + let linkData: LinkData | null = null; + if (!linkSegment.link && (linkData = matchLink(linkSegment.text))) { + addLink(linkSegment, { format: { - href: link.text, + href: linkData.normalizedUrl, underline: true, }, dataset: {}, diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts index d529474d025..ca39668f0b5 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/createLinkAfterSpace.ts @@ -1,57 +1,41 @@ -import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; import { matchLink } from 'roosterjs-content-model-api'; import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; -import type { IEditor, LinkData } from 'roosterjs-content-model-types'; +import type { + ContentModelParagraph, + ContentModelText, + FormatContentModelContext, + LinkData, +} from 'roosterjs-content-model-types'; /** * @internal */ -export function createLinkAfterSpace(editor: IEditor) { - editor.formatContentModel((model, context) => { - const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( - model, - false /* includingFormatHolder */ +export function createLinkAfterSpace( + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext +) { + const link = previousSegment.text.split(' ').pop(); + const url = link?.trim(); + let linkData: LinkData | null = null; + if (url && link && (linkData = matchLink(url))) { + const linkSegment = splitTextSegment( + previousSegment, + paragraph, + previousSegment.text.length - link.trimLeft().length, + previousSegment.text.trimRight().length ); - if (selectedSegmentsAndParagraphs.length > 0 && selectedSegmentsAndParagraphs[0][1]) { - const markerIndex = selectedSegmentsAndParagraphs[0][1].segments.findIndex( - segment => segment.segmentType == 'SelectionMarker' - ); - const paragraph = selectedSegmentsAndParagraphs[0][1]; - if (markerIndex > 0) { - const textSegment = paragraph.segments[markerIndex - 1]; - const marker = paragraph.segments[markerIndex]; - if ( - marker.segmentType == 'SelectionMarker' && - textSegment && - textSegment.segmentType == 'Text' && - !textSegment.link - ) { - const link = textSegment.text.split(' ').pop(); - const url = link?.trim(); - let linkData: LinkData | null = null; - if (url && link && (linkData = matchLink(url))) { - const linkSegment = splitTextSegment( - textSegment, - paragraph, - textSegment.text.length - link.trimLeft().length, - textSegment.text.trimRight().length - ); - linkSegment.link = { - format: { - href: linkData.normalizedUrl, - underline: true, - }, - dataset: {}, - }; + linkSegment.link = { + format: { + href: linkData.normalizedUrl, + underline: true, + }, + dataset: {}, + }; - context.canUndoByBackspace = true; + context.canUndoByBackspace = true; - return true; - } - } - } - } - - return false; - }); + return true; + } + return false; } diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkSegment.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkSegment.ts deleted file mode 100644 index caa1e54845e..00000000000 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/getLinkSegment.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; -import { matchLink } from 'roosterjs-content-model-api'; -import type { ContentModelDocument } from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export function getLinkSegment(model: ContentModelDocument) { - const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( - model, - false /* includingFormatHolder */ - ); - if (selectedSegmentsAndParagraphs.length == 1 && selectedSegmentsAndParagraphs[0][1]) { - const selectedParagraph = selectedSegmentsAndParagraphs[0][1]; - const marker = selectedParagraph.segments[selectedParagraph.segments.length - 1]; - const link = selectedParagraph.segments[selectedParagraph.segments.length - 2]; - if ( - marker && - link && - marker.segmentType === 'SelectionMarker' && - marker.isSelected && - link.segmentType === 'Text' && - (matchLink(link.text) || link.link) - ) { - return link; - } - } - return undefined; -} diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/unlink.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/unlink.ts index 4648cc5b3e1..bdda50afa17 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/link/unlink.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/link/unlink.ts @@ -1,18 +1,18 @@ -import { getLinkSegment } from './getLinkSegment'; +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; + import type { IEditor } from 'roosterjs-content-model-types'; /** * @internal */ export function unlink(editor: IEditor, rawEvent: KeyboardEvent) { - editor.formatContentModel(model => { - const link = getLinkSegment(model); - if (link?.link) { - link.link = undefined; + formatTextSegmentBeforeSelectionMarker(editor, (_model, linkSegment, _paragraph) => { + if (linkSegment?.link) { + linkSegment.link = undefined; rawEvent.preventDefault(); + return true; } - 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 91357ddd9b1..12d8d632375 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts @@ -1,47 +1,41 @@ import { getListTypeStyle } from './getListTypeStyle'; -import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; import { setListType, setModelListStartNumber, setModelListStyle, } from 'roosterjs-content-model-api'; -import type { ContentModelDocument, IEditor } from 'roosterjs-content-model-types'; +import type { + ContentModelDocument, + ContentModelParagraph, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; /** * @internal */ export function keyboardListTrigger( - editor: IEditor, + model: ContentModelDocument, + paragraph: ContentModelParagraph, + context: FormatContentModelContext, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ) { if (shouldSearchForBullet || shouldSearchForNumbering) { - editor.formatContentModel( - (model, context) => { - const listStyleType = getListTypeStyle( - model, - shouldSearchForBullet, - shouldSearchForNumbering - ); - if (listStyleType) { - const segmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); - if (segmentsAndParagraphs[0] && segmentsAndParagraphs[0][1]) { - segmentsAndParagraphs[0][1].segments.splice(0, 1); - } - const { listType, styleType, index } = listStyleType; - triggerList(model, listType, styleType, index); - context.canUndoByBackspace = true; - - return true; - } - - return false; - }, - { - apiName: 'autoToggleList', - } + 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 false; } const triggerList = ( diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteList.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteList.ts index 57ff4c1bfd5..ed4107097f5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteList.ts @@ -1,22 +1,32 @@ import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-dom'; -import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; +import type { DeleteSelectionStep, ContentModelListItem } from 'roosterjs-content-model-types'; /** * @internal */ export const deleteList: DeleteSelectionStep = context => { + if (context.deleteResult != 'notDeleted') { + return; + } + const { paragraph, marker, path } = context.insertPoint; - if (context.deleteResult == 'nothingToDelete' || context.deleteResult == 'notDeleted') { - const index = getClosestAncestorBlockGroupIndex(path, ['ListItem', 'TableCell']); - const item = path[index]; - if ( - item && - index >= 0 && - paragraph.segments[0] == marker && - item.blockGroupType == 'ListItem' - ) { - item.levels = []; + if (paragraph.segments[0] == marker) { + const index = getClosestAncestorBlockGroupIndex( + path, + ['ListItem'], + ['TableCell', 'FormatContainer'] + ); + const item = path[index] as ContentModelListItem | undefined; + const lastLevel = item?.levels[item.levels.length - 1]; + + if (lastLevel && item?.blocks[0] == paragraph) { + if (lastLevel.format.displayForDummyItem == 'block') { + item.levels.pop(); + } else { + lastLevel.format.displayForDummyItem = 'block'; + } + context.deleteResult = 'range'; } } 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 e5b487781f5..46d08fc71da 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -115,6 +115,7 @@ const createNewListLevel = (listItem: ContentModelListItem) => { { ...level.format, startNumberOverride: undefined, + displayForDummyItem: undefined, // When ENTER, we should create a new regular list item, so force its dummy item display to undefined }, level.dataset ); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index b72cb8d0cff..e4fadcc1a1b 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -1,12 +1,12 @@ +import { deleteAllSegmentBefore } from './deleteSteps/deleteAllSegmentBefore'; +import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; +import { deleteList } from './deleteSteps/deleteList'; import { ChangeSource, deleteSelection, isModifierKey, isNodeOfType, } from 'roosterjs-content-model-dom'; -import { deleteAllSegmentBefore } from './deleteSteps/deleteAllSegmentBefore'; -import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; -import { deleteList } from './deleteSteps/deleteList'; import { handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, @@ -73,8 +73,8 @@ function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelecti return [ deleteAllSegmentBeforeStep, deleteWordSelection, + isForward ? null : deleteList, deleteCollapsedSelection, - deleteList, deleteQuote, ]; } diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index c1f03431991..0fb3dd7e327 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -1,10 +1,13 @@ import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-dom'; import { handleTabOnList } from './tabUtils/handleTabOnList'; import { handleTabOnParagraph } from './tabUtils/handleTabOnParagraph'; +import { handleTabOnTable } from './tabUtils/handleTabOnTable'; +import { handleTabOnTableCell } from './tabUtils/handleTabOnTableCell'; import { setModelIndentation } from 'roosterjs-content-model-api'; import type { ContentModelDocument, ContentModelListItem, + ContentModelTableCell, IEditor, } from 'roosterjs-content-model-types'; @@ -14,31 +17,51 @@ import type { export function keyboardTab(editor: IEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); - if (selection?.type == 'range') { - editor.formatContentModel( - model => { - return handleTab(model, rawEvent); - }, - { - apiName: 'handleTabKey', - } - ); + switch (selection?.type) { + case 'range': + editor.formatContentModel( + model => { + return handleTab(model, rawEvent); + }, + { + apiName: 'handleTabKey', + } + ); - return true; + return true; + case 'table': + editor.formatContentModel( + model => { + return handleTabOnTable(model, rawEvent); + }, + { + apiName: 'handleTabKey', + } + ); + return true; } } /** * If multiple blocks are selected, indent or outdent the selected blocks with setModelIndentation. - * If only one block is selected, call handleTabOnParagraph or handleTabOnList to handle the tab key. + * If only one block is selected: + * - If it is a table cell, call handleTabOnTableCell to handle the tab key. + * - 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) { - const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + 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(); return true; + } else if (isBlockGroupOfType(block, 'TableCell')) { + return handleTabOnTableCell(model, block, rawEvent); } else if (block?.blockType === 'Paragraph') { return handleTabOnParagraph(model, block, rawEvent); } else if (isBlockGroupOfType(block, 'ListItem')) { diff --git a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnTable.ts b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnTable.ts new file mode 100644 index 00000000000..5224a6c75ad --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnTable.ts @@ -0,0 +1,24 @@ +import { getFirstSelectedTable } from 'roosterjs-content-model-dom'; +import { setModelIndentation } from 'roosterjs-content-model-api'; +import type { ContentModelDocument, ContentModelTable } from 'roosterjs-content-model-types'; + +/** + * When the whole table is selected, indent or outdent the whole table with setModelIndentation. + * @internal + */ +export function handleTabOnTable(model: ContentModelDocument, rawEvent: KeyboardEvent) { + const tableModel = getFirstSelectedTable(model)[0]; + if (tableModel && isWholeTableSelected(tableModel)) { + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); + rawEvent.preventDefault(); + return true; + } + return false; +} + +function isWholeTableSelected(tableModel: ContentModelTable) { + return ( + tableModel.rows[0]?.cells[0]?.isSelected && + tableModel.rows[tableModel.rows.length - 1]?.cells[tableModel.widths.length - 1]?.isSelected + ); +} diff --git a/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnTableCell.ts b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnTableCell.ts new file mode 100644 index 00000000000..9e5387c9905 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/tabUtils/handleTabOnTableCell.ts @@ -0,0 +1,53 @@ +import { clearSelectedCells, insertTableRow } from 'roosterjs-content-model-api'; +import { + createSelectionMarker, + getFirstSelectedTable, + normalizeTable, + setParagraphNotImplicit, + setSelection, +} from 'roosterjs-content-model-dom'; +import type { ContentModelDocument, ContentModelTableCell } from 'roosterjs-content-model-types'; + +/** + * When the cursor is on the last cell of a table, add new row and focus first new cell. + * @internal + */ +export function handleTabOnTableCell( + model: ContentModelDocument, + cell: ContentModelTableCell, + rawEvent: KeyboardEvent +) { + const tableModel = getFirstSelectedTable(model)[0]; + // Check if cursor is on last cell of the table + if ( + !rawEvent.shiftKey && + tableModel && + tableModel.rows[tableModel.rows.length - 1]?.cells[tableModel.widths.length - 1] === cell + ) { + insertTableRow(tableModel, 'insertBelow'); + + // Clear Table selection + clearSelectedCells(tableModel, { + firstRow: tableModel.rows.length - 1, + firstColumn: 0, + lastRow: tableModel.rows.length - 1, + lastColumn: tableModel.widths.length - 1, + }); + normalizeTable(tableModel, model.format); + + // Add selection marker to the first cell of the new row + const markerParagraph = tableModel.rows[tableModel.rows.length - 1]?.cells[0]?.blocks[0]; + if (markerParagraph.blockType == 'Paragraph') { + const marker = createSelectionMarker(model.format); + + markerParagraph.segments.unshift(marker); + setParagraphNotImplicit(markerParagraph); + setSelection(tableModel.rows[tableModel.rows.length - 1].cells[0], marker); + } + + rawEvent.preventDefault(); + return true; + } + + return false; +} diff --git a/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkPlugin.ts b/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkPlugin.ts new file mode 100644 index 00000000000..3e137362211 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkPlugin.ts @@ -0,0 +1,164 @@ +import { matchLink } from 'roosterjs-content-model-api'; +import type { HyperlinkToolTip } from './HyperlinkToolTip'; +import type { + DOMHelper, + EditorPlugin, + IEditor, + PluginEvent, + LinkData, +} from 'roosterjs-content-model-types'; + +const defaultToolTipCallback: HyperlinkToolTip = (url: string) => url; + +/** + * Hyperlink plugin does the following jobs for a hyperlink in editor: + * 1. When hover on a link, show a tool tip + * 2. When Ctrl+Click on a link, open a new window with the link + * 3. When type directly on a link whose text matches its link url, update the link url with the link text + */ +export class HyperlinkPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private domHelper: DOMHelper | null = null; + private isMac: boolean = false; + private disposer: (() => void) | null = null; + + private currentNode: Node | null = null; + private currentLink: HTMLAnchorElement | null = null; + + /** + * Create a new instance of HyperLink class + * @param tooltip Tooltip to show when mouse hover over a link + * Default value is to return the href itself. If null, there will be no tooltip text. + * @param target (Optional) Target window name for hyperlink. If null, will use "_blank" + * @param onLinkClick (Optional) Open link callback (return false to use default behavior) + */ + constructor( + private tooltip: HyperlinkToolTip = defaultToolTipCallback, + private target?: string, + private onLinkClick?: (anchor: HTMLAnchorElement, mouseEvent: MouseEvent) => boolean | void + ) {} + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Hyperlink'; + } + + /** + * Initialize this plugin + * @param editor The editor instance + */ + public initialize(editor: IEditor): void { + this.editor = editor; + this.domHelper = editor.getDOMHelper(); + this.isMac = !!editor.getEnvironment().isMac; + this.disposer = editor.attachDomEvent({ + mouseover: { beforeDispatch: this.onMouse }, + mouseout: { beforeDispatch: this.onMouse }, + }); + } + + /** + * Dispose this plugin + */ + public dispose(): void { + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + + this.currentNode = null; + this.currentLink = null; + this.editor = null; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + public onPluginEvent(event: PluginEvent): void { + let matchedLink: LinkData | null; + + if (event.eventType == 'keyDown') { + const selection = this.editor?.getDOMSelection(); + const node = + selection?.type == 'range' ? selection.range.commonAncestorContainer : null; + + if (node && node != this.currentNode) { + this.currentNode = node; + this.currentLink = null; + + this.runWithHyperlink(node, (href, a) => { + if ( + node.textContent && + (matchedLink = matchLink(node.textContent)) && + matchedLink.normalizedUrl == href + ) { + this.currentLink = a; + } + }); + } + } else if (event.eventType == 'keyUp') { + const selection = this.editor?.getDOMSelection(); + const node = + selection?.type == 'range' ? selection.range.commonAncestorContainer : null; + + if ( + node && + node == this.currentNode && + this.currentLink && + this.currentLink.contains(node) && + node.textContent && + (matchedLink = matchLink(node.textContent)) + ) { + this.currentLink.setAttribute('href', matchedLink.normalizedUrl); + } + } else if (event.eventType == 'mouseUp' && event.isClicking) { + this.runWithHyperlink(event.rawEvent.target as Node, (href, anchor) => { + if ( + !this.onLinkClick?.(anchor, event.rawEvent) && + this.isCtrlOrMetaPressed(event.rawEvent) && + event.rawEvent.button === 0 + ) { + event.rawEvent.preventDefault(); + + const target = this.target || '_blank'; + const window = this.editor?.getDocument().defaultView; + + try { + window?.open(href, target); + } catch {} + } + }); + } + } + + protected onMouse = (e: Event) => { + this.runWithHyperlink(e.target as Node, (href, a) => { + const tooltip = + e.type == 'mouseover' + ? typeof this.tooltip == 'function' + ? this.tooltip(href, a) + : this.tooltip + : null; + this.domHelper?.setDomAttribute('title', tooltip); + }); + }; + + private runWithHyperlink(node: Node, callback: (href: string, a: HTMLAnchorElement) => void) { + const a = this.domHelper?.findClosestElementAncestor( + node, + 'a[href]' + ) as HTMLAnchorElement | null; + const href = a?.getAttribute('href'); + + if (href && a) { + callback(href, a); + } + } + + private isCtrlOrMetaPressed(event: KeyboardEvent | MouseEvent): boolean { + return this.isMac ? event.metaKey : event.ctrlKey; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkToolTip.ts b/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkToolTip.ts new file mode 100644 index 00000000000..2d1b0055a60 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/hyperlink/HyperlinkToolTip.ts @@ -0,0 +1,7 @@ +/** + * A type to specify how to get a tool tip of hyperlink in editor + * string: Use this string as tooltip + * null: No tooltip + * function: Call this function to get a tooltip + */ +export type HyperlinkToolTip = string | null | ((url: string, anchor: HTMLAnchorElement) => string); diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index e691f97a3e0..b0de73404cc 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -25,3 +25,5 @@ export { ContextMenuPluginBase, ContextMenuOptions } from './contextMenuBase/Con export { WatermarkPlugin } from './watermark/WatermarkPlugin'; export { WatermarkFormat } from './watermark/WatermarkFormat'; export { MarkdownPlugin, MarkdownOptions } from './markdown/MarkdownPlugin'; +export { HyperlinkPlugin } from './hyperlink/HyperlinkPlugin'; +export { HyperlinkToolTip } from './hyperlink/HyperlinkToolTip'; diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts b/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts index 0be7a451014..5ef27d3592d 100644 --- a/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts @@ -1,6 +1,7 @@ import { setFormat } from './utils/setFormat'; import type { ContentChangedEvent, + ContentModelCodeFormat, EditorInputEvent, EditorPlugin, IEditor, @@ -9,18 +10,24 @@ import type { } from 'roosterjs-content-model-types'; /** + * * Options for Markdown plugin + * - strikethrough: If true text between ~ will receive strikethrough format. + * - bold: If true text between * will receive bold format. + * - italic: If true text between _ will receive italic format. + * - codeFormat: If provided, text between ` will receive code format. If equal to {}, it will set the default code format. */ export interface MarkdownOptions { strikethrough?: boolean; bold?: boolean; italic?: boolean; + codeFormat?: ContentModelCodeFormat; } /** * @internal */ -const DefaultOptions: Required = { +const DefaultOptions: Partial = { strikethrough: false, bold: false, italic: false, @@ -34,13 +41,15 @@ export class MarkdownPlugin implements EditorPlugin { private shouldBold = false; private shouldItalic = false; private shouldStrikethrough = false; + private shouldCode = false; private lastKeyTyped: string | null = null; /** * @param options An optional parameter that takes in an object of type MarkdownOptions, which includes the following properties: - * - strikethrough: If true text between ~ will receive strikethrough format. Defaults to true. - * - bold: If true text between * will receive bold format. Defaults to true. - * - italic: If true text between _ will receive italic format. Defaults to true. + * - strikethrough: If true text between ~ will receive strikethrough format. Defaults to false. + * - bold: If true text between * will receive bold format. Defaults to false. + * - italic: If true text between _ will receive italic format. Defaults to false. + * - codeFormat: If provided, text between ` will receive code format. Defaults to undefined. */ constructor(private options: MarkdownOptions = DefaultOptions) {} @@ -68,9 +77,7 @@ export class MarkdownPlugin implements EditorPlugin { */ dispose() { this.editor = null; - this.shouldBold = false; - this.shouldItalic = false; - this.shouldStrikethrough = false; + this.disableAllFeatures(); this.lastKeyTyped = null; } @@ -138,6 +145,16 @@ export class MarkdownPlugin implements EditorPlugin { } } break; + case '`': + if (this.options.codeFormat) { + if (this.shouldCode) { + setFormat(editor, '`', {} /* format */, this.options.codeFormat); + this.shouldCode = false; + } else { + this.shouldCode = true; + } + } + break; } } } @@ -147,9 +164,7 @@ export class MarkdownPlugin implements EditorPlugin { if (!event.handledByEditFeature && !rawEvent.defaultPrevented) { switch (rawEvent.key) { case 'Enter': - this.shouldBold = false; - this.shouldItalic = false; - this.shouldStrikethrough = false; + this.disableAllFeatures(); this.lastKeyTyped = null; break; case ' ': @@ -159,6 +174,8 @@ export class MarkdownPlugin implements EditorPlugin { this.shouldStrikethrough = false; } else if (this.lastKeyTyped === '_' && this.shouldItalic) { this.shouldItalic = false; + } else if (this.lastKeyTyped === '`' && this.shouldCode) { + this.shouldCode = false; } this.lastKeyTyped = null; break; @@ -177,6 +194,8 @@ export class MarkdownPlugin implements EditorPlugin { this.shouldStrikethrough = false; } else if (this.lastKeyTyped === '_' && this.shouldItalic) { this.shouldItalic = false; + } else if (this.lastKeyTyped === '`' && this.shouldCode) { + this.shouldCode = false; } this.lastKeyTyped = null; } @@ -184,9 +203,14 @@ export class MarkdownPlugin implements EditorPlugin { private handleContentChangedEvent(event: ContentChangedEvent) { if (event.source == 'Format') { - this.shouldBold = false; - this.shouldItalic = false; - this.shouldStrikethrough = false; + this.disableAllFeatures(); } } + + private disableAllFeatures() { + this.shouldBold = false; + this.shouldItalic = false; + this.shouldStrikethrough = false; + this.shouldCode = false; + } } 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 9cd658045e3..8596e1b92b4 100644 --- a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts +++ b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts @@ -1,66 +1,61 @@ -import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-dom'; +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; import { splitTextSegment } from '../../pluginUtils/splitTextSegment'; -import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; +import type { + ContentModelCodeFormat, + ContentModelSegmentFormat, + IEditor, +} from 'roosterjs-content-model-types'; /** * @internal */ -export function setFormat(editor: IEditor, character: string, format: ContentModelSegmentFormat) { - editor.formatContentModel((model, context) => { - const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( - model, - false /*includeFormatHolder*/ - ); - - if (selectedSegmentsAndParagraphs.length > 0 && selectedSegmentsAndParagraphs[0][1]) { - const marker = selectedSegmentsAndParagraphs[0][0]; - context.newPendingFormat = { - ...marker.format, - strikethrough: !!marker.format.strikethrough, - italic: !!marker.format.italic, - fontWeight: marker.format?.fontWeight ? 'bold' : undefined, - }; - - const paragraph = selectedSegmentsAndParagraphs[0][1]; - if (marker.segmentType == 'SelectionMarker') { - const markerIndex = paragraph.segments.indexOf(marker); - if (markerIndex > 0 && paragraph.segments[markerIndex - 1]) { - const segmentBeforeMarker = paragraph.segments[markerIndex - 1]; - - if ( - segmentBeforeMarker.segmentType == 'Text' && - segmentBeforeMarker.text[segmentBeforeMarker.text.length - 1] == character - ) { - const textBeforeMarker = segmentBeforeMarker.text.slice(0, -1); - if (textBeforeMarker.indexOf(character) > -1) { - const lastCharIndex = segmentBeforeMarker.text.length; - const firstCharIndex = segmentBeforeMarker.text - .substring(0, lastCharIndex - 1) - .lastIndexOf(character); - - const formattedText = splitTextSegment( - segmentBeforeMarker, - paragraph, - firstCharIndex, - lastCharIndex - ); - - formattedText.text = formattedText.text - .replace(character, '') - .slice(0, -1); - formattedText.format = { - ...formattedText.format, - ...format, - }; - - context.canUndoByBackspace = true; - return true; - } +export function setFormat( + editor: IEditor, + character: string, + format: ContentModelSegmentFormat, + codeFormat?: ContentModelCodeFormat +) { + formatTextSegmentBeforeSelectionMarker( + editor, + (_model, previousSegment, paragraph, markerFormat, context) => { + if (previousSegment.text[previousSegment.text.length - 1] == character) { + const textBeforeMarker = previousSegment.text.slice(0, -1); + context.newPendingFormat = { + ...markerFormat, + strikethrough: !!markerFormat.strikethrough, + italic: !!markerFormat.italic, + fontWeight: markerFormat?.fontWeight ? 'bold' : undefined, + }; + if (textBeforeMarker.indexOf(character) > -1) { + const lastCharIndex = previousSegment.text.length; + const firstCharIndex = previousSegment.text + .substring(0, lastCharIndex - 1) + .lastIndexOf(character); + + 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, + }; } + + context.canUndoByBackspace = true; + return true; } } + return false; } - return false; - }); + ); } diff --git a/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts b/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts index 93b7dd5e3ac..edfecf5c90f 100644 --- a/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts +++ b/packages/roosterjs-content-model-plugins/lib/shortcut/shortcuts.ts @@ -71,7 +71,7 @@ export const ShortcutUnderline: ShortcutCommand = { /** * Shortcut command for Clear Format * Windows: Ctrl + Space - * MacOS: Meta + Space + * MacOS: N/A */ export const ShortcutClearFormat: ShortcutCommand = { shortcutKey: { @@ -80,6 +80,7 @@ export const ShortcutClearFormat: ShortcutCommand = { which: Keys.SPACE, }, onClick: editor => clearFormat(editor), + environment: 'nonMac', }; /** @@ -200,7 +201,7 @@ export const ShortcutDecreaseFont: ShortcutCommand = { /** * Shortcut command for Intent list * Windows: Alt + Shift + Arrow Right - * MacOS: Option + Shift+ Arrow Right + * MacOS: N/A */ export const ShortcutIndentList: ShortcutCommand = { shortcutKey: { @@ -211,12 +212,13 @@ export const ShortcutIndentList: ShortcutCommand = { onClick: editor => { setShortcutIndentationCommand(editor, 'indent'); }, + environment: 'nonMac', }; /** * Shortcut command for Outdent list * Windows: Alt + Shift + Arrow Left - * MacOS: Option + Shift+ Arrow Left + * MacOS: N/A */ export const ShortcutOutdentList: ShortcutCommand = { shortcutKey: { @@ -227,4 +229,5 @@ export const ShortcutOutdentList: ShortcutCommand = { onClick: editor => { setShortcutIndentationCommand(editor, 'outdent'); }, + environment: 'nonMac', }; diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 23afee5ee30..726cb514e71 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1,39 +1,46 @@ import * as createLink from '../../lib/autoFormat/link/createLink'; -import * as createLinkAfterSpace from '../../lib/autoFormat/link/createLinkAfterSpace'; -import * as keyboardTrigger from '../../lib/autoFormat/list/keyboardListTrigger'; +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 { createLinkAfterSpace } from '../../lib/autoFormat/link/createLinkAfterSpace'; +import { keyboardListTrigger } from '../../lib/autoFormat/list/keyboardListTrigger'; +import { transformHyphen } from '../../lib/autoFormat/hyphen/transformHyphen'; import { ContentChangedEvent, + ContentModelDocument, + ContentModelParagraph, + ContentModelText, EditorInputEvent, + FormatContentModelContext, IEditor, KeyDownEvent, } from 'roosterjs-content-model-types'; describe('Content Model Auto Format Plugin Test', () => { let editor: IEditor; + let formatTextSegmentBeforeSelectionMarkerSpy: jasmine.Spy; beforeEach(() => { + formatTextSegmentBeforeSelectionMarkerSpy = spyOn( + formatTextSegmentBeforeSelectionMarker, + 'formatTextSegmentBeforeSelectionMarker' + ); editor = ({ focus: () => {}, getDOMSelection: () => ({ - type: -1, + type: 'range', + range: { + collapsed: true, + }, } as any), // Force return invalid range to go through content model code formatContentModel: () => {}, } as any) as IEditor; }); describe('onPluginEvent - keyboardListTrigger', () => { - let keyboardListTriggerSpy: jasmine.Spy; - - beforeEach(() => { - keyboardListTriggerSpy = spyOn(keyboardTrigger, 'keyboardListTrigger'); - }); - function runTest( event: EditorInputEvent, - shouldCallTrigger: boolean, options?: { autoBullet: boolean; autoNumbering: boolean; @@ -44,15 +51,25 @@ describe('Content Model Auto Format Plugin Test', () => { plugin.onPluginEvent(event); - if (shouldCallTrigger) { - expect(keyboardListTriggerSpy).toHaveBeenCalledWith( + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback) => { + expect(callback).toBe( editor, - options ? options.autoBullet : true, - options ? options.autoNumbering : true + ( + model: ContentModelDocument, + _previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext + ) => { + return keyboardListTrigger( + model, + paragraph, + context, + options!.autoBullet, + options!.autoNumbering + ); + } ); - } else { - expect(keyboardListTriggerSpy).not.toHaveBeenCalled(); - } + }); } it('should trigger keyboardListTrigger', () => { @@ -60,7 +77,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, true, { + runTest(event, { autoBullet: true, autoNumbering: true, }); @@ -71,7 +88,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: '*', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, false, { + runTest(event, { autoBullet: true, autoNumbering: true, }); @@ -82,7 +99,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, true, { autoBullet: false, autoNumbering: false } as AutoFormatOptions); + runTest(event, { autoBullet: false, autoNumbering: false } as AutoFormatOptions); }); it('should trigger keyboardListTrigger with auto bullet only', () => { @@ -90,7 +107,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, true, { autoBullet: true, autoNumbering: false } as AutoFormatOptions); + runTest(event, { autoBullet: true, autoNumbering: false } as AutoFormatOptions); }); it('should trigger keyboardListTrigger with auto numbering only', () => { @@ -98,7 +115,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', defaultPrevented: false, inputType: 'insertText' } as any, }; - runTest(event, true, { autoBullet: false, autoNumbering: true } as AutoFormatOptions); + runTest(event, { autoBullet: false, autoNumbering: true } as AutoFormatOptions); }); it('should not trigger keyboardListTrigger if the input type is different from insertText', () => { @@ -106,7 +123,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { key: ' ', defaultPrevented: false, inputType: 'test' } as any, }; - runTest(event, false, { autoBullet: true, autoNumbering: true } as AutoFormatOptions); + runTest(event, { autoBullet: true, autoNumbering: true } as AutoFormatOptions); }); }); @@ -221,15 +238,8 @@ describe('Content Model Auto Format Plugin Test', () => { }); describe('onPluginEvent - createLinkAfterSpace', () => { - let createLinkAfterSpaceSpy: jasmine.Spy; - - beforeEach(() => { - createLinkAfterSpaceSpy = spyOn(createLinkAfterSpace, 'createLinkAfterSpace'); - }); - function runTest( event: EditorInputEvent, - shouldCallTrigger: boolean, options?: { autoLink: boolean; } @@ -238,12 +248,23 @@ describe('Content Model Auto Format Plugin Test', () => { plugin.initialize(editor); plugin.onPluginEvent(event); - - if (shouldCallTrigger) { - expect(createLinkAfterSpaceSpy).toHaveBeenCalledWith(editor); - } else { - expect(createLinkAfterSpaceSpy).not.toHaveBeenCalled(); - } + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback) => { + expect(callback).toBe( + editor, + ( + _model: ContentModelDocument, + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext + ) => { + return ( + options && + options.autoLink && + createLinkAfterSpace(previousSegment, paragraph, context) + ); + } + ); + }); } it('should call createLinkAfterSpace', () => { @@ -251,7 +272,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, true, { + runTest(event, { autoLink: true, }); }); @@ -261,7 +282,7 @@ describe('Content Model Auto Format Plugin Test', () => { eventType: 'input', rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, }; - runTest(event, false, { + runTest(event, { autoLink: false, }); }); @@ -275,9 +296,74 @@ describe('Content Model Auto Format Plugin Test', () => { inputType: 'insertText', } as any, }; - runTest(event, false, { + runTest(event, { autoLink: true, }); }); }); + + describe('onPluginEvent - transformHyphen', () => { + function runTest( + event: EditorInputEvent, + options?: { + autoHyphen: boolean; + } + ) { + const plugin = new AutoFormatPlugin(options as AutoFormatOptions); + plugin.initialize(editor); + + plugin.onPluginEvent(event); + formatTextSegmentBeforeSelectionMarkerSpy.and.callFake((editor, callback) => { + expect(callback).toBe( + editor, + ( + _model: ContentModelDocument, + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext + ) => { + return ( + options && + options.autoHyphen && + transformHyphen(previousSegment, paragraph, context) + ); + } + ); + }); + } + + it('should call transformHyphen', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, + }; + runTest(event, { + autoHyphen: true, + }); + }); + + it('should not call transformHyphen - disable options', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { data: ' ', preventDefault: () => {}, inputType: 'insertText' } as any, + }; + runTest(event, { + autoHyphen: false, + }); + }); + + it('should not call transformHyphen - not space', () => { + const event: EditorInputEvent = { + eventType: 'input', + rawEvent: { + data: 'Backspace', + preventDefault: () => {}, + inputType: 'insertText', + } as any, + }; + runTest(event, { + autoHyphen: true, + }); + }); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/hyphen/transformHyphenTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/hyphen/transformHyphenTest.ts new file mode 100644 index 00000000000..ffbd8b55c75 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/hyphen/transformHyphenTest.ts @@ -0,0 +1,446 @@ +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; +import { transformHyphen } from '../../../lib/autoFormat/hyphen/transformHyphen'; +import { + ContentModelDocument, + ContentModelParagraph, + ContentModelText, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; + +describe('transformHyphen', () => { + function runTest( + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext, + expectedResult: boolean + ) { + const result = transformHyphen(previousSegment, paragraph, context); + expect(result).toBe(expectedResult); + } + + it('with hyphen', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test--test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('No hyphen', () => { + 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 hyphen between spaces', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test -- test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('with hyphen at the end', () => { + 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 hyphen at the start', () => { + 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 hyphen and space right', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test-- test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); + + it('with hyphen and space left', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test --test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); + + it('with hyphen and more text', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'testing hyphen test test--test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('text after dashes', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test--test testing hyphen test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); +}); + +describe('formatTextSegmentBeforeSelectionMarker - transformHyphen', () => { + function runTest( + input: ContentModelDocument, + expectedModel: ContentModelDocument, + expectedResult: boolean + ) { + const formatWithContentModelSpy = jasmine + .createSpy('formatWithContentModel') + .and.callFake((callback, options) => { + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + canUndoByBackspace: true, + }); + expect(result).toBe(expectedResult); + }); + + formatTextSegmentBeforeSelectionMarker( + { + focus: () => {}, + formatContentModel: formatWithContentModelSpy, + } as any, + (_model, previousSegment, paragraph, _markerFormat, context) => { + return transformHyphen(previousSegment, paragraph, context); + } + ); + + expect(formatWithContentModelSpy).toHaveBeenCalled(); + expect(input).toEqual(expectedModel); + } + + it('No hyphen', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, input, false); + }); + + it('with hyphen', () => { + const text = 'test--test'; + spyOn(text, 'split').and.returnValue(['test--test ']); + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: text, + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test—tes', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: 't', + format: {}, + isSelected: undefined, + }, + + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('with hyphen and left space', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test-- test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + runTest(input, input, false); + }); + + it('with hyphen and left space', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test --test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + runTest(input, input, false); + }); + + it('with hyphen between spaces', () => { + const text = 'test -- test'; + spyOn(text, 'split').and.returnValue(['test', '--', 'test']); + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test -- test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test ', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: '—', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: ' test', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); + + it('with hyphen and multiple words', () => { + const text = 'testing test--test'; + spyOn(text, 'split').and.returnValue(['testing', 'test--test ']); + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: text, + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'testing ', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: 'test—tes', + format: {}, + isSelected: undefined, + }, + { + segmentType: 'Text', + text: 't', + format: {}, + isSelected: undefined, + }, + + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, expected, true); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts index a93df5e9573..9ad724e4b9f 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkAfterSpaceTest.ts @@ -1,7 +1,67 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createLinkAfterSpace } from '../../../lib/autoFormat/link/createLinkAfterSpace'; +import { formatTextSegmentBeforeSelectionMarker } from 'roosterjs-content-model-api'; +import { + ContentModelDocument, + ContentModelParagraph, + ContentModelText, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; describe('createLinkAfterSpace', () => { + function runTest( + previousSegment: ContentModelText, + paragraph: ContentModelParagraph, + context: FormatContentModelContext, + expectedResult: boolean + ) { + const result = createLinkAfterSpace(previousSegment, paragraph, context); + expect(result).toBe(expectedResult); + } + + it('with link', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'test http://bing.com', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, true); + }); + + it('No link', () => { + 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 text after link ', () => { + const segment: ContentModelText = { + segmentType: 'Text', + text: 'http://bing.com test', + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [segment], + format: {}, + }; + runTest(segment, paragraph, { canUndoByBackspace: true } as any, false); + }); +}); + +describe('formatTextSegmentBeforeSelectionMarker - createLinkAfterSpace', () => { function runTest( input: ContentModelDocument, expectedModel: ContentModelDocument, @@ -19,36 +79,20 @@ describe('createLinkAfterSpace', () => { expect(result).toBe(expectedResult); }); - createLinkAfterSpace({ - focus: () => {}, - formatContentModel: formatWithContentModelSpy, - } as any); + formatTextSegmentBeforeSelectionMarker( + { + focus: () => {}, + formatContentModel: formatWithContentModelSpy, + } as any, + (_model, previousSegment, paragraph, _markerFormat, context) => { + return createLinkAfterSpace(previousSegment, paragraph, context); + } + ); expect(formatWithContentModelSpy).toHaveBeenCalled(); expect(input).toEqual(expectedModel); } - it('no selected segments', () => { - const input: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }; - runTest(input, input, false); - }); - it('no link segment', () => { const input: ContentModelDocument = { blockGroupType: 'Document', @@ -164,7 +208,7 @@ describe('createLinkAfterSpace', () => { format: {}, }; - runTest(input, input, false); + runTest(input, input, true); }); it('link with text', () => { 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 d87b4f9c562..038ce610a2f 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/link/createLinkTest.ts @@ -110,7 +110,7 @@ describe('createLink', () => { format: {}, link: { format: { - href: 'www.bing.com', + href: 'http://www.bing.com', underline: true, }, dataset: {}, diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/list/keyboardListTriggerTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/list/keyboardListTriggerTest.ts index 991c67f1e11..a2cb74f5401 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/list/keyboardListTriggerTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/list/keyboardListTriggerTest.ts @@ -1,587 +1,83 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { keyboardListTrigger } from '../../../lib/autoFormat/list/keyboardListTrigger'; +import { + ContentModelDocument, + ContentModelParagraph, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; describe('keyboardListTrigger', () => { function runTest( - input: ContentModelDocument, - expectedModel: ContentModelDocument, + model: ContentModelDocument, + paragraph: ContentModelParagraph, + context: FormatContentModelContext, expectedResult: boolean, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ) { - const formatWithContentModelSpy = jasmine - .createSpy('formatWithContentModel') - .and.callFake((callback, options) => { - const result = callback(input, { - newEntities: [], - deletedEntities: [], - newImages: [], - canUndoByBackspace: true, - }); - expect(result).toBe(expectedResult); - expect(options.apiName).toBe('autoToggleList'); - }); - - keyboardListTrigger( - { - focus: () => {}, - formatContentModel: formatWithContentModelSpy, - } as any, - shouldSearchForBullet, - shouldSearchForNumbering - ); - - expect(formatWithContentModelSpy).toHaveBeenCalled(); - expect(input).toEqual(expectedModel); - } - - it('trigger numbering list', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '1)', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: { - startNumberOverride: 1, - direction: undefined, - textAlign: undefined, - marginBottom: undefined, - marginTop: undefined, - }, - dataset: { - editingInfo: '{"orderedStyleType":3}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: { - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - }, - }, - format: {}, - }, - ], - format: {}, - }, - true - ); - }); - - it('trigger continued numbering list', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: ' test', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: {}, - dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, - format: { - listStyleType: '"1) "', - }, - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '2)', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: {}, - dataset: { - editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, - format: { - listStyleType: '"1) "', - }, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: { - startNumberOverride: 2, - direction: undefined, - textAlign: undefined, - marginBottom: undefined, - marginTop: undefined, - }, - dataset: { - editingInfo: '{"orderedStyleType":3}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: { - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - }, - }, - format: {}, - }, - ], - format: {}, - }, - true - ); - }); - - it('should not trigger numbering list', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '1)', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '1)', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }, - false, - undefined, - false - ); - }); - - it('should trigger bullet list', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '*', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'UL', - format: { - startNumberOverride: 1, - direction: undefined, - textAlign: undefined, - marginBottom: undefined, - marginTop: undefined, - }, - dataset: { - editingInfo: '{"unorderedStyleType":1}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: { - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - }, - }, - format: {}, - }, - ], - format: {}, - }, - true - ); - }); - - it('should not trigger bullet list', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '*', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '*', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }, - false, - false - ); - }); - - it('trigger continued numbering list between lists', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: {}, - dataset: { - editingInfo: '{"orderedStyleType":3}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, - format: { - listStyleType: '"1) "', - }, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: {}, - dataset: { - editingInfo: '{"orderedStyleType":3}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, - format: { - listStyleType: '"2) "', - }, - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: '3)', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: { - startNumberOverride: 1, - }, - dataset: { - editingInfo: '{"orderedStyleType":10}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, - format: { - listStyleType: '"A) "', - }, - }, + const result = keyboardListTrigger( + model, + paragraph, + context, + shouldSearchForBullet, + shouldSearchForNumbering + ); + expect(result).toBe(expectedResult); + } + + it('trigger numbering list', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + runTest( + { + blockGroupType: 'Document', + blocks: [paragraph], + format: {}, + }, + paragraph, + { canUndoByBackspace: true } as any, + true + ); + }); + + it('trigger continued numbering list', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '2)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }; + runTest( + { + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -591,7 +87,7 @@ describe('keyboardListTrigger', () => { segments: [ { segmentType: 'Text', - text: 'test', + text: ' test', format: {}, }, ], @@ -603,7 +99,7 @@ describe('keyboardListTrigger', () => { listType: 'OL', format: {}, dataset: { - editingInfo: '{"orderedStyleType":10}', + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', }, }, ], @@ -613,13 +109,124 @@ describe('keyboardListTrigger', () => { format: {}, }, format: { - listStyleType: '"B) "', + listStyleType: '"1) "', }, }, + paragraph, ], format: {}, }, + paragraph, + { canUndoByBackspace: true } as any, + true + ); + }); + + it('should not trigger numbering list', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + runTest( + { + blockGroupType: 'Document', + blocks: [paragraph], + format: {}, + }, + paragraph, + { canUndoByBackspace: true } as any, + false + ); + }); + + it('should trigger bullet list', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + runTest( + { + blockGroupType: 'Document', + blocks: [paragraph], + format: {}, + }, + paragraph, + { canUndoByBackspace: true } as any, + true + ); + }); + + it('should not trigger bullet list', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + runTest( + { + blockGroupType: 'Document', + blocks: [paragraph], + format: {}, + }, + paragraph, + {} as any, + false + ); + }); + it('trigger continued numbering list between lists', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '3)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; + runTest( { blockGroupType: 'Document', blocks: [ @@ -691,52 +298,7 @@ describe('keyboardListTrigger', () => { listStyleType: '"2) "', }, }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: { - startNumberOverride: 3, - direction: undefined, - textAlign: undefined, - marginBottom: undefined, - marginTop: undefined, - }, - dataset: { - editingInfo: '{"orderedStyleType":3}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: { - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - }, - }, - format: {}, - }, + paragraph, { blockType: 'Paragraph', segments: [ @@ -820,11 +382,29 @@ describe('keyboardListTrigger', () => { ], format: {}, }, + paragraph, + { canUndoByBackspace: true } as any, true ); }); it('trigger a new numbering list after a numbering list', () => { + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }; runTest( { blockGroupType: 'Document', @@ -907,155 +487,12 @@ describe('keyboardListTrigger', () => { ], format: {}, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'A)', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: {}, - dataset: { - editingInfo: '{"orderedStyleType":3}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, - format: { - listStyleType: '"1) "', - }, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: {}, - dataset: { - editingInfo: '{"orderedStyleType":3}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, - format: { - listStyleType: '"2) "', - }, - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: { - startNumberOverride: 1, - direction: undefined, - textAlign: undefined, - marginBottom: undefined, - marginTop: undefined, - }, - dataset: { - editingInfo: '{"orderedStyleType":10}', - }, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: { - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - }, - }, - format: {}, - }, + paragraph, ], format: {}, }, + paragraph, + { canUndoByBackspace: true } as any, true ); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts index 721a853d875..4e98c4bc154 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts @@ -50,6 +50,81 @@ describe('deleteList', () => { normalizeContentModel(model); expect(result.deleteResult).toEqual('range'); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + format: { + listStyleType: '"1. "', + }, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + displayForDummyItem: 'block', + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + }, + ], + }); + + const result2 = deleteSelection(model, [deleteList]); + normalizeContentModel(model); + expect(result2.deleteResult).toEqual('range'); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + isImplicit: false, + }, + ], + }); + + const result3 = deleteSelection(model, [deleteList]); + normalizeContentModel(model); + expect(result3.deleteResult).toEqual('notDeleted'); + expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -401,6 +476,89 @@ describe('deleteList', () => { }; const result = deleteSelection(model, [deleteList]); normalizeContentModel(model); + + expect(result.deleteResult).toBe('range'); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + isImplicit: false, + }, + ], + levels: [ + { + listType: 'UL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'disc', + displayForDummyItem: 'block', + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + useBorderBox: true, + borderCollapse: true, + }, + widths: [120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', + }, + }, + ], + format: {}, + }); + + const result2 = deleteSelection(model, [deleteList]); + normalizeContentModel(model); + + expect(result2.deleteResult).toBe('range'); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -453,7 +611,6 @@ describe('deleteList', () => { ], format: {}, }); - expect(result.deleteResult).toEqual('range'); }); it('delete list if the cursor is before text', () => { @@ -507,6 +664,60 @@ describe('deleteList', () => { const result = deleteSelection(model, [deleteList]); normalizeContentModel(model); expect(result.deleteResult).toEqual('range'); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + format: { + listStyleType: '"1. "', + }, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'text', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + displayForDummyItem: 'block', + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + }, + ], + }); + + const result2 = deleteSelection(model, [deleteList]); + normalizeContentModel(model); + expect(result2.deleteResult).toEqual('range'); + expect(model).toEqual({ blockGroupType: 'Document', blocks: [ 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 e8b88ac05e7..3edb4b8fff5 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -301,6 +301,7 @@ describe('handleEnterOnList', () => { marginBottom: '0px', listStyleType: 'decimal', startNumberOverride: undefined, + displayForDummyItem: undefined, }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -436,6 +437,7 @@ describe('handleEnterOnList', () => { marginBottom: '0px', listStyleType: 'decimal', startNumberOverride: undefined, + displayForDummyItem: undefined, }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -817,6 +819,7 @@ describe('handleEnterOnList', () => { marginTop: '0px', marginBottom: '0px', startNumberOverride: undefined, + displayForDummyItem: undefined, }, dataset: { editingInfo: '{"orderedStyleType":10}', @@ -1898,6 +1901,7 @@ describe(' handleEnterOnList - keyboardInput', () => { marginBottom: '0px', listStyleType: 'decimal', startNumberOverride: undefined, + displayForDummyItem: undefined, }, dataset: { editingInfo: '{"orderedStyleType":1}', diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 73aa831ef2c..9678665979c 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -90,7 +90,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, null!, forwardDeleteCollapsedSelection, deleteList, null!], + [null!, null!, null!, forwardDeleteCollapsedSelection, null!], 'notDeleted', true, 0 @@ -108,7 +108,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, null!, backwardDeleteCollapsedSelection, deleteList, deleteEmptyQuote], + [null!, null!, deleteList, backwardDeleteCollapsedSelection, deleteEmptyQuote], 'notDeleted', true, 0 @@ -128,7 +128,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, forwardDeleteWordSelection, forwardDeleteCollapsedSelection, deleteList, null!], + [null!, forwardDeleteWordSelection, null!, forwardDeleteCollapsedSelection, null!], 'notDeleted', true, 0 @@ -151,8 +151,8 @@ describe('keyboardDelete', () => { [ null!, backwardDeleteWordSelection, - backwardDeleteCollapsedSelection, deleteList, + backwardDeleteCollapsedSelection, deleteEmptyQuote, ], 'notDeleted', @@ -174,7 +174,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, null!, forwardDeleteCollapsedSelection, deleteList, null!], + [null!, null!, null!, forwardDeleteCollapsedSelection, null!], 'notDeleted', true, 0 @@ -197,8 +197,8 @@ describe('keyboardDelete', () => { [ deleteAllSegmentBefore, null!, - backwardDeleteCollapsedSelection, deleteList, + backwardDeleteCollapsedSelection, deleteEmptyQuote, ], 'notDeleted', @@ -242,7 +242,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, forwardDeleteCollapsedSelection, deleteList, null!], + [null!, null!, null!, forwardDeleteCollapsedSelection, null!], 'notDeleted', true, 0 @@ -284,7 +284,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, backwardDeleteCollapsedSelection, deleteList, deleteEmptyQuote], + [null!, null!, deleteList, backwardDeleteCollapsedSelection, deleteEmptyQuote], 'notDeleted', true, 0 @@ -336,7 +336,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, forwardDeleteCollapsedSelection, deleteList, null!], + [null!, null!, null!, forwardDeleteCollapsedSelection, null!], 'singleChar', false, 1 @@ -388,7 +388,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, backwardDeleteCollapsedSelection, deleteList, deleteEmptyQuote], + [null!, null!, deleteList, backwardDeleteCollapsedSelection, deleteEmptyQuote], 'singleChar', false, 1 @@ -482,7 +482,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, backwardDeleteCollapsedSelection, deleteList, deleteEmptyQuote], + [null!, null!, deleteList, backwardDeleteCollapsedSelection, deleteEmptyQuote], 'singleChar', false, 1 diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts index 03f30f31e1d..9fb4f67f241 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -668,6 +668,156 @@ describe('keyboardTab', () => { runTest(model, undefined, false, true); }); + it('tab on the start first item on the list while list is in table cell', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 20, + format: {}, + cells: [ + { + spanLeft: false, + spanAbove: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [20], + dataset: {}, + }, + ], + format: {}, + }; + + runTest(model, 'indent', false, true); + }); + it('shift tab on empty list item', () => { const model: ContentModelDocument = { blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnTableCellTest.ts b/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnTableCellTest.ts new file mode 100644 index 00000000000..b068822eac5 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnTableCellTest.ts @@ -0,0 +1,716 @@ +import { getSelectedCells } from 'roosterjs-content-model-dom'; +import { handleTabOnTableCell } from '../../../lib/edit/tabUtils/handleTabOnTableCell'; +import { + ContentModelDocument, + ContentModelTableCell, + ContentModelTable, + ContentModelParagraph, +} from 'roosterjs-content-model-types'; + +describe('handleTabOnTableCell', () => { + function runTest( + model: ContentModelDocument, + cell: ContentModelTableCell, + rawEvent: KeyboardEvent, + expectedReturnValue: boolean + ) { + // Act + const result = handleTabOnTableCell(model, cell, rawEvent); + + // Assert + expect(result).toBe(expectedReturnValue); + } + + it('Create new row', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [20, 20, 20], + dataset: {}, + }, + ], + format: {}, + }; + const table = model.blocks[0] as ContentModelTable; + const tableCell = table.rows[2].cells[2] as ContentModelTableCell; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + runTest(model, tableCell, rawEvent, true); + expect(table.rows.length).toBe(4); + expect(getSelectedCells(table)).toEqual({ + firstRow: 3, + firstColumn: 0, + lastRow: 3, + lastColumn: 0, + }); + expect( + (table.rows[3]?.cells[0]?.blocks[0] as ContentModelParagraph)?.segments[0]?.segmentType + ).toEqual('SelectionMarker'); + }); + + it('Not create new row - Shift+Tab', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [20, 20, 20], + dataset: {}, + }, + ], + format: {}, + }; + const table = model.blocks[0] as ContentModelTable; + const tableCell = table.rows[2].cells[2] as ContentModelTableCell; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + runTest(model, tableCell, rawEvent, false); + expect(table.rows.length).toBe(3); + }); + + it('Not create new row - Shift+Tab', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [20, 20, 20], + dataset: {}, + }, + ], + format: {}, + }; + const table = model.blocks[0] as ContentModelTable; + const tableCell = table.rows[0].cells[0] as ContentModelTableCell; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + runTest(model, tableCell, rawEvent, false); + expect(table.rows.length).toBe(3); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnTableTest.ts b/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnTableTest.ts new file mode 100644 index 00000000000..669ac6a10ae --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/edit/tabUtils/handleTabOnTableTest.ts @@ -0,0 +1,904 @@ +import { ContentModelDocument, ContentModelTable } from 'roosterjs-content-model-types'; +import { handleTabOnTable } from '../../../lib/edit/tabUtils/handleTabOnTable'; + +describe('handleTabOnTable', () => { + function runTest( + model: ContentModelDocument, + rawEvent: KeyboardEvent, + expectedReturnValue: boolean + ) { + // Act + const result = handleTabOnTable(model, rawEvent); + + // Assert + expect(result).toBe(expectedReturnValue); + } + + it('Indent - whole selected table', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + ], + }, + ], + format: { + id: 'table_0', + marginLeft: '40px', + }, + widths: [120, 120, 120], + dataset: {}, + }, + ], + format: {}, + }; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + const tableBefore = model.blocks[0] as ContentModelTable; + const marginLeft = tableBefore.format.marginLeft; + runTest(model, rawEvent, true); + const tableAfter = model.blocks[0] as ContentModelTable; + expect(parseInt(tableAfter.format.marginLeft)).toBeGreaterThan(parseInt(marginLeft)); + }); + + it('Outdent - whole selected table', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + ], + }, + ], + format: { + id: 'table_0', + marginLeft: '40px', + }, + widths: [120, 120, 120], + dataset: {}, + }, + ], + format: {}, + }; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + }); + const tableBefore = model.blocks[0] as ContentModelTable; + const marginLeft = tableBefore.format.marginLeft; + runTest(model, rawEvent, true); + const tableAfter = model.blocks[0] as ContentModelTable; + expect(parseInt(tableAfter.format.marginLeft)).toBeLessThan(parseInt(marginLeft)); + }); + + it('No Indent - partially selected table', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + isSelected: true, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + }, + ], + format: { + id: 'table_0', + marginLeft: '40px', + }, + widths: [120, 120, 120], + dataset: {}, + }, + ], + format: {}, + }; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + runTest(model, rawEvent, false); + }); + + it('No Indent - no selected table', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + }, + { + height: 22, + format: {}, + cells: [ + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + { + spanAbove: false, + spanLeft: false, + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + dataset: {}, + }, + ], + }, + ], + format: { + id: 'table_0', + marginLeft: '40px', + }, + widths: [120, 120, 120], + dataset: {}, + }, + ], + format: {}, + }; + const rawEvent = new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: false, + }); + runTest(model, rawEvent, false); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/hyperlink/HyperlinkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/hyperlink/HyperlinkPluginTest.ts new file mode 100644 index 00000000000..47d20637d85 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/hyperlink/HyperlinkPluginTest.ts @@ -0,0 +1,458 @@ +import * as matchLink from 'roosterjs-content-model-api/lib/modelApi/link/matchLink'; +import { HyperlinkPlugin } from '../../lib/hyperlink/HyperlinkPlugin'; +import { + DOMEventHandlerFunction, + DOMEventRecord, + DOMHelper, + EditorEnvironment, + IEditor, +} from 'roosterjs-content-model-types'; + +describe('HyperlinkPlugin', () => { + const MockedTooltip = 'Tooltip'; + + let editor: IEditor; + let mockedDomHelper: DOMHelper; + let mockedEnvironment: EditorEnvironment; + let mockedWindow: Window; + + let attachDomEventSpy: jasmine.Spy; + let findClosestElementAncestorSpy: jasmine.Spy; + let setDomAttributeSpy: jasmine.Spy; + let openSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let matchLinkSpy: jasmine.Spy; + + beforeEach(() => { + findClosestElementAncestorSpy = jasmine.createSpy('findClosestElementAncestor'); + attachDomEventSpy = jasmine.createSpy('attachDomEvent'); + setDomAttributeSpy = jasmine.createSpy('setDomAttribute'); + openSpy = jasmine.createSpy('open'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + matchLinkSpy = spyOn(matchLink, 'matchLink'); + + mockedDomHelper = { + findClosestElementAncestor: findClosestElementAncestorSpy, + setDomAttribute: setDomAttributeSpy, + } as any; + mockedEnvironment = {} as any; + mockedWindow = { + open: openSpy, + } as any; + + editor = { + getDOMHelper: () => mockedDomHelper, + getEnvironment: () => mockedEnvironment, + attachDomEvent: attachDomEventSpy, + getDocument: () => ({ + defaultView: mockedWindow, + }), + getDOMSelection: getDOMSelectionSpy, + } as any; + }); + + it('MouseOver', () => { + const tooltipSpy = jasmine.createSpy('tooltip').and.returnValue(MockedTooltip); + const plugin = new HyperlinkPlugin(tooltipSpy); + const mockedNode = 'NODE' as any; + const mockedNode2 = 'NODE2' as any; + + let mouseOver: DOMEventHandlerFunction | undefined; + + attachDomEventSpy.and.callFake((eventMap: Record) => { + mouseOver = eventMap.mouseover.beforeDispatch!; + }); + + plugin.initialize(editor); + + expect(mouseOver).toBeDefined(); + + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + + findClosestElementAncestorSpy.and.callFake((node: Node) => { + return node == mockedNode ? mockedLink : null; + }); + + mouseOver!({ + type: 'mouseover', + target: mockedNode2, + } as any); + + expect(findClosestElementAncestorSpy).toHaveBeenCalledWith(mockedNode2, 'a[href]'); + expect(getAttributeSpy).not.toHaveBeenCalled(); + expect(tooltipSpy).not.toHaveBeenCalled(); + expect(setDomAttributeSpy).not.toHaveBeenCalled(); + + mouseOver!({ + type: 'mouseover', + target: mockedNode, + } as any); + + expect(findClosestElementAncestorSpy).toHaveBeenCalledWith(mockedNode, 'a[href]'); + expect(getAttributeSpy).toHaveBeenCalledWith('href'); + expect(tooltipSpy).toHaveBeenCalledWith(mockedUrl, mockedLink); + expect(setDomAttributeSpy).toHaveBeenCalledWith('title', MockedTooltip); + + plugin.dispose(); + }); + + it('MouseOut', () => { + const tooltipSpy = jasmine.createSpy('tooltip').and.returnValue(MockedTooltip); + const plugin = new HyperlinkPlugin(tooltipSpy); + const mockedNode = 'NODE' as any; + + let mouseOut: DOMEventHandlerFunction | undefined; + + attachDomEventSpy.and.callFake((eventMap: Record) => { + mouseOut = eventMap.mouseout.beforeDispatch!; + }); + + plugin.initialize(editor); + + expect(mouseOut).toBeDefined(); + + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + + findClosestElementAncestorSpy.and.callFake((node: Node) => { + return node == mockedNode ? mockedLink : null; + }); + + mouseOut!({ + type: 'mouseout', + target: mockedNode, + } as any); + + expect(findClosestElementAncestorSpy).toHaveBeenCalledWith(mockedNode, 'a[href]'); + expect(getAttributeSpy).toHaveBeenCalledWith('href'); + expect(tooltipSpy).not.toHaveBeenCalled(); + expect(setDomAttributeSpy).toHaveBeenCalledWith('title', null); + + plugin.dispose(); + }); + + it('mouseUp', () => { + const plugin = new HyperlinkPlugin(); + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedNode = 'NODE' as any; + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: false, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 1, + preventDefault: preventDefaultSpy, + }, + } as any); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: false, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith(mockedUrl, '_blank'); + + plugin.dispose(); + }); + + it('mouseUp with target', () => { + const mockedTarget = 'target'; + const plugin = new HyperlinkPlugin(undefined, mockedTarget); + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedNode = 'NODE' as any; + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith(mockedUrl, mockedTarget); + + plugin.dispose(); + }); + + it('mouseUp with onLinkClick parameter', () => { + const onLinkClickSpy = jasmine.createSpy('onLinkClick'); + const plugin = new HyperlinkPlugin(undefined, undefined, onLinkClickSpy); + const mockedUrl = 'Url'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const mockedNode = 'NODE' as any; + const mockedLink = { + getAttribute: getAttributeSpy, + } as any; + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + onLinkClickSpy.and.returnValue(true); + + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(findClosestElementAncestorSpy).toHaveBeenCalledWith(mockedNode, 'a[href]'); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + + onLinkClickSpy.and.returnValue(undefined); + + plugin.onPluginEvent({ + eventType: 'mouseUp', + isClicking: true, + rawEvent: { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }, + } as any); + + expect(onLinkClickSpy).toHaveBeenCalledWith(mockedLink, { + target: mockedNode, + ctrlKey: true, + button: 0, + preventDefault: preventDefaultSpy, + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith(mockedUrl, '_blank'); + + plugin.dispose(); + }); + + it('keyDown and keyUp', () => { + const plugin = new HyperlinkPlugin(); + const mockedUrl = 'Url'; + const mockedUrl2 = 'Url2'; + const mockedTextContent = 'textContent'; + const mockedTextContent2 = 'textContent2'; + const mockedNode = { + textContent: mockedTextContent, + } as any; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const setAttributeSpy = jasmine.createSpy('setAttribute'); + const containsSpy = jasmine.createSpy('contains').and.returnValue(true); + const mockedLink = { + getAttribute: getAttributeSpy, + setAttribute: setAttributeSpy, + contains: containsSpy, + } as any; + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + commonAncestorContainer: mockedNode, + }, + }); + + plugin.onPluginEvent({ + eventType: 'keyUp', + } as any); + + expect(setAttributeSpy).not.toHaveBeenCalled(); + + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + } as any); + + expect(matchLinkSpy).toHaveBeenCalledWith(mockedTextContent); + + mockedNode.textContent = mockedTextContent2; + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl2, + }); + + plugin.onPluginEvent({ + eventType: 'keyUp', + } as any); + + expect(containsSpy).toHaveBeenCalledWith(mockedNode); + expect(setAttributeSpy).toHaveBeenCalledWith('href', mockedUrl2); + + plugin.dispose(); + }); + + it('keyDown and keyUp, not contain', () => { + const plugin = new HyperlinkPlugin(); + const mockedUrl = 'Url'; + const mockedUrl2 = 'Url2'; + const mockedTextContent = 'textContent'; + const mockedTextContent2 = 'textContent2'; + const mockedNode = { + textContent: mockedTextContent, + } as any; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const setAttributeSpy = jasmine.createSpy('setAttribute'); + const containsSpy = jasmine.createSpy('contains').and.returnValue(false); + const mockedLink = { + getAttribute: getAttributeSpy, + setAttribute: setAttributeSpy, + contains: containsSpy, + } as any; + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + commonAncestorContainer: mockedNode, + }, + }); + + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + } as any); + + expect(matchLinkSpy).toHaveBeenCalledWith(mockedTextContent); + + mockedNode.textContent = mockedTextContent2; + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl2, + }); + + plugin.onPluginEvent({ + eventType: 'keyUp', + } as any); + + expect(containsSpy).toHaveBeenCalledWith(mockedNode); + expect(setAttributeSpy).not.toHaveBeenCalled(); + + plugin.dispose(); + }); + + it('keyDown and keyUp, url not match', () => { + const plugin = new HyperlinkPlugin(); + const mockedUrl = 'Url'; + const mockedUrl2 = 'Url2'; + const mockedTextContent = 'textContent'; + const mockedTextContent2 = 'textContent2'; + const mockedNode = { + textContent: mockedTextContent, + } as any; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedUrl); + const setAttributeSpy = jasmine.createSpy('setAttribute'); + const containsSpy = jasmine.createSpy('contains').and.returnValue(true); + const mockedLink = { + getAttribute: getAttributeSpy, + setAttribute: setAttributeSpy, + contains: containsSpy, + } as any; + + findClosestElementAncestorSpy.and.returnValue(mockedLink); + + plugin.initialize(editor); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + commonAncestorContainer: mockedNode, + }, + }); + + matchLinkSpy.and.returnValue({ + normalizedUrl: mockedUrl2, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + } as any); + + expect(matchLinkSpy).toHaveBeenCalledWith(mockedTextContent); + + mockedNode.textContent = mockedTextContent2; + + plugin.onPluginEvent({ + eventType: 'keyUp', + } as any); + + expect(containsSpy).not.toHaveBeenCalled(); + expect(setAttributeSpy).not.toHaveBeenCalled(); + + plugin.dispose(); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/markdown/markdownPluginTest.ts b/packages/roosterjs-content-model-plugins/test/markdown/markdownPluginTest.ts index 619f42cea70..f9dd8c28ea0 100644 --- a/packages/roosterjs-content-model-plugins/test/markdown/markdownPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/markdown/markdownPluginTest.ts @@ -2,6 +2,7 @@ import * as setFormat from '../../lib/markdown/utils/setFormat'; import { MarkdownOptions, MarkdownPlugin } from '../../lib/markdown/MarkdownPlugin'; import { ContentChangedEvent, + ContentModelCodeFormat, ContentModelSegmentFormat, EditorInputEvent, IEditor, @@ -32,7 +33,8 @@ describe('MarkdownPlugin', () => { shouldCallTrigger: boolean, options?: MarkdownOptions, expectedChar?: string, - expectedFormat?: ContentModelSegmentFormat + expectedFormat?: ContentModelSegmentFormat, + expectedCode?: ContentModelCodeFormat ) { const plugin = new MarkdownPlugin(options); plugin.initialize(editor); @@ -40,7 +42,16 @@ describe('MarkdownPlugin', () => { events.forEach(event => plugin.onPluginEvent(event)); if (shouldCallTrigger) { - expect(setFormatSpy).toHaveBeenCalledWith(editor, expectedChar, expectedFormat); + if (expectedCode) { + expect(setFormatSpy).toHaveBeenCalledWith( + editor, + expectedChar, + expectedFormat, + expectedCode + ); + } else { + expect(setFormatSpy).toHaveBeenCalledWith(editor, expectedChar, expectedFormat); + } } else { expect(setFormatSpy).not.toHaveBeenCalled(); } @@ -178,6 +189,51 @@ describe('MarkdownPlugin', () => { ); }); + it('should trigger setFormat for code', () => { + runTest( + [ + { + rawEvent: { data: '`', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + { + rawEvent: { data: 't', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + { + rawEvent: { data: '`', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + ], + true, + { bold: true, italic: true, strikethrough: true, codeFormat: {} }, + '`', + {}, + {} + ); + }); + + it('Feature disabled - should not trigger setFormat for code', () => { + runTest( + [ + { + rawEvent: { data: '`', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + { + rawEvent: { data: 't', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + { + rawEvent: { data: '`', inputType: 'insertText' }, + eventType: 'input', + } as EditorInputEvent, + ], + false, + { bold: true, italic: true, strikethrough: true, codeFormat: undefined } + ); + }); + it('Backspace - should not trigger setFormat for bold', () => { runTest( [ 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 15761899b41..346133c8567 100644 --- a/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts +++ b/packages/roosterjs-content-model-plugins/test/markdown/utils/setFormatTest.ts @@ -1,5 +1,9 @@ -import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { setFormat } from '../../../lib/markdown/utils/setFormat'; +import { + ContentModelCodeFormat, + ContentModelDocument, + ContentModelSegmentFormat, +} from 'roosterjs-content-model-types'; describe('setFormat', () => { function runTest( @@ -7,7 +11,8 @@ describe('setFormat', () => { char: string, format: ContentModelSegmentFormat, expectedModel: ContentModelDocument, - expectedResult: boolean + expectedResult: boolean, + code: ContentModelCodeFormat | undefined = undefined ) { const formatWithContentModelSpy = jasmine .createSpy('formatWithContentModel') @@ -27,7 +32,8 @@ describe('setFormat', () => { formatContentModel: formatWithContentModelSpy, } as any, char, - format + format, + code ); expect(formatWithContentModelSpy).toHaveBeenCalled(); @@ -214,6 +220,112 @@ describe('setFormat', () => { runTest(input, '_', { italic: true }, expectedModel, true); }); + it('should set code', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '`test`', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + code: { + format: {}, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, '`', {}, expectedModel, true, {}); + }); + + it('should set code with format', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '`test`', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + code: { + format: { + fontFamily: 'arial', + }, + }, + isSelected: undefined, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, '`', {}, expectedModel, true, { fontFamily: 'arial' }); + }); + it('should set bold in multiple words', () => { const input: ContentModelDocument = { blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts b/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts index a9b8014b025..1afe60d8740 100644 --- a/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/shortcut/ShortcutPluginTest.ts @@ -318,6 +318,48 @@ describe('ShortcutPlugin', () => { expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'decrease'); }); + + it('indent list', () => { + const apiSpy = spyOn(setShortcutIndentationCommand, 'setShortcutIndentationCommand'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.ArrowRight, false, true, true, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledTimes(1); + expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'indent'); + }); + + it('outdent list', () => { + const apiSpy = spyOn(setShortcutIndentationCommand, 'setShortcutIndentationCommand'); + const plugin = new ShortcutPlugin(); + const event: PluginEvent = { + eventType: 'keyDown', + rawEvent: createMockedEvent(Keys.ArrowLeft, false, true, true, false), + }; + + plugin.initialize(mockedEditor); + + const exclusively = plugin.willHandleEventExclusively(event); + + expect(exclusively).toBeTrue(); + expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); + + plugin.onPluginEvent(event); + + expect(apiSpy).toHaveBeenCalledTimes(1); + expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'outdent'); + }); }); describe('Mac', () => { @@ -406,26 +448,6 @@ describe('ShortcutPlugin', () => { expect(apiSpy).toHaveBeenCalledWith(mockedEditor); }); - it('clear format', () => { - const apiSpy = spyOn(clearFormat, 'clearFormat'); - const plugin = new ShortcutPlugin(); - const event: PluginEvent = { - eventType: 'keyDown', - rawEvent: createMockedEvent(Keys.SPACE, false, false, false, true), - }; - - plugin.initialize(mockedEditor); - - const exclusively = plugin.willHandleEventExclusively(event); - - expect(exclusively).toBeTrue(); - expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); - - plugin.onPluginEvent(event); - - expect(apiSpy).toHaveBeenCalledWith(mockedEditor); - }); - it('undo 1', () => { const apiSpy = spyOn(undo, 'undo'); const plugin = new ShortcutPlugin(); @@ -588,47 +610,5 @@ describe('ShortcutPlugin', () => { expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'decrease'); }); - - it('indent list', () => { - const apiSpy = spyOn(setShortcutIndentationCommand, 'setShortcutIndentationCommand'); - const plugin = new ShortcutPlugin(); - const event: PluginEvent = { - eventType: 'keyDown', - rawEvent: createMockedEvent(Keys.ArrowRight, false, true, true, false), - }; - - plugin.initialize(mockedEditor); - - const exclusively = plugin.willHandleEventExclusively(event); - - expect(exclusively).toBeTrue(); - expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); - - plugin.onPluginEvent(event); - - expect(apiSpy).toHaveBeenCalledTimes(1); - expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'indent'); - }); - - it('outdent list', () => { - const apiSpy = spyOn(setShortcutIndentationCommand, 'setShortcutIndentationCommand'); - const plugin = new ShortcutPlugin(); - const event: PluginEvent = { - eventType: 'keyDown', - rawEvent: createMockedEvent(Keys.ArrowLeft, false, true, true, false), - }; - - plugin.initialize(mockedEditor); - - const exclusively = plugin.willHandleEventExclusively(event); - - expect(exclusively).toBeTrue(); - expect(event.eventDataCache!.__ShortcutCommandCache).toBeDefined(); - - plugin.onPluginEvent(event); - - expect(apiSpy).toHaveBeenCalledTimes(1); - expect(apiSpy).toHaveBeenCalledWith(mockedEditor, 'outdent'); - }); }); }); diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index b524c6a70bc..031ad49a633 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -21,5 +21,5 @@ "rootDir": "..", "lib": ["es6", "dom"] }, - "include": ["./*/lib/**/*.ts", "./*/lib/**/*.tsx"] + "include": ["./*/test/**/*.ts", "./*/test/**/*.tsx"] } diff --git a/versions.json b/versions.json index 6d4591138ec..618ac735efe 100644 --- a/versions.json +++ b/versions.json @@ -1,9 +1,6 @@ { "legacy": "8.62.0", "react": "8.56.0", - "main": "9.0.0", - "legacyAdapter": "8.62.0", - "overrides": { - "roosterjs-content-model-plugins": "9.0.1" - } + "main": "9.1.0", + "legacyAdapter": "8.62.0" }