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/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/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 8cfea08f8bc..4916feeebd3 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 371ec790044..d1272a37900 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/getDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts index c2e0404f19a..63c77110276 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/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/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index fba89dd2964..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,11 +1,16 @@ import { addRangeToSelection } from './addRangeToSelection'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; -import { parseTableCells } from '../../publicApi/domUtils/tableCellUtils'; +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'; @@ -49,11 +54,47 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC setRangeSelection(doc, image); break; case 'table': - const { table, firstColumn, firstRow } = selection; - const tableSelectors = buildTableSelectors( - ensureUniqueId(table, TABLE_ID), - selection - ); + const { table, firstColumn, firstRow, lastColumn, lastRow } = selection; + const parsedTable = parseTableCells(selection.table); + 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( @@ -64,7 +105,12 @@ 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 nodeToSelect = firstCell.cell?.firstElementChild || firstCell.cell; + + if (nodeToSelect) { + setRangeSelection(doc, (nodeToSelect as HTMLElement) || undefined); + } + break; case 'range': addRangeToSelection(doc, selection.range, selection.isReverted); @@ -90,25 +136,13 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function buildTableSelectors(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; - return isAllTableSelected - ? [`#${tableId}`, `#${tableId} *`] - : handleTableSelected(tableId, selection, cells); -} - function handleTableSelected( + parsedTable: ParsedTable, tableId: string, - selection: TableSelection, - cells: (HTMLTableCellElement | null)[][] + table: HTMLTableElement, + firstCell: TableCellCoordinate, + lastCell: TableCellCoordinate ) { - const { firstRow, firstColumn, lastRow, lastColumn, table } = selection; const selectors: string[] = []; // Get whether table has thead, tbody or tfoot, then Set the start and end of each of the table children, @@ -132,7 +166,7 @@ function handleTableSelected( return result; }); - cells.forEach((row, rowIndex) => { + parsedTable.forEach((row, rowIndex) => { let tdCount = 0; //Get current TBODY/THEAD/TFOOT @@ -146,14 +180,14 @@ function handleTableSelected( for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { const cell = row[cellIndex]; - if (cell) { + if (typeof cell == 'object') { 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/entity/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts index f55468cc3e4..467daaa9b34 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/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 { addDelimiters, createBr, @@ -132,14 +133,10 @@ function getFocusedElement( let node: Node | null = isReverted ? range.startContainer : range.endContainer; let offset = isReverted ? range.startOffset : range.endOffset; - while (node?.lastChild) { - if (offset == node.childNodes.length) { - node = node.lastChild; - offset = node.childNodes.length; - } else { - node = node.childNodes[offset]; - offset = 0; - } + if (node) { + const pos = normalizePos(node, offset); + node = pos.node; + offset = pos.offset; } if (!isNodeOfType(node, 'ELEMENT_NODE')) { 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 f5c15052d74..726fa8d501a 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -1,5 +1,11 @@ +import { isCharacterValue, isModifierKey } from '../../publicApi/domUtils/eventUtils'; import { isElementOfType, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; -import { isModifierKey } from '../../publicApi/domUtils/eventUtils'; +import { normalizePos } from '../../publicApi/domUtils/normalizePos'; +import { + findCoordinate, + findTableCellElement, + parseTableCells, +} from '../../publicApi/domUtils/tableCellUtils'; import type { DOMSelection, IEditor, @@ -7,10 +13,20 @@ import type { PluginWithState, SelectionPluginState, EditorOptions, + DOMHelper, + MouseUpEvent, + ParsedTable, + TableSelectionInfo, + TableCellCoordinate, } from 'roosterjs-content-model-types'; +const MouseLeftButton = 0; const MouseMiddleButton = 1; const MouseRightButton = 2; +const Up = 'ArrowUp'; +const Down = 'ArrowDown'; +const Left = 'ArrowLeft'; +const Right = 'ArrowRight'; class SelectionPlugin implements PluginWithState { private editor: IEditor | null = null; @@ -22,6 +38,7 @@ class SelectionPlugin implements PluginWithState { constructor(options: EditorOptions) { this.state = { selection: null, + tableSelection: null, imageSelectionBorderColor: options.imageSelectionBorderColor, }; } @@ -41,11 +58,15 @@ class SelectionPlugin implements PluginWithState { if (this.isSafari) { document.addEventListener('selectionchange', this.onSelectionChangeSafari); - this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus } }); + 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 }, }); } } @@ -60,6 +81,7 @@ class SelectionPlugin implements PluginWithState { this.disposer = null; } + this.detachMouseEvent(); this.editor = null; } @@ -72,68 +94,318 @@ 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 - ) { + this.onKeyDown(this.editor, event.rawEvent); + break; + + case 'contentChanged': + this.state.tableSelection = null; + break; + } + } + + private onMouseDown(editor: IEditor, rawEvent: MouseEvent) { + const selection = editor.getDOMSelection(); + let image: HTMLImageElement | null; + + // Image selection + if ( + rawEvent.button === MouseRightButton && + (image = + this.getClickingImage(rawEvent) ?? + this.getContainedTargetImage(rawEvent, selection)) && + image.isContentEditable + ) { + this.selectImage(image); + + return; + } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { + this.selectBeforeImage(editor, selection.image); + + return; + } + + // Table selection + if (selection?.type == 'table' && rawEvent.button == MouseLeftButton) { + this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); + } + + let tableSelection: TableSelectionInfo | null; + const target = rawEvent.target as Node; + + if ( + target && + rawEvent.button == MouseLeftButton && + (tableSelection = this.parseTableSelection(target, target, editor.getDOMHelper())) + ) { + this.state.tableSelection = tableSelection; + + if (rawEvent.detail >= 3) { + const lastCo = findCoordinate( + tableSelection.parsedTable, + rawEvent.target as Node, + editor.getDOMHelper() + ); + + if (lastCo) { + // Triple click, select the current cell + tableSelection.lastCo = lastCo; + this.updateTableSelection(lastCo); + rawEvent.preventDefault(); + } + } + + this.state.mouseDisposer = editor.attachDomEvent({ + mousemove: { + beforeDispatch: this.onMouseMove, + }, + }); + } + } + + private onMouseMove = (event: Event) => { + if (this.editor && this.state.tableSelection) { + const hasTableSelection = !!this.state.tableSelection.lastCo; + const currentNode = event.target as Node; + const domHelper = this.editor.getDOMHelper(); + + const range = this.editor.getDocument().createRange(); + const startNode = this.state.tableSelection.startNode; + const isReverted = + currentNode.compareDocumentPosition(startNode) == Node.DOCUMENT_POSITION_FOLLOWING; + + if (isReverted) { + range.setStart(currentNode, 0); + range.setEnd( + startNode, + isNodeOfType(startNode, 'TEXT_NODE') + ? startNode.nodeValue?.length ?? 0 + : startNode.childNodes.length + ); + } else { + range.setStart(startNode, 0); + range.setEnd(currentNode, 0); + } + + // Use common container of the range to search a common table that covers both start and end node + const tableStart = range.commonAncestorContainer; + const newTableSelection = this.parseTableSelection(tableStart, startNode, domHelper); + + if (newTableSelection) { + const lastCo = findCoordinate( + newTableSelection.parsedTable, + currentNode, + domHelper + ); + + if (newTableSelection.table != this.state.tableSelection.table) { + // Move mouse into another table (nest table scenario) + this.state.tableSelection = newTableSelection; + this.state.tableSelection.lastCo = lastCo ?? undefined; + } + + const updated = lastCo && this.updateTableSelection(lastCo); + + if (hasTableSelection || updated) { + event.preventDefault(); + } + } else if (this.editor.getDOMSelection()?.type == 'table') { + // Move mouse out of table + this.setDOMSelection( + { + type: 'range', + range, + isReverted, + }, + this.state.tableSelection + ); + } + } + }; + + private 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); + } + + this.detachMouseEvent(); + } + + private onDrop = () => { + this.detachMouseEvent(); + }; + + private onKeyDown(editor: IEditor, rawEvent: KeyboardEvent) { + const key = rawEvent.key; + const selection = editor.getDOMSelection(); + const win = editor.getDocument().defaultView; + + switch (selection?.type) { + case 'image': + if (!isModifierKey(rawEvent) && !rawEvent.shiftKey && selection.image.parentNode) { if (key === 'Escape') { - this.selectBeforeImage(this.editor, selection.image); - event.rawEvent.stopPropagation(); + this.selectBeforeImage(editor, selection.image); + rawEvent.stopPropagation(); } else if (key !== 'Delete' && key !== 'Backspace') { - this.selectBeforeImage(this.editor, selection.image); + this.selectBeforeImage(editor, selection.image); + } + } + 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.state.tableSelection?.lastCo) { + const { shiftKey, key } = rawEvent; + + if (shiftKey && (key == Left || key == Right)) { + const isRtl = + win?.getComputedStyle(this.state.tableSelection.table).direction == + 'rtl'; + + this.updateTableSelectionFromKeyboard( + 0, + (key == Left ? -1 : 1) * (isRtl ? -1 : 1) + ); + rawEvent.preventDefault(); + } else if (shiftKey && (key == Up || key == Down)) { + this.updateTableSelectionFromKeyboard(key == Up ? -1 : 1, 0); + rawEvent.preventDefault(); + } else if (key != 'Shift' && !isCharacterValue(rawEvent)) { + if (key == Up || key == Down || key == Left || key == Right) { + this.setDOMSelection(null /*domSelection*/, this.state.tableSelection); + win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); + } + } + } + break; + } + } + + private handleSelectionInTable(key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') { + if (!this.editor || !this.state.tableSelection) { + return; + } + + const selection = this.editor.getDOMSelection(); + const domHelper = this.editor.getDOMHelper(); + + if (selection?.type == 'range') { + const { + range: { collapsed, startContainer, endContainer, commonAncestorContainer }, + isReverted, + } = selection; + const start = isReverted ? endContainer : startContainer; + const end: Node | null = isReverted ? startContainer : endContainer; + const tableSel = this.parseTableSelection(commonAncestorContainer, start, domHelper); + + if (!tableSel) { + return; + } + + let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); + const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; + + if (lastCo && tableSel.table == table && lastCo.col != oldCo.col) { + if (key == Up || key == Down) { + const change = key == Up ? -1 : 1; + const originalTd = findTableCellElement(parsedTable, oldCo)?.cell; + let td: HTMLTableCellElement | null = null; + + lastCo = { row: oldCo.row + change, col: oldCo.col }; + + while (lastCo.row >= 0 && lastCo.row < parsedTable.length) { + td = findTableCellElement(parsedTable, lastCo)?.cell || null; + + if (td == originalTd) { + lastCo.row += change; + } else { + break; + } + } + + if (collapsed && td) { + 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*/ + ); + } + } else { + this.state.tableSelection = null; + } + } + + if (!collapsed && lastCo) { + this.state.tableSelection = tableSel; + this.updateTableSelection(lastCo); + } + } + } + + private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { + if (this.state.tableSelection?.lastCo && this.editor) { + const { lastCo, parsedTable } = this.state.tableSelection; + const row = lastCo.row + rowChange; + const col = lastCo.col + colChange; + + if (row >= 0 && row < parsedTable.length && col >= 0 && col < parsedTable[row].length) { + this.updateTableSelection({ row, col }); + } } } - private selectImage(editor: IEditor, image: HTMLImageElement) { - editor.setDOMSelection({ - type: 'image', - image: image, - }); + private selectImage(image: HTMLImageElement) { + this.setDOMSelection( + { + type: 'image', + image: image, + }, + null /*tableSelection*/ + ); } private selectBeforeImage(editor: IEditor, image: HTMLImageElement) { @@ -146,11 +418,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 +437,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 +460,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.state.tableSelection); } if (this.state.selection?.type == 'range' && !this.isSafari) { @@ -211,6 +486,73 @@ class SelectionPlugin implements PluginWithState { } } }; + + private parseTableSelection( + tableStart: Node, + tdStart: Node, + domHelper: DOMHelper + ): TableSelectionInfo | null { + let table: HTMLTableElement | null; + let parsedTable: ParsedTable | null; + let firstCo: TableCellCoordinate | null; + + if ( + (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && + (parsedTable = parseTableCells(table)) && + (firstCo = findCoordinate(parsedTable, tdStart, domHelper)) + ) { + return { table, parsedTable, firstCo, startNode: tdStart }; + } else { + return null; + } + } + + private updateTableSelection(lastCo: TableCellCoordinate) { + if (this.state.tableSelection && this.editor) { + const { + table, + firstCo, + parsedTable, + startNode, + lastCo: oldCo, + } = this.state.tableSelection; + + if (oldCo || firstCo.row != lastCo.row || firstCo.col != lastCo.col) { + this.state.tableSelection.lastCo = lastCo; + + this.setDOMSelection( + { + type: 'table', + table, + firstRow: firstCo.row, + firstColumn: firstCo.col, + lastRow: lastCo.row, + lastColumn: lastCo.col, + }, + { table, firstCo, lastCo, parsedTable, startNode } + ); + + return true; + } + } + + return false; + } + + private setDOMSelection( + selection: DOMSelection | null, + tableSelection: TableSelectionInfo | null + ) { + this.editor?.setDOMSelection(selection); + this.state.tableSelection = tableSelection; + } + + private detachMouseEvent() { + if (this.state.mouseDisposer) { + this.state.mouseDisposer(); + this.state.mouseDisposer = undefined; + } + } } /** 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..4e503c5478b --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts @@ -0,0 +1,25 @@ +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) { + 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..7fd6c67b9ce 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,21 @@ import { toArray } from 'roosterjs-content-model-dom'; -import type { TableSelection } from 'roosterjs-content-model-types'; +import type { + DOMHelper, + ParsedTable, + TableCellCoordinate, + 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. * @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 +28,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 +48,116 @@ export function parseTableCells(table: HTMLTableElement): (HTMLTableCellElement 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 + * @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, + coordinate: TableCellCoordinate +): TableCellCoordinateWithCell | null { + let { row, col } = coordinate; + + 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, row, col }; + } else if (cell == 'spanLeft' || cell == 'spanBoth') { + col--; + } else { + row--; + } + } + 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 + */ +export function findCoordinate( + parsedTable: ParsedTable, + element: Node, + domHelper: DOMHelper +): TableCellCoordinate | null { + const td = domHelper.findClosestElementAncestor(element, TableCellSelector); + let result: TableCellCoordinate | 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 ? { row: rowIndex, col: 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 ? { row: rowIndex, col: colIndex } : null); + }); + } + + return result; +} + /** * Create ranges from a table selection * @param selection The source table selection @@ -49,7 +172,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-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) diff --git a/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts index e3575d2b4b0..b3841ece03c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/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/setContentModel/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index 1ef705b02a8..73cd453126e 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/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/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index 530ed49ec5e..51f72c581a6 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/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, @@ -449,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( @@ -587,6 +570,93 @@ 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 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 18d79dc59f4..68e83fe8f95 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1,5 +1,7 @@ +import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; import { createSelectionPlugin } from '../../../lib/corePlugin/selection/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 = { @@ -187,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, @@ -217,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, @@ -249,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, @@ -268,7 +258,7 @@ describe('SelectionPlugin handle image selection', () => { image: mockedImage, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, @@ -288,7 +278,7 @@ describe('SelectionPlugin handle image selection', () => { image: mockedImage, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, @@ -303,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, @@ -317,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, @@ -333,7 +323,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'true'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: true, rawEvent: { @@ -353,7 +343,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'false'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: true, rawEvent: { @@ -369,7 +359,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'true'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: false, rawEvent: { @@ -386,7 +376,7 @@ describe('SelectionPlugin handle image selection', () => { } as any; getDOMSelectionSpy.and.returnValue(null); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -402,7 +392,7 @@ describe('SelectionPlugin handle image selection', () => { type: 'range', }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -434,7 +424,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -472,7 +462,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -511,7 +501,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -543,7 +533,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -553,6 +543,1029 @@ 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: { row: 0, col: 0 }, + startNode: td, + }, + mouseDisposer: mouseMoveDisposer, + imageSelectionBorderColor: undefined, + }); + 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 }, + startNode: td, + }, + 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 - in same table', () => { + 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'); + + td1.id = 'td1'; + td2.id = 'td2'; + + 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: { 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, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(state.tableSelection).toEqual({ + 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, + 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: { row: 0, col: 0 }, + lastCo: { row: 0, col: 1 }, + startNode: td1, + }); + + 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: { 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, + }); + }); + + 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: { row: 1, col: 1 }, + lastCo: { row: 0, col: 1 }, + startNode: td4, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 1, + firstColumn: 1, + lastRow: 0, + lastColumn: 1, + }); + }); + + it('From Range, Press Shift+Down', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td3, + endOffset: 0, + commonAncestorContainer: table, + collapsed: false, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 0, + firstColumn: 1, + lastRow: 1, + lastColumn: 1, + }); + }); + + it('From Range, Press Shift+Down to ouside of table', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: div, + endOffset: 0, + commonAncestorContainer: contentDiv, + collapsed: false, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('From Table, Press A', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'a', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }, + 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: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }; + + 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: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }, + 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: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }; + + 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: { row: 0, col: 1 }, + lastCo: { row: 1, col: 0 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 0, + firstColumn: 1, + lastRow: 1, + lastColumn: 0, + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('From Table, Press Shift+Up', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 1, col: 0 }, + lastCo: { row: 1, col: 1 }, + startNode: td3, + }; + + 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: { row: 1, col: 0 }, + lastCo: { row: 0, col: 1 }, + startNode: td3, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 1, + firstColumn: 0, + lastRow: 0, + lastColumn: 1, + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); +}); + describe('SelectionPlugin on Safari', () => { let disposer: jasmine.Spy; let appendChildSpy: jasmine.Spy; @@ -603,6 +1616,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 +1651,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -664,6 +1679,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedNewSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -691,6 +1707,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -718,6 +1735,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -745,6 +1763,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); }); @@ -772,6 +1791,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 ebf926eee41..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,15 +1,23 @@ -import { createTableRanges, parseTableCells } from '../../../lib/publicApi/domUtils/tableCellUtils'; -import { DOMSelection } from 'roosterjs-content-model-types'; +import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; +import { DOMHelper, DOMSelection, ParsedTable } from 'roosterjs-content-model-types'; +import { + TableCellCoordinateWithCell, + createTableRanges, + findCoordinate, + findLastedCoInMergedCell, + findTableCellElement, + parseTableCells, +} from '../../../lib/publicApi/domUtils/tableCellUtils'; 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 +45,7 @@ describe('parseTableCells', () => { '
', [ ['td1', 'td2'], - [null, 'td4'], + ['spanTop', 'td4'], ] ); }); @@ -46,7 +54,7 @@ describe('parseTableCells', () => { runTest( '
', [ - ['td1', null], + ['td1', 'spanLeft'], ['td3', 'td4'], ] ); @@ -54,8 +62,8 @@ describe('parseTableCells', () => { it('table with all merged cell', () => { runTest('
', [ - ['td1', null], - [null, null], + ['td1', 'spanLeft'], + ['spanTop', 'spanBoth'], ]); }); @@ -73,14 +81,232 @@ describe('parseTableCells', () => { runTest( '
', [ - ['td1', null, 'td3'], - ['td4', 'td5', null], - [null, 'td8', null], + ['td1', 'spanLeft', 'td3'], + ['td4', 'td5', 'spanTop'], + ['spanTop', 'td8', 'spanLeft'], ] ); }); }); +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: TableCellCoordinateWithCell | 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 = findLastedCoInMergedCell(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, { row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { row: 0, col: 1 }); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, { 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, { 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, { 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, { 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); + runTest(parsedTable, 3, 2, null); + runTest(parsedTable, 3, 3, null); + }); + + it('span both', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, 'spanLeft'], + ['spanTop', 'spanBoth'], + ]; + + 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, { 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); + 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({ row: 0, col: 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({ row: 0, col: 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({ row: 1, col: 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 e74dafa0d08..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 } from './pluginState/SelectionPluginState'; +export { + SelectionPluginState, + TableSelectionInfo, + TableCellCoordinate, +} from './pluginState/SelectionPluginState'; export { UndoPluginState } from './pluginState/UndoPluginState'; export { PluginKey, @@ -293,6 +297,7 @@ export { 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'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 0e09215976b..82b6443e17d 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 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-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index d37315b11f1..556f4ecccb3 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -1,5 +1,51 @@ +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 + */ +export interface TableSelectionInfo { + /** + * Selected table + */ + table: HTMLTableElement; + + /** + * Parsed table structure, cache this value to avoid calculating again while selecting table + */ + parsedTable: ParsedTable; + + /** + * The node where the focus is at when start selection + */ + startNode: Node; + + /** + * Coordinate for first selected table cell + */ + firstCo: TableCellCoordinate; + + /** + * Coordinate for last selected table cell + */ + lastCo?: TableCellCoordinate; +} + /** * The state object for SelectionPlugin */ @@ -9,6 +55,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/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'; diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index a766c9f950f..482701720eb 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -17,21 +17,32 @@ 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, @@ -40,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(); @@ -76,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(); @@ -111,11 +128,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 +217,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 +267,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 +351,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 +416,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,