diff --git a/packages/roosterjs-editor-dom/lib/index.ts b/packages/roosterjs-editor-dom/lib/index.ts index fdcc5044ff6..0782db9c753 100644 --- a/packages/roosterjs-editor-dom/lib/index.ts +++ b/packages/roosterjs-editor-dom/lib/index.ts @@ -42,6 +42,7 @@ export { getNextLeafSibling, getPreviousLeafSibling } from './utils/getLeafSibli export { getFirstLeafNode, getLastLeafNode } from './utils/getLeafNode'; export { default as getTextContent } from './utils/getTextContent'; export { default as splitTextNode } from './utils/splitTextNode'; +export { default as normalizeRect } from './utils/normalizeRect'; export { default as toArray } from './utils/toArray'; export { default as safeInstanceOf } from './utils/safeInstanceOf'; export { default as readFile } from './utils/readFile'; diff --git a/packages/roosterjs-editor-dom/lib/selection/getPositionRect.ts b/packages/roosterjs-editor-dom/lib/selection/getPositionRect.ts index c3ee853a173..4e54ec39be9 100644 --- a/packages/roosterjs-editor-dom/lib/selection/getPositionRect.ts +++ b/packages/roosterjs-editor-dom/lib/selection/getPositionRect.ts @@ -1,4 +1,5 @@ import createRange from './createRange'; +import normalizeRect from '../utils/normalizeRect'; import { NodePosition, NodeType, Rect } from 'roosterjs-editor-types'; /** @@ -52,17 +53,3 @@ export default function getPositionRect(position: NodePosition): Rect { return null; } - -function normalizeRect(clientRect: ClientRect): Rect { - // A ClientRect of all 0 is possible. i.e. chrome returns a ClientRect of 0 when the cursor is on an empty p - // We validate that and only return a rect when the passed in ClientRect is valid - let { left, right, top, bottom } = clientRect || {}; - return left + right + top + bottom > 0 - ? { - left: Math.round(left), - right: Math.round(right), - top: Math.round(top), - bottom: Math.round(bottom), - } - : null; -} diff --git a/packages/roosterjs-editor-dom/lib/utils/normalizeRect.ts b/packages/roosterjs-editor-dom/lib/utils/normalizeRect.ts new file mode 100644 index 00000000000..c209b407333 --- /dev/null +++ b/packages/roosterjs-editor-dom/lib/utils/normalizeRect.ts @@ -0,0 +1,18 @@ +import { Rect } from 'roosterjs-editor-types'; + +/** + * A ClientRect of all 0 is possible. i.e. chrome returns a ClientRect of 0 when the cursor is on an empty p + * We validate that and only return a rect when the passed in ClientRect is valid + */ +export default function normalizeRect(clientRect: ClientRect): Rect { + let { left, right, top, bottom } = + clientRect || { left: 0, right: 0, top: 0, bottom: 0 }; + return left + right + top + bottom > 0 + ? { + left: Math.round(left), + right: Math.round(right), + top: Math.round(top), + bottom: Math.round(bottom), + } + : null; +} diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableResize/TableResize.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableResize/TableResize.ts index d6af93749c1..f98dd2e03a3 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableResize/TableResize.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableResize/TableResize.ts @@ -1,35 +1,47 @@ -import { contains, fromHtml, isRtl, safeInstanceOf, VTable } from 'roosterjs-editor-dom'; import { Editor, EditorPlugin } from 'roosterjs-editor-core'; +import { fromHtml, normalizeRect, VTable } from 'roosterjs-editor-dom'; import { - ContentPosition, PluginEvent, PluginEventType, - PluginMouseEvent, + Rect, ChangeSource, + TableOperation, + ContentPosition, } from 'roosterjs-editor-types'; -const TABLE_RESIZE_HANDLE_KEY = 'TABLE_RESIZE_HANDLE'; -const HANDLE_WIDTH = 6; -const CONTAINER_HTML = `
`; +const INSERTER_COLOR = '#4A4A4A'; +const INSERTER_SIDE_LENGTH = 12; +const INSERTER_BORDER_SIZE = 1; + +const CELL_RESIZER_WIDTH = 4; +const HORIZONTAL_RESIZER_HTML = + '
'; +const VERTICAL_RESIZER_HTML = + '
'; + +const enum ResizeState { + None, + Horizontal, + Vertical, +} /** * TableResize plugin, provides the ability to resize a table by drag-and-drop */ export default class TableResize implements EditorPlugin { private editor: Editor; - private onMouseOverDisposer: () => void; - private td: HTMLTableCellElement; - private pageX = -1; - private initialPageX: number; + private onMouseMoveDisposer: () => void; + private tableRectMap: { table: HTMLTableElement; rect: Rect }[] = null; + private resizerContainer: HTMLDivElement; + private currentTable: HTMLTableElement; + private currentTd: HTMLTableCellElement; + private horizontalResizer: HTMLDivElement; + private verticalResizer: HTMLDivElement; + private resizingState: ResizeState = ResizeState.None; - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: Editor) { - this.editor = editor; - this.onMouseOverDisposer = this.editor.addDomEventHandler('mouseover', this.onMouseOver); - } + private currentInsertTd: HTMLTableCellElement; + private insertingState: ResizeState = ResizeState.None; + private inserter: HTMLDivElement; /** * Get a friendly name of this plugin @@ -38,185 +50,353 @@ export default class TableResize implements EditorPlugin { return 'TableResize'; } + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: Editor) { + this.editor = editor; + this.setupResizerContainer(); + this.onMouseMoveDisposer = this.editor.addDomEventHandler('mousemove', this.onMouseMove); + } + /** * Dispose this plugin */ dispose() { - this.detachMouseEvents(); + this.onMouseMoveDisposer(); + this.destoryRectMap(); + this.removeResizerContainer(); + this.editor = null; - this.onMouseOverDisposer(); } /** * Handle events triggered from editor * @param event PluginEvent object */ - onPluginEvent(event: PluginEvent) { - if ( - this.td && - (event.eventType == PluginEventType.KeyDown || - event.eventType == PluginEventType.ContentChanged || - (event.eventType == PluginEventType.MouseDown && !this.clickIntoCurrentTd(event))) - ) { - this.td = null; - this.calcAndShowHandle(); + onPluginEvent(e: PluginEvent) { + switch (e.eventType) { + case PluginEventType.Input: + case PluginEventType.ContentChanged: + case PluginEventType.Scroll: + this.destoryRectMap(); + break; } } - private clickIntoCurrentTd(event: PluginMouseEvent) { - let mouseEvent = event.rawEvent; - let target = mouseEvent.target; - return ( - safeInstanceOf(target, 'Node') && - contains(this.td, target, true /*treatSameNodeAsContain*/) - ); + private setupResizerContainer() { + this.resizerContainer = this.editor.getDocument().createElement('div'); + this.editor.insertNode(this.resizerContainer, { + updateCursor: false, + insertOnNewLine: false, + replaceSelection: false, + position: ContentPosition.Outside, + }); + } + + private removeResizerContainer() { + this.resizerContainer.parentNode.removeChild(this.resizerContainer); + this.resizerContainer = null; } - private onMouseOver = (e: MouseEvent) => { - let node = (e.srcElement || e.target); - if ( - this.pageX < 0 && - node && - (node.tagName == 'TD' || node.tagName == 'TH') && - node != this.td - ) { - this.td = node; - this.calcAndShowHandle(); + private onMouseMove = (e: MouseEvent) => { + if (this.resizingState != ResizeState.None) { + return; + } + + if (!this.tableRectMap) { + this.cacheRects(); + } + + if (this.tableRectMap) { + let i = this.tableRectMap.length - 1; + for (; i >= 0; i--) { + const { table, rect } = this.tableRectMap[i]; + if ( + e.pageX >= rect.left - INSERTER_SIDE_LENGTH && + e.pageX <= rect.right && + e.pageY >= rect.top - INSERTER_SIDE_LENGTH && + e.pageY <= rect.bottom + ) { + this.setCurrentTable(table, rect); + break; + } + } + + if (i < 0) { + this.setCurrentTable(null); + } + + if (this.currentTable) { + const map = this.tableRectMap.filter(map => map.table == this.currentTable)[0]; + + for (let i = 0; i < this.currentTable.rows.length; i++) { + const tr = this.currentTable.rows[i]; + + let j = 0; + for (; j < tr.cells.length; j++) { + const td = tr.cells[Math.max(0, j)]; + const tdRect = normalizeRect(td.getBoundingClientRect()); + + if (e.pageX <= tdRect.right && e.pageY < tdRect.bottom) { + if (i == 0 && e.pageY < tdRect.top) { + this.setCurrentTd(null); + this.setCurrentInsertTd(ResizeState.Vertical, td, map.rect); + break; + } else if (j == 0 && e.pageX < tdRect.left) { + this.setCurrentTd(null); + this.setCurrentInsertTd(ResizeState.Horizontal, td, map.rect); + break; + } else { + this.setCurrentTd(td, map.rect, tdRect.right, tdRect.bottom); + this.setCurrentInsertTd(ResizeState.None); + break; + } + } + } + if (j < tr.cells.length) { + break; + } + } + } } }; - private calcAndShowHandle() { - if (this.td) { - let tr = this.editor.getElementAtCursor('TR', this.td); - let table = this.editor.getElementAtCursor('TABLE', tr); - if (tr && table) { - let [left, top] = this.getPosition(table); - let handle = this.getResizeHandle(); - - left += - this.td.offsetLeft + (isRtl(table) ? 0 : this.td.offsetWidth - HANDLE_WIDTH); - handle.style.display = ''; - handle.style.top = top + 'px'; - handle.style.height = table.offsetHeight + 'px'; - handle.style.left = left + 'px'; + private setCurrentInsertTd(insertingState: ResizeState.None): void; + private setCurrentInsertTd( + insertingState: ResizeState, + td: HTMLTableCellElement, + tableRect: Rect + ): void; + private setCurrentInsertTd( + insertingState: ResizeState, + td?: HTMLTableCellElement, + tableRect?: Rect + ) { + if (td != this.currentInsertTd || insertingState != this.insertingState) { + if (this.currentInsertTd) { + this.resizerContainer.removeChild(this.inserter); + this.inserter = null; + } + this.insertingState = insertingState; + this.currentInsertTd = td; + if (this.currentInsertTd) { + this.inserter = this.createInserter(tableRect); + this.resizerContainer.appendChild(this.inserter); } - } else { - this.getResizeHandle().style.display = 'none'; } } - private adjustHandle(pageX: number) { - let handle = this.getResizeHandle(); - handle.style.left = handle.offsetLeft + pageX - this.pageX + 'px'; - this.pageX = pageX; + private createInserter(tableRect: Rect) { + const rect = normalizeRect(this.currentInsertTd.getBoundingClientRect()); + const editorBackgroundColor = this.editor.getDefaultFormat().backgroundColor; + const inserterBackgroundColor = editorBackgroundColor || 'white'; + const HORIZONTAL_INSERTER_HTML = `
+
`; + const VERTICAL_INSERTER_HTML = `
+
`; + + const inserter = fromHtml( + this.insertingState == ResizeState.Horizontal + ? HORIZONTAL_INSERTER_HTML + : VERTICAL_INSERTER_HTML, + this.editor.getDocument() + )[0] as HTMLDivElement; + + if (this.insertingState == ResizeState.Horizontal) { + inserter.style.left = `${ + rect.left - (INSERTER_SIDE_LENGTH - 1 + 2 * INSERTER_BORDER_SIZE) + }px`; + inserter.style.top = `${rect.bottom - 8}px`; + (inserter.firstChild as HTMLElement).style.width = `${ + tableRect.right - tableRect.left + }px`; + } else { + inserter.style.left = `${rect.right - 8}px`; + inserter.style.top = `${ + rect.top - (INSERTER_SIDE_LENGTH - 1 + 2 * INSERTER_BORDER_SIZE) + }px`; + (inserter.firstChild as HTMLElement).style.height = `${ + tableRect.bottom - tableRect.top + }px`; + } + + inserter.addEventListener('click', this.insertTd); + + return inserter; } - private getPosition(e: HTMLElement): [number, number] { - let parent = e.offsetParent; - let [left, top] = parent ? this.getPosition(parent) : [0, 0]; - return [left + e.offsetLeft - e.scrollLeft, top + e.offsetTop - e.scrollTop]; + private insertTd = () => { + this.editor.addUndoSnapshot((start, end) => { + const vtable = new VTable(this.currentInsertTd); + vtable.edit( + this.insertingState == ResizeState.Horizontal + ? TableOperation.InsertBelow + : TableOperation.InsertRight + ); + vtable.writeBack(); + this.editor.select(start, end); + this.setCurrentInsertTd(ResizeState.None); + }, ChangeSource.Format); + }; + + private setCurrentTable(table: HTMLTableElement, rect: Rect): void; + private setCurrentTable(table: null): void; + private setCurrentTable(table: HTMLTableElement, rect?: Rect) { + if (this.currentTable != table) { + this.setCurrentTd(null); + this.setCurrentInsertTd(null); + this.currentTable = table; + } } - private getResizeHandle() { - return this.editor.getCustomData( - TABLE_RESIZE_HANDLE_KEY, - () => { - let document = this.editor.getDocument(); - let handle = fromHtml(CONTAINER_HTML, document)[0] as HTMLElement; - this.editor.insertNode(handle, { - position: ContentPosition.Outside, - updateCursor: false, - replaceSelection: false, - insertOnNewLine: false, - }); - handle.addEventListener('mousedown', this.onMouseDown); - return handle; - }, - handle => { - handle.removeEventListener('mousedown', this.onMouseDown); - handle.parentNode.removeChild(handle); + private setCurrentTd(td: null): void; + private setCurrentTd( + td: HTMLTableCellElement, + tableRect: Rect, + right: number, + bottom: number + ): void; + private setCurrentTd( + td: HTMLTableCellElement, + tableRect?: Rect, + right?: number, + bottom?: number + ) { + if (this.currentTd != td) { + if (this.currentTd) { + this.resizerContainer.removeChild(this.horizontalResizer); + this.resizerContainer.removeChild(this.verticalResizer); + this.horizontalResizer = null; + this.verticalResizer = null; } - ); - } - private cancelEvent(e: MouseEvent) { - e.stopPropagation(); - e.preventDefault(); - } + this.currentTd = td; - private onMouseDown = (e: MouseEvent) => { - if (!this.editor || this.editor.isDisposed()) { - return; + if (this.currentTd) { + this.horizontalResizer = this.createResizer( + true /*horizontal*/, + tableRect.left, + bottom - CELL_RESIZER_WIDTH + 1, + tableRect.right - tableRect.left, + CELL_RESIZER_WIDTH + ); + this.verticalResizer = this.createResizer( + false /*horizontal*/, + right - CELL_RESIZER_WIDTH + 1, + tableRect.top, + CELL_RESIZER_WIDTH, + tableRect.bottom - tableRect.top + ); + + this.resizerContainer.appendChild(this.horizontalResizer); + this.resizerContainer.appendChild(this.verticalResizer); + } } + } - this.pageX = e.pageX; - this.initialPageX = e.pageX; - this.attachMouseEvents(); + private createResizer( + horizontal: boolean, + left: number, + top: number, + width: number, + height: number + ) { + const div = fromHtml( + horizontal ? HORIZONTAL_RESIZER_HTML : VERTICAL_RESIZER_HTML, + this.editor.getDocument() + )[0] as HTMLDivElement; + div.style.top = `${top}px`; + div.style.left = `${left}px`; + div.style.width = `${width}px`; + div.style.height = `${height}px`; - let handle = this.getResizeHandle(); - handle.style.borderWidth = '0 1px'; + div.addEventListener( + 'mousedown', + horizontal ? this.startHorizontalResizeTable : this.startVerticalResizeTable + ); + + return div; + } - this.cancelEvent(e); + private startHorizontalResizeTable = (e: MouseEvent) => { + this.resizingState = ResizeState.Horizontal; + this.startResizeTable(e); }; - private onMouseMove = (e: MouseEvent) => { - this.adjustHandle(e.pageX); - this.cancelEvent(e); + private startVerticalResizeTable = (e: MouseEvent) => { + this.resizingState = ResizeState.Vertical; + this.startResizeTable(e); }; - private onMouseUp = (e: MouseEvent) => { - this.detachMouseEvents(); - - let handle = this.getResizeHandle(); - handle.style.borderWidth = '0'; - - let table = this.editor.getElementAtCursor('TABLE', this.td) as HTMLTableElement; - let cellPadding = parseInt(table.cellPadding); - cellPadding = isNaN(cellPadding) ? 0 : cellPadding; - - if (e.pageX != this.initialPageX) { - let newWidth = - this.td.clientWidth - - cellPadding * 2 + - (e.pageX - this.initialPageX) * (isRtl(table) ? -1 : 1); - this.editor.addUndoSnapshot((start, end) => { - this.setTableColumnWidth(newWidth + 'px'); - this.editor.select(start, end); - }, ChangeSource.Format); - } + private startResizeTable(e: MouseEvent) { + const doc = this.editor.getDocument(); + doc.addEventListener('mousemove', this.frameAnimateResizeTable, true); + doc.addEventListener('mouseup', this.endResizeTable, true); + } - this.pageX = -1; - this.calcAndShowHandle(); - this.editor.focus(); - this.cancelEvent(e); + private frameAnimateResizeTable = (e: MouseEvent) => { + this.editor.runAsync(() => this.resizeTable(e)); }; - private attachMouseEvents() { - if (this.editor && !this.editor.isDisposed()) { - let document = this.editor.getDocument(); - document.addEventListener('mousemove', this.onMouseMove, true); - document.addEventListener('mouseup', this.onMouseUp, true); - } - } + private resizeTable = (e: MouseEvent) => { + if (this.currentTd) { + const rect = normalizeRect(this.currentTd.getBoundingClientRect()); + const newPos = this.resizingState == ResizeState.Horizontal ? e.pageY : e.pageX; - private detachMouseEvents() { - if (this.editor && !this.editor.isDisposed()) { - let document = this.editor.getDocument(); - document.removeEventListener('mousemove', this.onMouseMove, true); - document.removeEventListener('mouseup', this.onMouseUp, true); + let vtable = new VTable(this.currentTd); + + if (this.resizingState == ResizeState.Horizontal) { + vtable.table.style.height = null; + vtable.forEachCellOfCurrentRow(cell => { + if (cell.td) { + cell.td.style.height = + cell.td == this.currentTd ? `${newPos - rect.top}px` : null; + } + }); + } else { + vtable.table.style.width = ''; + vtable.table.width = ''; + vtable.forEachCellOfCurrentColumn(cell => { + if (cell.td) { + cell.td.style.width = + cell.td == this.currentTd ? `${newPos - rect.left}px` : null; + } + }); + } + vtable.writeBack(); } + }; + + private endResizeTable = (e: MouseEvent) => { + const doc = this.editor.getDocument(); + doc.removeEventListener('mousemove', this.frameAnimateResizeTable, true); + doc.removeEventListener('mouseup', this.endResizeTable, true); + + this.editor.addUndoSnapshot((start, end) => { + this.frameAnimateResizeTable(e); + this.editor.select(start, end); + }, ChangeSource.Format); + + this.setCurrentTd(null); + this.resizingState = ResizeState.None; + }; + + private destoryRectMap() { + this.setCurrentTable(null); + this.tableRectMap = null; } - private setTableColumnWidth(width: string) { - let vtable = new VTable(this.td); - vtable.table.style.width = ''; - vtable.table.width = ''; - vtable.forEachCellOfCurrentColumn(cell => { - if (cell.td) { - cell.td.style.width = cell.td == this.td ? width : ''; + private cacheRects() { + this.destoryRectMap(); + this.tableRectMap = []; + this.editor.queryElements('table', table => { + const rect = normalizeRect(table.getBoundingClientRect()); + if (rect) { + this.tableRectMap.push({ + table, + rect, + }); } }); - vtable.writeBack(); - return this.editor.contains(this.td) ? this.td : vtable.getCurrentTd(); } }