import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import type { EditorState, Transaction } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; import type { EditorView } from '@tiptap/pm/view'; import { TableMap, cellAround, pointsAtCell } from '@tiptap/pm/tables'; import { columnWeightsFromRow, columnPercents } from './tableView'; import { PX_PER_CM } from '../../storage/pageMargins'; // Column resizing: an inner border trades width between its two columns // (table width fixed); an outer edge moves the table's `marginLeft`/`marginRight` (cm) // and the adjacent column. Live drag pokes the DOM; release writes the attrs. const HANDLE_WIDTH = 5; // px from a column border that activates the handle const CELL_MIN_PX = 30; // smallest a column may be dragged to // Resolution of the stored integer weights (percent × this). Only ratios matter. const WEIGHT_SCALE = 100; // Which border of the active cell is hovered: a column border, or the table's own // left/right edge (the first/last column's outer side). type EdgeSide = 'inner' | 'left' | 'right'; interface ResizeState { activeHandle: number; // doc pos of the cell owning the active border, or -1 edge: EdgeSide; dragging: boolean; } type ResizeAction = | { setHandle: number; edge: EdgeSide } | { setDragging: boolean }; const round3 = (v: number) => Math.round(v * 1000) / 1000; // A CSS margin (our own output is cm, pasted HTML may be px) as cm; 0 when absent. // Negative is real: a Word table hangs its left cell margin into the page margin. function marginCm(value: string): number { const n = parseFloat(value); if (!Number.isFinite(n)) return 0; return value.trim().endsWith('px') ? round3(n / PX_PER_CM) : round3(n); } // Inline style for the static HTML paths: the wrapper-less carries the margins // itself, so its width must shrink by them (editor.css has it at width:100%). function tableMarginStyle(attrs: Record): Record { const ml = Number(attrs.marginLeft) || 0; const mr = Number(attrs.marginRight) || 0; const mt = Number(attrs.marginTop) || 0; const mb = Number(attrs.marginBottom) || 0; const decls: string[] = []; if (ml || mr) decls.push(`margin-left: ${ml}cm`, `margin-right: ${mr}cm`, `width: calc(100% - ${round3(ml + mr)}cm)`); if (mt) decls.push(`margin-top: ${mt}cm`); if (mb) decls.push(`margin-bottom: ${mb}cm`); return decls.length ? { style: decls.join('; ') } : {}; } export const tableColumnResizeKey = new PluginKey('tableColumnResize'); // ─── DOM / geometry helpers (mirrors prosemirror-tables) ─────────────────── export function domCellAround(target: HTMLElement | null): HTMLElement | null { let el: HTMLElement | null = target; while (el && el.nodeName !== 'TD' && el.nodeName !== 'TH') { el = el.classList && el.classList.contains('ProseMirror') ? null : (el.parentNode as HTMLElement | null); } return el; } // Resolve the border near the pointer: the cell that owns it plus which border it is. // For "right" that's the cell left of the border, for "left" the cell right of it — // unless the border is the table's own outer edge (first/last column). cell -1 = none. function borderAt(view: EditorView, event: MouseEvent, side: 'left' | 'right'): { cell: number; edge: EdgeSide } { const none = { cell: -1, edge: 'inner' as EdgeSide }; const offset = side === 'right' ? -HANDLE_WIDTH : HANDLE_WIDTH; const found = view.posAtCoords({ left: event.clientX + offset, top: event.clientY }); if (!found) return none; const $cell = cellAround(view.state.doc.resolve(found.pos)); if (!$cell) return none; const map = TableMap.get($cell.node(-1)); const start = $cell.start(-1); if (side === 'left') { const index = map.map.indexOf($cell.pos - start); if (index < 0) return none; return index % map.width === 0 ? { cell: $cell.pos, edge: 'left' } : { cell: start + map.map[index - 1], edge: 'inner' }; } const colspan = ($cell.nodeAfter?.attrs.colspan as number) ?? 1; const col = map.colCount($cell.pos - start) + colspan - 1; return { cell: $cell.pos, edge: col >= map.width - 1 ? 'right' : 'inner' }; } function tableDOM(view: EditorView, tableStart: number): HTMLTableElement | null { let dom = view.domAtPos(tableStart).node as HTMLElement | null; while (dom && dom.nodeName !== 'TABLE') dom = dom.parentNode as HTMLElement | null; return (dom as HTMLTableElement) ?? null; } // ─── Event handlers ──────────────────────────────────────────────────────── function handleMouseMove(view: EditorView, event: MouseEvent): void { if (!view.editable) return; const state = tableColumnResizeKey.getState(view.state); if (!state || state.dragging) return; const target = domCellAround(event.target as HTMLElement | null); let cell = -1; let edge: EdgeSide = 'inner'; if (target) { const { left, right } = target.getBoundingClientRect(); if (event.clientX - left <= HANDLE_WIDTH) ({ cell, edge } = borderAt(view, event, 'left')); else if (right - event.clientX <= HANDLE_WIDTH) ({ cell, edge } = borderAt(view, event, 'right')); } if (cell !== state.activeHandle || edge !== state.edge) { view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: cell, edge } as ResizeAction)); } } // Outer-edge drag: only the adjacent column gives up width, so every // other gridline stays put. All lengths in unscaled document px; the edge stops at // the text margin (margins never go negative) and at the column's minimum width. export function edgeResize( percents: number[], tableWidth: number, marginLeftPx: number, marginRightPx: number, side: 'left' | 'right', dx: number, ): { marginLeftPx: number; marginRightPx: number; percents: number[] } { const abs = percents.map(p => (p / 100) * tableWidth); const i = side === 'left' ? 0 : abs.length - 1; const delta = side === 'left' ? Math.max(-marginLeftPx, Math.min(abs[i] - CELL_MIN_PX, dx)) : Math.max(CELL_MIN_PX - abs[i], Math.min(marginRightPx, dx)); abs[i] += side === 'left' ? -delta : delta; const total = abs.reduce((a, b) => a + b, 0); return { marginLeftPx: side === 'left' ? marginLeftPx + delta : marginLeftPx, marginRightPx: side === 'right' ? marginRightPx - delta : marginRightPx, percents: abs.map(w => (w / total) * 100), }; } // Write the final per-column weights into every cell's `colwidth` attribute, plus the // table's own margins when an outer edge was dragged (sizes are unchanged, so both // markups can share one transaction). function commitColumnWidths( view: EditorView, activeHandle: number, finalPercents: number[], tableMargins?: { marginLeft: number; marginRight: number }, ): void { const $cell = view.state.doc.resolve(activeHandle); const table = $cell.node(-1); const map = TableMap.get(table); const start = $cell.start(-1); const weights = finalPercents.map((p) => Math.max(1, Math.round(p * WEIGHT_SCALE))); const tr = view.state.tr; const seen = new Set(); for (let row = 0; row < map.height; row++) { for (let col = 0; col < map.width; col++) { const pos = map.map[row * map.width + col]; if (seen.has(pos)) continue; seen.add(pos); const cellNode = table.nodeAt(pos); if (!cellNode) continue; const colStart = map.colCount(pos); const span = (cellNode.attrs.colspan as number) ?? 1; const colwidth: number[] = []; for (let k = 0; k < span; k++) { colwidth.push(weights[colStart + k] ?? weights[weights.length - 1] ?? 1); } tr.setNodeMarkup(start + pos, undefined, { ...cellNode.attrs, colwidth }); } } if (tableMargins) tr.setNodeMarkup(start - 1, undefined, { ...table.attrs, ...tableMargins }); if (tr.docChanged) view.dispatch(tr); } // Drag of the table's left/right edge: moves that edge only (margin + adjacent column). function startEdgeDrag(view: EditorView, event: MouseEvent, cellPos: number, side: 'left' | 'right'): boolean { const $cell = view.state.doc.resolve(cellPos); const table = $cell.node(-1); const tableStart = $cell.start(-1); const startPercents = columnPercents(columnWeightsFromRow(table.firstChild)); if (startPercents.length !== TableMap.get(table).width) return false; const dom = tableDOM(view, tableStart); const wrapper = dom?.parentElement; const colgroup = dom?.querySelector(':scope > colgroup') as HTMLElement | null; if (!dom || !wrapper || !colgroup) return false; const tableWidth = dom.offsetWidth; // unscaled document px if (!tableWidth) return false; // offsetWidth is unscaled, getBoundingClientRect is scaled by the .paper zoom: // their ratio converts the mouse delta back to document px (mirrors tableRowResize). const zoom = dom.getBoundingClientRect().width / tableWidth || 1; const ml0 = (((table.attrs.marginLeft as number) || 0)) * PX_PER_CM; const mr0 = (((table.attrs.marginRight as number) || 0)) * PX_PER_CM; const startX = event.clientX; let last = { marginLeftPx: ml0, marginRightPx: mr0, percents: startPercents }; let moved = false; const win = view.dom.ownerDocument.defaultView ?? window; view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setDragging: true } as ResizeAction)); function move(e: MouseEvent): void { if (!e.buttons) { finish(); return; } last = edgeResize(startPercents, tableWidth, ml0, mr0, side, (e.clientX - startX) / zoom); wrapper!.style.marginLeft = `${last.marginLeftPx}px`; wrapper!.style.marginRight = `${last.marginRightPx}px`; const cols = colgroup!.children as HTMLCollectionOf; last.percents.forEach((p, i) => { if (cols[i]) cols[i].style.width = `${p}%`; }); moved = true; } function finish(): void { win.removeEventListener('mouseup', finish); win.removeEventListener('mousemove', move); const st = tableColumnResizeKey.getState(view.state); if (st?.dragging) { if (moved) { commitColumnWidths(view, cellPos, last.percents, { marginLeft: round3(last.marginLeftPx / PX_PER_CM), marginRight: round3(last.marginRightPx / PX_PER_CM), }); } view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setDragging: false } as ResizeAction)); } } win.addEventListener('mouseup', finish); win.addEventListener('mousemove', move); event.preventDefault(); return true; } function handleMouseDown(view: EditorView, event: MouseEvent): boolean { if (!view.editable) return false; const state = tableColumnResizeKey.getState(view.state); if (!state || state.activeHandle === -1 || state.dragging) return false; if (state.edge !== 'inner') return startEdgeDrag(view, event, state.activeHandle, state.edge); const activeHandle = state.activeHandle; const $cell = view.state.doc.resolve(activeHandle); const table = $cell.node(-1); const map = TableMap.get(table); const tableStart = $cell.start(-1); const colspan = ($cell.nodeAfter?.attrs.colspan as number) ?? 1; const leftCol = map.colCount($cell.pos - tableStart) + colspan - 1; const rightCol = leftCol + 1; const startPercents = columnPercents(columnWeightsFromRow(table.firstChild)); if (startPercents.length !== map.width) return false; const dom = tableDOM(view, tableStart); if (!dom) return false; // The node view keeps a stable element but replaces its // children whenever it rebuilds (e.g. on the setDragging transaction below), so // query the children live inside move() rather than caching them here. const colgroup = dom.querySelector(':scope > colgroup') as HTMLElement | null; if (!colgroup) return false; const contentWidth = dom.getBoundingClientRect().width; if (!contentWidth) return false; const startX = event.clientX; const minPct = (CELL_MIN_PX / contentWidth) * 100; const pairSum = startPercents[leftCol] + startPercents[rightCol]; let lastLeft = startPercents[leftCol]; let lastRight = startPercents[rightCol]; let moved = false; const win = view.dom.ownerDocument.defaultView ?? window; // Flag the drag so hover handling pauses and the column gets a dragging class. view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setDragging: true } as ResizeAction)); function move(e: MouseEvent): void { if (!e.buttons) { finish(); return; } let newLeft = startPercents[leftCol] + ((e.clientX - startX) / contentWidth) * 100; newLeft = Math.max(minPct, Math.min(pairSum - minPct, newLeft)); const newRight = pairSum - newLeft; const cols = colgroup!.children as HTMLCollectionOf; if (cols[leftCol]) cols[leftCol].style.width = `${newLeft}%`; if (cols[rightCol]) cols[rightCol].style.width = `${newRight}%`; lastLeft = newLeft; lastRight = newRight; moved = true; } function finish(): void { win.removeEventListener('mouseup', finish); win.removeEventListener('mousemove', move); const st = tableColumnResizeKey.getState(view.state); if (st?.dragging) { if (moved) { const finalPercents = startPercents.slice(); finalPercents[leftCol] = lastLeft; finalPercents[rightCol] = lastRight; commitColumnWidths(view, activeHandle, finalPercents); } view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setDragging: false } as ResizeAction)); } } win.addEventListener('mouseup', finish); win.addEventListener('mousemove', move); event.preventDefault(); return true; } // ─── Decorations: the visible handle at the active column border ───────────── function handleDecorations(state: EditorState, cell: number, edge: EdgeSide): DecorationSet { 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 colspan = ($cell.nodeAfter?.attrs.colspan as number) ?? 1; const col = edge === 'left' ? 0 : map.colCount($cell.pos - start) + colspan - 1; const dragging = tableColumnResizeKey.getState(state)?.dragging; for (let row = 0; row < map.height; row++) { const index = col + row * map.width; // Skip cells that span across this border (irrelevant at the left edge) or that // merge into the row above. const spansBorder = edge !== 'left' && col < map.width - 1 && map.map[index] === map.map[index + 1]; const mergedUp = row > 0 && map.map[index] === map.map[index - map.width]; if (spansBorder || mergedUp) continue; const cellPos = map.map[index]; const cellNode = table.nodeAt(cellPos); if (!cellNode) continue; // Widgets sit INSIDE the cell (which is the handle's positioning context). const pos = edge === 'left' ? start + cellPos + 1 : start + cellPos + cellNode.nodeSize - 1; const dom = document.createElement('div'); dom.className = edge === 'left' ? 'column-resize-handle left' : 'column-resize-handle'; if (dragging) { decorations.push( Decoration.node(start + cellPos, start + cellPos + cellNode.nodeSize, { class: 'column-resize-dragging', }), ); } decorations.push(Decoration.widget(pos, dom)); } return DecorationSet.create(state.doc, decorations); } // ─── Plugin / extension ────────────────────────────────────────────────────── export const TableColumnResize = Extension.create({ name: 'tableColumnResize', // The table's own margins (cm, 0 = flush with the text area), set by the outer-edge // drag. Rendered on the wrapper by TableView; the inline style here serves the // static HTML paths (generateHTML → PDF export, clipboard round trip). addGlobalAttributes() { return [ { types: ['table'], attributes: { marginLeft: { default: 0, parseHTML: (element: HTMLElement) => marginCm(element.style.marginLeft), renderHTML: (attributes: Record) => tableMarginStyle(attributes), }, marginRight: { default: 0, parseHTML: (element: HTMLElement) => marginCm(element.style.marginRight), renderHTML: () => ({}), }, // The space above/below the table (ODF fo:margin-top/-bottom on the table // style). Word has no equivalent, so it only round-trips through ODF. marginTop: { default: 0, parseHTML: (element: HTMLElement) => marginCm(element.style.marginTop), renderHTML: () => ({}), }, marginBottom: { default: 0, parseHTML: (element: HTMLElement) => marginCm(element.style.marginBottom), renderHTML: () => ({}), }, }, }, ]; }, addProseMirrorPlugins() { return [ new Plugin({ key: tableColumnResizeKey, state: { init: (): ResizeState => ({ activeHandle: -1, edge: 'inner', dragging: false }), apply(tr: Transaction, prev: ResizeState): ResizeState { const action = tr.getMeta(tableColumnResizeKey) as ResizeAction | undefined; if (action && 'setHandle' in action) return { activeHandle: action.setHandle, edge: action.edge, dragging: false }; if (action && 'setDragging' in action) return { ...prev, dragging: action.setDragging }; if (prev.activeHandle > -1 && tr.docChanged) { let handle = tr.mapping.map(prev.activeHandle, -1); if (!pointsAtCell(tr.doc.resolve(handle))) handle = -1; return { activeHandle: handle, edge: prev.edge, dragging: prev.dragging }; } return prev; }, }, props: { // Whole-editor cursor while a border is active (matches the handle). attributes: (state): Record => { const s = tableColumnResizeKey.getState(state); return s && s.activeHandle > -1 ? { class: 'resize-cursor' } : {}; }, handleDOMEvents: { mousemove: (view, event) => { handleMouseMove(view, event as MouseEvent); return false; }, mouseleave: (view) => { const s = tableColumnResizeKey.getState(view.state); if (s && s.activeHandle > -1 && !s.dragging) { view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: -1, edge: 'inner' } as ResizeAction)); } return false; }, mousedown: (view, event) => handleMouseDown(view, event as MouseEvent), }, decorations: (state) => { const s = tableColumnResizeKey.getState(state); if (s && s.activeHandle > -1) return handleDecorations(state, s.activeHandle, s.edge); return null; }, }, }), ]; }, });