import type { Node as ProseMirrorNode } from "@tiptap/pm/model" import { Plugin, PluginKey, type EditorState, type Transaction } from "@tiptap/pm/state" import { Decoration, DecorationSet, type EditorView, type NodeView, } from "@tiptap/pm/view" import { TableMap, TableView, cellAround, pointsAtCell, } from "@tiptap/pm/tables" type Dragging = { startX: number; startWidth: number } class DocsResizeState { activeHandle: number dragging: Dragging | false constructor(activeHandle: number, dragging: Dragging | false) { this.activeHandle = activeHandle this.dragging = dragging } apply(tr: Transaction): DocsResizeState { const action = tr.getMeta(docsColumnResizingPluginKey) as | { setHandle?: number; setDragging?: Dragging | null } | undefined if (action && action.setHandle != null) { return new DocsResizeState(action.setHandle, false) } if (action && action.setDragging !== undefined) { return new DocsResizeState(this.activeHandle, action.setDragging ?? false) } if (this.activeHandle > -1 && tr.docChanged) { let handle = tr.mapping.map(this.activeHandle, -1) if (!pointsAtCell(tr.doc.resolve(handle))) handle = -1 return new DocsResizeState(handle, this.dragging) } return this } } export const docsColumnResizingPluginKey = new PluginKey( "docsTableColumnResizing" ) function readDocsPageScale(view: EditorView): number { const stack = view.dom.closest("[data-docs-page-stack]") if (!stack) return 1 const scale = Number.parseFloat(stack.getAttribute("data-docs-page-scale") ?? "1") return Number.isFinite(scale) && scale > 0 ? scale : 1 } /** Keep table full width; only colgroup drives column sizes (avoids border jump on hover/drag). */ export function docsUpdateColumnsOnResize( node: ProseMirrorNode, colgroup: HTMLTableColElement, table: HTMLTableElement, defaultCellMinWidth: number, overrideCol?: number, overrideValue?: number ): void { let totalWidth = 0 let fixedWidth = true let nextDOM = colgroup.firstChild as HTMLElement | null const row = node.firstChild if (!row) return for (let i = 0, col = 0; i < row.childCount; i++) { const { colspan, colwidth } = row.child(i).attrs as { colspan: number colwidth?: number[] | null } for (let j = 0; j < colspan; j++, col++) { const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j] const cssWidth = hasWidth ? `${hasWidth}px` : "" totalWidth += hasWidth || defaultCellMinWidth if (!hasWidth) fixedWidth = false if (!nextDOM) { const colEl = document.createElement("col") colEl.style.width = cssWidth colgroup.appendChild(colEl) } else { if (nextDOM.style.width !== cssWidth) nextDOM.style.width = cssWidth nextDOM = nextDOM.nextSibling as HTMLElement | null } } } while (nextDOM) { const after = nextDOM.nextSibling nextDOM.parentNode?.removeChild(nextDOM) nextDOM = after as HTMLElement | null } table.style.width = "100%" table.style.minWidth = fixedWidth ? "" : `${totalWidth}px` } export class DocsTableView extends TableView { constructor(node: ProseMirrorNode, defaultCellMinWidth: number, _view?: EditorView) { super(node, defaultCellMinWidth) docsUpdateColumnsOnResize(node, this.colgroup, this.table, defaultCellMinWidth) } update(node: ProseMirrorNode): boolean { if (node.type !== this.node.type) return false this.node = node docsUpdateColumnsOnResize(node, this.colgroup, this.table, this.defaultCellMinWidth) return true } } function tableNodeName(state: EditorState): string { for (const name of Object.keys(state.schema.nodes)) { if (state.schema.nodes[name].spec.tableRole === "table") return name } return "table" } function domCellAround(target: EventTarget | null): HTMLElement | null { let el = target as HTMLElement | null while (el && el.nodeName !== "TD" && el.nodeName !== "TH") { el = el.classList?.contains("ProseMirror") ? null : (el.parentNode as HTMLElement | null) } return el } function edgeCell( view: EditorView, event: MouseEvent, side: "left" | "right", handleWidth: number ): number { const offset = side === "right" ? -handleWidth : handleWidth const found = view.posAtCoords({ left: event.clientX + offset, 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)) const start = $cell.start(-1) const index = map.map.indexOf($cell.pos - start) return index % map.width === 0 ? -1 : start + map.map[index - 1] } function currentColWidth( view: EditorView, cellPos: number, { colspan, colwidth }: { colspan: number; colwidth?: number[] | null } ): number { const stored = colwidth && colwidth[colwidth.length - 1] if (stored) return stored const dom = view.domAtPos(cellPos) const cellDom = dom.node.childNodes[dom.offset] as HTMLElement let domWidth = cellDom.offsetWidth let parts = colspan if (colwidth) { for (let i = 0; i < colspan; i++) { if (colwidth[i]) { domWidth -= colwidth[i]! parts-- } } } return domWidth / parts } function draggedWidth(dragging: Dragging, event: MouseEvent, minWidth: number, scale: number) { const offset = (event.clientX - dragging.startX) / scale return Math.max(minWidth, dragging.startWidth + offset) } function updateHandle(view: EditorView, value: number) { view.dispatch(view.state.tr.setMeta(docsColumnResizingPluginKey, { setHandle: value })) } function displayColumnWidth( view: EditorView, cell: number, width: number, defaultCellMinWidth: number ) { const $cell = view.state.doc.resolve(cell) const table = $cell.node(-1) const start = $cell.start(-1) const col = TableMap.get(table).colCount($cell.pos - start) + ($cell.nodeAfter?.attrs.colspan ?? 1) - 1 let dom: Node | null = view.domAtPos($cell.start(-1)).node while (dom && dom.nodeName !== "TABLE") dom = dom.parentNode if (!dom || dom.nodeName !== "TABLE") return docsUpdateColumnsOnResize( table, (dom as HTMLTableElement).firstChild as HTMLTableColElement, dom as HTMLTableElement, defaultCellMinWidth, col, width ) } function zeroes(n: number) { return Array(n).fill(0) } function updateColumnWidth(view: EditorView, cell: number, width: number) { const $cell = view.state.doc.resolve(cell) const table = $cell.node(-1) const map = TableMap.get(table) const start = $cell.start(-1) const col = map.colCount($cell.pos - start) + ($cell.nodeAfter?.attrs.colspan ?? 1) - 1 const tr = view.state.tr for (let row = 0; row < map.height; row++) { const mapIndex = row * map.width + col if (row && map.map[mapIndex] === map.map[mapIndex - map.width]) continue const pos = map.map[mapIndex] const attrs = table.nodeAt(pos)!.attrs 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, { ...attrs, colwidth }) } if (tr.docChanged) view.dispatch(tr) } function handleDecorations(state: EditorState, cell: number) { const decorations: Decoration[] = [] 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 ?? 1) - 1 const pluginState = docsColumnResizingPluginKey.getState(state) for (let row = 0; row < map.height; row++) { const index = col + row * map.width if ( (col === map.width - 1 || map.map[index] !== map.map[index + 1]) && (row === 0 || map.map[index] !== map.map[index - 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" if (pluginState?.dragging) { decorations.push( Decoration.node(start + cellPos, start + cellPos + table.nodeAt(cellPos)!.nodeSize, { class: "column-resize-dragging", }) ) } decorations.push(Decoration.widget(pos, dom)) } } return DecorationSet.create(state.doc, decorations) } function handleMouseMove( view: EditorView, event: MouseEvent, handleWidth: number, lastColumnResizable: boolean ) { if (!view.editable) return const pluginState = docsColumnResizingPluginKey.getState(view.state) if (!pluginState || pluginState.dragging) return const target = domCellAround(event.target) let cell = -1 if (target) { const { left, right } = target.getBoundingClientRect() if (event.clientX - left <= handleWidth) cell = edgeCell(view, event, "left", handleWidth) else if (right - event.clientX <= handleWidth) cell = edgeCell(view, event, "right", handleWidth) } if (cell === pluginState.activeHandle) return if (!lastColumnResizable && cell !== -1) { const $cell = view.state.doc.resolve(cell) const table = $cell.node(-1) const map = TableMap.get(table) const tableStart = $cell.start(-1) if ( map.colCount($cell.pos - tableStart) + ($cell.nodeAfter?.attrs.colspan ?? 1) - 1 === map.width - 1 ) { return } } updateHandle(view, cell) } function handleMouseLeave(view: EditorView) { if (!view.editable) return const pluginState = docsColumnResizingPluginKey.getState(view.state) if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) { updateHandle(view, -1) } } function handleMouseDown( view: EditorView, event: MouseEvent, cellMinWidth: number, defaultCellMinWidth: number ) { if (!view.editable) return false const win = view.dom.ownerDocument.defaultView ?? window const pluginState = docsColumnResizingPluginKey.getState(view.state) if (!pluginState || pluginState.activeHandle === -1 || pluginState.dragging) return false const cell = view.state.doc.nodeAt(pluginState.activeHandle) if (!cell) return false const width = currentColWidth(view, pluginState.activeHandle, { colspan: cell.attrs.colspan as number, colwidth: cell.attrs.colwidth as number[] | null | undefined, }) const scale = readDocsPageScale(view) view.dispatch( view.state.tr.setMeta(docsColumnResizingPluginKey, { setDragging: { startX: event.clientX, startWidth: width }, }) ) const finish = (ev: MouseEvent) => { win.removeEventListener("mouseup", finish) win.removeEventListener("mousemove", move) const state = docsColumnResizingPluginKey.getState(view.state) if (state?.dragging) { updateColumnWidth( view, state.activeHandle, draggedWidth(state.dragging, ev, cellMinWidth, scale) ) view.dispatch(view.state.tr.setMeta(docsColumnResizingPluginKey, { setDragging: null })) } } const move = (ev: MouseEvent) => { if (!ev.buttons) return finish(ev) const state = docsColumnResizingPluginKey.getState(view.state) if (!state?.dragging) return displayColumnWidth( view, state.activeHandle, draggedWidth(state.dragging, ev, cellMinWidth, scale), defaultCellMinWidth ) } displayColumnWidth(view, pluginState.activeHandle, width, defaultCellMinWidth) win.addEventListener("mouseup", finish) win.addEventListener("mousemove", move) event.preventDefault() return true } export function docsColumnResizing({ handleWidth = 5, cellMinWidth = 25, defaultCellMinWidth = 100, View = DocsTableView, lastColumnResizable = true, }: { handleWidth?: number cellMinWidth?: number defaultCellMinWidth?: number View?: (new (node: ProseMirrorNode, cellMinWidth: number, view: EditorView) => NodeView) | null lastColumnResizable?: boolean } = {}) { const plugin = new Plugin({ key: docsColumnResizingPluginKey, state: { init(_, state) { const tableName = tableNodeName(state) if (View && plugin.spec.props?.nodeViews) { plugin.spec.props.nodeViews[tableName] = (node, view) => new View(node, defaultCellMinWidth, view) } return new DocsResizeState(-1, false) }, apply(tr, prev) { return prev.apply(tr) }, }, props: { attributes(state) { const pluginState = docsColumnResizingPluginKey.getState(state) return pluginState && pluginState.activeHandle > -1 ? { class: "resize-cursor" } : {} }, handleDOMEvents: { mousemove: (view, event) => { handleMouseMove(view, event, handleWidth, lastColumnResizable) }, mouseleave: (view) => { handleMouseLeave(view) }, mousedown: (view, event) => { return handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) }, }, decorations(state) { const pluginState = docsColumnResizingPluginKey.getState(state) if (pluginState && pluginState.activeHandle > -1) { return handleDecorations(state, pluginState.activeHandle) } return undefined }, nodeViews: {}, }, }) return plugin }