diff --git a/demo/scripts/controls/BuildInPluginState.ts b/demo/scripts/controls/BuildInPluginState.ts index fb61f825b38..919c41da93b 100644 --- a/demo/scripts/controls/BuildInPluginState.ts +++ b/demo/scripts/controls/BuildInPluginState.ts @@ -15,7 +15,6 @@ export interface BuildInPluginList { imageEdit: boolean; cutPasteListChain: boolean; tableCellSelection: boolean; - tableResize: boolean; customReplace: boolean; listEditMenu: boolean; imageEditMenu: boolean; diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index f8999ff58f5..b09021fd353 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -20,7 +20,6 @@ import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyBut import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; import { arrayPush } from 'roosterjs-editor-dom'; -import { AutoFormatPlugin, EditPlugin, PastePlugin } from 'roosterjs-content-model-plugins'; import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; import { boldButton } from './ribbonButtons/contentModel/boldButton'; @@ -81,6 +80,12 @@ import { underlineButton } from './ribbonButtons/contentModel/underlineButton'; import { undoButton } from './ribbonButtons/contentModel/undoButton'; import { zoom } from './ribbonButtons/contentModel/zoom'; import { ContentModelSegmentFormat, IEditor, Snapshots } from 'roosterjs-content-model-types'; +import { + AutoFormatPlugin, + EditPlugin, + PastePlugin, + TableEditPlugin, +} from 'roosterjs-content-model-plugins'; import { spaceAfterButton, spaceBeforeButton, @@ -170,6 +175,7 @@ class ContentModelEditorMainPane extends MainPaneBase private formatPainterPlugin: ContentModelFormatPainterPlugin; private pastePlugin: PastePlugin; private sampleEntityPlugin: SampleEntityPlugin; + private tableEditPlugin: TableEditPlugin; private snapshots: Snapshots; private buttons: ContentModelRibbonButton[] = [ formatPainterButton, @@ -259,6 +265,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); + this.tableEditPlugin = new TableEditPlugin(); this.pastePlugin = new PastePlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); this.state = { @@ -378,6 +385,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelRibbonPlugin, this.formatPainterPlugin, this.pastePlugin, + this.tableEditPlugin, this.contentModelAutoFormatPlugin, this.contentModelEditPlugin, this.contentModelPanePlugin.getInnerRibbonPlugin(), diff --git a/demo/scripts/controls/StandaloneEditorMainPane.tsx b/demo/scripts/controls/StandaloneEditorMainPane.tsx index a55e13cbe73..6aeb849214e 100644 --- a/demo/scripts/controls/StandaloneEditorMainPane.tsx +++ b/demo/scripts/controls/StandaloneEditorMainPane.tsx @@ -17,7 +17,7 @@ import { alignCenterButton } from './ribbonButtons/contentModel/alignCenterButto import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyButton'; import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; -import { AutoFormatPlugin, EditPlugin } from 'roosterjs-content-model-plugins'; +import { AutoFormatPlugin, EditPlugin, TableEditPlugin } from 'roosterjs-content-model-plugins'; import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; import { boldButton } from './ribbonButtons/contentModel/boldButton'; @@ -166,6 +166,7 @@ class ContentModelEditorMainPane extends MainPaneBase private contentAutoFormatPlugin: AutoFormatPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; private formatPainterPlugin: ContentModelFormatPainterPlugin; + private tableEditPlugin: TableEditPlugin; private snapshots: Snapshots; private buttons: ContentModelRibbonButton[] = [ formatPainterButton, @@ -253,6 +254,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentAutoFormatPlugin = new AutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); + this.tableEditPlugin = new TableEditPlugin(); this.state = { showSidePane: window.location.hash != '', popoutWindow: null, @@ -345,6 +347,7 @@ class ContentModelEditorMainPane extends MainPaneBase plugins={[ this.contentModelRibbonPlugin, this.formatPainterPlugin, + this.tableEditPlugin, this.contentModelEditPlugin, this.contentAutoFormatPlugin, ]} diff --git a/demo/scripts/controls/getToggleablePlugins.ts b/demo/scripts/controls/getToggleablePlugins.ts index 9a3512a312c..27ad93ed877 100644 --- a/demo/scripts/controls/getToggleablePlugins.ts +++ b/demo/scripts/controls/getToggleablePlugins.ts @@ -9,7 +9,6 @@ import { HyperLink } from 'roosterjs-editor-plugins/lib/HyperLink'; import { ImageEdit } from 'roosterjs-editor-plugins/lib/ImageEdit'; import { Paste } from 'roosterjs-editor-plugins/lib/Paste'; import { TableCellSelection } from 'roosterjs-editor-plugins/lib/TableCellSelection'; -import { TableResize } from 'roosterjs-editor-plugins/lib/TableResize'; import { Watermark } from 'roosterjs-editor-plugins/lib/Watermark'; import { createContextMenuPlugin, @@ -43,9 +42,6 @@ export default function getToggleablePlugins(initState: BuildInPluginState) { imageEdit, cutPasteListChain: pluginList.cutPasteListChain ? new CutPasteListChain() : null, tableCellSelection: pluginList.tableCellSelection ? new TableCellSelection() : null, - tableResize: pluginList.tableResize - ? new TableResize(undefined, initState.tableFeaturesContainerSelector) - : null, customReplace: pluginList.customReplace ? new CustomReplacePlugin() : null, autoFormat: pluginList.autoFormat ? new AutoFormat() : null, listEditMenu: diff --git a/demo/scripts/controls/sidePane/editorOptions/ContentModelEditorOptionsPlugin.ts b/demo/scripts/controls/sidePane/editorOptions/ContentModelEditorOptionsPlugin.ts index 0f85a463869..7b4befdfa18 100644 --- a/demo/scripts/controls/sidePane/editorOptions/ContentModelEditorOptionsPlugin.ts +++ b/demo/scripts/controls/sidePane/editorOptions/ContentModelEditorOptionsPlugin.ts @@ -29,7 +29,6 @@ const initialState: BuildInPluginState = { imageEdit: true, cutPasteListChain: false, tableCellSelection: true, - tableResize: true, customReplace: true, listEditMenu: true, imageEditMenu: true, diff --git a/demo/scripts/controls/sidePane/editorOptions/ContentModelPlugins.tsx b/demo/scripts/controls/sidePane/editorOptions/ContentModelPlugins.tsx index a1944307c38..8ec200746bf 100644 --- a/demo/scripts/controls/sidePane/editorOptions/ContentModelPlugins.tsx +++ b/demo/scripts/controls/sidePane/editorOptions/ContentModelPlugins.tsx @@ -63,7 +63,6 @@ export default class ContentModelPlugins extends React.Component (state.applyChangesOnMouseUp = value) ) )} - {this.renderPluginItem('tableResize', 'Table Resize Plugin')} {this.renderPluginItem('customReplace', 'Custom Replace Plugin (autocomplete)')} {this.renderPluginItem( 'contextMenu', diff --git a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts index 01c06bc44d0..47528ae642d 100644 --- a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -13,7 +13,6 @@ const initialState: BuildInPluginState = { imageEdit: true, cutPasteListChain: true, tableCellSelection: true, - tableResize: true, customReplace: true, listEditMenu: true, imageEditMenu: true, diff --git a/demo/scripts/controls/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controls/sidePane/editorOptions/Plugins.tsx index 952f890ce85..8135a4c0638 100644 --- a/demo/scripts/controls/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controls/sidePane/editorOptions/Plugins.tsx @@ -54,7 +54,6 @@ export default class Plugins extends React.Component { ) )} {this.renderPluginItem('cutPasteListChain', 'CutPasteListChainPlugin')} - {this.renderPluginItem('tableResize', 'Table Resize Plugin')} {this.renderPluginItem('customReplace', 'Custom Replace Plugin (autocomplete)')} {this.renderPluginItem( 'contextMenu', diff --git a/demo/scripts/controls/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controls/sidePane/editorOptions/codes/PluginsCode.ts index 0148327ffff..10fa248b3b0 100644 --- a/demo/scripts/controls/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controls/sidePane/editorOptions/codes/PluginsCode.ts @@ -9,7 +9,6 @@ import { CutPasteListChainCode, ImageEditCode, ContentModelPasteCode, - TableResizeCode, } from './SimplePluginCode'; export default class PluginsCode extends CodeElement { @@ -26,7 +25,6 @@ export default class PluginsCode extends CodeElement { pluginList.watermark && new WatermarkCode(this.state.watermarkText), pluginList.imageEdit && new ImageEditCode(), pluginList.cutPasteListChain && new CutPasteListChainCode(), - pluginList.tableResize && new TableResizeCode(), pluginList.customReplace && new CustomReplaceCode(), pluginList.tableCellSelection && new TableCellSelectionCode(), ].filter(plugin => !!plugin); diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index 9a2034e3450..739257e9367 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -33,7 +33,7 @@ export { export { setSelection } from './publicApi/selection/setSelection'; export { applyTableFormat } from './publicApi/table/applyTableFormat'; -export { normalizeTable } from './publicApi/table/normalizeTable'; +export { normalizeTable, MIN_ALLOWED_TABLE_CELL_WIDTH } from './publicApi/table/normalizeTable'; export { setTableCellBackgroundColor } from './publicApi/table/setTableCellBackgroundColor'; export { getSelectedCells } from './publicApi/table/getSelectedCells'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts index 3a43078f9cd..d4052e6c64c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts @@ -6,6 +6,10 @@ import type { ContentModelTableCell, } from 'roosterjs-content-model-types'; +/** + * Minimum width for a table cell + */ +export const MIN_ALLOWED_TABLE_CELL_WIDTH: number = 30; const MIN_HEIGHT = 22; /** @@ -75,6 +79,8 @@ export function normalizeTable( for (let i = 0; i < columns; i++) { if (table.widths[i] === undefined) { table.widths[i] = getTableCellWidth(columns); + } else if (table.widths[i] < MIN_ALLOWED_TABLE_CELL_WIDTH) { + table.widths[i] = MIN_ALLOWED_TABLE_CELL_WIDTH; } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index fe5a21770a7..1c3da4abc9d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,3 +1,4 @@ +export { TableEditPlugin } from './tableEdit/TableEditPlugin'; export { PastePlugin } from './paste/PastePlugin'; export { EditPlugin } from './edit/EditPlugin'; export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts new file mode 100644 index 00000000000..f2b0c1b9649 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/TableEditPlugin.ts @@ -0,0 +1,175 @@ +import normalizeRect from '../pluginUtils/Rect/normalizeRect'; +import TableEditor from './editors/TableEditor'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type { EditorPlugin, IEditor, PluginEvent, Rect } from 'roosterjs-content-model-types'; + +const TABLE_RESIZER_LENGTH = 12; + +/** + * TableEdit plugin, provides the ability to resize a table by drag-and-drop + */ +export class TableEditPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private onMouseMoveDisposer: (() => void) | null = null; + private tableRectMap: { table: HTMLTableElement; rect: Rect }[] | null = null; + private tableEditor: TableEditor | null = null; + + /** + * Construct a new instance of TableResize plugin + * @param anchorContainerSelector An optional selector string to specify the container to host the plugin. + * The container must not be affected by transform: scale(), otherwise the position calculation will be wrong. + * If not specified, the plugin will be inserted in document.body + */ + constructor(private anchorContainerSelector?: string) {} + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'TableEdit'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + this.onMouseMoveDisposer = this.editor.attachDomEvent({ + mousemove: { beforeDispatch: this.onMouseMove }, + }); + const scrollContainer = this.editor.getScrollContainer(); + scrollContainer.addEventListener('mouseout', this.onMouseOut); + } + + private onMouseOut = ({ relatedTarget, currentTarget }: MouseEvent) => { + const relatedTargetNode = relatedTarget as Node; + const currentTargetNode = currentTarget as Node; + if ( + isNodeOfType(relatedTargetNode, 'ELEMENT_NODE') && + isNodeOfType(currentTargetNode, 'ELEMENT_NODE') && + this.tableEditor && + !this.tableEditor.isOwnedElement(relatedTargetNode) && + !currentTargetNode.contains(relatedTargetNode) + ) { + this.setTableEditor(null); + } + }; + + /** + * Dispose this plugin + */ + dispose() { + const scrollContainer = this.editor?.getScrollContainer(); + scrollContainer?.removeEventListener('mouseout', this.onMouseOut); + this.onMouseMoveDisposer?.(); + this.invalidateTableRects(); + this.disposeTableEditor(); + this.editor = null; + this.onMouseMoveDisposer = null; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(e: PluginEvent) { + switch (e.eventType) { + case 'input': + case 'contentChanged': + case 'scroll': + case 'zoomChanged': + this.setTableEditor(null); + this.invalidateTableRects(); + break; + } + } + + private onMouseMove = (event: Event) => { + const e = event as MouseEvent; + + if (e.buttons > 0 || !this.editor) { + return; + } + + this.ensureTableRects(); + + const editorWindow = this.editor.getDocument().defaultView || window; + const x = e.pageX - editorWindow.scrollX; + const y = e.pageY - editorWindow.scrollY; + let currentTable: HTMLTableElement | null = null; + + //Find table in range of mouse + if (this.tableRectMap) { + for (let i = this.tableRectMap.length - 1; i >= 0; i--) { + const { table, rect } = this.tableRectMap[i]; + + if ( + x >= rect.left - TABLE_RESIZER_LENGTH && + x <= rect.right + TABLE_RESIZER_LENGTH && + y >= rect.top - TABLE_RESIZER_LENGTH && + y <= rect.bottom + TABLE_RESIZER_LENGTH + ) { + currentTable = table; + break; + } + } + } + + this.setTableEditor(currentTable, e); + this.tableEditor?.onMouseMove(x, y); + }; + + /** + * @internal Public only for unit test + * @param table Table to use when setting the Editors + * @param event (Optional) Mouse event + */ + public setTableEditor(table: HTMLTableElement | null, event?: MouseEvent) { + if (this.tableEditor && !this.tableEditor.isEditing() && table != this.tableEditor.table) { + this.disposeTableEditor(); + } + + if (!this.tableEditor && table && this.editor && table.rows.length > 0) { + const container = this.anchorContainerSelector + ? this.editor.getDOMHelper().queryElements(this.anchorContainerSelector)[0] + : undefined; + + this.tableEditor = new TableEditor( + this.editor, + table, + this.invalidateTableRects, + isNodeOfType(container as Node, 'ELEMENT_NODE') ? container : undefined, + event?.currentTarget + ); + } + } + + private invalidateTableRects = () => { + this.tableRectMap = null; + }; + + private disposeTableEditor() { + this.tableEditor?.dispose(); + this.tableEditor = null; + } + + private ensureTableRects() { + if (!this.tableRectMap && this.editor) { + this.tableRectMap = []; + + const tables = this.editor.getDOMHelper().queryElements('table'); + tables.forEach(table => { + if (table.isContentEditable) { + const rect = normalizeRect(table.getBoundingClientRect()); + if (rect && this.tableRectMap) { + this.tableRectMap.push({ + table, + rect, + }); + } + } + }); + } + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts new file mode 100644 index 00000000000..96cedfe6d22 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/TableEditor.ts @@ -0,0 +1,385 @@ +import createCellResizer from './features/CellResizer'; +import createTableInserter from './features/TableInserter'; +import createTableMover from './features/TableMover'; +import createTableResizer from './features/TableResizer'; +import normalizeRect from '../../pluginUtils/Rect/normalizeRect'; +import { disposeTableEditFeature } from './features/TableEditorFeature'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type TableEditFeature from './features/TableEditorFeature'; +import type { IEditor, TableSelection } from 'roosterjs-content-model-types'; + +const INSERTER_HOVER_OFFSET = 6; +const enum TOP_OR_SIDE { + top = 0, + side = 1, +} +/** + * @internal + * + * A table has 6 hot areas to be resized/edited (take LTR example): + * + * [6] [ ] + * +[ 1 ]+--------------------+ + * |[ ]| | + * [ ] [ ] | + * [ ] [ ] | + * [2] [3] | + * [ ] [ ] | + * [ ][ 4 ]| | + * +------------------+--------------------+ + * | | | + * | | | + * | | | + * +------------------+--------------------+ + * [5] + * + * 1 - Hover area to show insert column button + * 2 - Hover area to show insert row button + * 3 - Hover area to show vertical resizing bar + * 4 - Hover area to show horizontal resizing bar + * 5 - Hover area to show whole table resize handle + * 6 - Hover area to show whole table mover handle + * + * When set a different current table or change current TD, we need to update these areas + */ +export default class TableEditor { + // 1, 2 - Insert a column or a row + private horizontalInserter: TableEditFeature | null = null; + private verticalInserter: TableEditFeature | null = null; + + // 3, 4 - Resize a column or a row from a cell + private horizontalResizer: TableEditFeature | null = null; + private verticalResizer: TableEditFeature | null = null; + + // 5 - Resize whole table + private tableResizer: TableEditFeature | null = null; + + // 6 - Move as well as select whole table + private tableMover: TableEditFeature | null = null; + + private isRTL: boolean; + private range: Range | null = null; + private isCurrentlyEditing: boolean; + + constructor( + private editor: IEditor, + public readonly table: HTMLTableElement, + private onChanged: () => void, + private anchorContainer?: HTMLElement, + private contentDiv?: EventTarget | null + ) { + this.isRTL = editor.getDocument().defaultView?.getComputedStyle(table).direction == 'rtl'; + this.setEditorFeatures(); + this.isCurrentlyEditing = false; + } + + dispose() { + this.disposeTableResizer(); + this.disposeCellResizers(); + this.disposeTableInserter(); + this.disposeTableMover(); + } + + isEditing(): boolean { + return this.isCurrentlyEditing; + } + + isOwnedElement(node: Node) { + return [ + this.tableResizer, + this.tableMover, + this.horizontalInserter, + this.verticalInserter, + this.horizontalResizer, + this.verticalResizer, + ] + .filter(feature => !!feature?.div) + .some(feature => feature?.div == node); + } + + onMouseMove(x: number, y: number) { + // Get whole table rect + const tableRect = normalizeRect(this.table.getBoundingClientRect()); + + //console.log('>>>tableRect', tableRect); + if (!tableRect) { + return; + } + + // Determine if cursor is on top or side + const topOrSide = + y <= tableRect.top + INSERTER_HOVER_OFFSET + ? TOP_OR_SIDE.top + : this.isRTL + ? x >= tableRect.right - INSERTER_HOVER_OFFSET + ? TOP_OR_SIDE.side + : undefined + : x <= tableRect.left + INSERTER_HOVER_OFFSET + ? TOP_OR_SIDE.side + : undefined; + const topOrSideBinary = topOrSide ? 1 : 0; + + // i is row index, j is column index + for (let i = 0; i < this.table.rows.length; i++) { + const tr = this.table.rows[i]; + let j = 0; + for (; j < tr.cells.length; j++) { + const td = tr.cells[j]; + const tdRect = normalizeRect(td.getBoundingClientRect()); + + if (!tdRect || !tableRect) { + continue; + } + + // Determine the cell the cursor is in range of + // Offset is only used for first row and column + const lessThanBottom = y <= tdRect.bottom; + const lessThanRight = this.isRTL + ? x <= tdRect.right + INSERTER_HOVER_OFFSET * topOrSideBinary + : x <= tdRect.right; + const moreThanLeft = this.isRTL + ? x >= tdRect.left + : x >= tdRect.left - INSERTER_HOVER_OFFSET * topOrSideBinary; + + if (lessThanBottom && lessThanRight && moreThanLeft) { + if (i === 0 && topOrSide == TOP_OR_SIDE.top) { + const center = (tdRect.left + tdRect.right) / 2; + const isOnRightHalf = this.isRTL ? x < center : x > center; + this.setInserterTd( + isOnRightHalf ? td : tr.cells[j - 1], + false /*isHorizontal*/ + ); + } else if (j === 0 && topOrSide == TOP_OR_SIDE.side) { + const tdAbove = this.table.rows[i - 1]?.cells[0]; + const tdAboveRect = tdAbove + ? normalizeRect(tdAbove.getBoundingClientRect()) + : null; + + const isTdNotAboveMerged = !tdAboveRect + ? null + : this.isRTL + ? tdAboveRect.right === tdRect.right + : tdAboveRect.left === tdRect.left; + + this.setInserterTd( + y < (tdRect.top + tdRect.bottom) / 2 && isTdNotAboveMerged + ? tdAbove + : td, + true /*isHorizontal*/ + ); + } else { + this.setInserterTd(null); + } + + this.setResizingTd(td); + + //Cell found + break; + } + } + + if (j < tr.cells.length) { + break; + } + } + + // Create Mover and Resizer + this.setEditorFeatures(); + } + + private setEditorFeatures() { + if (!this.tableMover) { + this.tableMover = createTableMover( + this.table, + this.editor, + this.isRTL, + this.onSelect, + this.getOnMouseOut, + this.contentDiv, + this.anchorContainer + ); + } + + if (!this.tableResizer) { + this.tableResizer = createTableResizer( + this.table, + this.editor, + this.isRTL, + this.onStartTableResize, + this.onFinishEditing, + this.contentDiv, + this.anchorContainer + ); + } + } + + private setResizingTd(td: HTMLTableCellElement) { + if (this.horizontalResizer && this.horizontalResizer.node != td) { + this.disposeCellResizers(); + } + + if (!this.horizontalResizer && td) { + this.horizontalResizer = createCellResizer( + this.editor, + td, + this.table, + this.isRTL, + true /*isHorizontal*/, + this.onStartCellResize, + this.onFinishEditing, + this.anchorContainer + ); + this.verticalResizer = createCellResizer( + this.editor, + td, + this.table, + this.isRTL, + false /*isHorizontal*/, + this.onStartCellResize, + this.onFinishEditing, + this.anchorContainer + ); + } + } + + /** + * create or remove TableInserter + * @param td td to attach to, set this to null to remove inserters (both horizontal and vertical) + */ + private setInserterTd(td: HTMLTableCellElement | null, isHorizontal?: boolean) { + const inserter = isHorizontal ? this.horizontalInserter : this.verticalInserter; + if (td === null || (inserter && inserter.node != td)) { + this.disposeTableInserter(); + } + + if (!this.horizontalInserter && !this.verticalInserter && td) { + const newInserter = createTableInserter( + this.editor, + td, + this.table, + this.isRTL, + !!isHorizontal, + this.onInserted, + this.getOnMouseOut, + this.anchorContainer + ); + if (isHorizontal) { + this.horizontalInserter = newInserter; + } else { + this.verticalInserter = newInserter; + } + } + } + + private disposeTableResizer() { + if (this.tableResizer) { + disposeTableEditFeature(this.tableResizer); + this.tableResizer = null; + } + } + + private disposeTableInserter() { + if (this.horizontalInserter) { + disposeTableEditFeature(this.horizontalInserter); + this.horizontalInserter = null; + } + if (this.verticalInserter) { + disposeTableEditFeature(this.verticalInserter); + this.verticalInserter = null; + } + } + + private disposeCellResizers() { + if (this.horizontalResizer) { + disposeTableEditFeature(this.horizontalResizer); + this.horizontalResizer = null; + } + if (this.verticalResizer) { + disposeTableEditFeature(this.verticalResizer); + this.verticalResizer = null; + } + } + + private disposeTableMover() { + if (this.tableMover) { + disposeTableEditFeature(this.tableMover); + this.tableMover = null; + } + } + + private onFinishEditing = (): false => { + this.editor.focus(); + + if (this.range) { + this.editor.setDOMSelection({ type: 'range', range: this.range, isReverted: false }); + } + + this.editor.takeSnapshot(); // Pass in an empty callback to make sure ContentChangedEvent is triggered + this.onChanged(); + this.isCurrentlyEditing = false; + + return false; + }; + + private onStartTableResize = () => { + this.isCurrentlyEditing = true; + this.onStartResize(); + }; + + private onStartCellResize = () => { + this.isCurrentlyEditing = true; + this.disposeTableResizer(); + this.onStartResize(); + }; + + private onStartResize() { + this.isCurrentlyEditing = true; + const range = this.editor.getDOMSelection(); + + if (range && range.type == 'range') { + this.range = range.range; + } + + this.editor.takeSnapshot(); + } + + private onInserted = () => { + this.disposeTableResizer(); + this.onFinishEditing(); + }; + + /** + * Public only for testing purposes + * @param table the table to select + */ + public onSelect = (table: HTMLTableElement) => { + this.editor.focus(); + + if (table) { + const selection: TableSelection = { + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: table.rows.length - 1, + lastColumn: table.rows[table.rows.length - 1].cells.length - 1, + type: 'table', + }; + + this.editor.setDOMSelection(selection); + } + }; + + private getOnMouseOut = (feature: HTMLElement) => { + return (ev: MouseEvent) => { + if ( + feature && + ev.relatedTarget != feature && + isNodeOfType(this.contentDiv as Node, 'ELEMENT_NODE') && + isNodeOfType(ev.relatedTarget as Node, 'ELEMENT_NODE') && + !(this.contentDiv == ev.relatedTarget) + ) { + this.dispose(); + } + }; + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts new file mode 100644 index 00000000000..799879858be --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/CellResizer.ts @@ -0,0 +1,244 @@ +import createElement from '../../../pluginUtils/CreateElement/createElement'; +import DragAndDropHelper from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import normalizeRect from '../../../pluginUtils/Rect/normalizeRect'; +import { isElementOfType } from 'roosterjs-content-model-dom'; +import { + getFirstSelectedTable, + MIN_ALLOWED_TABLE_CELL_WIDTH, + normalizeTable, +} from 'roosterjs-content-model-core'; +import type DragAndDropHandler from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; +import type { ContentModelTable, IEditor } from 'roosterjs-content-model-types'; +import type TableEditFeature from './TableEditorFeature'; + +const CELL_RESIZER_WIDTH = 4; + +/** + * @internal + */ +export default function createCellResizer( + editor: IEditor, + td: HTMLTableCellElement, + table: HTMLTableElement, + isRTL: boolean, + isHorizontal: boolean, + onStart: () => void, + onEnd: () => false, + anchorContainer?: HTMLElement +): TableEditFeature | null { + const document = td.ownerDocument; + const createElementData = { + tag: 'div', + style: `position: fixed; cursor: ${isHorizontal ? 'row' : 'col'}-resize; user-select: none`, + }; + const zoomScale = editor.getDOMHelper().calculateZoomScale(); + + const div = createElement(createElementData, document) as HTMLDivElement; + + (anchorContainer || document.body).appendChild(div); + + const context: DragAndDropContext = { editor, td, table, isRTL, zoomScale, onStart }; + const setPosition = isHorizontal ? setHorizontalPosition : setVerticalPosition; + setPosition(context, div); + + const handler: DragAndDropHandler = { + onDragStart, + // Horizontal modifies row height, vertical modifies column width + onDragging: isHorizontal ? onDraggingHorizontal : onDraggingVertical, + onDragEnd: onEnd, + }; + + const featureHandler = new DragAndDropHelper( + div, + context, + setPosition, + handler, + zoomScale, + editor.getEnvironment().isMobileOrTablet + ); + + return { node: td, div, featureHandler }; +} + +interface DragAndDropContext { + editor: IEditor; + td: HTMLTableCellElement; + table: HTMLTableElement; + isRTL: boolean; + zoomScale: number; + onStart: () => void; +} + +interface DragAndDropInitValue { + cmTable: ContentModelTable | undefined; + anchorColumn: number | undefined; + anchorRow: number | undefined; + anchorRowHeight: number; + allWidths: number[]; +} + +function onDragStart(context: DragAndDropContext, event: MouseEvent): DragAndDropInitValue { + const { td, onStart } = context; + const rect = normalizeRect(td.getBoundingClientRect()); + + // Get cell coordinates + const columnIndex = td.cellIndex; + const row = + td.parentElement && isElementOfType(td.parentElement, 'tr') ? td.parentElement : undefined; + const rowIndex = row?.rowIndex; + + if (rowIndex == undefined) { + return { + cmTable: undefined, + anchorColumn: undefined, + anchorRow: undefined, + anchorRowHeight: -1, + allWidths: [], + }; // Just a fallback + } + + const { editor, table } = context; + + // Get current selection + const selection = editor.getDOMSelection(); + + // Select first cell of the table + editor.setDOMSelection({ + type: 'table', + firstColumn: 0, + firstRow: 0, + lastColumn: 0, + lastRow: 0, + table: table, + }); + + // Get the table content model + const cmTable = getFirstSelectedTable(editor.getContentModelCopy('disconnected'))[0]; + + // Restore selection + editor.setDOMSelection(selection); + + if (rect && cmTable) { + onStart(); + + return { + cmTable, + anchorColumn: columnIndex, + anchorRow: rowIndex, + anchorRowHeight: cmTable.rows[rowIndex].height, + allWidths: [...cmTable.widths], + }; + } else { + return { + cmTable, + anchorColumn: undefined, + anchorRow: undefined, + anchorRowHeight: -1, + allWidths: [], + }; // Just a fallback + } +} + +function onDraggingHorizontal( + context: DragAndDropContext, + event: MouseEvent, + initValue: DragAndDropInitValue, + deltaX: number, + deltaY: number +) { + const { table } = context; + const { cmTable, anchorRow, anchorRowHeight } = initValue; + + // Assign new widths and heights to the CM table + if (cmTable && anchorRow != undefined) { + // Modify the CM Table size + cmTable.rows[anchorRow].height = (anchorRowHeight ?? 0) + deltaY; + + // Normalize the table + normalizeTable(cmTable); + + // Writeback CM Table size changes to DOM Table + const tableRow = table.rows[anchorRow]; + for (let col = 0; col < tableRow.cells.length; col++) { + const td = tableRow.cells[col]; + td.style.height = cmTable.rows[anchorRow].height + 'px'; + } + + return true; + } else { + return false; + } +} + +function onDraggingVertical( + context: DragAndDropContext, + event: MouseEvent, + initValue: DragAndDropInitValue, + deltaX: number +) { + const { table, isRTL } = context; + const { cmTable, anchorColumn, allWidths } = initValue; + + // Assign new widths and heights to the CM table + if (cmTable && anchorColumn != undefined) { + // Modify the CM Table size + const lastColumn = anchorColumn == cmTable.widths.length - 1; + const change = deltaX * (isRTL ? -1 : 1); + // This is the last column + if (lastColumn) { + // Only the last column changes + cmTable.widths[anchorColumn] = allWidths[anchorColumn] + change; + } else { + // Any other two columns + const anchorChange = allWidths[anchorColumn] + change; + const nextAnchorChange = allWidths[anchorColumn + 1] - change; + if ( + anchorChange < MIN_ALLOWED_TABLE_CELL_WIDTH || + nextAnchorChange < MIN_ALLOWED_TABLE_CELL_WIDTH + ) { + return false; + } + cmTable.widths[anchorColumn] = anchorChange; + cmTable.widths[anchorColumn + 1] = nextAnchorChange; + } + + // Normalize the table + normalizeTable(cmTable); + + // Writeback CM Table size changes to DOM Table + for (let row = 0; row < table.rows.length; row++) { + const tableRow = table.rows[row]; + for (let col = 0; col < tableRow.cells.length; col++) { + tableRow.cells[col].style.width = cmTable.widths[col] + 'px'; + } + } + + return true; + } else { + return false; + } +} + +function setHorizontalPosition(context: DragAndDropContext, trigger: HTMLElement) { + const { td } = context; + const rect = normalizeRect(td.getBoundingClientRect()); + if (rect) { + trigger.id = 'horizontalResizer'; + trigger.style.top = rect.bottom - CELL_RESIZER_WIDTH + 'px'; + trigger.style.left = rect.left + 'px'; + trigger.style.width = rect.right - rect.left + 'px'; + trigger.style.height = CELL_RESIZER_WIDTH + 'px'; + } +} + +function setVerticalPosition(context: DragAndDropContext, trigger: HTMLElement) { + const { td, isRTL } = context; + const rect = normalizeRect(td.getBoundingClientRect()); + if (rect) { + trigger.id = 'verticalResizer'; + trigger.style.top = rect.top + 'px'; + trigger.style.left = (isRTL ? rect.left : rect.right) - CELL_RESIZER_WIDTH + 1 + 'px'; + trigger.style.width = CELL_RESIZER_WIDTH + 'px'; + trigger.style.height = rect.bottom - rect.top + 'px'; + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditorFeature.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditorFeature.ts new file mode 100644 index 00000000000..f244e2bd39d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableEditorFeature.ts @@ -0,0 +1,22 @@ +import type Disposable from '../../../pluginUtils/Disposable'; + +/** + * @internal + */ +export default interface TableEditFeature { + node: Node; + div: HTMLDivElement | null; + featureHandler: Disposable | null; +} + +/** + * @internal + */ +export function disposeTableEditFeature(resizer: TableEditFeature | null) { + if (resizer) { + resizer.div?.parentNode?.removeChild(resizer.div); + resizer.div = null; + resizer.featureHandler?.dispose(); + resizer.featureHandler = null; + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts new file mode 100644 index 00000000000..9a6d45eec5d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableInserter.ts @@ -0,0 +1,173 @@ +import createElement from '../../../pluginUtils/CreateElement/createElement'; +import getIntersectedRect from '../../../pluginUtils/Rect/getIntersectedRect'; +import normalizeRect from '../../../pluginUtils/Rect/normalizeRect'; +import { isElementOfType } from 'roosterjs-content-model-dom'; +import { + formatTableWithContentModel, + insertTableColumn, + insertTableRow, +} from 'roosterjs-content-model-api'; +import type CreateElementData from '../../../pluginUtils/CreateElement/CreateElementData'; +import type Disposable from '../../../pluginUtils/Disposable'; +import type TableEditFeature from './TableEditorFeature'; +import type { IEditor } from 'roosterjs-content-model-types'; + +const INSERTER_COLOR = '#4A4A4A'; +const INSERTER_COLOR_DARK_MODE = 'white'; +const INSERTER_SIDE_LENGTH = 12; +const INSERTER_BORDER_SIZE = 1; + +/** + * @internal + */ +export default function createTableInserter( + editor: IEditor, + td: HTMLTableCellElement, + table: HTMLTableElement, + isRTL: boolean, + isHorizontal: boolean, + onInsert: () => void, + getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, + anchorContainer?: HTMLElement +): TableEditFeature | null { + const tdRect = normalizeRect(td.getBoundingClientRect()); + const viewPort = editor.getVisibleViewport(); + const tableRect = table && viewPort ? getIntersectedRect([table], [viewPort]) : null; + + // set inserter position + if (tdRect && tableRect) { + const document = td.ownerDocument; + const createElementData = getInsertElementData( + isHorizontal, + editor.isDarkMode(), + isRTL, + editor.getDOMHelper().getDomStyle('backgroundColor') || 'white' + ); + + const div = createElement(createElementData, document) as HTMLDivElement; + + if (isHorizontal) { + // tableRect.left/right is used because the Inserter is always intended to be on the side + div.id = 'horizontalInserter'; + div.style.left = `${ + isRTL + ? tableRect.right + : tableRect.left - (INSERTER_SIDE_LENGTH - 1 + 2 * INSERTER_BORDER_SIZE) + }px`; + div.style.top = `${tdRect.bottom - 8}px`; + (div.firstChild as HTMLElement).style.width = `${tableRect.right - tableRect.left}px`; + } else { + div.id = 'verticalInserter'; + div.style.left = `${isRTL ? tdRect.left - 8 : tdRect.right - 8}px`; + // tableRect.top is used because the Inserter is always intended to be on top + div.style.top = `${ + tableRect.top - (INSERTER_SIDE_LENGTH - 1 + 2 * INSERTER_BORDER_SIZE) + }px`; + (div.firstChild as HTMLElement).style.height = `${tableRect.bottom - tableRect.top}px`; + } + + (anchorContainer || document.body).appendChild(div); + + const handler = new TableInsertHandler( + div, + td, + table, + isHorizontal, + editor, + onInsert, + getOnMouseOut + ); + + return { div, featureHandler: handler, node: td }; + } + + return null; +} + +class TableInsertHandler implements Disposable { + private onMouseOutEvent: null | ((ev: MouseEvent) => void); + constructor( + private div: HTMLDivElement, + private td: HTMLTableCellElement, + private table: HTMLTableElement, + private isHorizontal: boolean, + private editor: IEditor, + private onInsert: () => void, + getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void + ) { + this.div.addEventListener('click', this.insertTd); + this.onMouseOutEvent = getOnMouseOut(div); + this.div.addEventListener('mouseout', this.onMouseOutEvent); + } + + dispose() { + this.div.removeEventListener('click', this.insertTd); + + if (this.onMouseOutEvent) { + this.div.removeEventListener('mouseout', this.onMouseOutEvent); + } + + this.onMouseOutEvent = null; + } + + private insertTd = () => { + // Get cell coordinates + const columnIndex = this.td.cellIndex; + const row = + this.td.parentElement && isElementOfType(this.td.parentElement, 'tr') + ? this.td.parentElement + : undefined; + const rowIndex = row && row.rowIndex; + + if (row?.cells == undefined || rowIndex == undefined) { + return; + } + + // Insert row or column + formatTableWithContentModel( + this.editor, + 'editTablePlugin', + tableModel => { + this.isHorizontal + ? insertTableRow(tableModel, 'insertBelow') + : insertTableColumn(tableModel, 'insertRight'); + }, // Select cell to make insertion + { + type: 'table', + firstColumn: columnIndex, + firstRow: rowIndex, + lastColumn: columnIndex, + lastRow: rowIndex, + table: this.table, + } + ); + + this.onInsert(); + }; +} + +function getInsertElementData( + isHorizontal: boolean, + isDark: boolean, + isRTL: boolean, + backgroundColor: string +): CreateElementData { + const inserterColor = isDark ? INSERTER_COLOR_DARK_MODE : INSERTER_COLOR; + const outerDivStyle = `position: fixed; width: ${INSERTER_SIDE_LENGTH}px; height: ${INSERTER_SIDE_LENGTH}px; font-size: 16px; color: black; line-height: 8px; vertical-align: middle; text-align: center; cursor: pointer; border: solid ${INSERTER_BORDER_SIZE}px ${inserterColor}; border-radius: 50%; background-color: ${backgroundColor}`; + const leftOrRight = isRTL ? 'right' : 'left'; + const childBaseStyles = `position: absolute; box-sizing: border-box; background-color: ${backgroundColor};`; + const childInfo: CreateElementData = { + tag: 'div', + style: + childBaseStyles + + (isHorizontal + ? `${leftOrRight}: 12px; top: 5px; height: 3px; border-top: 1px solid ${inserterColor}; border-bottom: 1px solid ${inserterColor}; border-right: 1px solid ${inserterColor}; border-left: 0px;` + : `left: 5px; top: 12px; width: 3px; border-left: 1px solid ${inserterColor}; border-right: 1px solid ${inserterColor}; border-bottom: 1px solid ${inserterColor}; border-top: 0px;`), + }; + + return { + tag: 'div', + style: outerDivStyle, + children: [childInfo, '+'], + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts new file mode 100644 index 00000000000..d4dd46a94c0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts @@ -0,0 +1,136 @@ +import createElement from '../../../pluginUtils/CreateElement/createElement'; +import DragAndDropHelper from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import normalizeRect from '../../../pluginUtils/Rect/normalizeRect'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type DragAndDropHandler from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; +import type { IEditor, Rect } from 'roosterjs-content-model-types'; +import type TableEditorFeature from './TableEditorFeature'; + +const TABLE_MOVER_LENGTH = 12; +const TABLE_MOVER_ID = '_Table_Mover'; + +/** + * @internal + * Contains the function to select whole table + * Moving behavior not implemented yet + */ +export default function createTableMover( + table: HTMLTableElement, + editor: IEditor, + isRTL: boolean, + onFinishDragging: (table: HTMLTableElement) => void, + getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, + contentDiv?: EventTarget | null, + anchorContainer?: HTMLElement +): TableEditorFeature | null { + const rect = normalizeRect(table.getBoundingClientRect()); + + if (!isTableTopVisible(editor, rect, contentDiv as Node)) { + return null; + } + + const zoomScale = editor.getDOMHelper().calculateZoomScale(); + const document = table.ownerDocument; + const createElementData = { + tag: 'div', + style: 'position: fixed; cursor: all-scroll; user-select: none; border: 1px solid #808080', + }; + + const div = createElement(createElementData, document) as HTMLDivElement; + + div.id = TABLE_MOVER_ID; + div.style.width = `${TABLE_MOVER_LENGTH}px`; + div.style.height = `${TABLE_MOVER_LENGTH}px`; + + (anchorContainer || document.body).appendChild(div); + + const context: TableMoverContext = { + table, + zoomScale, + rect, + isRTL, + }; + + setDivPosition(context, div); + + const onDragEnd = (context: TableMoverContext, event: MouseEvent): false => { + if (event.target == div) { + onFinishDragging(context.table); + } + return false; + }; + + const featureHandler = new TableMoverFeature( + div, + context, + setDivPosition, + { + onDragEnd, + }, + context.zoomScale, + getOnMouseOut + ); + + return { div, featureHandler, node: table }; +} + +interface TableMoverContext { + table: HTMLTableElement; + zoomScale: number; + rect: Rect | null; + isRTL: boolean; +} + +interface TableMoverInitValue { + event: MouseEvent; +} + +class TableMoverFeature extends DragAndDropHelper { + private onMouseOut: ((ev: MouseEvent) => void) | null; + + constructor( + private div: HTMLElement, + context: TableMoverContext, + onSubmit: ( + context: TableMoverContext, + trigger: HTMLElement, + container?: HTMLElement + ) => void, + handler: DragAndDropHandler, + zoomScale: number, + getOnMouseOut: (feature: HTMLElement) => (ev: MouseEvent) => void, + forceMobile?: boolean | undefined, + container?: HTMLElement + ) { + super(div, context, onSubmit, handler, zoomScale, forceMobile); + this.onMouseOut = getOnMouseOut(div); + div.addEventListener('mouseout', this.onMouseOut); + } + + dispose(): void { + super.dispose(); + if (this.onMouseOut) { + this.div.removeEventListener('mouseout', this.onMouseOut); + } + this.onMouseOut = null; + } +} + +function setDivPosition(context: TableMoverContext, trigger: HTMLElement) { + const { rect } = context; + if (rect) { + trigger.style.top = `${rect.top - TABLE_MOVER_LENGTH}px`; + trigger.style.left = `${rect.left - TABLE_MOVER_LENGTH - 2}px`; + } +} + +function isTableTopVisible(editor: IEditor, rect: Rect | null, contentDiv?: Node | null): boolean { + const visibleViewport = editor.getVisibleViewport(); + if (isNodeOfType(contentDiv, 'ELEMENT_NODE') && visibleViewport && rect) { + const containerRect = normalizeRect(contentDiv.getBoundingClientRect()); + + return !!containerRect && containerRect.top <= rect.top && visibleViewport.top <= rect.top; + } + + return true; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts new file mode 100644 index 00000000000..f455c9f83a3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableResizer.ts @@ -0,0 +1,249 @@ +import createElement from '../../../pluginUtils/CreateElement/createElement'; +import DragAndDropHelper from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; +import normalizeRect from '../../../pluginUtils/Rect/normalizeRect'; +import { getFirstSelectedTable, normalizeTable } from 'roosterjs-content-model-core'; +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type { ContentModelTable, IEditor, Rect } from 'roosterjs-content-model-types'; +import type TableEditFeature from './TableEditorFeature'; + +const TABLE_RESIZER_LENGTH = 12; +const TABLE_RESIZER_ID = '_Table_Resizer'; + +/** + * @internal + */ +export default function createTableResizer( + table: HTMLTableElement, + editor: IEditor, + isRTL: boolean, + onStart: () => void, + onEnd: () => false, + contentDiv?: EventTarget | null, + anchorContainer?: HTMLElement +): TableEditFeature | null { + const rect = normalizeRect(table.getBoundingClientRect()); + + if (!isTableBottomVisible(editor, rect, contentDiv as Node)) { + return null; + } + + const document = table.ownerDocument; + const zoomScale = editor.getDOMHelper().calculateZoomScale(); + const createElementData = { + tag: 'div', + style: `position: fixed; cursor: ${ + isRTL ? 'ne' : 'nw' + }-resize; user-select: none; border: 1px solid #808080`, + }; + + const div = createElement(createElementData, document) as HTMLDivElement; + + div.id = TABLE_RESIZER_ID; + div.style.width = `${TABLE_RESIZER_LENGTH}px`; + div.style.height = `${TABLE_RESIZER_LENGTH}px`; + + (anchorContainer || document.body).appendChild(div); + + const context: DragAndDropContext = { + isRTL, + table, + zoomScale, + onStart, + onEnd, + div, + editor, + contentDiv, + }; + + setDivPosition(context, div); + + const featureHandler = new DragAndDropHelper( + div, + context, + hideResizer, // Resizer is hidden while dragging only + { + onDragStart, + onDragging, + onDragEnd, + }, + zoomScale, + editor.getEnvironment().isMobileOrTablet + ); + + return { node: table, div, featureHandler }; +} + +interface DragAndDropContext { + table: HTMLTableElement; + isRTL: boolean; + zoomScale: number; + onStart: () => void; + onEnd: () => false; + div: HTMLDivElement; + editor: IEditor; + contentDiv?: EventTarget | null; +} + +interface DragAndDropInitValue { + originalRect: DOMRect; + originalHeights: number[]; + originalWidths: number[]; + cmTable: ContentModelTable | undefined; +} + +function onDragStart(context: DragAndDropContext, event: MouseEvent) { + context.onStart(); + + const { editor, table } = context; + + // Get current selection + const selection = editor.getDOMSelection(); + + // Select first cell of the table + editor.setDOMSelection({ + type: 'table', + firstColumn: 0, + firstRow: 0, + lastColumn: 0, + lastRow: 0, + table: table, + }); + + // Get the table content model + const cmTable = getFirstSelectedTable(editor.getContentModelCopy('disconnected'))[0]; + + // Restore selection + editor.setDOMSelection(selection); + + // Save original widths and heights + const heights: number[] = []; + cmTable?.rows.forEach(row => { + heights.push(row.height); + }); + const widths: number[] = []; + cmTable?.widths.forEach(width => { + widths.push(width); + }); + + return { + originalRect: table.getBoundingClientRect(), + cmTable, + originalHeights: heights ?? [], + originalWidths: widths ?? [], + }; +} + +function onDragging( + context: DragAndDropContext, + event: MouseEvent, + initValue: DragAndDropInitValue, + deltaX: number, + deltaY: number +) { + const { isRTL, zoomScale, table } = context; + const { originalRect, originalHeights, originalWidths, cmTable } = initValue; + + const ratioX = 1.0 + (deltaX / originalRect.width) * zoomScale * (isRTL ? -1 : 1); + const ratioY = 1.0 + (deltaY / originalRect.height) * zoomScale; + const shouldResizeX = Math.abs(ratioX - 1.0) > 1e-3; + const shouldResizeY = Math.abs(ratioY - 1.0) > 1e-3; + + // If the width of some external table is fixed, we need to make it resizable + table.style.setProperty('width', null); + // If the height of some external table is fixed, we need to make it resizable + table.style.setProperty('height', null); + + // Assign new widths and heights to the CM table + if (cmTable && cmTable.rows && (shouldResizeX || shouldResizeY)) { + // Modify the CM Table size + for (let i = 0; i < cmTable.rows.length; i++) { + for (let j = 0; j < cmTable.rows[i].cells.length; j++) { + const cell = cmTable.rows[i].cells[j]; + if (cell) { + if (shouldResizeX && i == 0) { + cmTable.widths[j] = (originalWidths[j] ?? 0) * ratioX; + } + if (shouldResizeY && j == 0) { + cmTable.rows[i].height = (originalHeights[i] ?? 0) * ratioY; + } + } + } + } + + // Normalize the table + normalizeTable(cmTable); + + // Writeback CM Table size changes to DOM Table + for (let row = 0; row < table.rows.length; row++) { + const tableRow = table.rows[row]; + + if (tableRow.cells.length == 0) { + // Skip empty row + continue; + } + + for (let col = 0; col < tableRow.cells.length; col++) { + const td = tableRow.cells[col]; + td.style.width = cmTable.widths[col] + 'px'; + td.style.height = cmTable.rows[row].height + 'px'; + } + } + return true; + } else { + return false; + } +} + +function onDragEnd( + context: DragAndDropContext, + event: MouseEvent, + initValue: DragAndDropInitValue | undefined +) { + if ( + isTableBottomVisible( + context.editor, + normalizeRect(context.table.getBoundingClientRect()), + context.contentDiv as Node + ) + ) { + context.div.style.visibility = 'visible'; + setDivPosition(context, context.div); + } + context.onEnd(); + return false; +} + +function setDivPosition(context: DragAndDropContext, trigger: HTMLElement) { + const { table, isRTL } = context; + const rect = normalizeRect(table.getBoundingClientRect()); + + if (rect) { + trigger.style.top = `${rect.bottom}px`; + trigger.style.left = isRTL + ? `${rect.left - TABLE_RESIZER_LENGTH - 2}px` + : `${rect.right}px`; + } +} + +function hideResizer(context: DragAndDropContext, trigger: HTMLElement) { + trigger.style.visibility = 'hidden'; +} + +function isTableBottomVisible( + editor: IEditor, + rect: Rect | null, + contentDiv?: Node | null +): boolean { + const visibleViewport = editor.getVisibleViewport(); + if (isNodeOfType(contentDiv, 'ELEMENT_NODE') && visibleViewport && rect) { + const containerRect = normalizeRect(contentDiv.getBoundingClientRect()); + + return ( + !!containerRect && + containerRect.bottom >= rect.bottom && + visibleViewport.bottom >= rect.bottom + ); + } + + return true; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/TestHelper.ts b/packages-content-model/roosterjs-content-model-plugins/test/TestHelper.ts new file mode 100644 index 00000000000..b9260f1468c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/TestHelper.ts @@ -0,0 +1,27 @@ +import { ContentModelDocument, CoreApiMap, EditorPlugin } from 'roosterjs-content-model-types'; +import { Editor } from 'roosterjs-content-model-core'; + +export function initEditor( + id: string, + plugins?: EditorPlugin[], + initialModel?: ContentModelDocument, + coreApiOverride?: Partial +) { + let node = document.createElement('div'); + node.id = id; + document.body.insertBefore(node, document.body.childNodes[0]); + + return new Editor(node, { + plugins, + initialModel, + coreApiOverride, + }); +} + +// Remove the element with id from the DOM +export function removeElement(id: string) { + let node = document.getElementById(id); + if (node && node.parentNode) { + node.parentNode.removeChild(node); + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/TableEditTestHelper.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/TableEditTestHelper.ts new file mode 100644 index 00000000000..4450662d622 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/TableEditTestHelper.ts @@ -0,0 +1,253 @@ +import * as TestHelper from '../TestHelper'; +import { DOMEventHandlerFunction } from 'roosterjs-editor-types'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { normalizeTable } from 'roosterjs-content-model-core'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; +import { + ContentModelTable, + DOMEventRecord, + EditorCore, + IEditor, +} from 'roosterjs-content-model-types'; + +/** + * Function to be called before each Table Edit test + * @param TEST_ID The id of the editor div + * @returns The editor, plugin, and handler to be used in the test + */ +export function beforeTableTest(TEST_ID: string) { + const plugin = new TableEditPlugin(); + + let handler: Record = {}; + const attachDomEvent = jasmine + .createSpy('attachDomEvent') + .and.callFake((core: EditorCore, eventMap: Record>) => { + getObjectKeys(eventMap || {}).forEach(key => { + const eventname = key as keyof HTMLElementEventMap; + const { beforeDispatch } = eventMap[key]; + const onEvent = (event: HTMLElementEventMap[typeof eventname]) => { + beforeDispatch && beforeDispatch(event); + }; + handler[eventname] = onEvent; + }); + return () => { + handler = {}; + }; + }); + + const coreApiOverride = { + attachDomEvent, + }; + const editor = TestHelper.initEditor(TEST_ID, [plugin], undefined, coreApiOverride); + + plugin.initialize(editor); + + return { editor, plugin, handler }; +} + +/** + * Function to be called after each Table Edit test + * @param editor The editor to be disposed + * @param plugin The plugin to be disposed + * @param TEST_ID The id of the editor div + */ +export function afterTableTest(editor: IEditor, plugin: TableEditPlugin, TEST_ID: string) { + editor.dispose(); + plugin.dispose(); + TestHelper.removeElement(TEST_ID); + document.body = document.createElement('body'); +} + +/** + * Function to get the current table in the editor + * @param editor The editor to get the table from + * @returns The current table in the editor + */ +export function getCurrentTable(editor: IEditor): HTMLTableElement { + const table = editor.getDOMHelper().queryElements('table')[0] as HTMLTableElement; + return table; +} + +/** + * Function to get the number of rows in the table + * @param table The table to get the number of rows from + * @returns The number of rows in the table + */ +export function getTableRows(table: HTMLTableElement): number { + return table.rows.length; +} + +/** + * Function to get the number of columns in the table + * @param table The table to get the number of columns from + * @returns The number of columns in the table + */ +export function getTableColumns(table: HTMLTableElement): number { + return table.rows[0].cells.length; +} + +/** + * Function to get the rect of a cell in the table + * @param editor The editor to get the table from + * @param i The row index of the cell + * @param j The column index of the cell + * @returns The rect of the cell + */ +export function getCellRect(editor: IEditor, i: number, j: number): DOMRect | undefined { + const tables = editor.getDOMHelper().queryElements('table'); + if (!tables || tables.length < 1) { + return undefined; + } + + const table = tables[0]; + if (i >= table.rows.length || j >= table.rows[i].cells.length) { + return undefined; + } + + const cell = table.rows[i].cells[j]; + return cell.getBoundingClientRect(); +} + +/** + * Insert the content model table on the edito + * @param editor The editor to insert the table into + * @param table The table to insert + * @param isRtl Whether the table is RTL + * @returns The rect of the table + */ +export function initialize( + editor: IEditor, + table: ContentModelTable, + isRtl: boolean = false +): DOMRect { + if (isRtl) { + editor.getDocument().body.style.direction = 'rtl'; + } + editor.formatContentModel((model, context) => { + normalizeTable(table); + model.blocks = [table]; + return true; + }); + const DOMTable = editor.getDOMHelper().queryElements('table')[0]; + return DOMTable.getBoundingClientRect(); +} + +/* Used to specify mouse coordinates */ +export type Position = { + x: number; + y: number; +}; +/* Used to specify the direction of the resize */ +export type resizeDirection = 'horizontal' | 'vertical' | 'both'; + +/* IDs for the resizers */ +const VERTICAL_RESIZER_ID = 'verticalResizer'; +const HORIZONTAL_RESIZER_ID = 'horizontalResizer'; +const TABLE_RESIZER_ID = '_Table_Resizer'; + +/** + * Function to move and resize the table + * @param mouseStart The starting position of the mouse + * @param mouseEnd The ending position of the mouse + * @param resizeState The direction of the resize + * @param editor The editor to resize the table in + * @param handler The handler to handle the mouse events + * @param TEST_ID The id of the editor div + */ +export function moveAndResize( + mouseStart: Position, + mouseEnd: Position, + resizeState: resizeDirection, + editor: IEditor, + handler: Record, + TEST_ID: string +) { + const editorDiv = editor.getDocument().getElementById(TEST_ID); + let resizerId: string; + switch (resizeState) { + case 'both': + resizerId = TABLE_RESIZER_ID; + break; + case 'horizontal': + resizerId = HORIZONTAL_RESIZER_ID; + break; + case 'vertical': + resizerId = VERTICAL_RESIZER_ID; + break; + default: + resizerId = ''; + } + + // Move mouse to show resizer + const mouseMoveEvent = new MouseEvent('mousemove', { + clientX: mouseStart.x, + clientY: mouseStart.y, + }); + handler.mousemove(mouseMoveEvent); + + let resizer = editor.getDocument().getElementById(resizerId); + if (!!resizer && editorDiv) { + const tableBeforeClick = getTableRectSet(getCurrentTable(editor)); + // Click on the resizer to start resizing + const mouseClickEvent = new MouseEvent('mousedown', { + clientX: mouseStart.x, + clientY: mouseStart.y, + }); + resizer.dispatchEvent(mouseClickEvent); + const tableAfterClick = getTableRectSet(getCurrentTable(editor)); + + // Validate the table doesn't shift after clicking on the resizer + runTableShapeTest(tableBeforeClick, tableAfterClick); + + // Move mouse and resize + const mouseMoveResize = new MouseEvent('mousemove', { + clientX: mouseEnd.x, + clientY: mouseEnd.y, + }); + + editorDiv.dispatchEvent(mouseMoveResize); + handler.mousemove(mouseMoveResize); + + // Release mouse and stop resizing + const mouseMoveEndEvent = new MouseEvent('mouseup'); + editorDiv.dispatchEvent(mouseMoveEndEvent); + } +} + +/** + * Function to ckeck if the table rects are the same + * @param tableRectSet1 The first set of table rects + * @param tableRectSet2 The second set of table rects + */ +function runTableShapeTest(tableRectSet1: DOMRect[], tableRectSet2: DOMRect[]) { + expect(tableRectSet1.length).toBe(tableRectSet2.length); + const isSameRect = (rect1: DOMRect, rect2: DOMRect): boolean => { + return ( + rect1.left == rect2.left && + rect1.right == rect2.right && + rect1.top == rect2.top && + rect1.bottom == rect2.bottom + ); + }; + tableRectSet1.forEach((rect, i) => { + expect(isSameRect(rect, tableRectSet2[i])).toBe(true); + }); +} + +/** + * Get all rects from a table + * @param table The table to get the rects from + * @returns The set of rects for the table, first the whole table rect and then the cell rects + */ +export function getTableRectSet(table: HTMLTableElement): DOMRect[] { + const rectSet: DOMRect[] = []; + if (!!table) { + rectSet.push(table.getBoundingClientRect()); + } + for (let i = 0; i < table.rows.length; i++) { + for (let j = 0; j < table.rows[i].cells.length; j++) { + rectSet.push(table.rows[i].cells[j].getBoundingClientRect()); + } + } + return rectSet; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/cellResizerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/cellResizerTest.ts new file mode 100644 index 00000000000..d1a47fbc6b5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/cellResizerTest.ts @@ -0,0 +1,157 @@ +import { ContentModelTable, DOMEventHandlerFunction, IEditor } from 'roosterjs-content-model-types'; +import { getModelTable } from './tableData'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; +import { + afterTableTest, + beforeTableTest, + getCellRect, + getCurrentTable, + initialize, + moveAndResize, +} from './TableEditTestHelper'; + +describe('Cell Resizer tests', () => { + let editor: IEditor; + let plugin: TableEditPlugin; + const TEST_ID = 'cellResizerTest'; + let handler: Record; + + beforeEach(() => { + const setup = beforeTableTest(TEST_ID); + editor = setup.editor; + plugin = setup.plugin; + handler = setup.handler; + }); + + afterEach(() => { + afterTableTest(editor, plugin, TEST_ID); + }); + + /************************ Resizing row related tests ************************/ + + function resizeRowTest( + table: ContentModelTable, + growth: number, + cellRow: number, + cellColumn: number + ) { + initialize(editor, table); + const delta = 50 * growth; + const cellRect = getCellRect(editor, cellRow, cellColumn); + const targetPos: number = cellRect.bottom + delta; + + const beforeHeight = getCurrentTable(editor).rows[cellRow].getBoundingClientRect().height; + moveAndResize( + { x: cellRect.left + cellRect.width / 2, y: cellRect.bottom }, + { x: cellRect.left + cellRect.width / 2, y: targetPos }, + 'horizontal', + editor, + handler, + TEST_ID + ); + const afterHeight = getCurrentTable(editor).rows[cellRow].getBoundingClientRect().height; + + growth > 0 + ? expect(afterHeight).toBeGreaterThan(beforeHeight) + : expect(afterHeight).toBeLessThan(beforeHeight); + } + + it('increases the height of the first row', () => { + resizeRowTest(getModelTable(), 1, 0, 0); + }); + + it('increases the height of the last row', () => { + const MODEL_TABLE = getModelTable(); + resizeRowTest(MODEL_TABLE, 1, MODEL_TABLE.rows.length - 1, MODEL_TABLE.widths.length - 1); + }); + + it('decreases the height of the first row', () => { + resizeRowTest(getModelTable(), -1, 0, 0); + }); + + it('decreases the height of the last row', () => { + const MODEL_TABLE = getModelTable(); + resizeRowTest(MODEL_TABLE, -1, MODEL_TABLE.rows.length - 1, MODEL_TABLE.widths.length - 1); + }); + + /************************ Resizing column related tests ************************/ + + function resizeColumnTest( + table: ContentModelTable, + direction: number, + cellRow: number, + cellColumn: number + ) { + initialize(editor, table); + const delta = 20 * direction; + const cellRect = getCellRect(editor, cellRow, cellColumn); + const targetPos: number = cellRect.right + delta; + + const beforeWidth = getCurrentTable(editor).rows[cellRow].cells[ + cellColumn + ].getBoundingClientRect().width; + const beforeNextWidth = + cellColumn < table.widths.length - 1 + ? getCurrentTable(editor).rows[cellRow].cells[ + cellColumn + 1 + ].getBoundingClientRect().width + : undefined; + + moveAndResize( + { x: cellRect.right, y: cellRect.top + cellRect.height / 2 }, + { x: targetPos, y: cellRect.top + cellRect.height / 2 }, + 'vertical', + editor, + handler, + TEST_ID + ); + + const afterWidth = getCurrentTable(editor).rows[cellRow].cells[ + cellColumn + ].getBoundingClientRect().width; + const afterNextWidth = + cellColumn < table.widths.length - 1 + ? getCurrentTable(editor).rows[cellRow].cells[ + cellColumn + 1 + ].getBoundingClientRect().width + : undefined; + + direction > 0 + ? expect(afterWidth).toBeGreaterThan(beforeWidth) + : expect(afterWidth).toBeLessThan(beforeWidth); + + if (beforeNextWidth && afterNextWidth) { + direction > 0 + ? expect(afterNextWidth).toBeLessThan(beforeNextWidth) + : expect(afterNextWidth).toBeGreaterThan(beforeNextWidth); + } + } + + it('increases the width of the first column', () => { + resizeColumnTest(getModelTable(), 1, 0, 0); + }); + + it('increases the width of the last column', () => { + const MODEL_TABLE = getModelTable(); + resizeColumnTest( + MODEL_TABLE, + 1, + MODEL_TABLE.rows.length - 1, + MODEL_TABLE.widths.length - 1 + ); + }); + + it('decreases the width of the first column', () => { + resizeColumnTest(getModelTable(), -1, 0, 0); + }); + + it('decreases the width of the last column', () => { + const MODEL_TABLE = getModelTable(); + resizeColumnTest( + MODEL_TABLE, + -1, + MODEL_TABLE.rows.length - 1, + MODEL_TABLE.widths.length - 1 + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableData.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableData.ts new file mode 100644 index 00000000000..802f58c1713 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableData.ts @@ -0,0 +1,862 @@ +import { ContentModelTable } from 'roosterjs-content-model-types'; + +export const WORD_TABLE = + '
dsfsdf2342323423
Sdf323234234234234
23232343242erfwfwf
'; +export const EXCEL_TABLE = + '
111222333
222333444
777666555
'; +export const DEFAULT_TABLE = + '









'; +export const DEFAULT_TABLE_MERGED = + '
















'; + +/** + * Regular 3 x 3 Table + */ +export function getModelTable(): ContentModelTable { + /* + * —————————————— + * | a1 | b1 | c1 | + * —————————————— + * | a2 | b2 | c2 | + * —————————————— + * | a3 | b3 | c3 | + * —————————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50, 50], + dataset: {}, + }; +} + +/** + * 3 x 3 Table with merged central column + */ +export function getMergedCenterColumnTable(): ContentModelTable { + /* + * —————————————— + * | a1 | | c1 | + * ———— ———— + * | a2 | b1 | c2 | + * ———— ———— + * | a3 | | c3 | + * —————————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: true, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: true, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50, 50], + dataset: {}, + }; +} + +/** + * 3 x 3 Table with merged central row + */ +export function getMergedCenterRowTable(): ContentModelTable { + /* + * —————————————— + * | a1 | b1 | c1 | + * —————————————— + * | a2 | + * —————————————— + * | a3 | b3 | c3 | + * —————————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'c3', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50, 50], + dataset: {}, + }; +} + +/** + * 2 x 2 Table with merged top row + */ +export function getMergedTopRowTable(): ContentModelTable { + /* + * ————————— + * | a1 | + * ————————— + * | a2 | b2 | + * ————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50], + dataset: {}, + }; +} + +/** + * 2 x 2 Table with merged first column + */ +export function getMergedFirstColumnTable(): ContentModelTable { + /* + * ————————— + * | a1 | b1 | + * ———— + * | | b2 | + * ————————— + */ + return { + blockType: 'Table', + rows: [ + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 50, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: true, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'b2', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [50, 50], + dataset: {}, + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts new file mode 100644 index 00000000000..664ea407aed --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts @@ -0,0 +1,229 @@ +import * as TestHelper from '../TestHelper'; +import createElement from '../../lib/pluginUtils/CreateElement/createElement'; +import { getModelTable } from './tableData'; +import { IEditor } from 'roosterjs-content-model-types'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; + +describe('TableEditPlugin', () => { + let editor: IEditor; + let plugin: TableEditPlugin; + const TEST_ID = 'inserterTest'; + + let mouseOutListener: undefined | ((this: HTMLElement, ev: MouseEvent) => any); + + beforeEach(() => { + editor = TestHelper.initEditor(TEST_ID); + plugin = new TableEditPlugin(); + + spyOn(editor, 'getScrollContainer').and.returnValue(({ + addEventListener: ( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ) => { + if (type == 'mouseout') { + mouseOutListener = listener as (this: HTMLElement, ev: MouseEvent) => any; + } + }, + removeEventListener: ( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventListenerOptions + ) => { + if (type == 'mouseout') { + mouseOutListener = undefined; + } + }, + })); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + editor.dispose(); + TestHelper.removeElement(TEST_ID); + document.body = document.createElement('body'); + }); + + it('setTableEditor - Dismiss table editor on mouse out', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + const table = editor.getDOMHelper().queryElements('table')[0]; + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).toHaveBeenCalledWith(null); + } + }); + + it('setTableEditor - Do not dismiss table editor on mouse out, related target is contained in scroll container', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + const table = editor.getDOMHelper().queryElements('table')[0]; + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(true); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('setTableEditor - Do not dismiss table editor on mouse out, table editor not', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('setTableEditor - Do not dismiss table editor on mouse out, related target null', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + const table = editor.getDOMHelper().queryElements('table')[0]; + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: null, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('setTableEditor - Do not dismiss table editor on mouse out, currentTarget null', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['test'], + }, + ], + }, + editor.getDocument() + ); + editor.formatContentModel(model => { + model.blocks = [getModelTable()]; + return true; + }); + + const table = editor.getDOMHelper().queryElements('table')[0]; + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + ele && spyOn(ele, 'contains').and.returnValue(false); + boundedListener(({ + currentTarget: null, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('returns the actual plugin name', () => { + const expectedName = 'TableEdit'; + const pluginName = plugin.getName(); + expect(pluginName).toBe(expectedName); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts new file mode 100644 index 00000000000..17901bace20 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts @@ -0,0 +1,115 @@ +import { DOMEventHandlerFunction, IEditor } from 'roosterjs-content-model-types'; +import { getMergedFirstColumnTable, getMergedTopRowTable, getModelTable } from './tableData'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; +import { + Position, + afterTableTest, + beforeTableTest, + getCurrentTable, + getTableColumns, + getTableRows, + initialize, +} from './TableEditTestHelper'; + +const VERTICAL_INSERTER_ID = 'verticalInserter'; +const HORIZONTAL_INSERTER_ID = 'horizontalInserter'; + +describe('Table Inserter tests', () => { + let editor: IEditor; + let plugin: TableEditPlugin; + const insideTheOffset = 5; + const TEST_ID = 'inserterTest'; + let handler: Record; + + beforeEach(() => { + const setup = beforeTableTest(TEST_ID); + editor = setup.editor; + plugin = setup.plugin; + handler = setup.handler; + }); + + afterEach(() => { + afterTableTest(editor, plugin, TEST_ID); + }); + + function isClickInsideInserter(click: Position, rect: DOMRect) { + return ( + click.x >= rect.left && + click.x <= rect.right && + click.y >= rect.top && + click.y <= rect.bottom + ); + } + + function runInserterTest(inserterType: string, mouseEnd: Position) { + handler.mousemove( + new MouseEvent('mousemove', { + clientX: mouseEnd.x, + clientY: mouseEnd.y, + }) + ); + + const inserter = editor.getDocument().getElementById(inserterType); + if (!!inserter) { + const inserterRect = inserter.getBoundingClientRect(); + if (!isClickInsideInserter(mouseEnd, inserterRect)) { + // Inserter is visible, but pointer is not over it + return 'not clickable'; + } + const table = getCurrentTable(editor); + const rows = getTableRows(table); + const cols = getTableColumns(table); + inserter.dispatchEvent(new MouseEvent('click')); + const newRows = getTableRows(table); + const newCols = getTableColumns(table); + expect(newRows).toBe(inserterType == VERTICAL_INSERTER_ID ? rows : rows + 1); + expect(newCols).toBe(inserterType == HORIZONTAL_INSERTER_ID ? cols : cols + 1); + } + return !!inserter ? 'found' : 'not found'; + } + + it('adds a new column if the vertical inserter is detected and clicked', () => { + const rect = initialize(editor, getModelTable()); + const inserterFound = runInserterTest(VERTICAL_INSERTER_ID, { + x: rect.right, + y: rect.top - insideTheOffset, + }); + expect(inserterFound).toBe('found'); + }); + + it('adds a new row if the horizontal inserter is detected and clicked', () => { + const rect = initialize(editor, getModelTable()); + const inserterFound = runInserterTest(HORIZONTAL_INSERTER_ID, { + x: rect.left - insideTheOffset, + y: rect.bottom, + }); + expect(inserterFound).toBe('found'); + }); + + it('does not add inserter if top left corner hovered', () => { + const rect = initialize(editor, getModelTable()); + const inserterFound = runInserterTest(VERTICAL_INSERTER_ID, { + x: rect.left - insideTheOffset, + y: rect.top - insideTheOffset, + }); + expect(inserterFound).toBe('not found'); + }); + + it('does not add new column if top middle clicked on merged top row', () => { + const rect = initialize(editor, getMergedTopRowTable()); + const inserterFound = runInserterTest(VERTICAL_INSERTER_ID, { + x: (rect.right - rect.left) / 2 + 10, + y: rect.top - insideTheOffset, + }); + expect(inserterFound).toBe('not clickable'); + }); + + it('does not add new row if left middle clicked on merged first column', () => { + const rect = initialize(editor, getMergedFirstColumnTable()); + const inserterFound = runInserterTest(HORIZONTAL_INSERTER_ID, { + x: rect.left - insideTheOffset, + y: (rect.bottom - rect.top) / 2, + }); + expect(inserterFound).toBe('not clickable'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts new file mode 100644 index 00000000000..2a9a49448a6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/tableMoverTest.ts @@ -0,0 +1,230 @@ +import createTableMover from '../../lib/tableEdit/editors/features/TableMover'; +import TableEditor from '../../lib/tableEdit/editors/TableEditor'; +import { Editor } from 'roosterjs-content-model-core'; +import { EditorOptions, IEditor } from 'roosterjs-content-model-types'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; + +describe('Table Mover Tests', () => { + let editor: IEditor; + let id = 'tableSelectionContainerId'; + let targetId = 'tableSelectionTestId'; + let tableEdit: TableEditPlugin; + let node: HTMLDivElement; + + beforeEach(() => { + document.body.innerHTML = ''; + node = document.createElement('div'); + node.id = id; + document.body.insertBefore(node, document.body.childNodes[0]); + tableEdit = new TableEditPlugin(); + + let options: EditorOptions = { + plugins: [tableEdit], + initialModel: { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 20, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a1', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'z1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 20, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a2', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'z2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + id: `${targetId}`, + }, + widths: [10, 10], + dataset: {}, + }, + ], + format: {}, + }, + }; + + editor = new Editor(node, options); + }); + + afterEach(() => { + editor.dispose(); + const div = document.getElementById(id); + div?.parentNode?.removeChild(div); + node.parentElement?.removeChild(node); + }); + + it('Display component on mouse move inside table', () => { + runTest(0, true); + }); + + it('Do not display component, top of table is no visible in the container.', () => { + //Arrange + runTest(15, false); + }); + + it('Do not display component, Top of table is no visible in the scroll container.', () => { + //Arrange + const scrollContainer = document.createElement('div'); + document.body.insertBefore(scrollContainer, document.body.childNodes[0]); + scrollContainer.append(node); + spyOn(editor, 'getScrollContainer').and.returnValue(scrollContainer); + + runTest(15, false); + }); + + it('Display component, Top of table is visible in the scroll container scrolled down.', () => { + //Arrange + const scrollContainer = document.createElement('div'); + scrollContainer.innerHTML = '
'; + document.body.insertBefore(scrollContainer, document.body.childNodes[0]); + scrollContainer.append(node); + spyOn(editor, 'getScrollContainer').and.returnValue(scrollContainer); + + runTest(0, true); + }); + + it('On click event', () => { + const table = document.getElementById(targetId) as HTMLTableElement; + + const tableEditor = new TableEditor(editor, table, () => true); + + tableEditor.onSelect(table); + + const selection = editor.getDOMSelection(); + expect(selection?.type).toBe('table'); + if (selection?.type == 'table') { + expect(selection).toEqual({ + table, + firstRow: 0, + firstColumn: 0, + lastRow: 1, + lastColumn: 1, + type: 'table', + }); + } + }); + + function runTest(scrollTop: number, isNotNull: boolean | null) { + //Arrange + node.style.height = '10px'; + node.style.overflowX = 'auto'; + node.scrollTop = scrollTop; + const target = document.getElementById(targetId); + editor.focus(); + + //Act + const result = createTableMover( + target as HTMLTableElement, + editor, + false, + () => {}, + () => () => {}, + node + ); + + //Assert + if (!isNotNull) { + expect(result).toBeNull(); + } else { + expect(result).toBeDefined(); + } + } +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/teableResizerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/teableResizerTest.ts new file mode 100644 index 00000000000..d4bba0575ab --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/tableEdit/teableResizerTest.ts @@ -0,0 +1,197 @@ +import { getModelTable } from './tableData'; +import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; +import { + ContentModelTable, + DOMEventHandlerFunction, + IEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; +import { + Position, + afterTableTest, + beforeTableTest, + getCellRect, + getCurrentTable, + getTableRectSet, + initialize, + moveAndResize, + resizeDirection, +} from './TableEditTestHelper'; + +const TABLE_RESIZER_ID = '_Table_Resizer'; + +describe('Table Resizer tests', () => { + let editor: IEditor; + let plugin: TableEditPlugin; + const TEST_ID = 'resizerTest'; + let handler: Record; + + beforeEach(() => { + const setup = beforeTableTest(TEST_ID); + editor = setup.editor; + plugin = setup.plugin; + handler = setup.handler; + }); + + afterEach(() => { + afterTableTest(editor, plugin, TEST_ID); + }); + + /************************** Resizier removing tests **************************/ + + function removeResizerTest(pluginEvent: PluginEvent) { + let resizer: HTMLElement | null = null; + plugin.initialize(editor); + initialize(editor, getModelTable()); + const cellRect = getCellRect(editor, 0, 0); + handler.mousemove( + new MouseEvent('mousemove', { clientX: cellRect?.right, clientY: cellRect?.bottom }) + ); + resizer = editor.getDocument().getElementById(TABLE_RESIZER_ID); + expect(!!resizer).toBe(true); + plugin.onPluginEvent(pluginEvent); + resizer = editor.getDocument().getElementById(TABLE_RESIZER_ID); + expect(!!resizer).toBe(false); + } + + it('removes table resizer on input', () => { + const pluginEvent: PluginEvent = { + eventType: 'input', + rawEvent: null, + }; + removeResizerTest(pluginEvent); + }); + + it('removes table resizer on content change', () => { + const pluginEvent: PluginEvent = { + eventType: 'contentChanged', + source: null, + }; + removeResizerTest(pluginEvent); + }); + + it('removes table resizer on scrolling', () => { + const pluginEvent: PluginEvent = { + eventType: 'scroll', + scrollContainer: editor.getDocument().body as HTMLElement, + rawEvent: null, + }; + removeResizerTest(pluginEvent); + }); + + /************************ Resizing table related tests ************************/ + + function resizeWholeTableTest( + table: ContentModelTable, + growth: number, + direction: resizeDirection + ) { + const delta = 20 * growth; + const tableRect = initialize(editor, table); + const mouseStart = { x: tableRect.right + 3, y: tableRect.bottom + 3 }; + let mouseEnd: Position = { x: 0, y: 0 }; + switch (direction) { + case 'horizontal': + mouseEnd = { x: tableRect.right + 3 + delta, y: tableRect.bottom + 3 }; + break; + case 'vertical': + mouseEnd = { x: tableRect.right + 3, y: tableRect.bottom + 3 + delta }; + break; + case 'both': + mouseEnd = { x: tableRect.right + 3 + delta, y: tableRect.bottom + 3 + delta }; + break; + } + const beforeSize = getTableRectSet(getCurrentTable(editor)); + moveAndResize(mouseStart, mouseEnd, 'both', editor, handler, TEST_ID); + const afterSize = getTableRectSet(getCurrentTable(editor)); + compareTableRects(beforeSize, afterSize, growth, direction); + } + + function verifyTableRectChange( + rect1: DOMRect, + rect2: DOMRect, + growth: number, + direction: resizeDirection + ): boolean { + switch (direction) { + case 'horizontal': + return growth > 0 ? rect1.width < rect2.width : rect1.width > rect2.width; + case 'vertical': + return growth > 0 ? rect1.height < rect2.height : rect1.height > rect2.height; + case 'both': + return growth > 0 + ? rect1.width < rect2.width && rect1.height < rect2.height + : rect1.width > rect2.width && rect1.height > rect2.height; + } + } + + function verifyCellRectChange( + rect1: DOMRect, + rect2: DOMRect, + growth: number, + direction: resizeDirection + ): boolean { + switch (direction) { + case 'horizontal': + return rect1.top == rect2.top && rect1.bottom == rect2.bottom && growth > 0 + ? rect1.left <= rect2.left && rect1.right <= rect2.right + : rect1.left >= rect2.left && rect1.right >= rect2.right; + case 'vertical': + return rect1.left == rect2.left && rect1.right == rect2.right && growth > 0 + ? rect1.top <= rect2.top && rect1.bottom <= rect2.bottom + : rect1.top >= rect2.top && rect1.bottom >= rect2.bottom; + case 'both': + return growth > 0 + ? rect1.left <= rect2.left && + rect1.right <= rect2.right && + rect1.top <= rect2.top && + rect1.bottom <= rect2.bottom + : rect1.left >= rect2.left && + rect1.right >= rect2.right && + rect1.top >= rect2.top && + rect1.bottom >= rect2.bottom; + } + } + + function compareTableRects( + beforeTableRectSet1: DOMRect[], + afterTableRectSet2: DOMRect[], + growth: number, + direction: resizeDirection + ) { + expect(beforeTableRectSet1.length).toBe(afterTableRectSet2.length); + beforeTableRectSet1.forEach((rect, i) => { + i == 0 + ? expect( + verifyTableRectChange(rect, afterTableRectSet2[i], growth, direction) + ).toBe(true) // Verify a change to whole table size + : expect(verifyCellRectChange(rect, afterTableRectSet2[i], growth, direction)).toBe( + true // Verify a change to each cell size + ); + }); + } + + it('increases the width of the table', () => { + resizeWholeTableTest(getModelTable(), 1, 'horizontal'); + }); + + it('increases the height of the table', () => { + resizeWholeTableTest(getModelTable(), 1, 'vertical'); + }); + + it('increases the width and height of the table', () => { + resizeWholeTableTest(getModelTable(), 1, 'both'); + }); + + it('decreases the width of the table', () => { + resizeWholeTableTest(getModelTable(), -1, 'horizontal'); + }); + + it('decreases the height of the table', () => { + resizeWholeTableTest(getModelTable(), -1, 'vertical'); + }); + + it('decreases the width and height of the table', () => { + resizeWholeTableTest(getModelTable(), -1, 'both'); + }); +});