import { EditorState, Plugin, PluginKey } from 'prosemirror-state'; import { Decoration, DecorationSet, EditorView, NodeView, } from 'prosemirror-view'; import { cellAround, pointsAtCell, _setAttr } from './util'; import { TableMap } from './tablemap'; import { TableView, updateColumnsOnResize } from './tableview'; import { tableNodeTypes } from './schema'; import { Attrs, Node as ProsemirrorNode } from 'prosemirror-model'; /** * @public */ export const columnResizingPluginKey = new PluginKey( 'tableColumnResizing', ); /** * @public */ export type ColumnResizingOptions = { handleWidth?: number; cellMinWidth?: number; lastColumnResizable?: boolean; View?: new ( node: ProsemirrorNode, cellMinWidth: number, view: EditorView, ) => NodeView; }; /** * @public */ export type Dragging = { startX: number; startWidth: number }; /** * @public */ export function columnResizing({ handleWidth = 5, cellMinWidth = 25, View = TableView, lastColumnResizable = true, }: ColumnResizingOptions = {}): Plugin { const plugin = new Plugin({ key: columnResizingPluginKey, state: { init(_, state) { this.spec.props.nodeViews[tableNodeTypes(state.schema).table.name] = ( node, view, ) => new View(node, cellMinWidth, view); return new ResizeState(-1, false); }, apply(tr, prev) { return prev.apply(tr); }, }, props: { attributes(state) { const pluginState = columnResizingPluginKey.getState(state); return pluginState.activeHandle > -1 ? { class: 'resize-cursor' } : null; }, handleDOMEvents: { mousemove(view, event) { handleMouseMove( view, event, handleWidth, cellMinWidth, lastColumnResizable, ); }, mouseleave(view) { handleMouseLeave(view); }, mousedown(view, event) { handleMouseDown(view, event, cellMinWidth); }, }, decorations(state) { const pluginState = columnResizingPluginKey.getState(state); if (pluginState.activeHandle > -1) return handleDecorations(state, pluginState.activeHandle); }, nodeViews: {}, }, }); return plugin; } /** * @public */ export class ResizeState { constructor(public activeHandle: number, public dragging: Dragging | false) {} apply(tr) { // eslint-disable-next-line @typescript-eslint/no-this-alias let state = this; const action = tr.getMeta(columnResizingPluginKey); if (action && action.setHandle != null) return new ResizeState(action.setHandle, null); if (action && action.setDragging !== undefined) return new ResizeState(state.activeHandle, action.setDragging); if (state.activeHandle > -1 && tr.docChanged) { let handle = tr.mapping.map(state.activeHandle, -1); if (!pointsAtCell(tr.doc.resolve(handle))) handle = null; state = new ResizeState(handle, state.dragging) as any; } return state; } } function handleMouseMove( view: EditorView, event: MouseEvent, handleWidth: number, cellMinWidth: number, lastColumnResizable: boolean, ): void { const pluginState = columnResizingPluginKey.getState(view.state); if (!pluginState.dragging) { const target = domCellAround(event.target as HTMLElement); let cell = -1; if (target) { const { left, right } = target.getBoundingClientRect(); if (event.clientX - left <= handleWidth) cell = edgeCell(view, event, 'left'); else if (right - event.clientX <= handleWidth) cell = edgeCell(view, event, 'right'); } if (cell != pluginState.activeHandle) { if (!lastColumnResizable && cell !== -1) { const $cell = view.state.doc.resolve(cell); const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1); const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; if (col == map.width - 1) { return; } } updateHandle(view, cell); } } } function handleMouseLeave(view: EditorView): void { const pluginState = columnResizingPluginKey.getState(view.state); if (pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1); } function handleMouseDown( view: EditorView, event: MouseEvent, cellMinWidth: number, ): boolean { const pluginState = columnResizingPluginKey.getState(view.state); if (pluginState.activeHandle == -1 || pluginState.dragging) return false; const cell = view.state.doc.nodeAt(pluginState.activeHandle); const width = currentColWidth(view, pluginState.activeHandle, cell.attrs); view.dispatch( view.state.tr.setMeta(columnResizingPluginKey, { setDragging: { startX: event.clientX, startWidth: width }, }), ); function finish(event: MouseEvent) { window.removeEventListener('mouseup', finish); window.removeEventListener('mousemove', move); const pluginState = columnResizingPluginKey.getState(view.state); if (pluginState.dragging) { updateColumnWidth( view, pluginState.activeHandle, draggedWidth(pluginState.dragging, event, cellMinWidth), ); view.dispatch( view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }), ); } } function move(event: MouseEvent): void { if (!event.which) return finish(event); const pluginState = columnResizingPluginKey.getState(view.state); if (pluginState.dragging) { const dragged = draggedWidth(pluginState.dragging, event, cellMinWidth); displayColumnWidth(view, pluginState.activeHandle, dragged, cellMinWidth); } } window.addEventListener('mouseup', finish); window.addEventListener('mousemove', move); event.preventDefault(); return true; } function currentColWidth( view: EditorView, cellPos: number, { colspan, colwidth }: Attrs, ): number { const width = colwidth && colwidth[colwidth.length - 1]; if (width) return width; const dom = view.domAtPos(cellPos); const node = dom.node.childNodes[dom.offset] as HTMLElement; let domWidth = node.offsetWidth, parts = colspan; if (colwidth) for (let i = 0; i < colspan; i++) if (colwidth[i]) { domWidth -= colwidth[i]; parts--; } return domWidth / parts; } function domCellAround(target: HTMLElement): HTMLElement | null { while (target && target.nodeName != 'TD' && target.nodeName != 'TH') target = target.classList && target.classList.contains('ProseMirror') ? null : (target.parentNode as HTMLElement); return target; } function edgeCell( view: EditorView, event: MouseEvent, side: 'left' | 'right', ): number { const found = view.posAtCoords({ left: event.clientX, top: event.clientY }); if (!found) return -1; const { pos } = found; const $cell = cellAround(view.state.doc.resolve(pos)); if (!$cell) return -1; if (side == 'right') return $cell.pos; const map = TableMap.get($cell.node(-1)), start = $cell.start(-1); const index = map.map.indexOf($cell.pos - start); return index % map.width == 0 ? -1 : start + map.map[index - 1]; } function draggedWidth( dragging: Dragging, event: MouseEvent, cellMinWidth: number, ): number { const offset = event.clientX - dragging.startX; return Math.max(cellMinWidth, dragging.startWidth + offset); } function updateHandle(view: EditorView, value: number): void { view.dispatch( view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }), ); } function updateColumnWidth( view: EditorView, cell: number, width: number, ): void { const $cell = view.state.doc.resolve(cell); const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1); const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; const tr = view.state.tr; for (let row = 0; row < map.height; row++) { const mapIndex = row * map.width + col; // Rowspanning cell that has already been handled if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue; const pos = map.map[mapIndex], { attrs } = table.nodeAt(pos); const index = attrs.colspan == 1 ? 0 : col - map.colCount(pos); if (attrs.colwidth && attrs.colwidth[index] == width) continue; const colwidth = attrs.colwidth ? attrs.colwidth.slice() : zeroes(attrs.colspan); colwidth[index] = width; tr.setNodeMarkup(start + pos, null, _setAttr(attrs, 'colwidth', colwidth)); } if (tr.docChanged) view.dispatch(tr); } function displayColumnWidth( view: EditorView, cell: number, width: number, cellMinWidth: number, ): void { const $cell = view.state.doc.resolve(cell); const table = $cell.node(-1), start = $cell.start(-1); const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1; let dom = view.domAtPos($cell.start(-1)).node; while (dom.nodeName != 'TABLE') dom = dom.parentNode; updateColumnsOnResize( table, dom.firstChild as HTMLTableColElement, dom as HTMLTableElement, cellMinWidth, col, width, ); } function zeroes(n: number): 0[] { const result = []; for (let i = 0; i < n; i++) result.push(0); return result; } export function handleDecorations( state: EditorState, cell: number, ): DecorationSet { const decorations = []; const $cell = state.doc.resolve(cell); const table = $cell.node(-1); if (!table) { return DecorationSet.empty; } const map = TableMap.get(table); const start = $cell.start(-1); const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan; for (let row = 0; row < map.height; row++) { const index = col + row * map.width - 1; // For positions that are have either a different cell or the end // of the table to their right, and either the top of the table or // a different cell above them, add a decoration if ( (col == map.width || map.map[index] != map.map[index + 1]) && (row == 0 || map.map[index - 1] != map.map[index - 1 - map.width]) ) { const cellPos = map.map[index]; const pos = start + cellPos + table.nodeAt(cellPos).nodeSize - 1; const dom = document.createElement('div'); dom.className = 'column-resize-handle'; decorations.push(Decoration.widget(pos, dom)); } } return DecorationSet.create(state.doc, decorations); }