From ebde4381f380d97a08be199bf4d81e915b809abe Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 12 Mar 2024 15:14:51 -0700 Subject: [PATCH 01/14] FHL: Port WatermarkPlugin --- .../demoButtons/formatPainterButton.ts | 22 +- demo/scripts/controlsV2/mainPane/MainPane.tsx | 14 +- .../controlsV2/mainPane/ribbonButtons.ts | 2 - .../controlsV2/plugins/FormatPainterPlugin.ts | 58 +-- .../controlsV2/plugins/createLegacyPlugins.ts | 2 - .../editorOptions/EditorOptionsPlugin.ts | 2 +- .../sidePane/editorOptions/OptionState.ts | 4 +- .../sidePane/editorOptions/Plugins.tsx | 24 +- .../editorOptions/codes/PluginsCode.ts | 2 +- .../lib/coreApi/setDOMSelection.ts | 111 ++--- .../coreApi/setEditorStyle/ensureUniqueId.ts | 15 + .../coreApi/setEditorStyle/setEditorStyle.ts | 77 ++++ .../lib/corePlugin/LifecyclePlugin.ts | 10 +- .../lib/corePlugin/SelectionPlugin.ts | 12 - .../lib/editor/Editor.ts | 17 + .../lib/editor/coreApiMap.ts | 2 + .../test/coreApi/setContentModelTest.ts | 3 - .../test/coreApi/setDOMSelectionTest.ts | 432 ++++++------------ .../setEditorStyle/ensureUniqueIdTest.ts | 47 ++ .../setEditorStyle/setEditorStyleTest.ts | 214 +++++++++ .../test/corePlugin/LifecyclePluginTest.ts | 37 +- .../test/corePlugin/SelectionPluginTest.ts | 30 -- .../test/editor/EditorTest.ts | 34 ++ .../lib/index.ts | 2 + .../lib/watermark/WatermarkFormat.ts | 10 + .../lib/watermark/WatermarkPlugin.ts | 101 ++++ .../lib/watermark/isModelEmptyFast.ts | 31 ++ .../test/tableEdit/tableEditPluginTest.ts | 2 +- .../test/tableEdit/tableResizerTest.ts | 1 - .../test/watermark/WatermarkPluginTest.ts | 152 ++++++ .../test/watermark/isModelEmptyFastTest.ts | 189 ++++++++ .../lib/editor/EditorCore.ts | 28 ++ .../lib/editor/IEditor.ts | 13 + .../lib/index.ts | 1 + .../lib/pluginState/LifecyclePluginState.ts | 5 + .../lib/pluginState/SelectionPluginState.ts | 5 - 36 files changed, 1230 insertions(+), 481 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/setEditorStyleTest.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/watermark/WatermarkFormat.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts create mode 100644 packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts create mode 100644 packages/roosterjs-content-model-plugins/test/watermark/isModelEmptyFastTest.ts diff --git a/demo/scripts/controlsV2/demoButtons/formatPainterButton.ts b/demo/scripts/controlsV2/demoButtons/formatPainterButton.ts index a5cc4a75611..ee406679c60 100644 --- a/demo/scripts/controlsV2/demoButtons/formatPainterButton.ts +++ b/demo/scripts/controlsV2/demoButtons/formatPainterButton.ts @@ -1,15 +1,19 @@ -import { FormatPainterPlugin } from '../plugins/FormatPainterPlugin'; +import { FormatPainterHandler } from '../plugins/FormatPainterPlugin'; import type { RibbonButton } from '../roosterjsReact/ribbon'; /** * @internal * "Format Painter" button on the format ribbon */ -export const formatPainterButton: RibbonButton<'formatPainter'> = { - key: 'formatPainter', - unlocalizedText: 'Format painter', - iconName: 'Brush', - onClick: () => { - FormatPainterPlugin.startFormatPainter(); - }, -}; +export function createFormatPainterButton( + handler: FormatPainterHandler +): RibbonButton<'formatPainter'> { + return { + key: 'formatPainter', + unlocalizedText: 'Format painter', + iconName: 'Brush', + onClick: () => { + handler.startFormatPainter(); + }, + }; +} diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index aab2f694eef..29dd626eab3 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -7,11 +7,12 @@ import { buttons, buttonsWithPopout } from './ribbonButtons'; import { Colors, EditorPlugin, IEditor, Snapshots } from 'roosterjs-content-model-types'; import { ContentModelPanePlugin } from '../sidePane/contentModel/ContentModelPanePlugin'; import { createEmojiPlugin } from '../roosterjsReact/emoji'; +import { createFormatPainterButton } from '../demoButtons/formatPainterButton'; import { createImageEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createImageEditMenuProvider'; import { createLegacyPlugins } from '../plugins/createLegacyPlugins'; import { createListEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createListEditMenuProvider'; import { createPasteOptionPlugin } from '../roosterjsReact/pasteOptions'; -import { createRibbonPlugin, Ribbon, RibbonPlugin } from '../roosterjsReact/ribbon'; +import { createRibbonPlugin, Ribbon, RibbonButton, RibbonPlugin } from '../roosterjsReact/ribbon'; import { Editor } from 'roosterjs-content-model-core'; import { EditorAdapter } from 'roosterjs-editor-adapter'; import { EditorOptionsPlugin } from '../sidePane/editorOptions/EditorOptionsPlugin'; @@ -41,6 +42,7 @@ import { PastePlugin, ShortcutPlugin, TableEditPlugin, + WatermarkPlugin, } from 'roosterjs-content-model-plugins'; const styles = require('./MainPane.scss'); @@ -75,6 +77,8 @@ export class MainPane extends React.Component<{}, MainPaneState> { private snapshotPlugin: SnapshotPlugin; private formatPainterPlugin: FormatPainterPlugin; private snapshots: Snapshots; + private buttons: RibbonButton[]; + private buttonsWithPopout: RibbonButton[]; protected sidePane = React.createRef(); protected updateContentPlugin: UpdateContentPlugin; @@ -110,6 +114,10 @@ export class MainPane extends React.Component<{}, MainPaneState> { this.contentModelPanePlugin = new ContentModelPanePlugin(); this.ribbonPlugin = createRibbonPlugin(); this.formatPainterPlugin = new FormatPainterPlugin(); + + const baseButtons = [createFormatPainterButton(this.formatPainterPlugin)]; + this.buttons = baseButtons.concat(buttons); + this.buttonsWithPopout = baseButtons.concat(buttonsWithPopout); this.state = { showSidePane: window.location.hash != '', popoutWindow: null, @@ -228,7 +236,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { private renderRibbon(isPopout: boolean) { return ( @@ -417,6 +425,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { listMenu, tableMenu, imageMenu, + watermarkText, } = this.state.initState; return [ pluginList.autoFormat && new AutoFormatPlugin(), @@ -424,6 +433,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { pluginList.paste && new PastePlugin(allowExcelNoBorderTable), pluginList.shortcut && new ShortcutPlugin(), pluginList.tableEdit && new TableEditPlugin(), + pluginList.watermark && new WatermarkPlugin(watermarkText), pluginList.emoji && createEmojiPlugin(), pluginList.pasteOption && createPasteOptionPlugin(), pluginList.sampleEntity && new SampleEntityPlugin(), diff --git a/demo/scripts/controlsV2/mainPane/ribbonButtons.ts b/demo/scripts/controlsV2/mainPane/ribbonButtons.ts index e1f8e0a94d0..3baace42176 100644 --- a/demo/scripts/controlsV2/mainPane/ribbonButtons.ts +++ b/demo/scripts/controlsV2/mainPane/ribbonButtons.ts @@ -15,7 +15,6 @@ import { decreaseIndentButton } from '../roosterjsReact/ribbon/buttons/decreaseI import { exportContentButton } from '../demoButtons/exportContentButton'; import { fontButton } from '../roosterjsReact/ribbon/buttons/fontButton'; import { fontSizeButton } from '../roosterjsReact/ribbon/buttons/fontSizeButton'; -import { formatPainterButton } from '../demoButtons/formatPainterButton'; import { formatTableButton } from '../demoButtons/formatTableButton'; import { imageBorderColorButton } from '../demoButtons/imageBorderColorButton'; import { imageBorderRemoveButton } from '../demoButtons/imageBorderRemoveButton'; @@ -65,7 +64,6 @@ import { import type { RibbonButton } from '../roosterjsReact/ribbon'; export const buttons: RibbonButton[] = [ - formatPainterButton, boldButton, italicButton, underlineButton, diff --git a/demo/scripts/controlsV2/plugins/FormatPainterPlugin.ts b/demo/scripts/controlsV2/plugins/FormatPainterPlugin.ts index 8bf0968bb10..8c2798aa6a7 100644 --- a/demo/scripts/controlsV2/plugins/FormatPainterPlugin.ts +++ b/demo/scripts/controlsV2/plugins/FormatPainterPlugin.ts @@ -1,5 +1,4 @@ import { applySegmentFormat, getFormatState } from 'roosterjs-content-model-api'; -import { MainPane } from '../mainPane/MainPane'; import { ContentModelSegmentFormat, EditorPlugin, @@ -9,16 +8,26 @@ import { const FORMATPAINTERCURSOR_SVG = require('./formatpaintercursor.svg'); const FORMATPAINTERCURSOR_STYLE = `cursor: url("${FORMATPAINTERCURSOR_SVG}") 8.5 16, auto`; +const FORMAT_PAINTER_STYLE_KEY = '_FormatPainter'; + +/** + * Format painter handler works together with a format painter button tot let implement format painter functioinality + */ +export interface FormatPainterHandler { + /** + * Let editor enter format painter state + */ + startFormatPainter(): void; +} -export class FormatPainterPlugin implements EditorPlugin { +/** + * Format painter plugin helps implement format painter functionality. + * To use this plugin, you need a button to let editor enter format painter state by calling formatPainterPlugin.startFormatPainter(), + * then this plugin will handle the rest work. + */ +export class FormatPainterPlugin implements EditorPlugin, FormatPainterHandler { private editor: IEditor | null = null; - private styleNode: HTMLStyleElement | null = null; private painterFormat: ContentModelSegmentFormat | null = null; - private static instance: FormatPainterPlugin | undefined; - - constructor() { - FormatPainterPlugin.instance = this; - } getName() { return 'FormatPainter'; @@ -26,20 +35,10 @@ export class FormatPainterPlugin implements EditorPlugin { initialize(editor: IEditor) { this.editor = editor; - - const doc = this.editor.getDocument(); - this.styleNode = doc.createElement('style'); - - doc.head.appendChild(this.styleNode); } dispose() { this.editor = null; - - if (this.styleNode) { - this.styleNode.parentNode?.removeChild(this.styleNode); - this.styleNode = null; - } } onPluginEvent(event: PluginEvent) { @@ -53,26 +52,19 @@ export class FormatPainterPlugin implements EditorPlugin { } private setFormatPainterCursor(format: ContentModelSegmentFormat | null) { - const sheet = this.styleNode.sheet; - - if (this.painterFormat) { - for (let i = sheet.cssRules.length - 1; i >= 0; i--) { - sheet.deleteRule(i); - } - } - this.painterFormat = format; - if (this.painterFormat) { - sheet.insertRule(`#${MainPane.editorDivId} {${FORMATPAINTERCURSOR_STYLE}}`); - } + this.editor?.setEditorStyle( + FORMAT_PAINTER_STYLE_KEY, + this.painterFormat ? FORMATPAINTERCURSOR_STYLE : null + ); } - static startFormatPainter() { - const format = getSegmentFormat(this.instance.editor); + startFormatPainter() { + if (this.editor) { + const format = getSegmentFormat(this.editor); - if (format) { - this.instance.setFormatPainterCursor(format); + this.setFormatPainterCursor(format); } } } diff --git a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts index f6cd26cf4dc..2d858317d1e 100644 --- a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts +++ b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts @@ -6,7 +6,6 @@ import { HyperLink, ImageEdit, TableCellSelection, - Watermark, } from 'roosterjs-editor-plugins'; import { LegacyPluginList, @@ -28,7 +27,6 @@ export function createLegacyPlugins(initState: OptionState): LegacyEditorPlugin[ : null ) : null, - watermark: pluginList.watermark ? new Watermark(initState.watermarkText) : null, imageEdit: pluginList.imageEdit ? new ImageEdit({ preserveRatio: initState.forcePreserveRatio, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 871a524ace4..89206def58e 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -12,6 +12,7 @@ const initialState: OptionState = { shortcut: true, tableEdit: true, contextMenu: true, + watermark: true, emoji: true, pasteOption: true, sampleEntity: true, @@ -19,7 +20,6 @@ const initialState: OptionState = { // Legacy plugins contentEdit: false, hyperlink: false, - watermark: false, imageEdit: false, tableCellSelection: true, customReplace: false, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 2fc3eb9767d..39b84763b81 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -5,7 +5,6 @@ import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; export interface LegacyPluginList { contentEdit: boolean; hyperlink: boolean; - watermark: boolean; imageEdit: boolean; tableCellSelection: boolean; customReplace: boolean; @@ -19,6 +18,7 @@ export interface NewPluginList { shortcut: boolean; tableEdit: boolean; contextMenu: boolean; + watermark: boolean; emoji: boolean; pasteOption: boolean; sampleEntity: boolean; @@ -34,12 +34,12 @@ export interface OptionState { listMenu: boolean; tableMenu: boolean; imageMenu: boolean; + watermarkText: string; // Legacy plugin options contentEditFeatures: ContentEditFeatureSettings; defaultFormat: ContentModelSegmentFormat; linkTitle: string; - watermarkText: string; forcePreserveRatio: boolean; tableFeaturesContainerSelector: string; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index ebc2503dab6..6623560090f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -104,7 +104,6 @@ abstract class PluginsBase extends Re export class LegacyPlugins extends PluginsBase { private linkTitle = React.createRef(); - private watermarkText = React.createRef(); private forcePreserveRatio = React.createRef(); render() { @@ -130,17 +129,6 @@ export class LegacyPlugins extends PluginsBase { (state, value) => (state.linkTitle = value) ) )} - {this.renderPluginItem( - 'watermark', - 'Watermark Plugin', - this.renderInputBox( - 'Watermark text: ', - this.watermarkText, - this.props.state.watermarkText, - '', - (state, value) => (state.watermarkText = value) - ) - )} {this.renderPluginItem( 'imageEdit', 'Image Edit Plugin', @@ -165,6 +153,7 @@ export class Plugins extends PluginsBase { private listMenu = React.createRef(); private tableMenu = React.createRef(); private imageMenu = React.createRef(); + private watermarkText = React.createRef(); render(): JSX.Element { return ( @@ -208,6 +197,17 @@ export class Plugins extends PluginsBase { )} )} + {this.renderPluginItem( + 'watermark', + 'Watermark Plugin', + this.renderInputBox( + 'Watermark text: ', + this.watermarkText, + this.props.state.watermarkText, + '', + (state, value) => (state.watermarkText = value) + ) + )} {this.renderPluginItem('emoji', 'Emoji')} {this.renderPluginItem('pasteOption', 'PasteOptions')} {this.renderPluginItem('sampleEntity', 'SampleEntity')} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 7b298b17e6b..61a0953da1c 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -44,6 +44,7 @@ export class PluginsCode extends PluginsCodeBase { pluginList.paste && new PastePluginCode(), pluginList.tableEdit && new TableEditPluginCode(), pluginList.shortcut && new ShortcutPluginCode(), + pluginList.watermark && new WatermarkCode(state.watermarkText), ]); } } @@ -55,7 +56,6 @@ export class LegacyPluginCode extends PluginsCodeBase { const plugins: CodeElement[] = [ pluginList.contentEdit && new ContentEditCode(state.contentEditFeatures), pluginList.hyperlink && new HyperLinkCode(state.linkTitle), - pluginList.watermark && new WatermarkCode(state.watermarkText), pluginList.imageEdit && new ImageEditCode(), pluginList.customReplace && new CustomReplaceCode(), pluginList.tableCellSelection && new TableCellSelectionCode(), diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts index db7ba578f61..248d9578130 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts @@ -1,4 +1,5 @@ import { addRangeToSelection } from '../corePlugin/utils/addRangeToSelection'; +import { ensureUniqueId } from './setEditorStyle/ensureUniqueId'; import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; import { parseTableCells } from '../publicApi/domUtils/tableCellUtils'; import type { @@ -7,13 +8,13 @@ import type { TableSelection, } from 'roosterjs-content-model-types'; +const DOM_SELECTION_CSS_KEY = '_DOMSelection'; +const HIDE_CURSOR_CSS_KEY = '_DOMSelectionHideCursor'; const IMAGE_ID = 'image'; const TABLE_ID = 'table'; -const CONTENT_DIV_ID = 'contentDiv'; const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; -const TABLE_CSS_RULE = '{background-color: rgb(198,198,198) !important;}'; -const CARET_CSS_RULE = '{caret-color: transparent}'; -const MAX_RULE_SELECTOR_LENGTH = 9000; +const TABLE_CSS_RULE = 'background-color:#C6C6C6!important;'; +const CARET_CSS_RULE = 'caret-color: transparent'; /** * @internal @@ -24,36 +25,44 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC const skipReselectOnFocus = core.selection.skipReselectOnFocus; const doc = core.physicalRoot.ownerDocument; - const sheet = core.selection.selectionStyleNode?.sheet; core.selection.skipReselectOnFocus = true; + core.api.setEditorStyle(core, DOM_SELECTION_CSS_KEY, null /*cssRule*/); + core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, null /*cssRule*/); try { - let selectionRules: string[] | undefined; - const rootSelector = '#' + addUniqueId(core.physicalRoot, CONTENT_DIV_ID); - switch (selection?.type) { case 'image': const image = selection.image; - selectionRules = buildImageCSS( - rootSelector, - addUniqueId(image, IMAGE_ID), - core.selection.imageSelectionBorderColor - ); core.selection.selection = selection; + core.api.setEditorStyle( + core, + DOM_SELECTION_CSS_KEY, + `outline-style:auto!important; outline-color:${ + core.selection.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR + }!important;`, + [`#${ensureUniqueId(image, IMAGE_ID)}`] + ); + core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); setRangeSelection(doc, image); break; case 'table': const { table, firstColumn, firstRow } = selection; - - selectionRules = buildTableCss( - rootSelector, - addUniqueId(table, TABLE_ID), + const tableSelectors = buildTableSelectors( + ensureUniqueId(table, TABLE_ID), selection ); + core.selection.selection = selection; + core.api.setEditorStyle( + core, + DOM_SELECTION_CSS_KEY, + TABLE_CSS_RULE, + tableSelectors + ); + core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); setRangeSelection(doc, table.rows[firstRow]?.cells[firstColumn]); break; @@ -67,18 +76,6 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC core.selection.selection = null; break; } - - if (sheet) { - for (let i = sheet.cssRules.length - 1; i >= 0; i--) { - sheet.deleteRule(i); - } - - if (selectionRules) { - for (let i = 0; i < selectionRules.length; i++) { - sheet.insertRule(selectionRules[i]); - } - } - } } finally { core.selection.skipReselectOnFocus = skipReselectOnFocus; } @@ -93,20 +90,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function buildImageCSS(editorSelector: string, imageId: string, borderColor?: string): string[] { - const color = borderColor || DEFAULT_SELECTION_BORDER_COLOR; - - return [ - `${editorSelector} #${imageId} {outline-style:auto!important;outline-color:${color}!important;}`, - `${editorSelector} ${CARET_CSS_RULE}`, - ]; -} - -function buildTableCss( - editorSelector: string, - tableId: string, - selection: TableSelection -): string[] { +function buildTableSelectors(tableId: string, selection: TableSelection): string[] { const { firstColumn, firstRow, lastColumn, lastRow } = selection; const cells = parseTableCells(selection.table); const isAllTableSelected = @@ -114,31 +98,13 @@ function buildTableCss( firstColumn == 0 && lastRow == cells.length - 1 && lastColumn == (cells[lastRow]?.length ?? 0) - 1; - const rootSelector = editorSelector + ' #' + tableId; - const selectors = isAllTableSelected - ? [rootSelector, `${rootSelector} *`] - : handleTableSelected(rootSelector, selection, cells); - - const cssRules: string[] = [`${editorSelector} ${CARET_CSS_RULE}`]; - let currentRules: string = ''; - - for (let i = 0; i < selectors.length; i++) { - currentRules += (currentRules.length > 0 ? ',' : '') + selectors[i] || ''; - - if ( - currentRules.length + (selectors[0]?.length || 0) > MAX_RULE_SELECTOR_LENGTH || - i == selectors.length - 1 - ) { - cssRules.push(currentRules + ' ' + TABLE_CSS_RULE); - currentRules = ''; - } - } - - return cssRules; + return isAllTableSelected + ? [`#${tableId}`, `#${tableId} *`] + : handleTableSelected(tableId, selection, cells); } function handleTableSelected( - rootSelector: string, + tableId: string, selection: TableSelection, cells: (HTMLTableCellElement | null)[][] ) { @@ -189,7 +155,7 @@ function handleTableSelected( cellIndex >= firstColumn && cellIndex <= lastColumn ) { - const selector = `${rootSelector}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; + const selector = `#${tableId}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; selectors.push(selector, selector + ' *'); } @@ -210,16 +176,3 @@ function setRangeSelection(doc: Document, element: HTMLElement | undefined) { addRangeToSelection(doc, range); } } - -function addUniqueId(element: HTMLElement, idPrefix: string): string { - idPrefix = element.id || idPrefix; - - const doc = element.ownerDocument; - let i = 0; - - while (!element.id || doc.querySelectorAll('#' + element.id).length > 1) { - element.id = idPrefix + '_' + i++; - } - - return element.id; -} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts new file mode 100644 index 00000000000..c3cb20c5363 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts @@ -0,0 +1,15 @@ +/** + * @internal + */ +export function ensureUniqueId(element: HTMLElement, idPrefix: string): string { + idPrefix = element.id || idPrefix; + + const doc = element.ownerDocument; + let i = 0; + + while (!element.id || doc.querySelectorAll('#' + element.id).length > 1) { + element.id = idPrefix + '_' + i++; + } + + return element.id; +} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts new file mode 100644 index 00000000000..a26a905e1f4 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts @@ -0,0 +1,77 @@ +import { ensureUniqueId } from './ensureUniqueId'; +import type { SetEditorStyle } from 'roosterjs-content-model-types'; + +const MAX_RULE_SELECTOR_LENGTH = 9000; +const CONTENT_DIV_ID = 'contentDiv'; + +/** + * @internal + */ +export const setEditorStyle: SetEditorStyle = ( + core, + key, + cssRule, + subSelectors, + maxRuleLength = MAX_RULE_SELECTOR_LENGTH +) => { + let styleElement = core.lifecycle.styleElements[key]; + + if (!styleElement && cssRule) { + const doc = core.physicalRoot.ownerDocument; + + styleElement = doc.createElement('style'); + doc.head.appendChild(styleElement); + + styleElement.dataset.roosterjsStyleKey = key; + core.lifecycle.styleElements[key] = styleElement; + } + + const sheet = styleElement?.sheet; + + if (sheet) { + for (let i = sheet.cssRules.length - 1; i >= 0; i--) { + sheet.deleteRule(i); + } + + if (cssRule) { + const rootSelector = '#' + ensureUniqueId(core.physicalRoot, CONTENT_DIV_ID); + const selectors = !subSelectors + ? [rootSelector] + : typeof subSelectors === 'string' + ? [`${rootSelector}::${subSelectors}`] + : buildSelectors( + rootSelector, + subSelectors, + maxRuleLength - cssRule.length - 3 // minus 3 for " {}" + ); + + selectors.forEach(selector => { + sheet.insertRule(`${selector} {${cssRule}}`); + }); + } + } +}; + +function buildSelectors(rootSelector: string, subSelectors: string[], maxLen: number): string[] { + const result: string[] = []; + + let stringBuilder: string[] = []; + let len = 0; + + subSelectors.forEach(subSelector => { + if (len >= maxLen) { + result.push(stringBuilder.join(',')); + stringBuilder = []; + len = 0; + } + + const selector = `${rootSelector} ${subSelector}`; + + len += selector.length + 1; // Add 1 for potential "," between selectors + stringBuilder.push(selector); + }); + + result.push(stringBuilder.join(',')); + + return result; +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts index e98d81313d4..e01e9df0463 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts @@ -1,5 +1,5 @@ import { ChangeSource } from '../constants/ChangeSource'; -import { setColor } from 'roosterjs-content-model-dom'; +import { getObjectKeys, setColor } from 'roosterjs-content-model-dom'; import type { IEditor, LifecyclePluginState, @@ -48,6 +48,7 @@ class LifecyclePlugin implements PluginWithState { this.state = { isDarkMode: !!options.inDarkMode, shadowEditFragment: null, + styleElements: {}, }; } @@ -81,6 +82,13 @@ class LifecyclePlugin implements PluginWithState { dispose() { this.editor?.triggerEvent('beforeDispose', {}, true /*broadcast*/); + getObjectKeys(this.state.styleElements).forEach(key => { + const element = this.state.styleElements[key]; + + element.parentElement?.removeChild(element); + delete this.state.styleElements[key]; + }); + if (this.disposer) { this.disposer(); this.disposer = null; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index 535c686a52f..9c6c08f99a7 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -22,7 +22,6 @@ class SelectionPlugin implements PluginWithState { constructor(options: EditorOptions) { this.state = { selection: null, - selectionStyleNode: null, imageSelectionBorderColor: options.imageSelectionBorderColor, }; } @@ -34,12 +33,6 @@ class SelectionPlugin implements PluginWithState { initialize(editor: IEditor) { this.editor = editor; - const doc = this.editor.getDocument(); - const styleNode = doc.createElement('style'); - - doc.head.appendChild(styleNode); - this.state.selectionStyleNode = styleNode; - const env = this.editor.getEnvironment(); const document = this.editor.getDocument(); @@ -62,11 +55,6 @@ class SelectionPlugin implements PluginWithState { ?.getDocument() .removeEventListener('selectionchange', this.onSelectionChangeSafari); - if (this.state.selectionStyleNode) { - this.state.selectionStyleNode.parentNode?.removeChild(this.state.selectionStyleNode); - this.state.selectionStyleNode = null; - } - if (this.disposer) { this.disposer(); this.disposer = null; diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 7b4f4a6660a..7ce41d6581f 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -371,6 +371,23 @@ export class Editor implements IEditor { return this.getCore().api.getVisibleViewport(this.getCore()); } + /** + * Add CSS rules for editor + * @param key A string to identify the CSS rule type. When set CSS rules with the same key again, existing rules with the same key will be replaced. + * @param cssRule The CSS rule string, must be a valid CSS rule string, or browser may throw exception. Pass null to clear existing rules + * @param subSelectors @optional If the rule is used for child element under editor, use this parameter to specify the child elements. Each item will be + * combined with root selector together to build a separate rule. + */ + setEditorStyle( + key: string, + cssRule: string | null, + subSelectors?: 'before' | 'after' | string[] + ): void { + const core = this.getCore(); + + core.api.setEditorStyle(core, key, cssRule, subSelectors); + } + /** * @returns the current EditorCore object * @throws a standard Error if there's no core object diff --git a/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts b/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts index 06d323c5227..11e2abccbc2 100644 --- a/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts +++ b/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts @@ -9,6 +9,7 @@ import { getVisibleViewport } from '../coreApi/getVisibleViewport'; import { restoreUndoSnapshot } from '../coreApi/restoreUndoSnapshot'; import { setContentModel } from '../coreApi/setContentModel'; import { setDOMSelection } from '../coreApi/setDOMSelection'; +import { setEditorStyle } from '../coreApi/setEditorStyle/setEditorStyle'; import { switchShadowEdit } from '../coreApi/switchShadowEdit'; import { triggerEvent } from '../coreApi/triggerEvent'; import type { CoreApiMap } from 'roosterjs-content-model-types'; @@ -35,4 +36,5 @@ export const coreApiMap: CoreApiMap = { switchShadowEdit: switchShadowEdit, getVisibleViewport: getVisibleViewport, + setEditorStyle: setEditorStyle, }; diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts index af1d6ae755b..cf4774a4b1d 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts @@ -159,7 +159,6 @@ describe('setContentModel', () => { core.selection = { selection: null, - selectionStyleNode: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, @@ -192,7 +191,6 @@ describe('setContentModel', () => { core.selection = { selection: null, - selectionStyleNode: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, @@ -221,7 +219,6 @@ describe('setContentModel', () => { core.selection = { selection: null, - selectionStyleNode: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts index d7859196dd8..ea75d2bbc27 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts @@ -9,12 +9,10 @@ describe('setDOMSelection', () => { let triggerEventSpy: jasmine.Spy; let addRangeToSelectionSpy: jasmine.Spy; let createRangeSpy: jasmine.Spy; - let deleteRuleSpy: jasmine.Spy; - let insertRuleSpy: jasmine.Spy; + let setEditorStyleSpy: jasmine.Spy; let doc: Document; let contentDiv: HTMLDivElement; let mockedRange = 'RANGE' as any; - let mockedStyleNode: HTMLStyleElement; beforeEach(() => { querySelectorAllSpy = jasmine.createSpy('querySelectorAll'); @@ -26,8 +24,7 @@ describe('setDOMSelection', () => { } ); createRangeSpy = jasmine.createSpy('createRange'); - deleteRuleSpy = jasmine.createSpy('deleteRule'); - insertRuleSpy = jasmine.createSpy('insertRule'); + setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); doc = { querySelectorAll: querySelectorAllSpy, @@ -37,22 +34,14 @@ describe('setDOMSelection', () => { contentDiv = { ownerDocument: doc, } as any; - mockedStyleNode = { - sheet: { - cssRules: [], - deleteRule: deleteRuleSpy, - insertRule: insertRuleSpy, - }, - } as any; core = { physicalRoot: contentDiv, logicalRoot: contentDiv, - selection: { - selectionStyleNode: mockedStyleNode, - }, + selection: {}, api: { triggerEvent: triggerEventSpy, + setEditorStyle: setEditorStyleSpy, }, domHelper: { hasFocus: hasFocusSpy, @@ -67,14 +56,12 @@ describe('setDOMSelection', () => { function runTest(originalSelection: DOMSelection | null) { core.selection.selection = originalSelection; - (core.selection.selectionStyleNode!.sheet!.cssRules as any) = ['Rule1', 'Rule2']; setDOMSelection(core, null); expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: null, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -85,11 +72,9 @@ describe('setDOMSelection', () => { true ); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); - expect(contentDiv.id).toBe('contentDiv_0'); - expect(deleteRuleSpy).toHaveBeenCalledTimes(2); - expect(deleteRuleSpy).toHaveBeenCalledWith(1); - expect(deleteRuleSpy).toHaveBeenCalledWith(0); - expect(insertRuleSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); } it('From null selection', () => { @@ -131,8 +116,6 @@ describe('setDOMSelection', () => { isReverted: false, } as any; - (core.selection.selectionStyleNode!.sheet!.cssRules as any) = ['Rule1', 'Rule2']; - querySelectorAllSpy.and.returnValue([]); hasFocusSpy.and.returnValue(true); @@ -141,8 +124,10 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: null, - selectionStyleNode: mockedStyleNode, } as any); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); expect(triggerEventSpy).toHaveBeenCalledWith( core, { @@ -156,41 +141,6 @@ describe('setDOMSelection', () => { mockedRange, false /* isReverted */ ); - expect(contentDiv.id).toBe('contentDiv_0'); - expect(deleteRuleSpy).toHaveBeenCalledTimes(2); - expect(deleteRuleSpy).toHaveBeenCalledWith(1); - expect(deleteRuleSpy).toHaveBeenCalledWith(0); - expect(insertRuleSpy).not.toHaveBeenCalled(); - }); - - it('range selection, with existing css rule', () => { - const mockedSelection = { - type: 'range', - range: mockedRange, - } as any; - - querySelectorAllSpy.and.returnValue([]); - hasFocusSpy.and.returnValue(true); - - setDOMSelection(core, mockedSelection); - - expect(core.selection).toEqual({ - skipReselectOnFocus: undefined, - selection: null, - selectionStyleNode: mockedStyleNode, - } as any); - expect(triggerEventSpy).toHaveBeenCalledWith( - core, - { - eventType: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); - expect(contentDiv.id).toBe('contentDiv_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); }); it('range selection, editor id is unique, editor has focus, do not trigger event', () => { @@ -208,13 +158,12 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: null, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(contentDiv.id).toBe('contentDiv_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); }); it('range selection, editor id is unique, editor does not have focus', () => { @@ -232,107 +181,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, - } as any); - expect(triggerEventSpy).toHaveBeenCalledWith( - core, - { - eventType: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(contentDiv.id).toBe('contentDiv_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); - }); - - it('range selection, editor has unique id', () => { - const mockedSelection = { - type: 'range', - range: mockedRange, - isReverted: false, - } as any; - contentDiv.id = 'testId'; - - querySelectorAllSpy.and.returnValue([]); - hasFocusSpy.and.returnValue(false); - - setDOMSelection(core, mockedSelection); - - expect(core.selection).toEqual({ - skipReselectOnFocus: undefined, - selection: mockedSelection, - selectionStyleNode: mockedStyleNode, - } as any); - expect(triggerEventSpy).toHaveBeenCalledWith( - core, - { - eventType: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(contentDiv.id).toBe('testId'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); - }); - - it('range selection, editor has duplicated id', () => { - const mockedSelection = { - type: 'range', - range: mockedRange, - isReverted: false, - } as any; - contentDiv.id = 'testId'; - - querySelectorAllSpy.and.callFake(selector => { - return selector == '#testId' ? ['', ''] : ['']; - }); - hasFocusSpy.and.returnValue(false); - - setDOMSelection(core, mockedSelection); - - expect(core.selection).toEqual({ - skipReselectOnFocus: undefined, - selection: mockedSelection, - selectionStyleNode: mockedStyleNode, - } as any); - expect(triggerEventSpy).toHaveBeenCalledWith( - core, - { - eventType: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(contentDiv.id).toBe('testId_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); - }); - - it('range selection, editor has duplicated id - 2', () => { - const mockedSelection = { - type: 'range', - range: mockedRange, - isReverted: false, - } as any; - contentDiv.id = 'testId'; - - querySelectorAllSpy.and.callFake(selector => { - return selector == '#testId' || selector == '#testId_0' ? ['', ''] : ['']; - }); - hasFocusSpy.and.returnValue(false); - - setDOMSelection(core, mockedSelection); - - expect(core.selection).toEqual({ - skipReselectOnFocus: undefined, - selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -343,9 +191,9 @@ describe('setDOMSelection', () => { true ); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(contentDiv.id).toBe('testId_1'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); }); }); @@ -380,7 +228,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -393,13 +240,20 @@ describe('setDOMSelection', () => { expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); expect(collapseSpy).toHaveBeenCalledWith(); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); - expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedImage.id).toBe('image_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledTimes(2); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); - expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:#DB626C!important;}' + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + 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'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' ); }); @@ -428,7 +282,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -441,13 +294,19 @@ describe('setDOMSelection', () => { expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); expect(collapseSpy).toHaveBeenCalledWith(); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); - expect(contentDiv.id).toBe('contentDiv_0'); - expect(mockedImage.id).toBe('image_0_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledTimes(2); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); - expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0_0 {outline-style:auto!important;outline-color:#DB626C!important;}' + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + 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_0'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' ); }); @@ -475,7 +334,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, imageSelectionBorderColor: 'red', } as any); expect(triggerEventSpy).toHaveBeenCalledWith( @@ -489,13 +347,19 @@ describe('setDOMSelection', () => { expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); expect(collapseSpy).toHaveBeenCalledWith(); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); - expect(contentDiv.id).toBe('contentDiv_0'); - expect(mockedImage.id).toBe('image_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledTimes(2); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); - expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:red!important;}' + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'outline-style:auto!important; outline-color:red!important;', + ['#image_0'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' ); }); @@ -523,7 +387,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -536,13 +399,20 @@ describe('setDOMSelection', () => { expect(selectNodeSpy).not.toHaveBeenCalled(); expect(collapseSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); - expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedImage.id).toBe('image_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledTimes(2); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); - expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:#DB626C!important;}' + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + 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'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' ); }); }); @@ -580,7 +450,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -593,11 +462,22 @@ describe('setDOMSelection', () => { expect(selectNodeSpy).not.toHaveBeenCalled(); expect(collapseSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); - expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedTable.id).toBe('table_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledTimes(1); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); + + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'background-color:#C6C6C6!important;', + [] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' + ); }); function runTest( @@ -606,7 +486,7 @@ describe('setDOMSelection', () => { firstRow: number, lastColumn: number, lastRow: number, - ...result: string[] + result: string[] ) { const mockedSelection = { type: 'table', @@ -633,7 +513,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -643,38 +522,39 @@ describe('setDOMSelection', () => { }, true ); - expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedTable.id).toBe('table_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledTimes(result.length); - - result.forEach(rule => { - expect(insertRuleSpy).toHaveBeenCalledWith(rule); - }); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'background-color:#C6C6C6!important;', + result + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' + ); } it('Select Table Cells TR under Table Tag', () => { - runTest( - buildTable(true), - 1, - 0, - 1, - 1, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' - ); + runTest(buildTable(true), 1, 0, 1, 1, [ + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *', + ]); }); it('Select Table Cells TBODY', () => { - runTest( - buildTable(false), - 0, - 0, - 0, - 1, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0> tr:nth-child(1)>TD:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(1)>TD:nth-child(1) *,#contentDiv_0 #table_0> tr:nth-child(2)>TD:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(2)>TD:nth-child(1) * {background-color: rgb(198,198,198) !important;}' - ); + runTest(buildTable(false), 0, 0, 0, 1, [ + '#table_0> tr:nth-child(1)>TD:nth-child(1)', + '#table_0> tr:nth-child(1)>TD:nth-child(1) *', + '#table_0> tr:nth-child(2)>TD:nth-child(1)', + '#table_0> tr:nth-child(2)>TD:nth-child(1) *', + ]); }); it('Select TH and TR in the same row', () => { @@ -699,75 +579,59 @@ describe('setDOMSelection', () => { table.appendChild(tr1); table.appendChild(tr2); - runTest( - table, - 0, - 0, - 0, - 1, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0> tr:nth-child(1)>TH:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(1)>TH:nth-child(1) *,#contentDiv_0 #table_0> tr:nth-child(2)>TH:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(2)>TH:nth-child(1) * {background-color: rgb(198,198,198) !important;}' - ); + runTest(table, 0, 0, 0, 1, [ + '#table_0> tr:nth-child(1)>TH:nth-child(1)', + '#table_0> tr:nth-child(1)>TH:nth-child(1) *', + '#table_0> tr:nth-child(2)>TH:nth-child(1)', + '#table_0> tr:nth-child(2)>TH:nth-child(1) *', + ]); }); it('Select Table Cells THEAD, TBODY', () => { - runTest( - buildTable(true /* tbody */, true /* thead */), - 1, - 1, - 2, - 2, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' - ); + runTest(buildTable(true /* tbody */, true /* thead */), 1, 1, 2, 2, [ + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + ]); }); it('Select Table Cells TBODY, TFOOT', () => { - runTest( - buildTable(true /* tbody */, false /* thead */, true /* tfoot */), - 1, - 1, - 2, - 2, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' - ); + runTest(buildTable(true /* tbody */, false /* thead */, true /* tfoot */), 1, 1, 2, 2, [ + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) *', + ]); }); it('Select Table Cells THEAD, TBODY, TFOOT', () => { - runTest( - buildTable(true /* tbody */, true /* thead */, true /* tfoot */), - 1, - 1, - 1, - 4, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' - ); + runTest(buildTable(true /* tbody */, true /* thead */, true /* tfoot */), 1, 1, 1, 4, [ + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) *', + ]); }); it('Select Table Cells THEAD, TFOOT', () => { - runTest( - buildTable(false /* tbody */, true /* thead */, true /* tfoot */), - 1, - 1, - 1, - 2, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important;}' - ); + runTest(buildTable(false /* tbody */, true /* thead */, true /* tfoot */), 1, 1, 1, 2, [ + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) *', + ]); }); it('Select All', () => { - runTest( - buildTable(true /* tbody */, false, false), - 0, - 0, - 1, - 1, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0,#contentDiv_0 #table_0 * {background-color: rgb(198,198,198) !important;}' - ); + runTest(buildTable(true /* tbody */, false, false), 0, 0, 1, 1, [ + '#table_0', + '#table_0 *', + ]); }); }); }); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts new file mode 100644 index 00000000000..d45aeeb931e --- /dev/null +++ b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts @@ -0,0 +1,47 @@ +import { ensureUniqueId } from '../../../lib/coreApi/setEditorStyle/ensureUniqueId'; + +describe('ensureUniqueId', () => { + let doc: Document; + let querySelectorAllSpy: jasmine.Spy; + + beforeEach(() => { + querySelectorAllSpy = jasmine.createSpy('querySelectorAll'); + doc = { + querySelectorAll: querySelectorAllSpy, + } as any; + }); + + it('no id', () => { + const element = { + ownerDocument: doc, + } as any; + querySelectorAllSpy.and.returnValue([]); + const result = ensureUniqueId(element, 'prefix'); + + expect(result).toBe('prefix_0'); + }); + + it('Has unique id', () => { + const element = { + ownerDocument: doc, + id: 'unique', + } as any; + querySelectorAllSpy.and.returnValue([{}]); + const result = ensureUniqueId(element, 'prefix'); + + expect(result).toBe('unique'); + }); + + it('Has duplicated', () => { + const element = { + ownerDocument: doc, + id: 'dup', + } as any; + querySelectorAllSpy.and.callFake((selector: string) => + selector == '#dup' ? [{}, {}] : [] + ); + const result = ensureUniqueId(element, 'prefix'); + + expect(result).toBe('dup_0'); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/setEditorStyleTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/setEditorStyleTest.ts new file mode 100644 index 00000000000..2c9492f8d5d --- /dev/null +++ b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/setEditorStyleTest.ts @@ -0,0 +1,214 @@ +import * as ensureUniqueId from '../../../lib/coreApi/setEditorStyle/ensureUniqueId'; +import { EditorCore } from 'roosterjs-content-model-types'; +import { setEditorStyle } from '../../../lib/coreApi/setEditorStyle/setEditorStyle'; + +describe('setEditorStyle', () => { + let core: EditorCore; + let createElementSpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let insertRuleSpy: jasmine.Spy; + let deleteRuleSpy: jasmine.Spy; + let ensureUniqueIdSpy: jasmine.Spy; + let mockedStyle: HTMLStyleElement; + + beforeEach(() => { + createElementSpy = jasmine.createSpy('createElement'); + appendChildSpy = jasmine.createSpy('appendChild'); + insertRuleSpy = jasmine.createSpy('insertRule'); + deleteRuleSpy = jasmine.createSpy('deleteRule'); + ensureUniqueIdSpy = spyOn(ensureUniqueId, 'ensureUniqueId').and.returnValue('uniqueId'); + core = { + physicalRoot: { + ownerDocument: { + createElement: createElementSpy, + head: { + appendChild: appendChildSpy, + }, + }, + }, + lifecycle: { + styleElements: {}, + }, + } as any; + + mockedStyle = { + dataset: {}, + sheet: { + cssRules: [], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any; + }); + + it('New key, empty rule', () => { + setEditorStyle(core, 'key', null); + + expect(core.lifecycle.styleElements).toEqual({}); + expect(createElementSpy).not.toHaveBeenCalled(); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).not.toHaveBeenCalled(); + expect(core.lifecycle.styleElements).toEqual({}); + expect(mockedStyle.dataset).toEqual({}); + }); + + it('New key, valid rule, no sub selector', () => { + createElementSpy.and.returnValue(mockedStyle); + + setEditorStyle(core, 'key0', 'rule'); + + expect(createElementSpy).toHaveBeenCalledWith('style'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedStyle); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith('#uniqueId {rule}'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: mockedStyle, + }); + expect(mockedStyle.dataset).toEqual({ roosterjsStyleKey: 'key0' }); + }); + + it('New key, valid rule, has sub selector array', () => { + createElementSpy.and.returnValue(mockedStyle); + + setEditorStyle(core, 'key0', 'rule', ['selector1', 'selector2']); + + expect(createElementSpy).toHaveBeenCalledWith('style'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedStyle); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#uniqueId selector1,#uniqueId selector2 {rule}' + ); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: mockedStyle, + }); + expect(mockedStyle.dataset).toEqual({ roosterjsStyleKey: 'key0' }); + }); + + it('New key, valid rule, has sub selector pseudo class', () => { + createElementSpy.and.returnValue(mockedStyle); + + setEditorStyle(core, 'key0', 'rule', 'before'); + + expect(createElementSpy).toHaveBeenCalledWith('style'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedStyle); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith('#uniqueId::before {rule}'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: mockedStyle, + }); + expect(mockedStyle.dataset).toEqual({ roosterjsStyleKey: 'key0' }); + }); + + it('Existing key, null rule', () => { + const existingStyle = { + sheet: { + cssRules: ['rule1', 'rule2'], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any; + core.lifecycle.styleElements.key0 = existingStyle; + + insertRuleSpy.and.callFake((rule: string) => { + existingStyle.sheet.cssRules.push(rule); + }); + deleteRuleSpy.and.callFake((index: number) => { + existingStyle.sheet.cssRules.splice(index, 1); + }); + + setEditorStyle(core, 'key0', null); + + expect(createElementSpy).not.toHaveBeenCalled(); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledTimes(0); + expect(deleteRuleSpy).toHaveBeenCalledTimes(2); + expect(deleteRuleSpy).toHaveBeenCalledWith(1); + expect(deleteRuleSpy).toHaveBeenCalledWith(0); + expect(ensureUniqueIdSpy).not.toHaveBeenCalled(); + expect(core.lifecycle.styleElements).toEqual({ + key0: { + sheet: { + cssRules: [], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any, + }); + expect(mockedStyle.dataset).toEqual({}); + }); + + it('Existing key, valid rule', () => { + const existingStyle = { + sheet: { + cssRules: ['rule1', 'rule2'], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any; + core.lifecycle.styleElements.key0 = existingStyle; + + insertRuleSpy.and.callFake((rule: string) => { + existingStyle.sheet.cssRules.push(rule); + }); + deleteRuleSpy.and.callFake((index: number) => { + existingStyle.sheet.cssRules.splice(index, 1); + }); + + setEditorStyle(core, 'key0', 'rule3'); + + expect(createElementSpy).not.toHaveBeenCalled(); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith('#uniqueId {rule3}'); + expect(deleteRuleSpy).toHaveBeenCalledTimes(2); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: { + sheet: { + cssRules: ['#uniqueId {rule3}'], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any, + }); + expect(mockedStyle.dataset).toEqual({}); + }); + + it('New key, valid rule, has super long sub selector array', () => { + createElementSpy.and.returnValue(mockedStyle); + const s1 = 'longSelector1'; + const s2 = 'longSelector2'; + const s3 = 'longSelector3'; + const s4 = 'longSelector4'; + const s5 = 'longSelector5'; + + const selectors = [s1, s2, s3, s4, s5]; + + setEditorStyle(core, 'key0', 'rule', selectors, 50); + + expect(createElementSpy).toHaveBeenCalledWith('style'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedStyle); + expect(insertRuleSpy).toHaveBeenCalledTimes(3); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#uniqueId longSelector1,#uniqueId longSelector2 {rule}' + ); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#uniqueId longSelector3,#uniqueId longSelector4 {rule}' + ); + expect(insertRuleSpy).toHaveBeenCalledWith('#uniqueId longSelector5 {rule}'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: mockedStyle, + }); + expect(mockedStyle.dataset).toEqual({ roosterjsStyleKey: 'key0' }); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts index 7e76078b19b..2c9bed20df0 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts @@ -20,6 +20,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); expect(div.isContentEditable).toBeTrue(); @@ -57,6 +58,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); expect(div.isContentEditable).toBeTrue(); @@ -131,6 +133,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); plugin.onPluginEvent({ @@ -161,6 +164,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); const mockedIsDarkColor = 'Dark' as any; @@ -205,7 +209,7 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, - getDarkColorHandler: () => mockedDarkColorHandler, + getColorManager: () => mockedDarkColorHandler, })); expect(setColorSpy).toHaveBeenCalledTimes(0); @@ -213,6 +217,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); const mockedIsDarkColor = 'Dark' as any; @@ -226,4 +231,34 @@ describe('LifecyclePlugin', () => { expect(setColorSpy).toHaveBeenCalledTimes(0); }); + + it('Dispose plugin and clean up style nodes', () => { + const div = document.createElement('div'); + const plugin = createLifecyclePlugin({}, div); + + plugin.initialize({ + getColorManager: jasmine.createSpy(), + triggerEvent: jasmine.createSpy(), + }); + + const state = plugin.getState(); + const removeChildSpy = jasmine.createSpy('removeChild'); + const style = { + parentElement: { + removeChild: removeChildSpy, + }, + } as any; + + state.styleElements.a = style; + + plugin.dispose(); + + expect(removeChildSpy).toHaveBeenCalledTimes(1); + expect(removeChildSpy).toHaveBeenCalledWith(style); + expect(state).toEqual({ + styleElements: {}, + isDarkMode: false, + shadowEditFragment: null, + }); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts index e5b624c4027..73f53eb763a 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -6,20 +6,14 @@ import { SelectionPluginState, } from 'roosterjs-content-model-types'; -const MockedStyleNode = 'STYLENODE' as any; - describe('SelectionPlugin', () => { it('init and dispose', () => { const plugin = createSelectionPlugin({}); const disposer = jasmine.createSpy('disposer'); - const createElementSpy = jasmine - .createSpy('createElement') - .and.returnValue(MockedStyleNode); const appendChildSpy = jasmine.createSpy('appendChild'); const attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - createElement: createElementSpy, head: { appendChild: appendChildSpy, }, @@ -36,7 +30,6 @@ describe('SelectionPlugin', () => { expect(state).toEqual({ selection: null, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(attachDomEvent).toHaveBeenCalled(); @@ -58,13 +51,9 @@ describe('SelectionPlugin', () => { const attachDomEvent = jasmine .createSpy('attachDomEvent') .and.returnValue(jasmine.createSpy('disposer')); - const createElementSpy = jasmine - .createSpy('createElement') - .and.returnValue(MockedStyleNode); const appendChildSpy = jasmine.createSpy('appendChild'); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - createElement: createElementSpy, head: { appendChild: appendChildSpy, }, @@ -79,7 +68,6 @@ describe('SelectionPlugin', () => { expect(state).toEqual({ selection: null, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: 'red', }); @@ -94,7 +82,6 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { let triggerEvent: jasmine.Spy; let eventMap: Record; let getElementAtCursorSpy: jasmine.Spy; - let createElementSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; let appendChildSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; @@ -105,11 +92,9 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { beforeEach(() => { triggerEvent = jasmine.createSpy('triggerEvent'); getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); - createElementSpy = jasmine.createSpy('createElement').and.returnValue(MockedStyleNode); appendChildSpy = jasmine.createSpy('appendChild'); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - createElement: createElementSpy, head: { appendChild: appendChildSpy, }, @@ -147,7 +132,6 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { eventMap.focus.beforeDispatch(); expect(plugin.getState()).toEqual({ selection: mockedRange, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, skipReselectOnFocus: false, }); @@ -163,7 +147,6 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { eventMap.focus.beforeDispatch(); expect(plugin.getState()).toEqual({ selection: mockedRange, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, skipReselectOnFocus: true, }); @@ -176,16 +159,13 @@ describe('SelectionPlugin handle image selection', () => { let getDOMSelectionSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; - let createElementSpy: jasmine.Spy; let createRangeSpy: jasmine.Spy; beforeEach(() => { getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); - createElementSpy = jasmine.createSpy('createElement').and.returnValue(MockedStyleNode); createRangeSpy = jasmine.createSpy('createRange'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - createElement: createElementSpy, createRange: createRangeSpy, head: { appendChild: () => {}, @@ -575,7 +555,6 @@ describe('SelectionPlugin handle image selection', () => { describe('SelectionPlugin on Safari', () => { let disposer: jasmine.Spy; - let createElementSpy: jasmine.Spy; let appendChildSpy: jasmine.Spy; let attachDomEvent: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; @@ -588,13 +567,11 @@ describe('SelectionPlugin on Safari', () => { beforeEach(() => { disposer = jasmine.createSpy('disposer'); - createElementSpy = jasmine.createSpy('createElement').and.returnValue(MockedStyleNode); appendChildSpy = jasmine.createSpy('appendChild'); attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); addEventListenerSpy = jasmine.createSpy('addEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - createElement: createElementSpy, head: { appendChild: appendChildSpy, }, @@ -625,7 +602,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: null, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(attachDomEvent).toHaveBeenCalled(); @@ -660,7 +636,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -688,7 +663,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedNewSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -716,7 +690,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -744,7 +717,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -772,7 +744,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); @@ -800,7 +771,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index e92935f9527..310abf7a793 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -917,4 +917,38 @@ describe('Editor', () => { expect(resetSpy).toHaveBeenCalledWith(); expect(() => editor.getVisibleViewport()).toThrow(); }); + + it('setEditorStyle', () => { + const div = document.createElement('div'); + const mockedScrollContainer: Rect = { top: 0, bottom: 100, left: 0, right: 100 }; + const resetSpy = jasmine.createSpy('reset'); + const setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { + setContentModel: setContentModelSpy, + setEditorStyle: setEditorStyleSpy, + }, + domEvent: { scrollContainer: mockedScrollContainer }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new Editor(div); + + editor.setEditorStyle('key', 'rule', ['rule1', 'rule2']); + + expect(setEditorStyleSpy).toHaveBeenCalledWith(mockedCore, 'key', 'rule', [ + 'rule1', + 'rule2', + ]); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.getVisibleViewport()).toThrow(); + }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index d84caa771f5..fb189b8842b 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -22,3 +22,5 @@ export { export { ShortcutPlugin } from './shortcut/ShortcutPlugin'; export { ShortcutKeyDefinition, ShortcutCommand } from './shortcut/ShortcutCommand'; export { ContextMenuPluginBase, ContextMenuOptions } from './contextMenuBase/ContextMenuPluginBase'; +export { WatermarkPlugin } from './watermark/WatermarkPlugin'; +export { WatermarkFormat } from './watermark/WatermarkFormat'; diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkFormat.ts b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkFormat.ts new file mode 100644 index 00000000000..b457b4a54db --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkFormat.ts @@ -0,0 +1,10 @@ +import type { + FontFamilyFormat, + FontSizeFormat, + TextColorFormat, +} from 'roosterjs-content-model-types'; + +/** + * Format type of watermark text + */ +export type WatermarkFormat = FontFamilyFormat & FontSizeFormat & TextColorFormat; diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts new file mode 100644 index 00000000000..23ac60fb547 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts @@ -0,0 +1,101 @@ +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { isModelEmptyFast } from './isModelEmptyFast'; +import type { WatermarkFormat } from './WatermarkFormat'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; + +const WATERMARK_CONTENT_KEY = '_WatermarkContent'; +const styleMap: Record = { + fontFamily: 'font-family', + fontSize: 'font-size', + textColor: 'color', +}; + +/** + * A watermark plugin to manage watermark string for roosterjs + */ +export class WatermarkPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private format: WatermarkFormat; + private isShowing = false; + + /** + * Create an instance of Watermark plugin + * @param watermark The watermark string + */ + constructor(private watermark: string | (() => string), format?: WatermarkFormat) { + this.format = format || { + fontSize: '14px', + textColor: '#AAAAAA', + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Watermark'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + const editor = this.editor; + + if ( + editor && + (event.eventType == 'editorReady' || + event.eventType == 'contentChanged' || + event.eventType == 'input' || + event.eventType == 'beforeDispose') + ) { + editor.formatContentModel(model => { + const isEmpty = isModelEmptyFast(model); + + if (this.isShowing && !isEmpty) { + this.hide(editor); + } else if (!this.isShowing && isEmpty) { + this.show(editor); + } + return false; + }); + } + } + + private show(editor: IEditor) { + let rule = `position: absolute; pointer-events: none; content: "${ + typeof this.watermark == 'function' ? this.watermark() : this.watermark + }";`; + + getObjectKeys(styleMap).forEach(x => { + if (this.format[x]) { + rule += `${styleMap[x]}: ${this.format[x]}!important;`; + } + }); + + editor.setEditorStyle(WATERMARK_CONTENT_KEY, rule, 'before'); + + this.isShowing = true; + } + + private hide(editor: IEditor) { + editor.setEditorStyle(WATERMARK_CONTENT_KEY, null); + this.isShowing = false; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts new file mode 100644 index 00000000000..bf78a5b03eb --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts @@ -0,0 +1,31 @@ +import type { ContentModelDocument } from 'roosterjs-content-model-types'; + +/** + * @internal + * A fast way to check if content model is empty + */ +export function isModelEmptyFast(model: ContentModelDocument): boolean { + const firstBlock = model.blocks[0]; + + if (model.blocks.length > 1) { + return false; // Multiple blocks, treat as not empty + } else if (!firstBlock) { + return true; // No block, it is empty + } else if (firstBlock.blockType != 'Paragraph') { + return false; // First block is not paragraph, treat as not empty + } else if (firstBlock.segments.length == 0) { + return true; // No segment, it is empty + } else if ( + firstBlock.segments.some( + x => + x.segmentType == 'Entity' || + x.segmentType == 'Image' || + x.segmentType == 'General' || + (x.segmentType == 'Text' && x.text) + ) + ) { + return false; // Has meaningful segments, it is not empty + } else { + return firstBlock.segments.filter(x => x.segmentType == 'Br').length <= 1; // If there are more than one BR, it is not empty, otherwise it is empty + } +} diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts index 93e5b98bd9d..5b0b2187b58 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts @@ -1,6 +1,6 @@ import * as TestHelper from '../TestHelper'; import { createElement } from '../../lib/pluginUtils/CreateElement/createElement'; -import { DOMEventHandlerFunction, IEditor } from 'roosterjs-editor-types'; +import { DOMEventHandlerFunction, IEditor } from 'roosterjs-content-model-types'; import { getModelTable } from './tableData'; import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; import { diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts index 9a32065e85a..a1a410397c5 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts @@ -1,4 +1,3 @@ -import * as TestHelper from '../TestHelper'; import { getModelTable } from './tableData'; import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; import { diff --git a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts new file mode 100644 index 00000000000..97907481a49 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts @@ -0,0 +1,152 @@ +import * as isModelEmptyFast from '../../lib/watermark/isModelEmptyFast'; +import { IEditor } from 'roosterjs-content-model-types'; +import { WatermarkPlugin } from '../../lib/watermark/WatermarkPlugin'; + +describe('WatermarkPlugin', () => { + let editor: IEditor; + let formatContentModelSpy: jasmine.Spy; + let isModelEmptyFastSpy: jasmine.Spy; + let setEditorStyleSpy: jasmine.Spy; + + const mockedModel = 'Model' as any; + + beforeEach(() => { + isModelEmptyFastSpy = spyOn(isModelEmptyFast, 'isModelEmptyFast'); + setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + const result = callback(mockedModel); + + expect(result).toBeFalse(); + }); + editor = { + formatContentModel: formatContentModelSpy, + setEditorStyle: setEditorStyleSpy, + } as any; + }); + + it('No format, empty editor, with text', () => { + isModelEmptyFastSpy.and.returnValue(true); + + const plugin = new WatermarkPlugin('test'); + + plugin.initialize(editor); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + 'position: absolute; pointer-events: none; content: "test";font-size: 14px!important;color: #AAAAAA!important;', + 'before' + ); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + + isModelEmptyFastSpy.and.returnValue(false); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith('_WatermarkContent', null); + }); + + it('No format, not empty editor, with text', () => { + isModelEmptyFastSpy.and.returnValue(false); + + const plugin = new WatermarkPlugin('test'); + + plugin.initialize(editor); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).not.toHaveBeenCalled(); + + isModelEmptyFastSpy.and.returnValue(true); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + 'position: absolute; pointer-events: none; content: "test";font-size: 14px!important;color: #AAAAAA!important;', + 'before' + ); + }); + + it('No format, empty editor, with callback', () => { + isModelEmptyFastSpy.and.returnValue(true); + + const watermarkCallback = jasmine.createSpy('watermark').and.returnValue('test'); + const plugin = new WatermarkPlugin(watermarkCallback); + + plugin.initialize(editor); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + 'position: absolute; pointer-events: none; content: "test";font-size: 14px!important;color: #AAAAAA!important;', + 'before' + ); + expect(watermarkCallback).toHaveBeenCalledTimes(1); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(watermarkCallback).toHaveBeenCalledTimes(1); + + isModelEmptyFastSpy.and.returnValue(false); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith('_WatermarkContent', null); + expect(watermarkCallback).toHaveBeenCalledTimes(1); + }); + + it('Has format, empty editor, with text', () => { + isModelEmptyFastSpy.and.returnValue(true); + + const plugin = new WatermarkPlugin('test', { + fontFamily: 'Arial', + fontSize: '20pt', + textColor: 'red', + }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + 'position: absolute; pointer-events: none; content: "test";font-family: Arial!important;font-size: 20pt!important;color: red!important;', + 'before' + ); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + + isModelEmptyFastSpy.and.returnValue(false); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith('_WatermarkContent', null); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/watermark/isModelEmptyFastTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/isModelEmptyFastTest.ts new file mode 100644 index 00000000000..dac437b0c1a --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/watermark/isModelEmptyFastTest.ts @@ -0,0 +1,189 @@ +import { isModelEmptyFast } from '../../lib/watermark/isModelEmptyFast'; +import { + createBr, + createContentModelDocument, + createDivider, + createEntity, + createFormatContainer, + createImage, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('isModelEmptyFast', () => { + it('Empty model', () => { + const model = createContentModelDocument(); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Divider block', () => { + const model = createContentModelDocument(); + + model.blocks.push(createDivider('div')); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single FormatContainer block', () => { + const model = createContentModelDocument(); + + model.blocks.push(createFormatContainer('div')); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Entity block', () => { + const model = createContentModelDocument(); + + model.blocks.push(createEntity({} as any)); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Paragraph block - no segment', () => { + const model = createContentModelDocument(); + + model.blocks.push(createParagraph()); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - one selection marker segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createSelectionMarker()); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - one BR segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createBr()); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - one selection marker and one BR segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createSelectionMarker(), createBr()); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - two BR segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createBr(), createBr()); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Paragraph block - one empty text segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createText('')); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - two empty text segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createText(''), createText('')); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - one text segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createText('test')); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Paragraph block - one image segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createImage('')); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Paragraph block - one entity segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createEntity({} as any)); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Multiple blocks', () => { + const model = createContentModelDocument(); + + model.blocks.push({} as any, {} as any); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); +}); diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 56169d4b2d6..7321fbed70d 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -145,6 +145,24 @@ export type AttachDomEvent = ( */ export type RestoreUndoSnapshot = (core: EditorCore, snapshot: Snapshot) => void; +/** + * Add CSS rules for editor + * @param core The EditorCore object + * @param key A string to identify the CSS rule type. When set CSS rules with the same key again, existing rules with the same key will be replaced. + * @param cssRule The CSS rule string, must be a valid CSS rule string, or browser may throw exception. Pass null to remove existing rules + * @param subSelectors @optional If the rule is used for child element under editor, use this parameter to specify the child elements. Each item will be + * combined with root selector together to build a separate rule. It also accepts pseudo classes "before" and "after" to create pseudo class rule "::before" + * and "::after" to the editor root element itself + * @param maxRuleLength @optional Set maximum length for a single rule. This is used by test code only + */ +export type SetEditorStyle = ( + core: EditorCore, + key: string, + cssRule: string | null, + subSelectors?: 'before' | 'after' | string[], + maxRuleLength?: number +) => void; + /** * The interface for the map of core API for Editor. * Editor can call call API from this map under EditorCore object @@ -249,6 +267,16 @@ export interface CoreApiMap { * @param broadcast Set to true to skip the shouldHandleEventExclusively check */ triggerEvent: TriggerEvent; + + /** + * Add CSS rules for editor + * @param core The EditorCore object + * @param key A string to identify the CSS rule type. When set CSS rules with the same key again, existing rules with the same key will be replaced. + * @param cssRule The CSS rule string, must be a valid CSS rule string, or browser may throw exception + * @param subSelectors @optional If the rule is used for child element under editor, use this parameter to specify the child elements. Each item will be + * combined with root selector together to build a separate rule. + */ + setEditorStyle: SetEditorStyle; } /** diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index 53ed1df5f2f..d185a9ca74c 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -197,4 +197,17 @@ export interface IEditor { * Retrieves the rect of the visible viewport of the editor. */ getVisibleViewport(): Rect | null; + + /** + * Add CSS rules for editor + * @param key A string to identify the CSS rule type. When set CSS rules with the same key again, existing rules with the same key will be replaced. + * @param cssRule The CSS rule string, must be a valid CSS rule string, or browser may throw exception. Pass null to clear existing rules + * @param subSelectors @optional If the rule is used for child element under editor, use this parameter to specify the child elements. Each item will be + * combined with root selector together to build a separate rule. + */ + setEditorStyle( + key: string, + cssRule: string | null, + subSelectors?: 'before' | 'after' | string[] + ): void; } diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 015c491a883..dfd53a09dfd 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -219,6 +219,7 @@ export { AttachDomEvent, RestoreUndoSnapshot, GetVisibleViewport, + SetEditorStyle, } from './editor/EditorCore'; export { EditorCorePlugins } from './editor/EditorCorePlugins'; export { EditorPlugin } from './editor/EditorPlugin'; diff --git a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts index f8c9a263881..fb7d1f07f24 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts @@ -11,4 +11,9 @@ export interface LifecyclePluginState { * Cached document fragment for original content */ shadowEditFragment: DocumentFragment | null; + + /** + * Style elements used for adding CSS rules for editor + */ + readonly styleElements: Record; } diff --git a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index 02390d727ef..d37315b11f1 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -9,11 +9,6 @@ export interface SelectionPluginState { */ selection: DOMSelection | null; - /** - * A style node in current document to help implement image and table selection - */ - selectionStyleNode: HTMLStyleElement | null; - /** * When set to true, onFocus event will not trigger reselect cached range */ From 6480d948a3fd49355df399fcc60b0967d2b2c9f8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 13 Mar 2024 17:11:59 -0700 Subject: [PATCH 02/14] Port table selection --- .../controlsV2/plugins/createLegacyPlugins.ts | 2 - .../editorOptions/EditorOptionsPlugin.ts | 1 - .../sidePane/editorOptions/OptionState.ts | 1 - .../sidePane/editorOptions/Plugins.tsx | 1 - .../editorOptions/codes/PluginsCode.ts | 2 - .../editorOptions/codes/SimplePluginCode.ts | 6 - .../lib/coreApi/getDOMSelection.ts | 19 +- .../lib/corePlugin/SelectionPlugin.ts | 365 ++++++++++++++---- .../lib/parameter/DOMHelper.ts | 14 + 9 files changed, 307 insertions(+), 104 deletions(-) diff --git a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts index 2d858317d1e..0e6e1c53229 100644 --- a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts +++ b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts @@ -5,7 +5,6 @@ import { CustomReplace, HyperLink, ImageEdit, - TableCellSelection, } from 'roosterjs-editor-plugins'; import { LegacyPluginList, @@ -33,7 +32,6 @@ export function createLegacyPlugins(initState: OptionState): LegacyEditorPlugin[ applyChangesOnMouseUp: initState.applyChangesOnMouseUp, }) : null, - tableCellSelection: pluginList.tableCellSelection ? new TableCellSelection() : null, customReplace: pluginList.customReplace ? new CustomReplace() : null, announce: pluginList.announce ? new Announce(getDefaultStringsMap()) : null, }; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 89206def58e..563ac80af0c 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -21,7 +21,6 @@ const initialState: OptionState = { contentEdit: false, hyperlink: false, imageEdit: false, - tableCellSelection: true, customReplace: false, announce: false, }, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 39b84763b81..e80c02db789 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -6,7 +6,6 @@ export interface LegacyPluginList { contentEdit: boolean; hyperlink: boolean; imageEdit: boolean; - tableCellSelection: boolean; customReplace: boolean; announce: boolean; } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index 6623560090f..edf89de3182 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -140,7 +140,6 @@ export class LegacyPlugins extends PluginsBase { ) )} {this.renderPluginItem('customReplace', 'Custom Replace Plugin (autocomplete)')} - {this.renderPluginItem('tableCellSelection', 'Table Cell Selection')} {this.renderPluginItem('announce', 'Announce')} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 61a0953da1c..4397b5de30e 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -9,7 +9,6 @@ import { EditPluginCode, ImageEditCode, PastePluginCode, - TableCellSelectionCode, TableEditPluginCode, ShortcutPluginCode, } from './SimplePluginCode'; @@ -58,7 +57,6 @@ export class LegacyPluginCode extends PluginsCodeBase { pluginList.hyperlink && new HyperLinkCode(state.linkTitle), pluginList.imageEdit && new ImageEditCode(), pluginList.customReplace && new CustomReplaceCode(), - pluginList.tableCellSelection && new TableCellSelectionCode(), ]; super(plugins); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index 5dc6d8d2f36..b881594b79f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -51,9 +51,3 @@ export class CustomReplaceCode extends SimplePluginCode { super('CustomReplace', 'roosterjs'); } } - -export class TableCellSelectionCode extends SimplePluginCode { - constructor() { - super('TableCellSelection', 'roosterjs'); - } -} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts index c2e0404f19a..63c77110276 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts @@ -19,24 +19,13 @@ function getNewSelection(core: EditorCore): DOMSelection | null { const selection = core.logicalRoot.ownerDocument.defaultView?.getSelection(); const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; - return range && core.logicalRoot.contains(range.commonAncestorContainer) + return selection && range && core.logicalRoot.contains(range.commonAncestorContainer) ? { type: 'range', range, - isReverted: isSelectionReverted(selection), + isReverted: + selection.focusNode != range.endContainer || + selection.focusOffset != range.endOffset, } : null; } - -function isSelectionReverted(selection: Selection | null | undefined): boolean { - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - return ( - !range.collapsed && - selection.focusNode != range.endContainer && - selection.focusOffset != range.endOffset - ); - } - - return false; -} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index 9c6c08f99a7..13e3fe6de9e 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -1,5 +1,6 @@ import { isElementOfType, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; import { isModifierKey } from '../publicApi/domUtils/eventUtils'; +import { parseTableCells } from '../publicApi/domUtils/tableCellUtils'; import type { DOMSelection, IEditor, @@ -7,17 +8,30 @@ import type { PluginWithState, SelectionPluginState, EditorOptions, + DOMHelper, + MouseUpEvent, } from 'roosterjs-content-model-types'; +const MouseLeftButton = 0; const MouseMiddleButton = 1; const MouseRightButton = 2; +const TableCellSelector = 'TH,TD'; + +interface TableSelectionInfo { + table: HTMLTableElement; + vTable: (HTMLTableCellElement | null)[][]; + firstCo: [number, number]; + lastCo?: [number, number]; +} class SelectionPlugin implements PluginWithState { private editor: IEditor | null = null; private state: SelectionPluginState; private disposer: (() => void) | null = null; + private mouseDisposer: (() => void) | null = null; private isSafari = false; private isMac = false; + private tableSelection: TableSelectionInfo | null = null; constructor(options: EditorOptions) { this.state = { @@ -39,27 +53,27 @@ class SelectionPlugin implements PluginWithState { this.isSafari = !!env.isSafari; this.isMac = !!env.isMac; - if (this.isSafari) { - document.addEventListener('selectionchange', this.onSelectionChangeSafari); - this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus } }); - } else { - this.disposer = this.editor.attachDomEvent({ - focus: { beforeDispatch: this.onFocus }, - blur: { beforeDispatch: this.onBlur }, - }); - } + document.addEventListener('selectionchange', this.onSelectionChange); + + this.disposer = this.editor.attachDomEvent({ + focus: { beforeDispatch: this.onFocus }, + blur: { beforeDispatch: this.isSafari ? undefined : this.onBlur }, + }); } dispose() { - this.editor - ?.getDocument() - .removeEventListener('selectionchange', this.onSelectionChangeSafari); + this.editor?.getDocument().removeEventListener('selectionchange', this.onSelectionChange); if (this.disposer) { this.disposer(); this.disposer = null; } + if (this.mouseDisposer) { + this.mouseDisposer(); + this.mouseDisposer = null; + } + this.editor = null; } @@ -72,68 +86,164 @@ class SelectionPlugin implements PluginWithState { return; } - let image: HTMLImageElement | null; - let selection: DOMSelection | null; - switch (event.eventType) { - case 'mouseUp': - if ( - (image = this.getClickingImage(event.rawEvent)) && - image.isContentEditable && - event.rawEvent.button != MouseMiddleButton && - (event.rawEvent.button == - MouseRightButton /* it's not possible to drag using right click */ || - event.isClicking) - ) { - this.selectImage(this.editor, image); - } + case 'mouseDown': + this.onMouseDown(this.editor, event.rawEvent); break; - case 'mouseDown': - selection = this.editor.getDOMSelection(); - if ( - event.rawEvent.button === MouseRightButton && - (image = - this.getClickingImage(event.rawEvent) ?? - this.getContainedTargetImage(event.rawEvent, selection)) && - image.isContentEditable - ) { - this.selectImage(this.editor, image); - } else if ( - selection?.type == 'image' && - selection.image !== event.rawEvent.target - ) { - this.selectBeforeImage(this.editor, selection.image); - } + case 'mouseUp': + this.onMouseUp(event); break; case 'keyDown': - const rawEvent = event.rawEvent; - const key = rawEvent.key; - selection = this.editor.getDOMSelection(); - - if ( - !isModifierKey(rawEvent) && - !rawEvent.shiftKey && - selection?.type == 'image' && - selection.image.parentNode - ) { - if (key === 'Escape') { - this.selectBeforeImage(this.editor, selection.image); - event.rawEvent.stopPropagation(); - } else if (key !== 'Delete' && key !== 'Backspace') { - this.selectBeforeImage(this.editor, selection.image); - } - } + this.onKeyDown(this.editor, event.rawEvent); + break; + + case 'contentChanged': + this.tableSelection = null; break; } } - private selectImage(editor: IEditor, image: HTMLImageElement) { - editor.setDOMSelection({ - type: 'image', - image: image, - }); + private onMouseDown(editor: IEditor, rawEvent: MouseEvent) { + const selection = editor.getDOMSelection(); + let image: HTMLImageElement | null; + + if ( + rawEvent.button === MouseRightButton && + (image = + this.getClickingImage(rawEvent) ?? + this.getContainedTargetImage(rawEvent, selection)) && + image.isContentEditable + ) { + this.selectImage(image); + + return; + } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { + this.selectBeforeImage(editor, selection.image); + + return; + } + + if (selection?.type == 'table' && rawEvent.button == MouseLeftButton) { + this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); + } + + let table: HTMLTableElement | null; + let vTable: (HTMLTableCellElement | null)[][] | null; + let firstCo: [number, number] | null; + const target = rawEvent.target as Node; + + if ( + rawEvent.button == MouseLeftButton && + (table = editor.getDOMHelper().findClosestElementAncestor(target, 'table')) && + (vTable = parseTableCells(table)) && + (firstCo = this.findCoordinate(vTable, target, editor.getDOMHelper())) + ) { + this.tableSelection = { + firstCo, + table, + vTable, + }; + this.mouseDisposer = editor.attachDomEvent({ + mousemove: { + beforeDispatch: this.onMouseMove, + }, + }); + } + } + + private onMouseMove = (event: Event) => { + if ( + !this.editor || + !this.tableSelection || + (event as MouseEvent).button != MouseLeftButton + ) { + return; + } + + let lastCo: [number, number] | null; + + if ( + (lastCo = this.findCoordinate( + this.tableSelection.vTable, + event.target as Node, + this.editor.getDOMHelper() + )) && + this.updateTableSelection(lastCo) + ) { + event.preventDefault(); + } + }; + + private onMouseUp(event: MouseUpEvent) { + let image: HTMLImageElement | null; + + if ( + (image = this.getClickingImage(event.rawEvent)) && + image.isContentEditable && + event.rawEvent.button != MouseMiddleButton && + (event.rawEvent.button == + MouseRightButton /* it's not possible to drag using right click */ || + event.isClicking) + ) { + this.selectImage(image); + } + + if (this.mouseDisposer) { + this.mouseDisposer(); + this.mouseDisposer = null; + } + } + + private onKeyDown(editor: IEditor, rawEvent: KeyboardEvent) { + const key = rawEvent.key; + const selection = editor.getDOMSelection(); + + if ( + !isModifierKey(rawEvent) && + !rawEvent.shiftKey && + selection?.type == 'image' && + selection.image.parentNode + ) { + if (key === 'Escape') { + this.selectBeforeImage(editor, selection.image); + rawEvent.stopPropagation(); + } else if (key !== 'Delete' && key !== 'Backspace') { + this.selectBeforeImage(editor, selection.image); + } + } else if (this.tableSelection?.lastCo && rawEvent.shiftKey && rawEvent.key != 'Shift') { + const { table, lastCo, vTable } = this.tableSelection; + const win = editor.getDocument().defaultView; + const isRtl = win?.getComputedStyle(table).direction == 'rtl'; + const row = lastCo[0] + (key == 'ArrowUp' ? -1 : key == 'ArrowDown' ? 1 : 0); + const col = + lastCo[1] + + (key == 'ArrowLeft' ? -1 : key == 'ArrowRight' ? 1 : 0) * (isRtl ? -1 : 1); + + if (row != lastCo[0] || col != lastCo[1]) { + rawEvent.preventDefault(); + + if (row >= 0 && row < vTable.length && col >= 0 && col < vTable[row].length) { + this.updateTableSelection([row, col]); + } + } else { + this.setDOMSelection(null, null); + } + } else { + //this.tableSelection = null; + //this.setDOMSelection(null, null); + } + } + + private selectImage(image: HTMLImageElement) { + this.setDOMSelection( + { + type: 'image', + image: image, + }, + null /*tableSelection*/ + ); } private selectBeforeImage(editor: IEditor, image: HTMLImageElement) { @@ -146,11 +256,14 @@ class SelectionPlugin implements PluginWithState { range.setStart(parent, index); range.collapse(); - editor.setDOMSelection({ - type: 'range', - range: range, - isReverted: false, - }); + this.setDOMSelection( + { + type: 'range', + range: range, + isReverted: false, + }, + null /*tableSelection*/ + ); } } @@ -162,8 +275,8 @@ class SelectionPlugin implements PluginWithState { : null; } - //MacOS will not create a mouseUp event if contextMenu event is not prevent defaulted. - //Make sure we capture image target even if image is wrapped + // MacOS will not create a mouseUp event if contextMenu event is not prevent defaulted. + // Make sure we capture image target even if image is wrapped private getContainedTargetImage = ( event: MouseEvent, previousSelection: DOMSelection | null @@ -185,7 +298,7 @@ class SelectionPlugin implements PluginWithState { private onFocus = () => { if (!this.state.skipReselectOnFocus && this.state.selection) { - this.editor?.setDOMSelection(this.state.selection); + this.setDOMSelection(this.state.selection, this.tableSelection); } if (this.state.selection?.type == 'range' && !this.isSafari) { @@ -200,17 +313,117 @@ class SelectionPlugin implements PluginWithState { } }; - 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 (newSelection?.type == 'range') { - this.state.selection = newSelection; + if (this.isSafari) { + // 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. + this.state.selection = newSelection; + } + + if (!this.tableSelection?.lastCo) { + const { + range: { collapsed, startContainer, endContainer, commonAncestorContainer }, + isReverted, + } = newSelection; + + const domHelper = this.editor.getDOMHelper(); + let table: HTMLTableElement | null; + let vTable: (HTMLTableCellElement | null)[][] | null; + let firstCo: [number, number] | null; + let lastCo: [number, number] | null; + + if ( + !collapsed && + (table = domHelper.findClosestElementAncestor( + commonAncestorContainer, + 'table' + )) && + (vTable = parseTableCells(table)) && + (firstCo = this.findCoordinate( + vTable, + isReverted ? endContainer : startContainer, + domHelper + )) && + (lastCo = this.findCoordinate( + vTable, + isReverted ? startContainer : endContainer, + domHelper + )) + ) { + this.tableSelection = { table, vTable, firstCo }; + this.updateTableSelection(lastCo); + } + } } } }; + + private updateTableSelection(lastCo: [number, number]) { + if (this.tableSelection) { + const { table, firstCo, vTable, lastCo: oldCo } = this.tableSelection; + + if (oldCo || firstCo[0] != lastCo[0] || firstCo[1] != lastCo[1]) { + this.tableSelection.lastCo = lastCo; + + this.setDOMSelection( + { + type: 'table', + table: table, + firstRow: Math.min(firstCo[0], lastCo[0]), + firstColumn: Math.min(firstCo[1], lastCo[1]), + lastRow: Math.max(firstCo[0], lastCo[0]), + lastColumn: Math.max(firstCo[1], lastCo[1]), + }, + { table, firstCo, lastCo, vTable } + ); + + return true; + } + } + + return false; + } + + private setDOMSelection( + selection: DOMSelection | null, + tableSelection: TableSelectionInfo | null + ) { + this.editor?.setDOMSelection(selection); + this.tableSelection = tableSelection; + } + + private findCoordinate( + vTable: (HTMLTableCellElement | null)[][], + node: Node, + domHelper: DOMHelper + ): [number, number] | null { + const td = domHelper.findClosestElementAncestor(node, TableCellSelector); + let result: [number, number] | null = null; + + // Try to do a fast check if both TD are in the given TABLE + if (td) { + vTable.some((row, rowIndex) => { + const colIndex = td ? row.indexOf(td as HTMLTableCellElement) : -1; + + return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); + }); + } + + // For nested table scenario, try to find the outer TAble cells + if (!result) { + vTable.some((row, rowIndex) => { + const colIndex = row.findIndex(cell => cell?.contains(node)); + + return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); + }); + } + + return result; + } } /** diff --git a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 0e09215976b..24eb7663455 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -55,6 +55,18 @@ export interface DOMHelper { */ getDomStyle(style: T): CSSStyleDeclaration[T]; + /** + * Find closest element ancestor start from the given node which matches the given selector + * @param node Find ancestor start from this node + * @param selector The expected selector. If null, return the first HTML Element found from start node + * @returns An HTML element which matches the given selector. If the given start node matches the selector, + * returns the given node + */ + findClosestElementAncestor( + node: Node, + selector?: T + ): HTMLElementTagNameMap[T] | null; + /** * Find closest element ancestor start from the given node which matches the given selector * @param node Find ancestor start from this node @@ -70,3 +82,5 @@ export interface DOMHelper { */ hasFocus(): boolean; } + +document.createElement; From c14f88db8daca74ae95e4d299d4603747e9a8e2b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 14 Mar 2024 15:52:02 -0700 Subject: [PATCH 03/14] improve --- .../editorOptions/ContentEditFeatures.tsx | 31 +- .../sidePane/editorOptions/OptionState.ts | 25 +- .../getDefaultContentEditFeatureSettings.ts | 57 +++- .../lib/coreApi/setDOMSelection.ts | 34 +- .../lib/corePlugin/SelectionPlugin.ts | 295 ++++++++++++------ .../corePlugin/utils/entityDelimiterUtils.ts | 7 + .../roosterjs-content-model-core/lib/index.ts | 6 +- .../lib/publicApi/domUtils/normalizePos.ts | 20 ++ .../lib/publicApi/domUtils/tableCellUtils.ts | 48 ++- .../lib/index.ts | 1 + .../lib/parameter/ParsedTable.ts | 9 + .../lib/corePlugins/BridgePlugin.ts | 6 +- .../lib/editor/IgnoredPluginNames.ts | 7 + .../roosterjs-editor-adapter/lib/index.ts | 2 + 14 files changed, 397 insertions(+), 151 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts create mode 100644 packages/roosterjs-content-model-types/lib/parameter/ParsedTable.ts create mode 100644 packages/roosterjs-editor-adapter/lib/editor/IgnoredPluginNames.ts diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx index e96cbfdb11f..019f429165b 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx @@ -1,25 +1,13 @@ import * as React from 'react'; -import { ContentEditFeatureSettings } from 'roosterjs-editor-types'; -import { getAllFeatures } from 'roosterjs-editor-plugins/lib/ContentEdit'; +import { ContentEditFeatureSettings, OptionState } from './OptionState'; +import { getAllFeatures } from './getDefaultContentEditFeatureSettings'; 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', @@ -35,36 +23,27 @@ const EditFeatureDescriptionMap: Record, -->, >, => 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', + 'When using BACKSPACE or DELETE in a Readonly inline entity delimiter, 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 { +export interface ContentEditFeaturesProps { state: ContentEditFeatureSettings; resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void; } -export default class ContentEditFeatures extends React.Component { +export default class ContentEditFeatures extends React.Component { render() { const features = getAllFeatures(); return ( diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index e80c02db789..095b400a45e 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -1,4 +1,15 @@ -import type { ContentEditFeatureSettings } from 'roosterjs-editor-types'; +import type { + AutoLinkFeatureSettings, + CodeFeatureSettings, + CursorFeatureSettings, + EntityFeatureSettings, + MarkdownFeatureSettings, + ShortcutFeatureSettings, + StructuredNodeFeatureSettings, + TableFeatureSettings, + TextFeatureSettings, + ContentEditFeatureSettings as ContentEditFeatureSettingsOriginal, +} from 'roosterjs-editor-types'; import type { SidePaneElementProps } from '../SidePaneElement'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; @@ -25,6 +36,16 @@ export interface NewPluginList { export interface BuildInPluginList extends LegacyPluginList, NewPluginList {} +export type ContentEditFeatureSettings = Omit & + StructuredNodeFeatureSettings & + AutoLinkFeatureSettings & + ShortcutFeatureSettings & + CursorFeatureSettings & + MarkdownFeatureSettings & + EntityFeatureSettings & + TextFeatureSettings & + CodeFeatureSettings; + export interface OptionState { pluginList: BuildInPluginList; @@ -36,7 +57,7 @@ export interface OptionState { watermarkText: string; // Legacy plugin options - contentEditFeatures: ContentEditFeatureSettings; + contentEditFeatures: ContentEditFeatureSettingsOriginal; defaultFormat: ContentModelSegmentFormat; linkTitle: string; forcePreserveRatio: boolean; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controlsV2/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 75840bd3ab2..92070c1326d 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -1,14 +1,59 @@ -import { ContentEditFeatureSettings } from 'roosterjs-editor-types'; -import { getAllFeatures } from 'roosterjs-editor-plugins'; +import { ContentEditFeatureSettings } from './OptionState'; +import { getAllFeatures as getAllFeaturesOriginal } from 'roosterjs-editor-plugins'; import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { + BuildInEditFeature, + ContentEditFeatureSettings as ContentEditFeatureSettingsOriginal, + PluginEvent, +} from 'roosterjs-editor-types'; -export function getDefaultContentEditFeatureSettings(): ContentEditFeatureSettings { - const allFeatures = getAllFeatures(); +const PortedFeatureKeys: (keyof ContentEditFeatureSettingsOriginal)[] = [ + 'autoBullet', + 'indentWhenTab', + 'outdentWhenShiftTab', + 'mergeInNewLineWhenBackspaceOnFirstChar', + 'mergeListOnBackspaceAfterList', + 'maintainListChain', + 'maintainListChainWhenDelete', + 'autoBulletList', + 'outdentWhenAltShiftLeft', + 'outdentWhenBackspaceOnEmptyFirstLine', + 'outdentWhenEnterOnEmptyLine', + 'unquoteWhenBackspaceOnEmptyFirstLine', + 'upDownInTable', + 'autoNumberingList', + 'indentWhenAltShiftRight', + 'unquoteWhenEnterOnEmptyLine', +]; + +export function getAllFeatures(): Record< + keyof ContentEditFeatureSettings, + BuildInEditFeature +> { + const allFeatures = getAllFeaturesOriginal(); + + return { + ...getObjectKeys(allFeatures).reduce((features, key) => { + if (isContentEditFeatureKey(key)) { + features[key] = allFeatures[key]; + } + return features; + }, {} as Record>), + }; +} + +export function getDefaultContentEditFeatureSettings(): ContentEditFeatureSettingsOriginal { + const allFeatures = getAllFeaturesOriginal(); return { ...getObjectKeys(allFeatures).reduce((settings, key) => { - settings[key] = !allFeatures[key].defaultDisabled; + settings[key] = + PortedFeatureKeys.indexOf(key) >= 0 ? false : !allFeatures[key].defaultDisabled; return settings; - }, {}), + }, {}), }; } + +function isContentEditFeatureKey(key: string): key is keyof ContentEditFeatureSettings { + return PortedFeatureKeys.indexOf(key as any) >= 0; +} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts index 248d9578130..99e73d70673 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts @@ -1,8 +1,9 @@ import { addRangeToSelection } from '../corePlugin/utils/addRangeToSelection'; import { ensureUniqueId } from './setEditorStyle/ensureUniqueId'; +import { findTableCellElement, parseTableCells } from '../publicApi/domUtils/tableCellUtils'; import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; -import { parseTableCells } from '../publicApi/domUtils/tableCellUtils'; import type { + ParsedTable, SelectionChangedEvent, SetDOMSelection, TableSelection, @@ -50,7 +51,9 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC break; case 'table': const { table, firstColumn, firstRow } = selection; + const parsedTable = parseTableCells(selection.table); const tableSelectors = buildTableSelectors( + parsedTable, ensureUniqueId(table, TABLE_ID), selection ); @@ -64,7 +67,11 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC ); core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); - setRangeSelection(doc, table.rows[firstRow]?.cells[firstColumn]); + const td = findTableCellElement(parsedTable, firstRow, firstColumn); + const nodeToSelect = td?.firstElementChild || td; + + setRangeSelection(doc, (nodeToSelect as HTMLElement) || undefined); + break; case 'range': addRangeToSelection(doc, selection.range, selection.isReverted); @@ -90,24 +97,23 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function buildTableSelectors(tableId: string, selection: TableSelection): string[] { +function buildTableSelectors( + parsedTable: ParsedTable, + tableId: string, + selection: TableSelection +): string[] { const { firstColumn, firstRow, lastColumn, lastRow } = selection; - const cells = parseTableCells(selection.table); const isAllTableSelected = firstRow == 0 && firstColumn == 0 && - lastRow == cells.length - 1 && - lastColumn == (cells[lastRow]?.length ?? 0) - 1; + lastRow == parsedTable.length - 1 && + lastColumn == (parsedTable[lastRow]?.length ?? 0) - 1; return isAllTableSelected ? [`#${tableId}`, `#${tableId} *`] - : handleTableSelected(tableId, selection, cells); + : handleTableSelected(tableId, selection, parsedTable); } -function handleTableSelected( - tableId: string, - selection: TableSelection, - cells: (HTMLTableCellElement | null)[][] -) { +function handleTableSelected(tableId: string, selection: TableSelection, parsedTable: ParsedTable) { const { firstRow, firstColumn, lastRow, lastColumn, table } = selection; const selectors: string[] = []; @@ -132,7 +138,7 @@ function handleTableSelected( return result; }); - cells.forEach((row, rowIndex) => { + parsedTable.forEach((row, rowIndex) => { let tdCount = 0; //Get current TBODY/THEAD/TFOOT @@ -146,7 +152,7 @@ function handleTableSelected( for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { const cell = row[cellIndex]; - if (cell) { + if (typeof cell == 'object') { tdCount++; if ( diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index 13e3fe6de9e..ef7ed159749 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -1,6 +1,7 @@ +import { findTableCellElement, parseTableCells } from '../publicApi/domUtils/tableCellUtils'; +import { isCharacterValue, isModifierKey } from '../publicApi/domUtils/eventUtils'; import { isElementOfType, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; -import { isModifierKey } from '../publicApi/domUtils/eventUtils'; -import { parseTableCells } from '../publicApi/domUtils/tableCellUtils'; +import { normalizePos } from '../publicApi/domUtils/normalizePos'; import type { DOMSelection, IEditor, @@ -10,16 +11,21 @@ import type { EditorOptions, DOMHelper, MouseUpEvent, + ParsedTable, } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; const MouseMiddleButton = 1; const MouseRightButton = 2; const TableCellSelector = 'TH,TD'; +const Up = 'ArrowUp'; +const Down = 'ArrowDown'; +const Left = 'ArrowLeft'; +const Right = 'ArrowRight'; interface TableSelectionInfo { table: HTMLTableElement; - vTable: (HTMLTableCellElement | null)[][]; + parsedTable: ParsedTable; firstCo: [number, number]; lastCo?: [number, number]; } @@ -58,6 +64,7 @@ class SelectionPlugin implements PluginWithState { this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus }, blur: { beforeDispatch: this.isSafari ? undefined : this.onBlur }, + drop: { beforeDispatch: this.onDrop }, }); } @@ -69,11 +76,7 @@ class SelectionPlugin implements PluginWithState { this.disposer = null; } - if (this.mouseDisposer) { - this.mouseDisposer(); - this.mouseDisposer = null; - } - + this.detachMouseEvent(); this.editor = null; } @@ -109,6 +112,7 @@ class SelectionPlugin implements PluginWithState { const selection = editor.getDOMSelection(); let image: HTMLImageElement | null; + // Image selection if ( rawEvent.button === MouseRightButton && (image = @@ -125,26 +129,19 @@ class SelectionPlugin implements PluginWithState { return; } + // Table selection if (selection?.type == 'table' && rawEvent.button == MouseLeftButton) { this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); } - let table: HTMLTableElement | null; - let vTable: (HTMLTableCellElement | null)[][] | null; - let firstCo: [number, number] | null; + let tableSelection: TableSelectionInfo | null; const target = rawEvent.target as Node; if ( rawEvent.button == MouseLeftButton && - (table = editor.getDOMHelper().findClosestElementAncestor(target, 'table')) && - (vTable = parseTableCells(table)) && - (firstCo = this.findCoordinate(vTable, target, editor.getDOMHelper())) + (tableSelection = this.parseTableSelection(target, target, editor.getDOMHelper())) ) { - this.tableSelection = { - firstCo, - table, - vTable, - }; + this.tableSelection = tableSelection; this.mouseDisposer = editor.attachDomEvent({ mousemove: { beforeDispatch: this.onMouseMove, @@ -166,7 +163,7 @@ class SelectionPlugin implements PluginWithState { if ( (lastCo = this.findCoordinate( - this.tableSelection.vTable, + this.tableSelection.parsedTable, event.target as Node, this.editor.getDOMHelper() )) && @@ -190,49 +187,161 @@ class SelectionPlugin implements PluginWithState { this.selectImage(image); } - if (this.mouseDisposer) { - this.mouseDisposer(); - this.mouseDisposer = null; - } + this.detachMouseEvent(); } + private onDrop = () => { + this.detachMouseEvent(); + }; + private onKeyDown(editor: IEditor, rawEvent: KeyboardEvent) { const key = rawEvent.key; const selection = editor.getDOMSelection(); - if ( - !isModifierKey(rawEvent) && - !rawEvent.shiftKey && - selection?.type == 'image' && - selection.image.parentNode - ) { - if (key === 'Escape') { - this.selectBeforeImage(editor, selection.image); - rawEvent.stopPropagation(); - } else if (key !== 'Delete' && key !== 'Backspace') { - this.selectBeforeImage(editor, selection.image); - } - } else if (this.tableSelection?.lastCo && rawEvent.shiftKey && rawEvent.key != 'Shift') { - const { table, lastCo, vTable } = this.tableSelection; - const win = editor.getDocument().defaultView; - const isRtl = win?.getComputedStyle(table).direction == 'rtl'; - const row = lastCo[0] + (key == 'ArrowUp' ? -1 : key == 'ArrowDown' ? 1 : 0); - const col = - lastCo[1] + - (key == 'ArrowLeft' ? -1 : key == 'ArrowRight' ? 1 : 0) * (isRtl ? -1 : 1); - - if (row != lastCo[0] || col != lastCo[1]) { - rawEvent.preventDefault(); - - if (row >= 0 && row < vTable.length && col >= 0 && col < vTable[row].length) { - this.updateTableSelection([row, col]); + switch (selection?.type) { + case 'image': + if (!isModifierKey(rawEvent) && !rawEvent.shiftKey && selection.image.parentNode) { + if (key === 'Escape') { + this.selectBeforeImage(editor, selection.image); + rawEvent.stopPropagation(); + } else if (key !== 'Delete' && key !== 'Backspace') { + this.selectBeforeImage(editor, selection.image); + } + } + break; + + case 'table': + if (this.tableSelection?.lastCo) { + const { shiftKey, key } = rawEvent; + + if (shiftKey && (key == Left || key == Right)) { + const win = editor.getDocument().defaultView; + const isRtl = + win?.getComputedStyle(this.tableSelection.table).direction == 'rtl'; + + this.updateTableSelectionFromKeyboard( + 0, + (key == Left ? -1 : 1) * (isRtl ? -1 : 1) + ); + rawEvent.preventDefault(); + } else if (shiftKey && (key == Up || key == Down)) { + this.updateTableSelectionFromKeyboard(key == Up ? -1 : 1, 0); + rawEvent.preventDefault(); + } else if (key != 'Shift' && !isCharacterValue(rawEvent)) { + if (key == Up || key == Down || key == Left || key == Right) { + this.setDOMSelection(null /*domSelection*/, this.tableSelection); + editor + .getDocument() + .defaultView?.requestAnimationFrame(() => + this.handleSelectionInTable(key) + ); + } + } + } + break; + + case 'range': + if (key == Up || key == Down || key == Left || key == Right) { + const start = selection.range.startContainer; + this.tableSelection = this.parseTableSelection( + start, + start, + editor.getDOMHelper() + ); + + if (this.tableSelection) { + editor + .getDocument() + .defaultView?.requestAnimationFrame(() => + this.handleSelectionInTable(key) + ); + } + } + break; + } + } + + private handleSelectionInTable(key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') { + if (!this.editor || !this.tableSelection) { + return; + } + + const selection = this.editor.getDOMSelection(); + const domHelper = this.editor.getDOMHelper(); + + if (selection?.type == 'range') { + const { + range: { collapsed, startContainer, endContainer, commonAncestorContainer }, + isReverted, + } = selection; + const start = isReverted ? endContainer : startContainer; + const end: Node | null = isReverted ? startContainer : endContainer; + const tableSel = this.parseTableSelection(commonAncestorContainer, start, domHelper); + + if (tableSel) { + let lastCo = this.findCoordinate(tableSel?.parsedTable, end, domHelper); + + if ( + lastCo && + (key == Up || key == Down) && + tableSel.table == this.tableSelection.table && + lastCo[1] != this.tableSelection.firstCo[1] + ) { + const { parsedTable, firstCo: oldCo } = this.tableSelection; + const change = key == Up ? -1 : 1; + const originalTd = findTableCellElement(parsedTable, oldCo[0], oldCo[1]); + let td: HTMLTableCellElement | null = null; + + lastCo = [oldCo[0] + change, oldCo[1]]; + + while (lastCo[0] >= 0 && lastCo[0] < parsedTable.length) { + td = findTableCellElement(parsedTable, lastCo[0], lastCo[1]); + + if (td == originalTd) { + lastCo[0] += change; + } else { + break; + } + } + + if (collapsed && td) { + const { node, offset } = normalizePos( + 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*/ + ); + } + } + + if (!collapsed && lastCo) { + this.tableSelection = tableSel; + this.updateTableSelection(lastCo); } - } else { - this.setDOMSelection(null, null); } - } else { - //this.tableSelection = null; - //this.setDOMSelection(null, null); + } + } + + private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { + if (this.tableSelection?.lastCo && this.editor) { + const { lastCo, parsedTable } = this.tableSelection; + const row = lastCo[0] + rowChange; + const col = lastCo[1] + colChange; + + if (row >= 0 && row < parsedTable.length && col >= 0 && col < parsedTable[row].length) { + this.updateTableSelection([row, col]); + } } } @@ -323,48 +432,33 @@ class SelectionPlugin implements PluginWithState { // So we always save a selection whenever editor has focus. Then after blur, we can still use this cached selection. this.state.selection = newSelection; } - - if (!this.tableSelection?.lastCo) { - const { - range: { collapsed, startContainer, endContainer, commonAncestorContainer }, - isReverted, - } = newSelection; - - const domHelper = this.editor.getDOMHelper(); - let table: HTMLTableElement | null; - let vTable: (HTMLTableCellElement | null)[][] | null; - let firstCo: [number, number] | null; - let lastCo: [number, number] | null; - - if ( - !collapsed && - (table = domHelper.findClosestElementAncestor( - commonAncestorContainer, - 'table' - )) && - (vTable = parseTableCells(table)) && - (firstCo = this.findCoordinate( - vTable, - isReverted ? endContainer : startContainer, - domHelper - )) && - (lastCo = this.findCoordinate( - vTable, - isReverted ? startContainer : endContainer, - domHelper - )) - ) { - this.tableSelection = { table, vTable, firstCo }; - this.updateTableSelection(lastCo); - } - } } } }; + private parseTableSelection( + tableStart: Node, + tdStart: Node, + domHelper: DOMHelper + ): TableSelectionInfo | null { + let table: HTMLTableElement | null; + let parsedTable: ParsedTable | null; + let firstCo: [number, number] | null; + + if ( + (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && + (parsedTable = parseTableCells(table)) && + (firstCo = this.findCoordinate(parsedTable, tdStart, domHelper)) + ) { + return { table, parsedTable, firstCo }; + } else { + return null; + } + } + private updateTableSelection(lastCo: [number, number]) { if (this.tableSelection) { - const { table, firstCo, vTable, lastCo: oldCo } = this.tableSelection; + const { table, firstCo, parsedTable, lastCo: oldCo } = this.tableSelection; if (oldCo || firstCo[0] != lastCo[0] || firstCo[1] != lastCo[1]) { this.tableSelection.lastCo = lastCo; @@ -378,7 +472,7 @@ class SelectionPlugin implements PluginWithState { lastRow: Math.max(firstCo[0], lastCo[0]), lastColumn: Math.max(firstCo[1], lastCo[1]), }, - { table, firstCo, lastCo, vTable } + { table, firstCo, lastCo, parsedTable } ); return true; @@ -397,7 +491,7 @@ class SelectionPlugin implements PluginWithState { } private findCoordinate( - vTable: (HTMLTableCellElement | null)[][], + parsedTable: ParsedTable, node: Node, domHelper: DOMHelper ): [number, number] | null { @@ -406,7 +500,7 @@ class SelectionPlugin implements PluginWithState { // Try to do a fast check if both TD are in the given TABLE if (td) { - vTable.some((row, rowIndex) => { + parsedTable.some((row, rowIndex) => { const colIndex = td ? row.indexOf(td as HTMLTableCellElement) : -1; return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); @@ -415,8 +509,10 @@ class SelectionPlugin implements PluginWithState { // For nested table scenario, try to find the outer TAble cells if (!result) { - vTable.some((row, rowIndex) => { - const colIndex = row.findIndex(cell => cell?.contains(node)); + parsedTable.some((row, rowIndex) => { + const colIndex = row.findIndex( + cell => typeof cell == 'object' && cell.contains(node) + ); return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); }); @@ -424,6 +520,13 @@ class SelectionPlugin implements PluginWithState { return result; } + + private detachMouseEvent() { + if (this.mouseDisposer) { + this.mouseDisposer(); + this.mouseDisposer = null; + } + } } /** diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts index 53d84e232b2..fc973245d9c 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -1,5 +1,6 @@ import { isCharacterValue } from '../../publicApi/domUtils/eventUtils'; import { iterateSelections } from '../../publicApi/selection/iterateSelections'; +import { normalizePos } from '../../publicApi/domUtils/normalizePos'; import type { CompositionEndEvent, ContentModelBlockGroup, @@ -132,6 +133,12 @@ function getFocusedElement( let node: Node | null = isReverted ? range.startContainer : range.endContainer; let offset = isReverted ? range.startOffset : range.endOffset; + if (node) { + const pos = normalizePos(node, offset); + node = pos.node; + offset = pos.offset; + } + while (node?.lastChild) { if (offset == node.childNodes.length) { node = node.lastChild; diff --git a/packages/roosterjs-content-model-core/lib/index.ts b/packages/roosterjs-content-model-core/lib/index.ts index c52185ef8ee..5002374178d 100644 --- a/packages/roosterjs-content-model-core/lib/index.ts +++ b/packages/roosterjs-content-model-core/lib/index.ts @@ -40,7 +40,11 @@ export { getSelectedCells } from './publicApi/table/getSelectedCells'; export { isCharacterValue, isModifierKey } from './publicApi/domUtils/eventUtils'; export { combineBorderValue, extractBorderValues } from './publicApi/domUtils/borderValues'; export { isPunctuation, isSpace, normalizeText } from './publicApi/domUtils/stringUtil'; -export { parseTableCells, createTableRanges } from './publicApi/domUtils/tableCellUtils'; +export { + parseTableCells, + createTableRanges, + findTableCellElement, +} from './publicApi/domUtils/tableCellUtils'; export { getSegmentTextFormat } from './publicApi/domUtils/getSegmentTextFormat'; export { readFile } from './publicApi/domUtils/readFile'; export { cacheGetEventData } from './publicApi/domUtils/cacheGetEventData'; diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts new file mode 100644 index 00000000000..26208a073d1 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts @@ -0,0 +1,20 @@ +import { isNodeOfType } from 'roosterjs-content-model-dom'; + +/** + * @internal + */ +export function normalizePos(node: Node, offset: number): { node: Node; offset: number } { + while (node?.lastChild) { + if (offset == node.childNodes.length) { + node = node.lastChild; + offset = isNodeOfType(node, 'TEXT_NODE') + ? node.nodeValue?.length ?? 0 + : node.childNodes.length; + } else { + node = node.childNodes[offset]; + offset = 0; + } + } + + return { node, offset }; +} diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts index b6b727e0e5f..24b3b682cfb 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts @@ -1,14 +1,14 @@ import { toArray } from 'roosterjs-content-model-dom'; -import type { TableSelection } from 'roosterjs-content-model-types'; +import type { ParsedTable, TableSelection } from 'roosterjs-content-model-types'; /** * Parse a table into a two dimensions array of TD elements. For those merged cells, the value will be null. * @param table Input HTML Table element * @returns Array of TD elements */ -export function parseTableCells(table: HTMLTableElement): (HTMLTableCellElement | null)[][] { +export function parseTableCells(table: HTMLTableElement): ParsedTable { const trs = toArray(table.rows); - const cells: (HTMLTableCellElement | null)[][] = trs.map(row => []); + const cells: ParsedTable = trs.map(row => []); trs.forEach((tr, rowIndex) => { for (let sourceCol = 0, targetCol = 0; sourceCol < tr.cells.length; sourceCol++) { @@ -21,7 +21,13 @@ export function parseTableCells(table: HTMLTableElement): (HTMLTableCellElement for (let rowSpan = 0; rowSpan < td.rowSpan; rowSpan++) { if (cells[rowIndex + rowSpan]) { cells[rowIndex + rowSpan][targetCol] = - colSpan == 0 && rowSpan == 0 ? td : null; + colSpan == 0 + ? rowSpan == 0 + ? td + : 'spanTop' + : rowSpan == 0 + ? 'spanLeft' + : 'spanBoth'; } } } @@ -35,6 +41,38 @@ export function parseTableCells(table: HTMLTableElement): (HTMLTableCellElement return cells; } +/** + * Try to find a TD/TH element from the given row and col number from the given parsed table + * @param parsedTable The parsed table + * @param row Row index + * @param col Column index + */ +export function findTableCellElement( + parsedTable: ParsedTable, + row: number, + col: number +): HTMLTableCellElement | null { + while ( + row >= 0 && + col >= 0 && + row < parsedTable.length && + col < (parsedTable[row]?.length ?? 0) + ) { + const cell = parsedTable[row]?.[col]; + + if (!cell) { + break; + } else if (typeof cell == 'object') { + return cell; + } else if (cell == 'spanLeft' || cell == 'spanBoth') { + col--; + } else { + row--; + } + } + return null; +} + /** * Create ranges from a table selection * @param selection The source table selection @@ -49,7 +87,7 @@ export function createTableRanges(selection: TableSelection): Range[] { for (let col = firstColumn; col <= lastColumn; col++) { const td = cells[row]?.[col]; - if (td) { + if (typeof td == 'object') { const range = table.ownerDocument.createRange(); range.selectNode(td); diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index dfd53a09dfd..709d82a5ad2 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -285,6 +285,7 @@ export { DOMHelper } from './parameter/DOMHelper'; export { ImageEditOperation, ImageEditor } from './parameter/ImageEditor'; export { CachedElementHandler, CloneModelOptions } from './parameter/CloneModelOptions'; export { LinkData } from './parameter/LinkData'; +export { ParsedTable, ParsedTableCell } from './parameter/ParsedTable'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/ParsedTable.ts b/packages/roosterjs-content-model-types/lib/parameter/ParsedTable.ts new file mode 100644 index 00000000000..8cbad8b8151 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/ParsedTable.ts @@ -0,0 +1,9 @@ +/** + * Parse a table, this type represents a parsed table cell. It can be a cell element, or a string to indicate where it is spanned from + */ +export type ParsedTableCell = HTMLTableCellElement | 'spanLeft' | 'spanTop' | 'spanBoth'; + +/** + * Represents a parsed table with its table cells + */ +export type ParsedTable = ParsedTableCell[][]; diff --git a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts index 7d1a76bac2a..4fe6b77d499 100644 --- a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts +++ b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts @@ -1,6 +1,7 @@ import { cacheGetEventData } from 'roosterjs-content-model-core'; import { createDarkColorHandler } from '../editor/DarkColorHandlerImpl'; import { createEditPlugin } from './EditPlugin'; +import { IgnoredPluginNames } from '../editor/IgnoredPluginNames'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import type { EditorPlugin as LegacyEditorPlugin, @@ -71,7 +72,10 @@ export class BridgePlugin implements ContextMenuProvider { ) { const editPlugin = createEditPlugin(); - this.legacyPlugins = [editPlugin, ...legacyPlugins.filter(x => !!x)]; + this.legacyPlugins = [ + editPlugin, + ...legacyPlugins.filter(x => !!x && IgnoredPluginNames.indexOf(x.getName()) < 0), + ]; this.edit = editPlugin.getState(); this.contextMenuProviders = this.legacyPlugins.filter(isContextMenuProvider); this.checkExclusivelyHandling = this.legacyPlugins.some( diff --git a/packages/roosterjs-editor-adapter/lib/editor/IgnoredPluginNames.ts b/packages/roosterjs-editor-adapter/lib/editor/IgnoredPluginNames.ts new file mode 100644 index 00000000000..32b3b2b62d7 --- /dev/null +++ b/packages/roosterjs-editor-adapter/lib/editor/IgnoredPluginNames.ts @@ -0,0 +1,7 @@ +/** + * Name of plugins to be ignored. + * Plugins with these names will not be added into editor + */ +export const IgnoredPluginNames = [ + 'TableCellSelection', // Ignore TableCellSelection plugin since its functionality is already integrated into SelectionPlugin +]; diff --git a/packages/roosterjs-editor-adapter/lib/index.ts b/packages/roosterjs-editor-adapter/lib/index.ts index 89c5b8e99bc..3646c7e890c 100644 --- a/packages/roosterjs-editor-adapter/lib/index.ts +++ b/packages/roosterjs-editor-adapter/lib/index.ts @@ -2,3 +2,5 @@ export { EditorAdapterOptions } from './publicTypes/EditorAdapterOptions'; export { BeforePasteAdapterEvent } from './publicTypes/BeforePasteAdapterEvent'; export { EditorAdapter } from './editor/EditorAdapter'; + +export { IgnoredPluginNames } from './editor/IgnoredPluginNames'; From bdee544d3127d428eaab60f1f292c8c8cff5cf40 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 14 Mar 2024 16:09:56 -0700 Subject: [PATCH 04/14] fix test --- .../lib/corePlugin/SelectionPlugin.ts | 34 +++++++++++-------- .../publicApi/domUtils/tableCellUtilsTest.ts | 18 +++++----- .../test/corePlugins/BridgePluginTest.ts | 12 +++++++ 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index ef7ed159749..4c6ecdbb6ae 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -59,17 +59,25 @@ class SelectionPlugin implements PluginWithState { this.isSafari = !!env.isSafari; this.isMac = !!env.isMac; - document.addEventListener('selectionchange', this.onSelectionChange); - - this.disposer = this.editor.attachDomEvent({ - focus: { beforeDispatch: this.onFocus }, - blur: { beforeDispatch: this.isSafari ? undefined : this.onBlur }, - drop: { beforeDispatch: this.onDrop }, - }); + if (this.isSafari) { + document.addEventListener('selectionchange', this.onSelectionChangeSafari); + this.disposer = this.editor.attachDomEvent({ + focus: { beforeDispatch: this.onFocus }, + drop: { beforeDispatch: this.onDrop }, + }); + } else { + this.disposer = this.editor.attachDomEvent({ + focus: { beforeDispatch: this.onFocus }, + blur: { beforeDispatch: this.onBlur }, + drop: { beforeDispatch: this.onDrop }, + }); + } } dispose() { - this.editor?.getDocument().removeEventListener('selectionchange', this.onSelectionChange); + this.editor + ?.getDocument() + .removeEventListener('selectionchange', this.onSelectionChangeSafari); if (this.disposer) { this.disposer(); @@ -422,16 +430,14 @@ class SelectionPlugin implements PluginWithState { } }; - private onSelectionChange = () => { + private onSelectionChangeSafari = () => { 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 (newSelection?.type == 'range') { - if (this.isSafari) { - // 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. - this.state.selection = newSelection; - } + this.state.selection = newSelection; } } }; diff --git a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts index ebf926eee41..3c0e63b0c6a 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts @@ -2,14 +2,14 @@ import { createTableRanges, parseTableCells } from '../../../lib/publicApi/domUt import { DOMSelection } from 'roosterjs-content-model-types'; describe('parseTableCells', () => { - function runTest(html: string, expectedResult: (string | null)[][]) { + function runTest(html: string, expectedResult: string[][]) { const div = document.createElement('div'); div.innerHTML = html; const table = div.firstChild as HTMLTableElement; const result = parseTableCells(table); - const idResult = result.map(row => row.map(td => (td ? td.id : null))); + const idResult = result.map(row => row.map(td => (typeof td == 'object' ? td.id : td))); expect(idResult).toEqual(expectedResult); } @@ -37,7 +37,7 @@ describe('parseTableCells', () => { '
', [ ['td1', 'td2'], - [null, 'td4'], + ['spanTop', 'td4'], ] ); }); @@ -46,7 +46,7 @@ describe('parseTableCells', () => { runTest( '
', [ - ['td1', null], + ['td1', 'spanLeft'], ['td3', 'td4'], ] ); @@ -54,8 +54,8 @@ describe('parseTableCells', () => { it('table with all merged cell', () => { runTest('
', [ - ['td1', null], - [null, null], + ['td1', 'spanLeft'], + ['spanTop', 'spanBoth'], ]); }); @@ -73,9 +73,9 @@ describe('parseTableCells', () => { runTest( '
', [ - ['td1', null, 'td3'], - ['td4', 'td5', null], - [null, 'td8', null], + ['td1', 'spanLeft', 'td3'], + ['td4', 'td5', 'spanTop'], + ['spanTop', 'td8', 'spanLeft'], ] ); }); diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index a766c9f950f..1aac751feb6 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -27,11 +27,13 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = { queryElements: queryElementsSpy, @@ -111,11 +113,13 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = { queryElements: queryElementsSpy, @@ -198,12 +202,14 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, willHandleEventExclusively: willHandleEventExclusivelySpy, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = 'EDITOR' as any; const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); @@ -246,11 +252,13 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = 'EDITOR' as any; @@ -328,11 +336,13 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = 'EDITOR' as any; @@ -391,12 +401,14 @@ describe('BridgePlugin', () => { onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, getContextMenuItems: getContextMenuItemsSpy1, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, getContextMenuItems: getContextMenuItemsSpy2, + getName: () => '', } as any; const mockedEditor = { queryElements: queryElementsSpy, From bb55615d8bc9eb15887890f14aeca1ff4831469e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 14 Mar 2024 16:13:17 -0700 Subject: [PATCH 05/14] improve --- .../lib/watermark/WatermarkPlugin.ts | 10 +++--- .../test/watermark/WatermarkPluginTest.ts | 33 ------------------- 2 files changed, 4 insertions(+), 39 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts index 23ac60fb547..1c0b9ea7cf0 100644 --- a/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts @@ -22,7 +22,7 @@ export class WatermarkPlugin implements EditorPlugin { * Create an instance of Watermark plugin * @param watermark The watermark string */ - constructor(private watermark: string | (() => string), format?: WatermarkFormat) { + constructor(private watermark: string, format?: WatermarkFormat) { this.format = format || { fontSize: '14px', textColor: '#AAAAAA', @@ -78,10 +78,8 @@ export class WatermarkPlugin implements EditorPlugin { } } - private show(editor: IEditor) { - let rule = `position: absolute; pointer-events: none; content: "${ - typeof this.watermark == 'function' ? this.watermark() : this.watermark - }";`; + protected show(editor: IEditor) { + let rule = `position: absolute; pointer-events: none; content: "${this.watermark}";`; getObjectKeys(styleMap).forEach(x => { if (this.format[x]) { @@ -94,7 +92,7 @@ export class WatermarkPlugin implements EditorPlugin { this.isShowing = true; } - private hide(editor: IEditor) { + protected hide(editor: IEditor) { editor.setEditorStyle(WATERMARK_CONTENT_KEY, null); this.isShowing = false; } diff --git a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts index 97907481a49..8691285530b 100644 --- a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts @@ -84,39 +84,6 @@ describe('WatermarkPlugin', () => { ); }); - it('No format, empty editor, with callback', () => { - isModelEmptyFastSpy.and.returnValue(true); - - const watermarkCallback = jasmine.createSpy('watermark').and.returnValue('test'); - const plugin = new WatermarkPlugin(watermarkCallback); - - plugin.initialize(editor); - - plugin.onPluginEvent({ eventType: 'editorReady' }); - - expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); - expect(setEditorStyleSpy).toHaveBeenCalledWith( - '_WatermarkContent', - 'position: absolute; pointer-events: none; content: "test";font-size: 14px!important;color: #AAAAAA!important;', - 'before' - ); - expect(watermarkCallback).toHaveBeenCalledTimes(1); - - plugin.onPluginEvent({ eventType: 'input' } as any); - expect(formatContentModelSpy).toHaveBeenCalledTimes(2); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); - expect(watermarkCallback).toHaveBeenCalledTimes(1); - - isModelEmptyFastSpy.and.returnValue(false); - - plugin.onPluginEvent({ eventType: 'input' } as any); - expect(formatContentModelSpy).toHaveBeenCalledTimes(3); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); - expect(setEditorStyleSpy).toHaveBeenCalledWith('_WatermarkContent', null); - expect(watermarkCallback).toHaveBeenCalledTimes(1); - }); - it('Has format, empty editor, with text', () => { isModelEmptyFastSpy.and.returnValue(true); From e62b3e207e1e2b861d8204ae74720a03bc3668d6 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 14 Mar 2024 16:58:31 -0700 Subject: [PATCH 06/14] improve --- .../lib/corePlugin/utils/entityDelimiterUtils.ts | 10 ---------- .../lib/parameter/DOMHelper.ts | 2 -- 2 files changed, 12 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts index fc973245d9c..6ba47aded4f 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -139,16 +139,6 @@ function getFocusedElement( offset = pos.offset; } - while (node?.lastChild) { - if (offset == node.childNodes.length) { - node = node.lastChild; - offset = node.childNodes.length; - } else { - node = node.childNodes[offset]; - offset = 0; - } - } - if (!isNodeOfType(node, 'ELEMENT_NODE')) { const textToCheck = existingTextInDelimiter ? ZeroWidthSpace + existingTextInDelimiter diff --git a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 24eb7663455..82b6443e17d 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -82,5 +82,3 @@ export interface DOMHelper { */ hasFocus(): boolean; } - -document.createElement; From d116f5435fa24cba1e2b6c59bdd0083da0f08072 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 15 Mar 2024 15:09:55 -0700 Subject: [PATCH 07/14] improve --- .../editorOptions/ContentEditFeatures.tsx | 31 +- .../sidePane/editorOptions/OptionState.ts | 25 +- .../getDefaultContentEditFeatureSettings.ts | 57 +- .../lib/corePlugin/SelectionPlugin.ts | 183 ++-- .../roosterjs-content-model-core/lib/index.ts | 6 +- .../lib/publicApi/domUtils/normalizePos.ts | 7 +- .../lib/publicApi/domUtils/tableCellUtils.ts | 40 +- .../test/coreApi/getDOMSelectionTest.ts | 34 + .../test/coreApi/setContentModelTest.ts | 3 + .../test/coreApi/setDOMSelectionTest.ts | 20 +- .../test/corePlugin/SelectionPluginTest.ts | 838 +++++++++++++++++- .../publicApi/domUtils/normalizePosTest.ts | 102 +++ .../publicApi/domUtils/tableCellUtilsTest.ts | 150 +++- .../lib/index.ts | 2 +- .../lib/pluginState/SelectionPluginState.ts | 36 + .../test/corePlugins/BridgePluginTest.ts | 27 +- 16 files changed, 1334 insertions(+), 227 deletions(-) create mode 100644 packages/roosterjs-content-model-core/test/publicApi/domUtils/normalizePosTest.ts diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx index 019f429165b..e96cbfdb11f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/ContentEditFeatures.tsx @@ -1,13 +1,25 @@ import * as React from 'react'; -import { ContentEditFeatureSettings, OptionState } from './OptionState'; -import { getAllFeatures } from './getDefaultContentEditFeatureSettings'; +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', @@ -23,27 +35,36 @@ const EditFeatureDescriptionMap: Record, -->, >, => 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 delimiter, trigger a Entity Operation', + '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 ContentEditFeaturesProps { +export interface ContentEditFeaturessProps { state: ContentEditFeatureSettings; resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void; } -export default class ContentEditFeatures extends React.Component { +export default class ContentEditFeatures extends React.Component { render() { const features = getAllFeatures(); return ( diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 095b400a45e..e80c02db789 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -1,15 +1,4 @@ -import type { - AutoLinkFeatureSettings, - CodeFeatureSettings, - CursorFeatureSettings, - EntityFeatureSettings, - MarkdownFeatureSettings, - ShortcutFeatureSettings, - StructuredNodeFeatureSettings, - TableFeatureSettings, - TextFeatureSettings, - ContentEditFeatureSettings as ContentEditFeatureSettingsOriginal, -} from 'roosterjs-editor-types'; +import type { ContentEditFeatureSettings } from 'roosterjs-editor-types'; import type { SidePaneElementProps } from '../SidePaneElement'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; @@ -36,16 +25,6 @@ export interface NewPluginList { export interface BuildInPluginList extends LegacyPluginList, NewPluginList {} -export type ContentEditFeatureSettings = Omit & - StructuredNodeFeatureSettings & - AutoLinkFeatureSettings & - ShortcutFeatureSettings & - CursorFeatureSettings & - MarkdownFeatureSettings & - EntityFeatureSettings & - TextFeatureSettings & - CodeFeatureSettings; - export interface OptionState { pluginList: BuildInPluginList; @@ -57,7 +36,7 @@ export interface OptionState { watermarkText: string; // Legacy plugin options - contentEditFeatures: ContentEditFeatureSettingsOriginal; + contentEditFeatures: ContentEditFeatureSettings; defaultFormat: ContentModelSegmentFormat; linkTitle: string; forcePreserveRatio: boolean; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controlsV2/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 92070c1326d..75840bd3ab2 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -1,59 +1,14 @@ -import { ContentEditFeatureSettings } from './OptionState'; -import { getAllFeatures as getAllFeaturesOriginal } from 'roosterjs-editor-plugins'; +import { ContentEditFeatureSettings } from 'roosterjs-editor-types'; +import { getAllFeatures } from 'roosterjs-editor-plugins'; import { getObjectKeys } from 'roosterjs-content-model-dom'; -import { - BuildInEditFeature, - ContentEditFeatureSettings as ContentEditFeatureSettingsOriginal, - PluginEvent, -} from 'roosterjs-editor-types'; -const PortedFeatureKeys: (keyof ContentEditFeatureSettingsOriginal)[] = [ - 'autoBullet', - 'indentWhenTab', - 'outdentWhenShiftTab', - 'mergeInNewLineWhenBackspaceOnFirstChar', - 'mergeListOnBackspaceAfterList', - 'maintainListChain', - 'maintainListChainWhenDelete', - 'autoBulletList', - 'outdentWhenAltShiftLeft', - 'outdentWhenBackspaceOnEmptyFirstLine', - 'outdentWhenEnterOnEmptyLine', - 'unquoteWhenBackspaceOnEmptyFirstLine', - 'upDownInTable', - 'autoNumberingList', - 'indentWhenAltShiftRight', - 'unquoteWhenEnterOnEmptyLine', -]; - -export function getAllFeatures(): Record< - keyof ContentEditFeatureSettings, - BuildInEditFeature -> { - const allFeatures = getAllFeaturesOriginal(); - - return { - ...getObjectKeys(allFeatures).reduce((features, key) => { - if (isContentEditFeatureKey(key)) { - features[key] = allFeatures[key]; - } - return features; - }, {} as Record>), - }; -} - -export function getDefaultContentEditFeatureSettings(): ContentEditFeatureSettingsOriginal { - const allFeatures = getAllFeaturesOriginal(); +export function getDefaultContentEditFeatureSettings(): ContentEditFeatureSettings { + const allFeatures = getAllFeatures(); return { ...getObjectKeys(allFeatures).reduce((settings, key) => { - settings[key] = - PortedFeatureKeys.indexOf(key) >= 0 ? false : !allFeatures[key].defaultDisabled; + settings[key] = !allFeatures[key].defaultDisabled; return settings; - }, {}), + }, {}), }; } - -function isContentEditFeatureKey(key: string): key is keyof ContentEditFeatureSettings { - return PortedFeatureKeys.indexOf(key as any) >= 0; -} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index 4c6ecdbb6ae..8f95262eca8 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -1,7 +1,11 @@ -import { findTableCellElement, parseTableCells } from '../publicApi/domUtils/tableCellUtils'; import { isCharacterValue, isModifierKey } from '../publicApi/domUtils/eventUtils'; import { isElementOfType, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; import { normalizePos } from '../publicApi/domUtils/normalizePos'; +import { + findCoordinate, + findTableCellElement, + parseTableCells, +} from '../publicApi/domUtils/tableCellUtils'; import type { DOMSelection, IEditor, @@ -12,36 +16,28 @@ import type { DOMHelper, MouseUpEvent, ParsedTable, + TableSelectionInfo, } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; const MouseMiddleButton = 1; const MouseRightButton = 2; -const TableCellSelector = 'TH,TD'; const Up = 'ArrowUp'; const Down = 'ArrowDown'; const Left = 'ArrowLeft'; const Right = 'ArrowRight'; -interface TableSelectionInfo { - table: HTMLTableElement; - parsedTable: ParsedTable; - firstCo: [number, number]; - lastCo?: [number, number]; -} - class SelectionPlugin implements PluginWithState { private editor: IEditor | null = null; private state: SelectionPluginState; private disposer: (() => void) | null = null; - private mouseDisposer: (() => void) | null = null; private isSafari = false; private isMac = false; - private tableSelection: TableSelectionInfo | null = null; constructor(options: EditorOptions) { this.state = { selection: null, + tableSelection: null, imageSelectionBorderColor: options.imageSelectionBorderColor, }; } @@ -111,7 +107,7 @@ class SelectionPlugin implements PluginWithState { break; case 'contentChanged': - this.tableSelection = null; + this.state.tableSelection = null; break; } } @@ -146,11 +142,12 @@ class SelectionPlugin implements PluginWithState { const target = rawEvent.target as Node; if ( + target && rawEvent.button == MouseLeftButton && (tableSelection = this.parseTableSelection(target, target, editor.getDOMHelper())) ) { - this.tableSelection = tableSelection; - this.mouseDisposer = editor.attachDomEvent({ + this.state.tableSelection = tableSelection; + this.state.mouseDisposer = editor.attachDomEvent({ mousemove: { beforeDispatch: this.onMouseMove, }, @@ -159,25 +156,18 @@ class SelectionPlugin implements PluginWithState { } private onMouseMove = (event: Event) => { - if ( - !this.editor || - !this.tableSelection || - (event as MouseEvent).button != MouseLeftButton - ) { - return; - } - - let lastCo: [number, number] | null; - - if ( - (lastCo = this.findCoordinate( - this.tableSelection.parsedTable, + if (this.editor && this.state.tableSelection) { + const hasTableSelection = !!this.state.tableSelection.lastCo; + const lastCo = findCoordinate( + this.state.tableSelection.parsedTable, event.target as Node, this.editor.getDOMHelper() - )) && - this.updateTableSelection(lastCo) - ) { - event.preventDefault(); + ); + const updated = lastCo && this.updateTableSelection(lastCo); + + if (hasTableSelection || updated) { + event.preventDefault(); + } } }; @@ -205,6 +195,7 @@ class SelectionPlugin implements PluginWithState { private onKeyDown(editor: IEditor, rawEvent: KeyboardEvent) { const key = rawEvent.key; const selection = editor.getDOMSelection(); + const win = editor.getDocument().defaultView; switch (selection?.type) { case 'image': @@ -218,14 +209,29 @@ class SelectionPlugin implements PluginWithState { } break; + case 'range': + if (key == Up || key == Down || key == Left || key == Right) { + const start = selection.range.startContainer; + this.state.tableSelection = this.parseTableSelection( + start, + start, + editor.getDOMHelper() + ); + + if (this.state.tableSelection) { + win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); + } + } + break; + case 'table': - if (this.tableSelection?.lastCo) { + if (this.state.tableSelection?.lastCo) { const { shiftKey, key } = rawEvent; if (shiftKey && (key == Left || key == Right)) { - const win = editor.getDocument().defaultView; const isRtl = - win?.getComputedStyle(this.tableSelection.table).direction == 'rtl'; + win?.getComputedStyle(this.state.tableSelection.table).direction == + 'rtl'; this.updateTableSelectionFromKeyboard( 0, @@ -237,40 +243,17 @@ class SelectionPlugin implements PluginWithState { rawEvent.preventDefault(); } else if (key != 'Shift' && !isCharacterValue(rawEvent)) { if (key == Up || key == Down || key == Left || key == Right) { - this.setDOMSelection(null /*domSelection*/, this.tableSelection); - editor - .getDocument() - .defaultView?.requestAnimationFrame(() => - this.handleSelectionInTable(key) - ); + this.setDOMSelection(null /*domSelection*/, this.state.tableSelection); + win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); } } } break; - - case 'range': - if (key == Up || key == Down || key == Left || key == Right) { - const start = selection.range.startContainer; - this.tableSelection = this.parseTableSelection( - start, - start, - editor.getDOMHelper() - ); - - if (this.tableSelection) { - editor - .getDocument() - .defaultView?.requestAnimationFrame(() => - this.handleSelectionInTable(key) - ); - } - } - break; } } private handleSelectionInTable(key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') { - if (!this.editor || !this.tableSelection) { + if (!this.editor || !this.state.tableSelection) { return; } @@ -286,16 +269,15 @@ class SelectionPlugin implements PluginWithState { const end: Node | null = isReverted ? startContainer : endContainer; const tableSel = this.parseTableSelection(commonAncestorContainer, start, domHelper); - if (tableSel) { - let lastCo = this.findCoordinate(tableSel?.parsedTable, end, domHelper); + if (!tableSel) { + return; + } - if ( - lastCo && - (key == Up || key == Down) && - tableSel.table == this.tableSelection.table && - lastCo[1] != this.tableSelection.firstCo[1] - ) { - const { parsedTable, firstCo: oldCo } = this.tableSelection; + let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); + const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; + + if (lastCo && tableSel.table == table && lastCo[1] != oldCo[1]) { + if (key == Up || key == Down) { const change = key == Up ? -1 : 1; const originalTd = findTableCellElement(parsedTable, oldCo[0], oldCo[1]); let td: HTMLTableCellElement | null = null; @@ -331,19 +313,21 @@ class SelectionPlugin implements PluginWithState { null /*tableSelection*/ ); } + } else { + this.state.tableSelection = null; } + } - if (!collapsed && lastCo) { - this.tableSelection = tableSel; - this.updateTableSelection(lastCo); - } + if (!collapsed && lastCo) { + this.state.tableSelection = tableSel; + this.updateTableSelection(lastCo); } } } private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { - if (this.tableSelection?.lastCo && this.editor) { - const { lastCo, parsedTable } = this.tableSelection; + if (this.state.tableSelection?.lastCo && this.editor) { + const { lastCo, parsedTable } = this.state.tableSelection; const row = lastCo[0] + rowChange; const col = lastCo[1] + colChange; @@ -415,7 +399,7 @@ class SelectionPlugin implements PluginWithState { private onFocus = () => { if (!this.state.skipReselectOnFocus && this.state.selection) { - this.setDOMSelection(this.state.selection, this.tableSelection); + this.setDOMSelection(this.state.selection, this.state.tableSelection); } if (this.state.selection?.type == 'range' && !this.isSafari) { @@ -454,7 +438,7 @@ class SelectionPlugin implements PluginWithState { if ( (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && (parsedTable = parseTableCells(table)) && - (firstCo = this.findCoordinate(parsedTable, tdStart, domHelper)) + (firstCo = findCoordinate(parsedTable, tdStart, domHelper)) ) { return { table, parsedTable, firstCo }; } else { @@ -463,11 +447,11 @@ class SelectionPlugin implements PluginWithState { } private updateTableSelection(lastCo: [number, number]) { - if (this.tableSelection) { - const { table, firstCo, parsedTable, lastCo: oldCo } = this.tableSelection; + if (this.state.tableSelection) { + const { table, firstCo, parsedTable, lastCo: oldCo } = this.state.tableSelection; if (oldCo || firstCo[0] != lastCo[0] || firstCo[1] != lastCo[1]) { - this.tableSelection.lastCo = lastCo; + this.state.tableSelection.lastCo = lastCo; this.setDOMSelection( { @@ -493,44 +477,13 @@ class SelectionPlugin implements PluginWithState { tableSelection: TableSelectionInfo | null ) { this.editor?.setDOMSelection(selection); - this.tableSelection = tableSelection; - } - - private findCoordinate( - parsedTable: ParsedTable, - node: Node, - domHelper: DOMHelper - ): [number, number] | null { - const td = domHelper.findClosestElementAncestor(node, TableCellSelector); - let result: [number, number] | null = null; - - // Try to do a fast check if both TD are in the given TABLE - if (td) { - parsedTable.some((row, rowIndex) => { - const colIndex = td ? row.indexOf(td as HTMLTableCellElement) : -1; - - return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); - }); - } - - // For nested table scenario, try to find the outer TAble cells - if (!result) { - parsedTable.some((row, rowIndex) => { - const colIndex = row.findIndex( - cell => typeof cell == 'object' && cell.contains(node) - ); - - return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); - }); - } - - return result; + this.state.tableSelection = tableSelection; } private detachMouseEvent() { - if (this.mouseDisposer) { - this.mouseDisposer(); - this.mouseDisposer = null; + if (this.state.mouseDisposer) { + this.state.mouseDisposer(); + this.state.mouseDisposer = undefined; } } } diff --git a/packages/roosterjs-content-model-core/lib/index.ts b/packages/roosterjs-content-model-core/lib/index.ts index 5002374178d..c52185ef8ee 100644 --- a/packages/roosterjs-content-model-core/lib/index.ts +++ b/packages/roosterjs-content-model-core/lib/index.ts @@ -40,11 +40,7 @@ export { getSelectedCells } from './publicApi/table/getSelectedCells'; export { isCharacterValue, isModifierKey } from './publicApi/domUtils/eventUtils'; export { combineBorderValue, extractBorderValues } from './publicApi/domUtils/borderValues'; export { isPunctuation, isSpace, normalizeText } from './publicApi/domUtils/stringUtil'; -export { - parseTableCells, - createTableRanges, - findTableCellElement, -} from './publicApi/domUtils/tableCellUtils'; +export { parseTableCells, createTableRanges } from './publicApi/domUtils/tableCellUtils'; export { getSegmentTextFormat } from './publicApi/domUtils/getSegmentTextFormat'; export { readFile } from './publicApi/domUtils/readFile'; export { cacheGetEventData } from './publicApi/domUtils/cacheGetEventData'; diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts index 26208a073d1..4e503c5478b 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts @@ -4,8 +4,13 @@ import { isNodeOfType } from 'roosterjs-content-model-dom'; * @internal */ export function normalizePos(node: Node, offset: number): { node: Node; offset: number } { + const len = isNodeOfType(node, 'TEXT_NODE') + ? node.nodeValue?.length ?? 0 + : node.childNodes.length; + offset = Math.max(Math.min(offset, len), 0); + while (node?.lastChild) { - if (offset == node.childNodes.length) { + if (offset >= node.childNodes.length) { node = node.lastChild; offset = isNodeOfType(node, 'TEXT_NODE') ? node.nodeValue?.length ?? 0 diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts index 24b3b682cfb..859ab9e46a2 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts @@ -1,5 +1,7 @@ import { toArray } from 'roosterjs-content-model-dom'; -import type { ParsedTable, TableSelection } from 'roosterjs-content-model-types'; +import type { DOMHelper, ParsedTable, TableSelection } from 'roosterjs-content-model-types'; + +const TableCellSelector = 'TH,TD'; /** * Parse a table into a two dimensions array of TD elements. For those merged cells, the value will be null. @@ -42,6 +44,7 @@ export function parseTableCells(table: HTMLTableElement): ParsedTable { } /** + * @internal * Try to find a TD/TH element from the given row and col number from the given parsed table * @param parsedTable The parsed table * @param row Row index @@ -73,6 +76,41 @@ export function findTableCellElement( return null; } +/** + * @internal + * Find coordinate of a given element from a parsed table + */ +export function findCoordinate( + parsedTable: ParsedTable, + element: Node, + domHelper: DOMHelper +): [number, number] | null { + const td = domHelper.findClosestElementAncestor(element, TableCellSelector); + let result: [number, number] | null = null; + + // Try to do a fast check if both TD are in the given TABLE + if (td) { + parsedTable.some((row, rowIndex) => { + const colIndex = td ? row.indexOf(td as HTMLTableCellElement) : -1; + + return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); + }); + } + + // For nested table scenario, try to find the outer TAble cells + if (!result) { + parsedTable.some((row, rowIndex) => { + const colIndex = row.findIndex( + cell => typeof cell == 'object' && cell.contains(element) + ); + + return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); + }); + } + + return result; +} + /** * Create ranges from a table selection * @param selection The source table selection diff --git a/packages/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts index 6703f8c2afe..0e0ae999f3e 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts @@ -132,6 +132,40 @@ describe('getDOMSelection', () => { }); }); + it('no cached selection, range selection is in editor, isReverted - 2', () => { + const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: mockedElementOffset, + collapsed: false, + } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); + const mockedSelection = { + rangeCount: 1, + getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: mockedElement, + focusOffset: mockedElementOffset, + }; + + getSelectionSpy.and.returnValue(mockedSelection); + containsSpy.and.returnValue(true); + hasFocusSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + isReverted: true, + }); + }); + it('has cached selection, editor is in shadowEdit', () => { const mockedSelection = 'SELECTION' as any; core.selection.selection = mockedSelection; diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts index cf4774a4b1d..f3632ad554d 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts @@ -159,6 +159,7 @@ describe('setContentModel', () => { core.selection = { selection: null, + tableSelection: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, @@ -191,6 +192,7 @@ describe('setContentModel', () => { core.selection = { selection: null, + tableSelection: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, @@ -219,6 +221,7 @@ describe('setContentModel', () => { core.selection = { selection: null, + tableSelection: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts index ea75d2bbc27..ba62e7c677c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts @@ -10,6 +10,7 @@ describe('setDOMSelection', () => { let addRangeToSelectionSpy: jasmine.Spy; let createRangeSpy: jasmine.Spy; let setEditorStyleSpy: jasmine.Spy; + let containsSpy: jasmine.Spy; let doc: Document; let contentDiv: HTMLDivElement; let mockedRange = 'RANGE' as any; @@ -25,11 +26,12 @@ describe('setDOMSelection', () => { ); createRangeSpy = jasmine.createSpy('createRange'); setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + containsSpy = jasmine.createSpy('contains').and.returnValue(true); doc = { querySelectorAll: querySelectorAllSpy, createRange: createRangeSpy, - contains: () => true, + contains: containsSpy, } as any; contentDiv = { ownerDocument: doc, @@ -587,6 +589,22 @@ describe('setDOMSelection', () => { ]); }); + it('Select TD after merged cell', () => { + const div = document.createElement('div'); + div.innerHTML = + '

'; + const table = div.firstChild as HTMLTableElement; + const innerDIV = div.querySelector('#div1'); + + runTest(table, 2, 0, 2, 0, [ + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + ]); + + expect(containsSpy).toHaveBeenCalledTimes(1); + expect(containsSpy).toHaveBeenCalledWith(innerDIV); + }); + it('Select Table Cells THEAD, TBODY', () => { runTest(buildTable(true /* tbody */, true /* thead */), 1, 1, 2, 2, [ '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', diff --git a/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts index 73f53eb763a..3c8f6aea3b4 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -1,5 +1,7 @@ +import { createDOMHelper } from '../../lib/editor/DOMHelperImpl'; import { createSelectionPlugin } from '../../lib/corePlugin/SelectionPlugin'; import { + DOMEventRecord, EditorPlugin, IEditor, PluginWithState, @@ -10,13 +12,9 @@ describe('SelectionPlugin', () => { it('init and dispose', () => { const plugin = createSelectionPlugin({}); const disposer = jasmine.createSpy('disposer'); - const appendChildSpy = jasmine.createSpy('appendChild'); const attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - head: { - appendChild: appendChildSpy, - }, removeEventListener: removeEventListenerSpy, }); const state = plugin.getState(); @@ -31,6 +29,7 @@ describe('SelectionPlugin', () => { expect(state).toEqual({ selection: null, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(attachDomEvent).toHaveBeenCalled(); expect(removeEventListenerSpy).not.toHaveBeenCalled(); @@ -51,12 +50,8 @@ describe('SelectionPlugin', () => { const attachDomEvent = jasmine .createSpy('attachDomEvent') .and.returnValue(jasmine.createSpy('disposer')); - const appendChildSpy = jasmine.createSpy('appendChild'); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - head: { - appendChild: appendChildSpy, - }, removeEventListener: removeEventListenerSpy, }); @@ -69,6 +64,7 @@ describe('SelectionPlugin', () => { expect(state).toEqual({ selection: null, imageSelectionBorderColor: 'red', + tableSelection: null, }); expect(attachDomEvent).toHaveBeenCalled(); @@ -83,7 +79,6 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { let eventMap: Record; let getElementAtCursorSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; - let appendChildSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; @@ -92,12 +87,8 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { beforeEach(() => { triggerEvent = jasmine.createSpy('triggerEvent'); getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); - appendChildSpy = jasmine.createSpy('appendChild'); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - head: { - appendChild: appendChildSpy, - }, removeEventListener: removeEventListenerSpy, }); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); @@ -134,6 +125,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { selection: mockedRange, imageSelectionBorderColor: undefined, skipReselectOnFocus: false, + tableSelection: null, }); }); @@ -149,6 +141,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { selection: mockedRange, imageSelectionBorderColor: undefined, skipReselectOnFocus: true, + tableSelection: null, }); }); }); @@ -167,9 +160,6 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy = jasmine.createSpy('createRange'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ createRange: createRangeSpy, - head: { - appendChild: () => {}, - }, }); editor = { @@ -553,6 +543,815 @@ describe('SelectionPlugin handle image selection', () => { }); }); +describe('SelectionPlugin handle table selection', () => { + let plugin: PluginWithState; + let editor: IEditor; + let contentDiv: HTMLElement; + let getDOMSelectionSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let createRangeSpy: jasmine.Spy; + let mouseDispatcher: Record; + let focusDispatcher: Record; + let focusDisposer: jasmine.Spy; + let mouseMoveDisposer: jasmine.Spy; + let requestAnimationFrameSpy: jasmine.Spy; + let getComputedStyleSpy: jasmine.Spy; + + beforeEach(() => { + contentDiv = document.createElement('div'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + createRangeSpy = jasmine.createSpy('createRange'); + requestAnimationFrameSpy = jasmine.createSpy('requestAnimationFrame'); + getComputedStyleSpy = jasmine.createSpy('getComputedStyle'); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + createRange: createRangeSpy, + defaultView: { + requestAnimationFrame: requestAnimationFrameSpy, + getComputedStyle: getComputedStyleSpy, + }, + }); + focusDisposer = jasmine.createSpy('focus'); + mouseMoveDisposer = jasmine.createSpy('mouseMove'); + + const domHelper = createDOMHelper(contentDiv); + + editor = { + getDOMSelection: getDOMSelectionSpy, + setDOMSelection: setDOMSelectionSpy, + getDocument: getDocumentSpy, + getEnvironment: () => ({}), + getDOMHelper: () => domHelper, + attachDomEvent: (map: Record>) => { + if (map.mousemove) { + mouseDispatcher = map; + return mouseMoveDisposer; + } else { + focusDispatcher = map; + return focusDisposer; + } + }, + } as any; + plugin = createSelectionPlugin({}); + plugin.initialize(editor); + }); + + afterEach(() => { + focusDispatcher = undefined; + mouseDispatcher = undefined; + }); + + it('MouseDown - has tableSelection, clear it when left click', () => { + const state = plugin.getState(); + const mockedTableSelection = 'TableSelection' as any; + + state.tableSelection = mockedTableSelection; + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent({ + eventType: 'mouseDown', + rawEvent: { + button: 2, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: mockedTableSelection, + imageSelectionBorderColor: undefined, + }); + + plugin.onPluginEvent({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(mouseDispatcher).toBeUndefined(); + }); + + it('MouseDown - save a table selection when left click', () => { + const state = plugin.getState(); + const table = document.createElement('table'); + const tr = document.createElement('tr'); + const td = document.createElement('td'); + const div = document.createElement('div'); + + tr.appendChild(td); + table.appendChild(tr); + contentDiv.appendChild(table); + contentDiv.appendChild(div); + + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: div, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + + plugin.onPluginEvent({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: { + table: table, + parsedTable: [[td]], + firstCo: [0, 0], + }, + mouseDisposer: mouseMoveDisposer, + imageSelectionBorderColor: undefined, + }); + expect(mouseDispatcher).toBeDefined(); + }); + + it('MouseMove', () => { + const state = plugin.getState(); + const table = document.createElement('table'); + const tr = document.createElement('tr'); + const td1 = document.createElement('td'); + const td2 = document.createElement('td'); + const div = document.createElement('div'); + + tr.appendChild(td1); + tr.appendChild(td2); + table.appendChild(tr); + contentDiv.appendChild(table); + contentDiv.appendChild(div); + + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td1, + } as any, + }); + + expect(mouseDispatcher.mousemove).toBeDefined(); + expect(state.tableSelection).toEqual({ + table, + parsedTable: [[td1, td2]], + firstCo: [0, 0], + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + mouseDispatcher.mousemove.beforeDispatch({ + target: td1, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(state.tableSelection).toEqual({ + table, + parsedTable: [[td1, td2]], + firstCo: [0, 0], + }); + + mouseDispatcher.mousemove.beforeDispatch({ + target: td2, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 1, + }); + expect(state.tableSelection).toEqual({ + table, + parsedTable: [[td1, td2]], + firstCo: [0, 0], + lastCo: [0, 1], + }); + + mouseDispatcher.mousemove.beforeDispatch({ + target: div, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 1, + }); + expect(state.tableSelection).toEqual({ + table, + parsedTable: [[td1, td2]], + firstCo: [0, 0], + lastCo: [0, 1], + }); + }); + + it('OnDrop', () => { + expect(focusDispatcher.drop).toBeDefined(); + + const state = plugin.getState(); + const disposer = jasmine.createSpy('disposer'); + + state.mouseDisposer = disposer; + + focusDispatcher.drop.beforeDispatch(null!); + + expect(disposer).toHaveBeenCalledTimes(1); + expect(state.mouseDisposer).toBeUndefined(); + + focusDispatcher.drop.beforeDispatch(null!); + + expect(disposer).toHaveBeenCalledTimes(1); + expect(state.mouseDisposer).toBeUndefined(); + }); + + describe('OnKeyDown', () => { + let td1: HTMLTableCellElement; + let td2: HTMLTableCellElement; + let td3: HTMLTableCellElement; + let td4: HTMLTableCellElement; + let tr1: HTMLElement; + let tr2: HTMLElement; + let table: HTMLTableElement; + let div: HTMLElement; + + beforeEach(() => { + table = document.createElement('table'); + tr1 = document.createElement('tr'); + tr2 = document.createElement('tr'); + td1 = document.createElement('td'); + td2 = document.createElement('td'); + td3 = document.createElement('td'); + td4 = document.createElement('td'); + div = document.createElement('div'); + + td1.id = 'td1'; + td2.id = 'td2'; + td3.id = 'td3'; + td4.id = 'td4'; + + tr1.appendChild(td1); + tr1.appendChild(td2); + tr2.appendChild(td3); + tr2.appendChild(td4); + table.appendChild(tr1); + table.appendChild(tr2); + contentDiv.appendChild(table); + contentDiv.appendChild(div); + }); + + it('From Range, Press A', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { startContainer: td1, startOffset: 0, endContainer: td1, endOffset: 0 }, + isReverted: false, + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'a', + } as any, + }); + + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('From Range, Press Right', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { startContainer: td1, startOffset: 0, endContainer: td1, endOffset: 0 }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowRight', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + 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: [1, 1], + lastCo: [0, 1], + }, + 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', () => { + 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: [0, 1], + lastCo: [1, 1], + }, + 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: [0, 1], + }, + 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: [0, 1], + lastCo: [1, 1], + }; + + 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, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: [0, 1], + lastCo: [1, 1], + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('From Table, Press Left', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: [0, 1], + lastCo: [1, 1], + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowLeft', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: [0, 1], + lastCo: [1, 1], + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('From Table, Press Shift+Left', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: [0, 1], + lastCo: [1, 1], + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + getComputedStyleSpy.and.returnValue({}); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowLeft', + shiftKey: true, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: [0, 1], + lastCo: [1, 0], + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 0, + firstColumn: 0, + lastRow: 1, + lastColumn: 1, + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('From Table, Press Shift+Up', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: [1, 0], + lastCo: [1, 1], + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + getComputedStyleSpy.and.returnValue({}); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowUp', + shiftKey: true, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: [1, 0], + lastCo: [0, 1], + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 0, + firstColumn: 0, + lastRow: 1, + lastColumn: 1, + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); +}); + describe('SelectionPlugin on Safari', () => { let disposer: jasmine.Spy; let appendChildSpy: jasmine.Spy; @@ -603,6 +1402,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: null, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(attachDomEvent).toHaveBeenCalled(); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); @@ -637,6 +1437,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -664,6 +1465,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedNewSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -691,6 +1493,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -718,6 +1521,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -745,6 +1549,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); }); @@ -772,6 +1577,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); }); diff --git a/packages/roosterjs-content-model-core/test/publicApi/domUtils/normalizePosTest.ts b/packages/roosterjs-content-model-core/test/publicApi/domUtils/normalizePosTest.ts new file mode 100644 index 00000000000..1eaa1cca34f --- /dev/null +++ b/packages/roosterjs-content-model-core/test/publicApi/domUtils/normalizePosTest.ts @@ -0,0 +1,102 @@ +import { normalizePos } from '../../../lib/publicApi/domUtils/normalizePos'; + +describe('normalizePos()', () => { + function runTest( + input: string, + getNode: (root: Node) => Node, + inputOffset: number, + expectNodeValue: string, + expectOffset: number + ) { + const div = document.createElement('div'); + document.body.appendChild(div); + + div.innerHTML = input; + const inputNode = getNode(div); + + const { node, offset } = normalizePos(inputNode, inputOffset); + + let value = node.nodeType == Node.TEXT_NODE ? node.nodeValue : node.textContent; + + expect(value).toBe(expectNodeValue, 'NodeValue'); + expect(offset).toBe(expectOffset, 'Offset'); + + document.body.removeChild(div); + } + + it('DIV - Begin', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1'), + 0, + 'test2', + 0 + ); + }); + it('DIV - With offset', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1'), + 1, + 'test2', + 5 + ); + }); + it('DIV - With offset out of range', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1'), + 2, + 'test2', + 5 + ); + }); + it('Text - Begin', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1').firstChild, + 0, + 'test2', + 0 + ); + }); + it('Text - End', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1').firstChild, + 5, + 'test2', + 5 + ); + }); + it('Text - With offset', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1').firstChild, + 2, + 'test2', + 2 + ); + }); + it('Text - With offset out of range', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1').firstChild, + 10, + 'test2', + 5 + ); + }); + it('VOID - Begin', () => { + runTest('test1test3', () => document.getElementById('id1'), 0, '', 0); + }); + it('VOID - End', () => { + runTest('test1test3', () => document.getElementById('id1'), 1, '', 0); + }); + it('VOID - With offset', () => { + runTest('test1test3', () => document.getElementById('id1'), 0, '', 0); + }); + it('VOID - With offset out of range', () => { + runTest('test1test3', () => document.getElementById('id1'), 2, '', 0); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts index 3c0e63b0c6a..55b9a9df449 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts @@ -1,5 +1,11 @@ -import { createTableRanges, parseTableCells } from '../../../lib/publicApi/domUtils/tableCellUtils'; -import { DOMSelection } from 'roosterjs-content-model-types'; +import { createDOMHelper } from '../../../lib/editor/DOMHelperImpl'; +import { DOMHelper, DOMSelection, ParsedTable } from 'roosterjs-content-model-types'; +import { + createTableRanges, + findCoordinate, + findTableCellElement, + parseTableCells, +} from '../../../lib/publicApi/domUtils/tableCellUtils'; describe('parseTableCells', () => { function runTest(html: string, expectedResult: string[][]) { @@ -81,6 +87,146 @@ describe('parseTableCells', () => { }); }); +describe('findTableCellElement', () => { + const mockedTd1 = { id: 'TD1' } as any; + const mockedTd2 = { id: 'TD2' } as any; + const mockedTd3 = { id: 'TD3' } as any; + const mockedTd4 = { id: 'TD4' } as any; + const mockedTd5 = { id: 'TD5' } as any; + + function runTest(parsedTable: ParsedTable, row: number, col: number, expectedResult: any) { + const result = findTableCellElement(parsedTable, row, col); + + expect(result).toBe(expectedResult); + } + + it('Null', () => { + runTest([], 0, 0, null); + }); + + it('Simple table', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, mockedTd2], + [mockedTd3, mockedTd4], + ]; + runTest(parsedTable, 0, 0, mockedTd1); + runTest(parsedTable, 0, 1, mockedTd2); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, mockedTd3); + runTest(parsedTable, 2, 0, null); + runTest(parsedTable, 2, 2, null); + }); + + it('Complex table', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, mockedTd2, 'spanLeft'], + ['spanTop', mockedTd3, mockedTd4], + [mockedTd5, 'spanLeft', 'spanTop'], + ]; + + runTest(parsedTable, 0, 0, mockedTd1); + runTest(parsedTable, 0, 1, mockedTd2); + runTest(parsedTable, 0, 2, mockedTd2); + runTest(parsedTable, 0, 3, null); + runTest(parsedTable, 1, 0, mockedTd1); + runTest(parsedTable, 1, 1, mockedTd3); + runTest(parsedTable, 1, 2, mockedTd4); + runTest(parsedTable, 1, 3, null); + runTest(parsedTable, 2, 0, mockedTd5); + runTest(parsedTable, 2, 1, mockedTd5); + runTest(parsedTable, 2, 2, mockedTd4); + runTest(parsedTable, 2, 3, null); + runTest(parsedTable, 3, 0, null); + runTest(parsedTable, 3, 1, null); + runTest(parsedTable, 3, 2, null); + runTest(parsedTable, 3, 3, null); + }); + + it('span both', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, 'spanLeft'], + ['spanTop', 'spanBoth'], + ]; + + runTest(parsedTable, 0, 0, mockedTd1); + runTest(parsedTable, 0, 1, mockedTd1); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, mockedTd1); + runTest(parsedTable, 1, 1, mockedTd1); + runTest(parsedTable, 1, 2, null); + runTest(parsedTable, 2, 0, null); + runTest(parsedTable, 2, 1, null); + runTest(parsedTable, 2, 2, null); + }); +}); + +describe('findCoordinate', () => { + let domHelper: DOMHelper; + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement('div'); + domHelper = createDOMHelper(root); + }); + + it('Empty table', () => { + const table: ParsedTable = []; + const text = document.createTextNode('test'); + + root.appendChild(text); + + const result = findCoordinate(table, text, domHelper); + + expect(result).toBeNull(); + }); + + it('Table contains node', () => { + const container = document.createElement('div') as any; + root.appendChild(container); + + const table: ParsedTable = [[container]]; + const text = document.createTextNode('test'); + + container.appendChild(text); + + const result = findCoordinate(table, text, domHelper); + + expect(result).toEqual([0, 0]); + }); + + it('Table contains node indirectly', () => { + const container = document.createElement('div') as any; + root.appendChild(container); + + const table: ParsedTable = [[container]]; + const span = document.createElement('span'); + const text = document.createTextNode('test'); + + span.appendChild(text); + container.appendChild(span); + + const result = findCoordinate(table, text, domHelper); + + expect(result).toEqual([0, 0]); + }); + + it('Table contains node on second row', () => { + const container = document.createElement('div') as any; + root.appendChild(container); + + const table: ParsedTable = [[], ['spanLeft', container]]; + const span = document.createElement('span'); + const text = document.createTextNode('test'); + + span.appendChild(text); + container.appendChild(span); + + const result = findCoordinate(table, text, domHelper); + + expect(result).toEqual([1, 1]); + }); +}); + describe('createTableRanges', () => { function runTest( html: string, diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 709d82a5ad2..c3bd6844fd3 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -232,7 +232,7 @@ export { CopyPastePluginState } from './pluginState/CopyPastePluginState'; export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; export { EntityPluginState, KnownEntityItem } from './pluginState/EntityPluginState'; -export { SelectionPluginState } from './pluginState/SelectionPluginState'; +export { SelectionPluginState, TableSelectionInfo } from './pluginState/SelectionPluginState'; export { UndoPluginState } from './pluginState/UndoPluginState'; export { PluginKey, diff --git a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index d37315b11f1..21446752d7e 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -1,5 +1,31 @@ +import type { ParsedTable } from '../parameter/ParsedTable'; import type { DOMSelection } from '../selection/DOMSelection'; +/** + * Table selection internal info for SelectionPlugin + */ +export interface TableSelectionInfo { + /** + * Selected table + */ + table: HTMLTableElement; + + /** + * Parsed table structure, cache this value to avoid calculating again while selecting table + */ + parsedTable: ParsedTable; + + /** + * Coordinate for first selected table cell + */ + firstCo: [number, number]; + + /** + * Coordinate for last selected table cell + */ + lastCo?: [number, number]; +} + /** * The state object for SelectionPlugin */ @@ -9,6 +35,16 @@ export interface SelectionPluginState { */ selection: DOMSelection | null; + /** + * Table selection internal info for SelectionPlugin + */ + tableSelection: TableSelectionInfo | null; + + /** + * Disposer function for MouseMove event + */ + mouseDisposer?: () => void; + /** * When set to true, onFocus event will not trigger reselect cached range */ diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index 1aac751feb6..482701720eb 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -17,24 +17,33 @@ describe('BridgePlugin', () => { }); it('Ctor and init', () => { - const initializeSpy = jasmine.createSpy('initialize'); + const initializeSpy1 = jasmine.createSpy('initialize1'); + const initializeSpy2 = jasmine.createSpy('initialize2'); + const initializeSpy3 = jasmine.createSpy('initialize3'); const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); + const onPluginEventSpy3 = jasmine.createSpy('onPluginEvent3'); const disposeSpy = jasmine.createSpy('dispose'); const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); const mockedPlugin1 = { - initialize: initializeSpy, + initialize: initializeSpy1, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, getName: () => '', } as any; const mockedPlugin2 = { - initialize: initializeSpy, + initialize: initializeSpy2, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, getName: () => '', } as any; + const mockedPlugin3 = { + initialize: initializeSpy3, + onPluginEvent: onPluginEventSpy3, + dispose: disposeSpy, + getName: () => 'TableCellSelection', + } as any; const mockedEditor = { queryElements: queryElementsSpy, } as any; @@ -42,8 +51,11 @@ describe('BridgePlugin', () => { const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ mockedPlugin1, mockedPlugin2, + mockedPlugin3, ]); - expect(initializeSpy).not.toHaveBeenCalled(); + expect(initializeSpy1).not.toHaveBeenCalled(); + expect(initializeSpy2).not.toHaveBeenCalled(); + expect(initializeSpy3).not.toHaveBeenCalled(); expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); expect(disposeSpy).not.toHaveBeenCalled(); @@ -78,9 +90,12 @@ describe('BridgePlugin', () => { contextMenuProviders: [], } as any); expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedInnerDarkColorHandler); - expect(initializeSpy).toHaveBeenCalledTimes(2); + expect(initializeSpy1).toHaveBeenCalledTimes(1); + expect(initializeSpy2).toHaveBeenCalledTimes(1); + expect(initializeSpy3).toHaveBeenCalledTimes(0); expect(disposeSpy).not.toHaveBeenCalled(); - expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); + expect(initializeSpy1).toHaveBeenCalledWith(mockedEditor); + expect(initializeSpy2).toHaveBeenCalledWith(mockedEditor); expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); From cc864a2bcef1f0c528f8536eb569c81af37335b8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 18 Mar 2024 09:56:31 -0700 Subject: [PATCH 08/14] fix build --- packages/roosterjs-content-model-types/lib/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index c3bd6844fd3..2ff0596de7a 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -285,6 +285,14 @@ export { DOMHelper } from './parameter/DOMHelper'; export { ImageEditOperation, ImageEditor } from './parameter/ImageEditor'; export { CachedElementHandler, CloneModelOptions } from './parameter/CloneModelOptions'; export { LinkData } from './parameter/LinkData'; +export { MergeModelOption } from './parameter/MergeModelOption'; +export { + IterateSelectionsCallback, + IterateSelectionsOption, +} from './parameter/IterateSelectionsOption'; +export { NodeTypeMap } from './parameter/NodeTypeMap'; +export { TypeOfBlockGroup } from './parameter/TypeOfBlockGroup'; +export { OperationalBlocks } from './parameter/OperationalBlocks'; export { ParsedTable, ParsedTableCell } from './parameter/ParsedTable'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; From 5fe9c6a16cd5582f758202fc2cd316111f9c275f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 18 Mar 2024 11:54:53 -0700 Subject: [PATCH 09/14] fix test --- .../setDOMSelection/setDOMSelection.ts | 90 +++++++---- .../corePlugin/selection/SelectionPlugin.ts | 37 ++--- .../lib/publicApi/domUtils/tableCellUtils.ts | 58 +++++-- .../setDOMSelection/setDOMSelectionTest.ts | 96 +++++++++--- .../selection/SelectionPluginTest.ts | 144 +++++++++--------- .../publicApi/domUtils/tableCellUtilsTest.ts | 130 +++++++++++++--- .../lib/index.ts | 6 +- .../lib/pluginState/SelectionPluginState.ts | 19 ++- 8 files changed, 402 insertions(+), 178 deletions(-) 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 b05616b5454..e8f73b807ad 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -1,12 +1,16 @@ import { addRangeToSelection } from './addRangeToSelection'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; -import { findTableCellElement, parseTableCells } from '../../publicApi/domUtils/tableCellUtils'; import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import { + findLastedCoInMergedCell, + findTableCellElement, + parseTableCells, +} from '../../publicApi/domUtils/tableCellUtils'; import type { ParsedTable, SelectionChangedEvent, SetDOMSelection, - TableSelection, + TableCellCoordinate, } from 'roosterjs-content-model-types'; const DOM_SELECTION_CSS_KEY = '_DOMSelection'; @@ -50,13 +54,47 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC setRangeSelection(doc, image); break; case 'table': - const { table, firstColumn, firstRow } = selection; + const { table, firstColumn, firstRow, lastColumn, lastRow } = selection; const parsedTable = parseTableCells(selection.table); - const tableSelectors = buildTableSelectors( - parsedTable, - ensureUniqueId(table, TABLE_ID), - selection - ); + let firstCell = { + row: Math.min(firstRow, lastRow), + col: Math.min(firstColumn, lastColumn), + cell: null, + }; + let lastCell = { + row: Math.max(firstRow, lastRow), + col: Math.max(firstColumn, lastColumn), + }; + + firstCell = findTableCellElement(parsedTable, firstCell) || firstCell; + lastCell = findLastedCoInMergedCell(parsedTable, lastCell) || lastCell; + + if ( + isNaN(firstCell.row) || + isNaN(firstCell.col) || + isNaN(lastCell.row) || + isNaN(lastCell.col) + ) { + return; + } + + selection = { + type: 'table', + table, + firstRow: firstCell.row, + firstColumn: firstCell.col, + lastRow: lastCell.row, + lastColumn: lastCell.col, + }; + + const tableId = ensureUniqueId(table, TABLE_ID); + const tableSelectors = + firstCell.row == 0 && + firstCell.col == 0 && + lastCell.row == parsedTable.length - 1 && + lastCell.col == (parsedTable[lastCell.row]?.length ?? 0) - 1 + ? [`#${tableId}`, `#${tableId} *`] + : handleTableSelected(parsedTable, tableId, table, firstCell, lastCell); core.selection.selection = selection; core.api.setEditorStyle( @@ -67,10 +105,11 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC ); core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); - const td = findTableCellElement(parsedTable, firstRow, firstColumn); - const nodeToSelect = td?.firstElementChild || td; + const nodeToSelect = firstCell.cell?.firstElementChild || firstCell.cell; - setRangeSelection(doc, (nodeToSelect as HTMLElement) || undefined); + if (nodeToSelect) { + setRangeSelection(doc, (nodeToSelect as HTMLElement) || undefined); + } break; case 'range': @@ -97,24 +136,13 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function buildTableSelectors( +function handleTableSelected( parsedTable: ParsedTable, tableId: string, - selection: TableSelection -): string[] { - const { firstColumn, firstRow, lastColumn, lastRow } = selection; - const isAllTableSelected = - firstRow == 0 && - firstColumn == 0 && - lastRow == parsedTable.length - 1 && - lastColumn == (parsedTable[lastRow]?.length ?? 0) - 1; - return isAllTableSelected - ? [`#${tableId}`, `#${tableId} *`] - : handleTableSelected(tableId, selection, parsedTable); -} - -function handleTableSelected(tableId: string, selection: TableSelection, parsedTable: ParsedTable) { - const { firstRow, firstColumn, lastRow, lastColumn, table } = selection; + table: HTMLTableElement, + firstCell: TableCellCoordinate, + lastCell: TableCellCoordinate +) { const selectors: string[] = []; // Get whether table has thead, tbody or tfoot, then Set the start and end of each of the table children, @@ -156,10 +184,10 @@ function handleTableSelected(tableId: string, selection: TableSelection, parsedT tdCount++; if ( - rowIndex >= firstRow && - rowIndex <= lastRow && - cellIndex >= firstColumn && - cellIndex <= lastColumn + rowIndex >= firstCell.row && + rowIndex <= lastCell.row && + cellIndex >= firstCell.col && + cellIndex <= lastCell.col ) { const selector = `#${tableId}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; 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 309d7d8e19b..5b9ab5a9fa0 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -17,6 +17,7 @@ import type { MouseUpEvent, ParsedTable, TableSelectionInfo, + TableCellCoordinate, } from 'roosterjs-content-model-types'; const MouseLeftButton = 0; @@ -276,19 +277,19 @@ 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[1] != oldCo[1]) { + if (lastCo && tableSel.table == table && lastCo.col != oldCo.col) { if (key == Up || key == Down) { const change = key == Up ? -1 : 1; - const originalTd = findTableCellElement(parsedTable, oldCo[0], oldCo[1]); + const originalTd = findTableCellElement(parsedTable, oldCo)?.cell; let td: HTMLTableCellElement | null = null; - lastCo = [oldCo[0] + change, oldCo[1]]; + lastCo = { row: oldCo.row + change, col: oldCo.col }; - while (lastCo[0] >= 0 && lastCo[0] < parsedTable.length) { - td = findTableCellElement(parsedTable, lastCo[0], lastCo[1]); + while (lastCo.row >= 0 && lastCo.row < parsedTable.length) { + td = findTableCellElement(parsedTable, lastCo)?.cell || null; if (td == originalTd) { - lastCo[0] += change; + lastCo.row += change; } else { break; } @@ -328,11 +329,11 @@ class SelectionPlugin implements PluginWithState { private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { if (this.state.tableSelection?.lastCo && this.editor) { const { lastCo, parsedTable } = this.state.tableSelection; - const row = lastCo[0] + rowChange; - const col = lastCo[1] + colChange; + const row = lastCo.row + rowChange; + const col = lastCo.col + colChange; if (row >= 0 && row < parsedTable.length && col >= 0 && col < parsedTable[row].length) { - this.updateTableSelection([row, col]); + this.updateTableSelection({ row, col }); } } } @@ -433,7 +434,7 @@ class SelectionPlugin implements PluginWithState { ): TableSelectionInfo | null { let table: HTMLTableElement | null; let parsedTable: ParsedTable | null; - let firstCo: [number, number] | null; + let firstCo: { row: number; col: number } | null; if ( (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && @@ -446,21 +447,21 @@ class SelectionPlugin implements PluginWithState { } } - private updateTableSelection(lastCo: [number, number]) { - if (this.state.tableSelection) { + private updateTableSelection(lastCo: TableCellCoordinate) { + if (this.state.tableSelection && this.editor) { const { table, firstCo, parsedTable, lastCo: oldCo } = this.state.tableSelection; - if (oldCo || firstCo[0] != lastCo[0] || firstCo[1] != lastCo[1]) { + if (oldCo || firstCo.row != lastCo.row || firstCo.col != lastCo.col) { this.state.tableSelection.lastCo = lastCo; this.setDOMSelection( { type: 'table', - table: table, - firstRow: Math.min(firstCo[0], lastCo[0]), - firstColumn: Math.min(firstCo[1], lastCo[1]), - lastRow: Math.max(firstCo[0], lastCo[0]), - lastColumn: Math.max(firstCo[1], lastCo[1]), + table, + firstRow: firstCo.row, + firstColumn: firstCo.col, + lastRow: lastCo.row, + lastColumn: lastCo.col, }, { table, firstCo, lastCo, parsedTable } ); diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts index 859ab9e46a2..e6cea004f98 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts @@ -1,5 +1,10 @@ import { toArray } from 'roosterjs-content-model-dom'; -import type { DOMHelper, ParsedTable, TableSelection } from 'roosterjs-content-model-types'; +import type { + DOMHelper, + ParsedTable, + TableCellCoordinate, + TableSelection, +} from 'roosterjs-content-model-types'; const TableCellSelector = 'TH,TD'; @@ -49,12 +54,14 @@ export function parseTableCells(table: HTMLTableElement): ParsedTable { * @param parsedTable The parsed table * @param row Row index * @param col Column index + * @param findLast True to find last merged cell instead of the first cell */ export function findTableCellElement( parsedTable: ParsedTable, - row: number, - col: number -): HTMLTableCellElement | null { + coordinate: TableCellCoordinate +): ({ cell: HTMLTableCellElement } & TableCellCoordinate) | null { + let { row, col } = coordinate; + while ( row >= 0 && col >= 0 && @@ -66,7 +73,7 @@ export function findTableCellElement( if (!cell) { break; } else if (typeof cell == 'object') { - return cell; + return { cell, row, col }; } else if (cell == 'spanLeft' || cell == 'spanBoth') { col--; } else { @@ -76,6 +83,39 @@ export function findTableCellElement( return null; } +/** + * @internal + * Try to find the last logic cell of a merged table cell + * @param parsedTable The parsed table + * @param row Row index + * @param col Column index + */ +export function findLastedCoInMergedCell( + parsedTable: ParsedTable, + coordinate: TableCellCoordinate +): TableCellCoordinate | null { + let { row, col } = coordinate; + + while ( + row >= 0 && + col >= 0 && + row < parsedTable.length && + col < (parsedTable[row]?.length ?? 0) + ) { + const right = parsedTable[row]?.[col + 1]; + const below = parsedTable[row + 1]?.[col]; + + if (right == 'spanLeft' || right == 'spanBoth') { + col++; + } else if (below == 'spanTop' || below == 'spanBoth') { + row++; + } else { + return { row, col }; + } + } + return null; +} + /** * @internal * Find coordinate of a given element from a parsed table @@ -84,16 +124,16 @@ export function findCoordinate( parsedTable: ParsedTable, element: Node, domHelper: DOMHelper -): [number, number] | null { +): { row: number; col: number } | null { const td = domHelper.findClosestElementAncestor(element, TableCellSelector); - let result: [number, number] | null = null; + let result: { row: number; col: number } | null = null; // Try to do a fast check if both TD are in the given TABLE if (td) { parsedTable.some((row, rowIndex) => { const colIndex = td ? row.indexOf(td as HTMLTableCellElement) : -1; - return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); + return (result = colIndex >= 0 ? { row: rowIndex, col: colIndex } : null); }); } @@ -104,7 +144,7 @@ export function findCoordinate( cell => typeof cell == 'object' && cell.contains(element) ); - return (result = colIndex >= 0 ? [rowIndex, colIndex] : null); + return (result = colIndex >= 0 ? { row: rowIndex, col: colIndex } : null); }); } 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 6b9bd53ddca..51f72c581a6 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -451,35 +451,16 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, - selection: mockedSelection, } as any); - expect(triggerEventSpy).toHaveBeenCalledWith( - core, - { - eventType: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); + expect(triggerEventSpy).not.toHaveBeenCalled(); expect(selectNodeSpy).not.toHaveBeenCalled(); expect(collapseSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); - expect(mockedTable.id).toBe('table_0'); + expect(mockedTable.id).toBeUndefined(); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); - expect(setEditorStyleSpy).toHaveBeenCalledWith( - core, - '_DOMSelection', - 'background-color:#C6C6C6!important;', - [] - ); - expect(setEditorStyleSpy).toHaveBeenCalledWith( - core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' - ); }); function runTest( @@ -605,6 +586,77 @@ describe('setDOMSelection', () => { expect(containsSpy).toHaveBeenCalledWith(innerDIV); }); + it('Select TD with double merged cell', () => { + const div = document.createElement('div'); + div.innerHTML = + '' + + '' + + '' + + '' + + '' + + '
'; + const table = div.firstChild as HTMLTableElement; + + const mockedSelection = { + type: 'table', + table: table, + firstColumn: 2, + firstRow: 1, + lastColumn: 1, + lastRow: 2, + } as any; + const selectNodeSpy = jasmine.createSpy('selectNode'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + selectNode: selectNodeSpy, + collapse: collapseSpy, + }; + + createRangeSpy.and.returnValue(mockedRange); + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + const resultSelection = { + type: 'table', + table: table, + firstColumn: 0, + firstRow: 0, + lastColumn: 3, + lastRow: 3, + }; + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: resultSelection, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: 'selectionChanged', + newSelection: resultSelection, + }, + true + ); + expect(table.id).toBe('table_0'); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'background-color:#C6C6C6!important;', + ['#table_0', '#table_0 *'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' + ); + }); + it('Select Table Cells THEAD, TBODY', () => { runTest(buildTable(true /* tbody */, true /* thead */), 1, 1, 2, 2, [ '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', 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 f7ff4ec9be0..e134df956fe 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -177,7 +177,7 @@ describe('SelectionPlugin handle image selection', () => { it('No selection, mouse down to div', () => { const node = document.createElement('div'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, @@ -207,7 +207,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); const node = document.createElement('div'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, @@ -239,7 +239,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); const node = document.createElement('div'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, @@ -258,7 +258,7 @@ describe('SelectionPlugin handle image selection', () => { image: mockedImage, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, @@ -278,7 +278,7 @@ describe('SelectionPlugin handle image selection', () => { image: mockedImage, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, @@ -293,7 +293,7 @@ describe('SelectionPlugin handle image selection', () => { const mockedImage = document.createElement('img'); mockedImage.contentEditable = 'true'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, @@ -307,7 +307,7 @@ describe('SelectionPlugin handle image selection', () => { it('Image selection, mouse down to div right click', () => { const node = document.createElement('div'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, @@ -323,7 +323,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'true'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: true, rawEvent: { @@ -343,7 +343,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'false'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: true, rawEvent: { @@ -359,7 +359,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'true'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: false, rawEvent: { @@ -376,7 +376,7 @@ describe('SelectionPlugin handle image selection', () => { } as any; getDOMSelectionSpy.and.returnValue(null); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -392,7 +392,7 @@ describe('SelectionPlugin handle image selection', () => { type: 'range', }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -424,7 +424,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -462,7 +462,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -501,7 +501,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -533,7 +533,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -598,8 +598,8 @@ describe('SelectionPlugin handle table selection', () => { }); afterEach(() => { - focusDispatcher = undefined; - mouseDispatcher = undefined; + focusDispatcher = undefined!; + mouseDispatcher = undefined!; }); it('MouseDown - has tableSelection, clear it when left click', () => { @@ -611,7 +611,7 @@ describe('SelectionPlugin handle table selection', () => { type: 'table', }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { button: 2, @@ -624,7 +624,7 @@ describe('SelectionPlugin handle table selection', () => { imageSelectionBorderColor: undefined, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { button: 0, @@ -655,7 +655,7 @@ describe('SelectionPlugin handle table selection', () => { type: 'table', }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { button: 0, @@ -669,7 +669,7 @@ describe('SelectionPlugin handle table selection', () => { imageSelectionBorderColor: undefined, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { button: 0, @@ -682,7 +682,7 @@ describe('SelectionPlugin handle table selection', () => { tableSelection: { table: table, parsedTable: [[td]], - firstCo: [0, 0], + firstCo: { row: 0, col: 0 }, }, mouseDisposer: mouseMoveDisposer, imageSelectionBorderColor: undefined, @@ -708,7 +708,7 @@ describe('SelectionPlugin handle table selection', () => { type: 'table', }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { button: 0, @@ -720,13 +720,13 @@ describe('SelectionPlugin handle table selection', () => { expect(state.tableSelection).toEqual({ table, parsedTable: [[td1, td2]], - firstCo: [0, 0], + firstCo: { row: 0, col: 0 }, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); const preventDefaultSpy = jasmine.createSpy('preventDefault'); - mouseDispatcher.mousemove.beforeDispatch({ + mouseDispatcher.mousemove.beforeDispatch!({ target: td1, preventDefault: preventDefaultSpy, } as any); @@ -736,10 +736,10 @@ describe('SelectionPlugin handle table selection', () => { expect(state.tableSelection).toEqual({ table, parsedTable: [[td1, td2]], - firstCo: [0, 0], + firstCo: { row: 0, col: 0 }, }); - mouseDispatcher.mousemove.beforeDispatch({ + mouseDispatcher.mousemove.beforeDispatch!({ target: td2, preventDefault: preventDefaultSpy, } as any); @@ -757,11 +757,11 @@ describe('SelectionPlugin handle table selection', () => { expect(state.tableSelection).toEqual({ table, parsedTable: [[td1, td2]], - firstCo: [0, 0], - lastCo: [0, 1], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 1 }, }); - mouseDispatcher.mousemove.beforeDispatch({ + mouseDispatcher.mousemove.beforeDispatch!({ target: div, preventDefault: preventDefaultSpy, } as any); @@ -779,8 +779,8 @@ describe('SelectionPlugin handle table selection', () => { expect(state.tableSelection).toEqual({ table, parsedTable: [[td1, td2]], - firstCo: [0, 0], - lastCo: [0, 1], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 1 }, }); }); @@ -792,12 +792,12 @@ describe('SelectionPlugin handle table selection', () => { state.mouseDisposer = disposer; - focusDispatcher.drop.beforeDispatch(null!); + focusDispatcher.drop.beforeDispatch!(null!); expect(disposer).toHaveBeenCalledTimes(1); expect(state.mouseDisposer).toBeUndefined(); - focusDispatcher.drop.beforeDispatch(null!); + focusDispatcher.drop.beforeDispatch!(null!); expect(disposer).toHaveBeenCalledTimes(1); expect(state.mouseDisposer).toBeUndefined(); @@ -845,7 +845,7 @@ describe('SelectionPlugin handle table selection', () => { isReverted: false, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'a', @@ -885,7 +885,7 @@ describe('SelectionPlugin handle table selection', () => { func(); }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'ArrowRight', @@ -940,7 +940,7 @@ describe('SelectionPlugin handle table selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'ArrowDown', @@ -992,7 +992,7 @@ describe('SelectionPlugin handle table selection', () => { func(); }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'ArrowUp', @@ -1009,8 +1009,8 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [1, 1], - lastCo: [0, 1], + firstCo: { row: 1, col: 1 }, + lastCo: { row: 0, col: 1 }, }, imageSelectionBorderColor: undefined, }); @@ -1018,9 +1018,9 @@ describe('SelectionPlugin handle table selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'table', table, - firstRow: 0, + firstRow: 1, firstColumn: 1, - lastRow: 1, + lastRow: 0, lastColumn: 1, }); }); @@ -1055,7 +1055,7 @@ describe('SelectionPlugin handle table selection', () => { func(); }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'ArrowDown', @@ -1072,8 +1072,8 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [0, 1], - lastCo: [1, 1], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, }, imageSelectionBorderColor: undefined, }); @@ -1118,7 +1118,7 @@ describe('SelectionPlugin handle table selection', () => { func(); }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'ArrowDown', @@ -1135,7 +1135,7 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [0, 1], + firstCo: { row: 0, col: 1 }, }, imageSelectionBorderColor: undefined, }); @@ -1152,13 +1152,13 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [0, 1], - lastCo: [1, 1], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'a', @@ -1175,8 +1175,8 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [0, 1], - lastCo: [1, 1], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, }, imageSelectionBorderColor: undefined, }); @@ -1194,8 +1194,8 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [0, 1], - lastCo: [1, 1], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); @@ -1217,7 +1217,7 @@ describe('SelectionPlugin handle table selection', () => { func(); }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'ArrowLeft', @@ -1234,8 +1234,8 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [0, 1], - lastCo: [1, 1], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, }, imageSelectionBorderColor: undefined, }); @@ -1255,15 +1255,15 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [0, 1], - lastCo: [1, 1], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); getComputedStyleSpy.and.returnValue({}); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'ArrowLeft', @@ -1280,8 +1280,8 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [0, 1], - lastCo: [1, 0], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 0 }, }, imageSelectionBorderColor: undefined, }); @@ -1290,9 +1290,9 @@ describe('SelectionPlugin handle table selection', () => { type: 'table', table, firstRow: 0, - firstColumn: 0, + firstColumn: 1, lastRow: 1, - lastColumn: 1, + lastColumn: 0, }); expect(preventDefaultSpy).toHaveBeenCalled(); }); @@ -1308,15 +1308,15 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [1, 0], - lastCo: [1, 1], + firstCo: { row: 1, col: 0 }, + lastCo: { row: 1, col: 1 }, }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); getComputedStyleSpy.and.returnValue({}); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent: { key: 'ArrowUp', @@ -1333,8 +1333,8 @@ describe('SelectionPlugin handle table selection', () => { [td1, td2], [td3, td4], ], - firstCo: [1, 0], - lastCo: [0, 1], + firstCo: { row: 1, col: 0 }, + lastCo: { row: 0, col: 1 }, }, imageSelectionBorderColor: undefined, }); @@ -1342,9 +1342,9 @@ describe('SelectionPlugin handle table selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'table', table, - firstRow: 0, + firstRow: 1, firstColumn: 0, - lastRow: 1, + lastRow: 0, lastColumn: 1, }); expect(preventDefaultSpy).toHaveBeenCalled(); diff --git a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts index 55b9a9df449..52934346c8f 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts @@ -1,8 +1,14 @@ -import { createDOMHelper } from '../../../lib/editor/DOMHelperImpl'; -import { DOMHelper, DOMSelection, ParsedTable } from 'roosterjs-content-model-types'; +import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; +import { + DOMHelper, + DOMSelection, + ParsedTable, + TableCellCoordinate, +} from 'roosterjs-content-model-types'; import { createTableRanges, findCoordinate, + findLastedCoInMergedCell, findTableCellElement, parseTableCells, } from '../../../lib/publicApi/domUtils/tableCellUtils'; @@ -94,10 +100,88 @@ describe('findTableCellElement', () => { const mockedTd4 = { id: 'TD4' } as any; const mockedTd5 = { id: 'TD5' } as any; + function runTest( + parsedTable: ParsedTable, + row: number, + col: number, + expectedResult: ({ cell: HTMLTableCellElement } & TableCellCoordinate) | null + ) { + const result = findTableCellElement(parsedTable, { row, col }); + + expect(result).toEqual(expectedResult); + } + + it('Null', () => { + runTest([], 0, 0, null); + }); + + it('Simple table', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, mockedTd2], + [mockedTd3, mockedTd4], + ]; + runTest(parsedTable, 0, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { cell: mockedTd2, row: 0, col: 1 }); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, { cell: mockedTd3, row: 1, col: 0 }); + runTest(parsedTable, 2, 0, null); + runTest(parsedTable, 2, 2, null); + }); + + it('Complex table', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, mockedTd2, 'spanLeft'], + ['spanTop', mockedTd3, mockedTd4], + [mockedTd5, 'spanLeft', 'spanTop'], + ]; + + runTest(parsedTable, 0, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { cell: mockedTd2, row: 0, col: 1 }); + runTest(parsedTable, 0, 2, { cell: mockedTd2, row: 0, col: 1 }); + runTest(parsedTable, 0, 3, null); + runTest(parsedTable, 1, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 1, 1, { cell: mockedTd3, row: 1, col: 1 }); + runTest(parsedTable, 1, 2, { cell: mockedTd4, row: 1, col: 2 }); + runTest(parsedTable, 1, 3, null); + runTest(parsedTable, 2, 0, { cell: mockedTd5, row: 2, col: 0 }); + runTest(parsedTable, 2, 1, { cell: mockedTd5, row: 2, col: 0 }); + runTest(parsedTable, 2, 2, { cell: mockedTd4, row: 1, col: 2 }); + runTest(parsedTable, 2, 3, null); + runTest(parsedTable, 3, 0, null); + runTest(parsedTable, 3, 1, null); + runTest(parsedTable, 3, 2, null); + runTest(parsedTable, 3, 3, null); + }); + + it('span both', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, 'spanLeft'], + ['spanTop', 'spanBoth'], + ]; + + runTest(parsedTable, 0, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 1, 1, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 1, 2, null); + runTest(parsedTable, 2, 0, null); + runTest(parsedTable, 2, 1, null); + runTest(parsedTable, 2, 2, null); + }); +}); + +describe('findLastedCoInMergedCell', () => { + const mockedTd1 = { id: 'TD1' } as any; + const mockedTd2 = { id: 'TD2' } as any; + const mockedTd3 = { id: 'TD3' } as any; + const mockedTd4 = { id: 'TD4' } as any; + const mockedTd5 = { id: 'TD5' } as any; + function runTest(parsedTable: ParsedTable, row: number, col: number, expectedResult: any) { - const result = findTableCellElement(parsedTable, row, col); + const result = findLastedCoInMergedCell(parsedTable, { row, col }); - expect(result).toBe(expectedResult); + expect(result).toEqual(expectedResult); } it('Null', () => { @@ -109,10 +193,10 @@ describe('findTableCellElement', () => { [mockedTd1, mockedTd2], [mockedTd3, mockedTd4], ]; - runTest(parsedTable, 0, 0, mockedTd1); - runTest(parsedTable, 0, 1, mockedTd2); + runTest(parsedTable, 0, 0, { row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { row: 0, col: 1 }); runTest(parsedTable, 0, 2, null); - runTest(parsedTable, 1, 0, mockedTd3); + runTest(parsedTable, 1, 0, { row: 1, col: 0 }); runTest(parsedTable, 2, 0, null); runTest(parsedTable, 2, 2, null); }); @@ -124,17 +208,17 @@ describe('findTableCellElement', () => { [mockedTd5, 'spanLeft', 'spanTop'], ]; - runTest(parsedTable, 0, 0, mockedTd1); - runTest(parsedTable, 0, 1, mockedTd2); - runTest(parsedTable, 0, 2, mockedTd2); + runTest(parsedTable, 0, 0, { row: 1, col: 0 }); + runTest(parsedTable, 0, 1, { row: 0, col: 2 }); + runTest(parsedTable, 0, 2, { row: 0, col: 2 }); runTest(parsedTable, 0, 3, null); - runTest(parsedTable, 1, 0, mockedTd1); - runTest(parsedTable, 1, 1, mockedTd3); - runTest(parsedTable, 1, 2, mockedTd4); + runTest(parsedTable, 1, 0, { row: 1, col: 0 }); + runTest(parsedTable, 1, 1, { row: 1, col: 1 }); + runTest(parsedTable, 1, 2, { row: 2, col: 2 }); runTest(parsedTable, 1, 3, null); - runTest(parsedTable, 2, 0, mockedTd5); - runTest(parsedTable, 2, 1, mockedTd5); - runTest(parsedTable, 2, 2, mockedTd4); + runTest(parsedTable, 2, 0, { row: 2, col: 1 }); + runTest(parsedTable, 2, 1, { row: 2, col: 1 }); + runTest(parsedTable, 2, 2, { row: 2, col: 2 }); runTest(parsedTable, 2, 3, null); runTest(parsedTable, 3, 0, null); runTest(parsedTable, 3, 1, null); @@ -148,11 +232,11 @@ describe('findTableCellElement', () => { ['spanTop', 'spanBoth'], ]; - runTest(parsedTable, 0, 0, mockedTd1); - runTest(parsedTable, 0, 1, mockedTd1); + runTest(parsedTable, 0, 0, { row: 1, col: 1 }); + runTest(parsedTable, 0, 1, { row: 1, col: 1 }); runTest(parsedTable, 0, 2, null); - runTest(parsedTable, 1, 0, mockedTd1); - runTest(parsedTable, 1, 1, mockedTd1); + runTest(parsedTable, 1, 0, { row: 1, col: 1 }); + runTest(parsedTable, 1, 1, { row: 1, col: 1 }); runTest(parsedTable, 1, 2, null); runTest(parsedTable, 2, 0, null); runTest(parsedTable, 2, 1, null); @@ -191,7 +275,7 @@ describe('findCoordinate', () => { const result = findCoordinate(table, text, domHelper); - expect(result).toEqual([0, 0]); + expect(result).toEqual({ row: 0, col: 0 }); }); it('Table contains node indirectly', () => { @@ -207,7 +291,7 @@ describe('findCoordinate', () => { const result = findCoordinate(table, text, domHelper); - expect(result).toEqual([0, 0]); + expect(result).toEqual({ row: 0, col: 0 }); }); it('Table contains node on second row', () => { @@ -223,7 +307,7 @@ describe('findCoordinate', () => { const result = findCoordinate(table, text, domHelper); - expect(result).toEqual([1, 1]); + expect(result).toEqual({ row: 1, col: 1 }); }); }); diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 2ff0596de7a..c5fd31168cb 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -232,7 +232,11 @@ export { CopyPastePluginState } from './pluginState/CopyPastePluginState'; export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; export { EntityPluginState, KnownEntityItem } from './pluginState/EntityPluginState'; -export { SelectionPluginState, TableSelectionInfo } from './pluginState/SelectionPluginState'; +export { + SelectionPluginState, + TableSelectionInfo, + TableCellCoordinate, +} from './pluginState/SelectionPluginState'; export { UndoPluginState } from './pluginState/UndoPluginState'; export { PluginKey, diff --git a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index 21446752d7e..7b138add8c7 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -1,6 +1,21 @@ import type { ParsedTable } from '../parameter/ParsedTable'; import type { DOMSelection } from '../selection/DOMSelection'; +/** + * Logical coordinate of a table cell + */ +export interface TableCellCoordinate { + /** + * Row index + */ + row: number; + + /** + * Column index + */ + col: number; +} + /** * Table selection internal info for SelectionPlugin */ @@ -18,12 +33,12 @@ export interface TableSelectionInfo { /** * Coordinate for first selected table cell */ - firstCo: [number, number]; + firstCo: TableCellCoordinate; /** * Coordinate for last selected table cell */ - lastCo?: [number, number]; + lastCo?: TableCellCoordinate; } /** From c1098a5ea089866f4780eba86c13eabd856aa92c Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 18 Mar 2024 12:05:04 -0700 Subject: [PATCH 10/14] fix build --- .../lib/publicApi/domUtils/tableCellUtils.ts | 9 ++++++++- .../test/publicApi/domUtils/tableCellUtilsTest.ts | 10 +++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts index e6cea004f98..066281f841d 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts @@ -48,6 +48,13 @@ export function parseTableCells(table: HTMLTableElement): ParsedTable { return cells; } +/** + * @internal + */ +export interface TableCellCoordinateWithCell extends TableCellCoordinate { + cell: HTMLTableCellElement; +} + /** * @internal * Try to find a TD/TH element from the given row and col number from the given parsed table @@ -59,7 +66,7 @@ export function parseTableCells(table: HTMLTableElement): ParsedTable { export function findTableCellElement( parsedTable: ParsedTable, coordinate: TableCellCoordinate -): ({ cell: HTMLTableCellElement } & TableCellCoordinate) | null { +): TableCellCoordinateWithCell | null { let { row, col } = coordinate; while ( diff --git a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts index 52934346c8f..7b3d5e4eff0 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts @@ -1,11 +1,7 @@ import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; +import { DOMHelper, DOMSelection, ParsedTable } from 'roosterjs-content-model-types'; import { - DOMHelper, - DOMSelection, - ParsedTable, - TableCellCoordinate, -} from 'roosterjs-content-model-types'; -import { + TableCellCoordinateWithCell, createTableRanges, findCoordinate, findLastedCoInMergedCell, @@ -104,7 +100,7 @@ describe('findTableCellElement', () => { parsedTable: ParsedTable, row: number, col: number, - expectedResult: ({ cell: HTMLTableCellElement } & TableCellCoordinate) | null + expectedResult: TableCellCoordinateWithCell | null ) { const result = findTableCellElement(parsedTable, { row, col }); From eaea9dbd573f33060c42ee64af0ed60f53d4d3c5 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 18 Mar 2024 12:07:19 -0700 Subject: [PATCH 11/14] fix build --- .../roosterjsReact/ribbon/buttons/insertTableButton.tsx | 3 ++- .../lib/corePlugin/selection/SelectionPlugin.ts | 2 +- .../lib/publicApi/domUtils/tableCellUtils.ts | 4 ++-- .../lib/publicApi/selection/setSelection.ts | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/insertTableButton.tsx b/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/insertTableButton.tsx index e0cd130e693..37418851440 100644 --- a/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/insertTableButton.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/insertTableButton.tsx @@ -3,6 +3,7 @@ import { FocusZone, FocusZoneDirection } from '@fluentui/react/lib/FocusZone'; import { insertTable } from 'roosterjs-content-model-api'; import { isNodeOfType } from 'roosterjs-content-model-dom'; import { mergeStyleSets } from '@fluentui/react/lib/Styling'; +import { TableCellCoordinate } from 'roosterjs-content-model-types'; import type { RibbonButton } from '../type/RibbonButton'; import type { InsertTableButtonStringKey } from '../type/RibbonButtonStringKeys'; import type { IContextualMenuItem } from '@fluentui/react/lib/ContextualMenu'; @@ -57,7 +58,7 @@ export const insertTableButton: RibbonButton = { }, }; -function parseKey(key: string): { row: number; col: number } { +function parseKey(key: string): TableCellCoordinate { const [row, col] = key.split(','); return { row: parseInt(row), 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 5b9ab5a9fa0..1e5b43e8a2f 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -434,7 +434,7 @@ class SelectionPlugin implements PluginWithState { ): TableSelectionInfo | null { let table: HTMLTableElement | null; let parsedTable: ParsedTable | null; - let firstCo: { row: number; col: number } | null; + let firstCo: TableCellCoordinate | null; if ( (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts index 066281f841d..7fd6c67b9ce 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts @@ -131,9 +131,9 @@ export function findCoordinate( parsedTable: ParsedTable, element: Node, domHelper: DOMHelper -): { row: number; col: number } | null { +): TableCellCoordinate | null { const td = domHelper.findClosestElementAncestor(element, TableCellSelector); - let result: { row: number; col: number } | null = null; + let result: TableCellCoordinate | null = null; // Try to do a fast check if both TD are in the given TABLE if (td) { diff --git a/packages/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts b/packages/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts index e0e445fe92b..ba1b5de2f8f 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts @@ -5,6 +5,7 @@ import type { ContentModelSegment, ContentModelTable, Selectable, + TableCellCoordinate, } from 'roosterjs-content-model-types'; /** @@ -133,7 +134,7 @@ function setSelectionToTable( return isInSelection; } -function findCell(table: ContentModelTable, cell: Selectable | null): { row: number; col: number } { +function findCell(table: ContentModelTable, cell: Selectable | null): TableCellCoordinate { let col = -1; const row = cell ? table.rows.findIndex(row => (col = (row.cells as Selectable[]).indexOf(cell)) >= 0) From 90e3fb1329ec0e7018079af0ea8e2da448f7368b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 19 Mar 2024 11:44:56 -0700 Subject: [PATCH 12/14] triple click --- .../corePlugin/selection/SelectionPlugin.ts | 16 +++++++ .../selection/SelectionPluginTest.ts | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+) 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 1e5b43e8a2f..b7006d9b862 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -148,6 +148,22 @@ class SelectionPlugin implements PluginWithState { (tableSelection = this.parseTableSelection(target, target, editor.getDOMHelper())) ) { this.state.tableSelection = tableSelection; + + if (rawEvent.detail >= 3) { + const lastCo = findCoordinate( + tableSelection.parsedTable, + rawEvent.target as Node, + editor.getDOMHelper() + ); + + if (lastCo) { + // Triple click, select the current cell + tableSelection.lastCo = lastCo; + this.updateTableSelection(lastCo); + rawEvent.preventDefault(); + } + } + this.state.mouseDisposer = editor.attachDomEvent({ mousemove: { beforeDispatch: this.onMouseMove, 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 e134df956fe..31b02095238 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -690,6 +690,52 @@ describe('SelectionPlugin handle table selection', () => { expect(mouseDispatcher).toBeDefined(); }); + it('MouseDown - triple click', () => { + const state = plugin.getState(); + const table = document.createElement('table'); + const tr = document.createElement('tr'); + const td = document.createElement('td'); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + tr.appendChild(td); + table.appendChild(tr); + contentDiv.appendChild(table); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td, + detail: 3, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: { + table: table, + parsedTable: [[td]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 0 }, + }, + mouseDisposer: mouseMoveDisposer, + imageSelectionBorderColor: undefined, + }); + expect(mouseDispatcher).toBeDefined(); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 0, + }); + }); + it('MouseMove', () => { const state = plugin.getState(); const table = document.createElement('table'); From 3a261032f498fa88357cceb4a0187233c3880b58 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 19 Mar 2024 15:43:45 -0700 Subject: [PATCH 13/14] cross table selection --- .../corePlugin/selection/SelectionPlugin.ts | 68 +++++-- .../selection/SelectionPluginTest.ts | 170 +++++++++++++++++- .../lib/pluginState/SelectionPluginState.ts | 5 + 3 files changed, 231 insertions(+), 12 deletions(-) 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 b7006d9b862..3dd5f445f4e 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -175,15 +175,55 @@ class SelectionPlugin implements PluginWithState { private onMouseMove = (event: Event) => { if (this.editor && this.state.tableSelection) { const hasTableSelection = !!this.state.tableSelection.lastCo; - const lastCo = findCoordinate( - this.state.tableSelection.parsedTable, - event.target as Node, - this.editor.getDOMHelper() - ); - const updated = lastCo && this.updateTableSelection(lastCo); + const currentNode = event.target as Node; + const domHelper = this.editor.getDOMHelper(); + + const range = this.editor.getDocument().createRange(); + const startNode = this.state.tableSelection.startNode; + const isReverted = + currentNode.compareDocumentPosition(startNode) == Node.DOCUMENT_POSITION_FOLLOWING; + + if (isReverted) { + range.setStart(currentNode, 0); + range.setEnd( + startNode, + isNodeOfType(startNode, 'TEXT_NODE') + ? startNode.nodeValue?.length ?? 0 + : startNode.childNodes.length + ); + } else { + range.setStart(startNode, 0); + range.setEnd(currentNode, 0); + } + + // Use common container of the range to search a common table that covers both start and end node + const tableStart = range.commonAncestorContainer; + const newTableSelection = this.parseTableSelection(tableStart, startNode, domHelper); + + if (newTableSelection) { + let lastCo = findCoordinate(newTableSelection.parsedTable, currentNode, domHelper); + + if (newTableSelection.table != this.state.tableSelection.table) { + // Move mouse into another table (nest table scenario) + this.state.tableSelection = newTableSelection; + this.state.tableSelection.lastCo = lastCo ?? undefined; + } - if (hasTableSelection || updated) { - event.preventDefault(); + const updated = lastCo && this.updateTableSelection(lastCo); + + if (hasTableSelection || updated) { + event.preventDefault(); + } + } else if (this.editor.getDOMSelection()?.type == 'table') { + // Move mouse out of table + this.setDOMSelection( + { + type: 'range', + range, + isReverted, + }, + this.state.tableSelection + ); } } }; @@ -457,7 +497,7 @@ class SelectionPlugin implements PluginWithState { (parsedTable = parseTableCells(table)) && (firstCo = findCoordinate(parsedTable, tdStart, domHelper)) ) { - return { table, parsedTable, firstCo }; + return { table, parsedTable, firstCo, startNode: tdStart }; } else { return null; } @@ -465,7 +505,13 @@ class SelectionPlugin implements PluginWithState { private updateTableSelection(lastCo: TableCellCoordinate) { if (this.state.tableSelection && this.editor) { - const { table, firstCo, parsedTable, lastCo: oldCo } = this.state.tableSelection; + const { + table, + firstCo, + parsedTable, + startNode, + lastCo: oldCo, + } = this.state.tableSelection; if (oldCo || firstCo.row != lastCo.row || firstCo.col != lastCo.col) { this.state.tableSelection.lastCo = lastCo; @@ -479,7 +525,7 @@ class SelectionPlugin implements PluginWithState { lastRow: lastCo.row, lastColumn: lastCo.col, }, - { table, firstCo, lastCo, parsedTable } + { table, firstCo, lastCo, parsedTable, startNode } ); return true; 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 31b02095238..68e83fe8f95 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -683,6 +683,7 @@ describe('SelectionPlugin handle table selection', () => { table: table, parsedTable: [[td]], firstCo: { row: 0, col: 0 }, + startNode: td, }, mouseDisposer: mouseMoveDisposer, imageSelectionBorderColor: undefined, @@ -719,6 +720,7 @@ describe('SelectionPlugin handle table selection', () => { parsedTable: [[td]], firstCo: { row: 0, col: 0 }, lastCo: { row: 0, col: 0 }, + startNode: td, }, mouseDisposer: mouseMoveDisposer, imageSelectionBorderColor: undefined, @@ -736,7 +738,7 @@ describe('SelectionPlugin handle table selection', () => { }); }); - it('MouseMove', () => { + it('MouseMove - in same table', () => { const state = plugin.getState(); const table = document.createElement('table'); const tr = document.createElement('tr'); @@ -744,6 +746,9 @@ describe('SelectionPlugin handle table selection', () => { const td2 = document.createElement('td'); const div = document.createElement('div'); + td1.id = 'td1'; + td2.id = 'td2'; + tr.appendChild(td1); tr.appendChild(td2); table.appendChild(tr); @@ -767,10 +772,19 @@ describe('SelectionPlugin handle table selection', () => { table, parsedTable: [[td1, td2]], firstCo: { row: 0, col: 0 }, + startNode: td1, }); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + + createRangeSpy.and.returnValue({ + setStart: setStartSpy, + setEnd: setEndSpy, + commonAncestorContainer: table, + }); mouseDispatcher.mousemove.beforeDispatch!({ target: td1, @@ -783,7 +797,10 @@ describe('SelectionPlugin handle table selection', () => { table, parsedTable: [[td1, td2]], firstCo: { row: 0, col: 0 }, + startNode: td1, }); + expect(setStartSpy).toHaveBeenCalledWith(td1, 0); + expect(setEndSpy).toHaveBeenCalledWith(td1, 0); mouseDispatcher.mousemove.beforeDispatch!({ target: td2, @@ -805,6 +822,7 @@ describe('SelectionPlugin handle table selection', () => { parsedTable: [[td1, td2]], firstCo: { row: 0, col: 0 }, lastCo: { row: 0, col: 1 }, + startNode: td1, }); mouseDispatcher.mousemove.beforeDispatch!({ @@ -827,6 +845,145 @@ describe('SelectionPlugin handle table selection', () => { parsedTable: [[td1, td2]], firstCo: { row: 0, col: 0 }, lastCo: { row: 0, col: 1 }, + startNode: td1, + }); + }); + + it('MouseMove - move to outer table', () => { + const state = plugin.getState(); + const table1 = document.createElement('table'); + const table2 = document.createElement('table'); + const tr1 = document.createElement('tr'); + const tr2 = document.createElement('tr'); + const td1 = document.createElement('td'); + const td2 = document.createElement('td'); + const div = document.createElement('div'); + + td1.id = 'td1'; + td2.id = 'td2'; + + tr1.appendChild(td1); + tr2.appendChild(td2); + table1.appendChild(tr1); + table2.appendChild(tr2); + + td1.appendChild(table2); + + contentDiv.appendChild(table1); + contentDiv.appendChild(div); + + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td2, + } as any, + }); + + expect(mouseDispatcher.mousemove).toBeDefined(); + expect(state.tableSelection).toEqual({ + table: table2, + parsedTable: [[td2]], + firstCo: { row: 0, col: 0 }, + startNode: td2, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + + createRangeSpy.and.returnValue({ + setStart: setStartSpy, + setEnd: setEndSpy, + commonAncestorContainer: table1, + }); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: td1, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table1, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 0, + }); + expect(state.tableSelection).toEqual({ + table: table1, + parsedTable: [[td1]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 0 }, + startNode: td2, + }); + expect(setStartSpy).toHaveBeenCalledWith(td2, 0); + expect(setEndSpy).toHaveBeenCalledWith(td1, 0); + + createRangeSpy.and.returnValue({ + setStart: setStartSpy, + setEnd: setEndSpy, + commonAncestorContainer: table2, + }); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: td2, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(3); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table2, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 0, + }); + expect(state.tableSelection).toEqual({ + table: table2, + parsedTable: [[td2]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 0 }, + startNode: td2, + }); + + const mockedRange = { + setStart: setStartSpy, + setEnd: setEndSpy, + commonAncestorContainer: contentDiv, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: div, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(4); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(state.tableSelection).toEqual({ + table: table2, + parsedTable: [[td2]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 0 }, + startNode: td2, }); }); @@ -1057,6 +1214,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 1, col: 1 }, lastCo: { row: 0, col: 1 }, + startNode: td4, }, imageSelectionBorderColor: undefined, }); @@ -1120,6 +1278,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 0, col: 1 }, lastCo: { row: 1, col: 1 }, + startNode: td2, }, imageSelectionBorderColor: undefined, }); @@ -1182,6 +1341,7 @@ describe('SelectionPlugin handle table selection', () => { [td3, td4], ], firstCo: { row: 0, col: 1 }, + startNode: td2, }, imageSelectionBorderColor: undefined, }); @@ -1200,6 +1360,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 0, col: 1 }, lastCo: { row: 1, col: 1 }, + startNode: td2, }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); @@ -1223,6 +1384,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 0, col: 1 }, lastCo: { row: 1, col: 1 }, + startNode: td2, }, imageSelectionBorderColor: undefined, }); @@ -1242,6 +1404,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 0, col: 1 }, lastCo: { row: 1, col: 1 }, + startNode: td2, }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); @@ -1282,6 +1445,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 0, col: 1 }, lastCo: { row: 1, col: 1 }, + startNode: td2, }, imageSelectionBorderColor: undefined, }); @@ -1303,6 +1467,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 0, col: 1 }, lastCo: { row: 1, col: 1 }, + startNode: td2, }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); @@ -1328,6 +1493,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 0, col: 1 }, lastCo: { row: 1, col: 0 }, + startNode: td2, }, imageSelectionBorderColor: undefined, }); @@ -1356,6 +1522,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 1, col: 0 }, lastCo: { row: 1, col: 1 }, + startNode: td3, }; const preventDefaultSpy = jasmine.createSpy('preventDefault'); @@ -1381,6 +1548,7 @@ describe('SelectionPlugin handle table selection', () => { ], firstCo: { row: 1, col: 0 }, lastCo: { row: 0, col: 1 }, + startNode: td3, }, imageSelectionBorderColor: undefined, }); diff --git a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index 7b138add8c7..556f4ecccb3 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -30,6 +30,11 @@ export interface TableSelectionInfo { */ parsedTable: ParsedTable; + /** + * The node where the focus is at when start selection + */ + startNode: Node; + /** * Coordinate for first selected table cell */ From cb53a5621f2aeb5d3f542894dfed6151a588deca Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 19 Mar 2024 16:01:11 -0700 Subject: [PATCH 14/14] fix test --- .../lib/corePlugin/selection/SelectionPlugin.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 3dd5f445f4e..726fa8d501a 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -201,7 +201,11 @@ class SelectionPlugin implements PluginWithState { const newTableSelection = this.parseTableSelection(tableStart, startNode, domHelper); if (newTableSelection) { - let lastCo = findCoordinate(newTableSelection.parsedTable, currentNode, domHelper); + const lastCo = findCoordinate( + newTableSelection.parsedTable, + currentNode, + domHelper + ); if (newTableSelection.table != this.state.tableSelection.table) { // Move mouse into another table (nest table scenario)