From bb281c60c117e73c5b8f27ef64dcd88c788cf273 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 8 Jun 2026 20:35:57 +0200 Subject: [PATCH] feat: move table row bars by mouse --- src/lib/editor/extensions.ts | 16 +- src/lib/editor/pageBreaks.ts | 21 ++- src/lib/editor/tableColumnResize.ts | 2 +- src/lib/editor/tableRow.ts | 37 ++++ src/lib/editor/tableRowResize.ts | 258 ++++++++++++++++++++++++++++ src/lib/export/odt.ts | 78 ++++++++- src/styles/editor.css | 19 ++ 7 files changed, 416 insertions(+), 15 deletions(-) create mode 100644 src/lib/editor/tableRow.ts create mode 100644 src/lib/editor/tableRowResize.ts diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index d9fb27e..91236d0 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -12,7 +12,8 @@ import Heading from '@tiptap/extension-heading'; import BulletList from '@tiptap/extension-bullet-list'; import OrderedList from '@tiptap/extension-ordered-list'; import ListItem from '@tiptap/extension-list-item'; -import { Table, TableRow, TableHeader, TableCell } from '@tiptap/extension-table'; +import { Table, TableHeader, TableCell } from '@tiptap/extension-table'; +import { ResizableTableRow } from './tableRow'; import History from '@tiptap/extension-history'; import Placeholder from '@tiptap/extension-placeholder'; import TextAlign from '@tiptap/extension-text-align'; @@ -22,6 +23,7 @@ import { ParagraphSpacing } from './paragraphSpacing'; import { FormattingMarks } from './formattingMarks'; import { TableView } from './tableView'; import { TableColumnResize } from './tableColumnResize'; +import { TableRowResize } from './tableRowResize'; export const extensions = [ Document, @@ -46,17 +48,21 @@ export const extensions = [ BulletList, OrderedList, ListItem, - // Columns are flexible and drag-resizable (Word-style). We keep TipTap's own - // resizable:false (so its columnResizing plugin isn't loaded) and instead supply + // Columns and rows are flexible and drag-resizable (Word-style). We keep TipTap's + // own resizable:false (so its columnResizing plugin isn't loaded) and instead supply // a custom node view that renders the with *percentage* widths from the // `colwidth` cell attrs — so the table is always full text width and stays in sync // with the ODT export (export/odt.ts emits per-column widths summing to the text - // width). The TableColumnResize extension below adds the Word-style drag handles. + // width). TableColumnResize adds the Word-style column drag handles; ResizableTableRow + // carries a per-row `rowHeight` attr and TableRowResize adds the row drag handles + // (exported as style:min-row-height). Table alone — unlike TableKit — does NOT + // auto-add child extensions, so listing ResizableTableRow here is the only tableRow. Table.configure({ resizable: false, View: TableView }), - TableRow, + ResizableTableRow, TableHeader, TableCell, TableColumnResize, + TableRowResize, History, Placeholder.configure({ placeholder: 'Start typing…' }), TextAlign.configure({ diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts index b2b4718..cf22015 100644 --- a/src/lib/editor/pageBreaks.ts +++ b/src/lib/editor/pageBreaks.ts @@ -529,14 +529,19 @@ export const PageBreaks = Extension.create({ cumulativeSpacerHeight += child.offsetHeight; continue; } - // Skip the column-resize handle widget (tableColumnResize.ts), which - // ProseMirror injects as a direct child of the active column's cells. - // It's position:absolute and full-cell-height, so it's neither document - // content nor part of the flow — measuring it as a leaf yields garbage - // geometry (negative offsetTop, full-table height) that corrupts - // pagination. Being out of flow it takes no vertical space, so unlike a - // page-break spacer we skip it without touching cumulativeSpacerHeight. - if (child.classList.contains('column-resize-handle')) continue; + // Skip the column-/row-resize handle widgets (tableColumnResize.ts / + // tableRowResize.ts), which ProseMirror injects as direct children of + // the active cells. They're position:absolute, so they're neither + // document content nor part of the flow — measuring one as a leaf yields + // garbage geometry (negative offsetTop, full-table height) that corrupts + // pagination. Being out of flow they take no vertical space, so unlike a + // page-break spacer we skip them without touching cumulativeSpacerHeight. + if ( + child.classList.contains('column-resize-handle') || + child.classList.contains('row-resize-handle') + ) { + continue; + } const tag = child.tagName; // TipTap renders tables inside a
node // view (see extensions.ts Table config), so the top-level child is diff --git a/src/lib/editor/tableColumnResize.ts b/src/lib/editor/tableColumnResize.ts index 2ae6cb0..9284a1e 100644 --- a/src/lib/editor/tableColumnResize.ts +++ b/src/lib/editor/tableColumnResize.ts @@ -35,7 +35,7 @@ export const tableColumnResizeKey = new PluginKey('tableColumnResiz // ─── DOM / geometry helpers (mirrors prosemirror-tables) ─────────────────── -function domCellAround(target: HTMLElement | null): HTMLElement | null { +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); diff --git a/src/lib/editor/tableRow.ts b/src/lib/editor/tableRow.ts new file mode 100644 index 0000000..a2b574d --- /dev/null +++ b/src/lib/editor/tableRow.ts @@ -0,0 +1,37 @@ +import { TableRow } from '@tiptap/extension-table'; + +// TableRow extended with a draggable row height. +// +// TipTap's stock TableRow has no height attribute. We add `rowHeight` (integer +// CSS px, unscaled document pixels at 96 dpi) and render it as `style="height: Npx"` +// on the . CSS treats a row's height as a *minimum* (the row grows if its +// content is taller), which is exactly the semantics we export — odf-kit gets a +// `style:min-row-height` (see export/odt.ts applyTableRowHeights). `null` means +// "no explicit height" → the row auto-sizes to its content (back-compat for +// documents saved before this feature). +// +// The actual drag interaction + live preview live in tableRowResize.ts; this file +// only owns the persisted attribute and its DOM/serialization mapping. + +export const ResizableTableRow = TableRow.extend({ + addAttributes() { + return { + ...this.parent?.(), + rowHeight: { + default: null as number | null, + // Round-trip a pasted/imported . Non-px units are + // ignored (null) — the editor only ever writes px. + parseHTML: (element) => { + const h = (element as HTMLElement).style?.height; + if (!h) return null; + const m = /^([\d.]+)px$/.exec(h.trim()); + return m ? Math.round(parseFloat(m[1])) : null; + }, + renderHTML: (attributes) => { + const h = attributes.rowHeight as number | null; + return h ? { style: `height: ${h}px` } : {}; + }, + }, + }; + }, +}); diff --git a/src/lib/editor/tableRowResize.ts b/src/lib/editor/tableRowResize.ts new file mode 100644 index 0000000..95ea3aa --- /dev/null +++ b/src/lib/editor/tableRowResize.ts @@ -0,0 +1,258 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { Transaction } from '@tiptap/pm/state'; +import type { EditorState } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { EditorView } from '@tiptap/pm/view'; +import { cellAround } from '@tiptap/pm/tables'; +import { domCellAround, tableColumnResizeKey } from './tableColumnResize'; + +// Word-style row-height dragging. +// +// Dragging a horizontal grid line changes the height of the row ABOVE it (Word +// behaviour). The height is a *minimum*: the row never clips, it grows when its +// content is taller (CSS `height` on a , exported as style:min-row-height — +// see export/odt.ts). The committed value lives in the tableRow node's `rowHeight` +// attribute (tableRow.ts). +// +// Live feedback during a drag is applied via a Decoration.node carrying a `style: +// height` — ProseMirror owns that DOM write, so (unlike a direct tr.style poke +// inside the content DOM) the DOM observer doesn't revert it. Each move dispatches +// a meta-only transaction (no doc change → no pagination recompute, no history +// entry); release commits the final height in one real transaction. +// +// Column resizing (tableColumnResize.ts) takes precedence at a corner: this plugin +// yields whenever a column border is the active handle. + +const HANDLE_WIDTH = 5; // px from a row border that activates the handle +const MIN_ROW_PX = 16; // smallest a row may be dragged to (content still protects it) + +interface RowResizeState { + activeRow: number; // doc pos directly before the active row, or -1 + dragging: boolean; + previewHeight: number | null; // live drag height in unscaled document px +} + +interface RowResizeAction { + setActive?: number; + setDragging?: boolean; + setPreview?: number | null; +} + +export const tableRowResizeKey = new PluginKey('tableRowResize'); + +// ─── Geometry helpers ──────────────────────────────────────────────────────── + +// Position directly before the row whose lower border is near the pointer. We +// probe just ABOVE the pointer so a border between row i and i+1 resolves to row i +// (the row that gets resized). Returns -1 above the table's first row. +function edgeRowPos(view: EditorView, event: MouseEvent): number { + const found = view.posAtCoords({ left: event.clientX, top: event.clientY - HANDLE_WIDTH }); + if (!found) return -1; + const $cell = cellAround(view.state.doc.resolve(found.pos)); + if (!$cell) return -1; + return $cell.before(); // position before the row node +} + +// The element for the row at `rowPos` (a before-row position). +function rowDOM(view: EditorView, rowPos: number): HTMLElement | null { + let dom = view.domAtPos(rowPos + 1).node as HTMLElement | null; + while (dom && dom.nodeName !== 'TR') dom = dom.parentNode as HTMLElement | null; + return (dom as HTMLElement) ?? null; +} + +// ─── Event handlers ──────────────────────────────────────────────────────── + +function handleMouseMove(view: EditorView, event: MouseEvent): void { + if (!view.editable) return; + const state = tableRowResizeKey.getState(view.state); + if (!state || state.dragging) return; + + // Corner precedence: if a column border is active, don't claim a row border. + const colState = tableColumnResizeKey.getState(view.state); + if (colState && colState.activeHandle > -1) { + if (state.activeRow !== -1) { + view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: -1 } as RowResizeAction)); + } + return; + } + + const target = domCellAround(event.target as HTMLElement | null); + let row = -1; + if (target) { + const { top, bottom } = target.getBoundingClientRect(); + if (bottom - event.clientY <= HANDLE_WIDTH || event.clientY - top <= HANDLE_WIDTH) { + row = edgeRowPos(view, event); + } + } + + if (row !== state.activeRow) { + view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: row } as RowResizeAction)); + } +} + +function handleMouseDown(view: EditorView, event: MouseEvent): boolean { + if (!view.editable) return false; + const state = tableRowResizeKey.getState(view.state); + if (!state || state.activeRow === -1 || state.dragging) return false; + + const activeRow = state.activeRow; + const rowNode = view.state.doc.nodeAt(activeRow); + if (!rowNode || rowNode.type.name !== 'tableRow') return false; + + const tr = rowDOM(view, activeRow); + if (!tr) return false; + const startHeight = tr.offsetHeight; // unscaled document px (ignores CSS zoom) + if (!startHeight) return false; + // offsetHeight is unscaled; getBoundingClientRect is scaled by the .paper zoom. + // Their ratio is the zoom factor, so the mouse delta (viewport px) converts back + // to document px without walking the ancestor chain. + const zoom = tr.getBoundingClientRect().height / startHeight || 1; + + const startY = event.clientY; + let lastHeight = startHeight; + let pendingHeight = startHeight; + let moved = false; + let rafPending = false; + const win = view.dom.ownerDocument.defaultView ?? window; + + view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setDragging: true } as RowResizeAction)); + + function flush(): void { + rafPending = false; + const st = tableRowResizeKey.getState(view.state); + if (!st?.dragging) return; + view.dispatch( + view.state.tr + .setMeta(tableRowResizeKey, { setPreview: pendingHeight } as RowResizeAction) + .setMeta('addToHistory', false), + ); + } + + function move(e: MouseEvent): void { + if (!e.buttons) { + finish(); + return; + } + pendingHeight = Math.max(MIN_ROW_PX, Math.round(startHeight + (e.clientY - startY) / zoom)); + lastHeight = pendingHeight; + moved = true; + if (!rafPending) { + rafPending = true; + win.requestAnimationFrame(flush); + } + } + + function finish(): void { + win.removeEventListener('mouseup', finish); + win.removeEventListener('mousemove', move); + const st = tableRowResizeKey.getState(view.state); + if (!st?.dragging) return; + const node = view.state.doc.nodeAt(activeRow); + if (moved && node && node.type.name === 'tableRow') { + const tx = view.state.tr.setNodeMarkup(activeRow, undefined, { ...node.attrs, rowHeight: lastHeight }); + tx.setMeta(tableRowResizeKey, { setDragging: false, setPreview: null } as RowResizeAction); + view.dispatch(tx); + } else { + view.dispatch( + view.state.tr.setMeta(tableRowResizeKey, { setDragging: false, setPreview: null } as RowResizeAction), + ); + } + } + + win.addEventListener('mouseup', finish); + win.addEventListener('mousemove', move); + event.preventDefault(); + return true; +} + +// ─── Decorations: handle at the active row's bottom edge + live preview ─────── + +function rowDecorations(state: EditorState, activeRow: number, previewHeight: number | null): DecorationSet { + const rowNode = state.doc.nodeAt(activeRow); + if (!rowNode || rowNode.type.name !== 'tableRow') return DecorationSet.empty; + const decos: Decoration[] = []; + const dragging = tableRowResizeKey.getState(state)?.dragging; + + // Live preview: apply the dragged height through ProseMirror so it isn't reverted. + if (dragging && previewHeight != null) { + decos.push( + Decoration.node(activeRow, activeRow + rowNode.nodeSize, { style: `height: ${previewHeight}px` }), + ); + } + + // One handle per cell at the row's lower edge → a continuous line across the row. + rowNode.forEach((cell, offset) => { + const cellPos = activeRow + 1 + offset; + const handle = document.createElement('div'); + handle.className = 'row-resize-handle'; + decos.push(Decoration.widget(cellPos + cell.nodeSize - 1, handle)); + }); + + return DecorationSet.create(state.doc, decos); +} + +// ─── Plugin / extension ────────────────────────────────────────────────────── + +export const TableRowResize = Extension.create({ + name: 'tableRowResize', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: tableRowResizeKey, + state: { + init: (): RowResizeState => ({ activeRow: -1, dragging: false, previewHeight: null }), + apply(tr: Transaction, prev: RowResizeState): RowResizeState { + const action = tr.getMeta(tableRowResizeKey) as RowResizeAction | undefined; + if (action) { + const next: RowResizeState = { ...prev }; + if ('setActive' in action) { + next.activeRow = action.setActive ?? -1; + next.dragging = false; + next.previewHeight = null; + } + if ('setDragging' in action) next.dragging = !!action.setDragging; + if ('setPreview' in action) next.previewHeight = action.setPreview ?? null; + return next; + } + if (prev.activeRow > -1 && tr.docChanged) { + const mapped = tr.mapping.map(prev.activeRow, -1); + const node = tr.doc.nodeAt(mapped); + if (!node || node.type.name !== 'tableRow') { + return { activeRow: -1, dragging: false, previewHeight: null }; + } + return { ...prev, activeRow: mapped }; + } + return prev; + }, + }, + props: { + attributes: (state): Record => { + const s = tableRowResizeKey.getState(state); + return s && s.activeRow > -1 ? { class: 'row-resize-cursor' } : {}; + }, + handleDOMEvents: { + mousemove: (view, event) => { + handleMouseMove(view, event as MouseEvent); + return false; + }, + mouseleave: (view) => { + const s = tableRowResizeKey.getState(view.state); + if (s && s.activeRow > -1 && !s.dragging) { + view.dispatch(view.state.tr.setMeta(tableRowResizeKey, { setActive: -1 } as RowResizeAction)); + } + return false; + }, + mousedown: (view, event) => handleMouseDown(view, event as MouseEvent), + }, + decorations: (state) => { + const s = tableRowResizeKey.getState(state); + if (s && s.activeRow > -1) return rowDecorations(state, s.activeRow, s.previewHeight); + return null; + }, + }, + }), + ]; + }, +}); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index cfb7916..28e4d69 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -172,6 +172,78 @@ function collectTableCellStyles(node: TiptapNode, result: ParaStyle[]): void { } } +// Collect each table row's explicit height (px → cm string), in DFS document +// order — matching the order odf-kit emits into content.xml. +// rowHeight is stored as unscaled document px at 96 dpi (tableRow.ts); convert to +// cm (px × 2.54 / 96). Rows without an explicit height yield null. +function collectTableRowHeights(node: TiptapNode, result: (string | null)[]): void { + if (node.type === 'table') { + for (const row of node.content ?? []) { + if (row.type !== 'tableRow') continue; + const h = row.attrs?.rowHeight; + if (typeof h === 'number' && h > 0) { + const cm = Math.round(((h * 2.54) / 96) * 1000) / 1000; + result.push(`${cm}cm`); + } else { + result.push(null); + } + } + return; + } + for (const child of node.content ?? []) { + collectTableRowHeights(child, result); + } +} + +// odf-kit's TableBuilder has no row-height option (TableRowOptions only carries +// backgroundColor → fo:background-color on style:table-row-properties). We add the +// dragged row heights by post-processing content.xml: each with a +// height gets a table:style-name pointing at a new automatic table-row style with +// style:min-row-height — a *minimum*, so the row grows with content and nothing is +// clipped (round-trips with LibreOffice/Word). use-optimal-row-height="false" stops +// LibreOffice from auto-shrinking back to the content height. The regex consumes one +// heights[] entry per so the DFS order from collectTableRowHeights +// stays aligned; rows already carrying a table:style-name (not produced today — the +// editor sets no row background) are skipped. +function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]): Uint8Array { + if (heights.every(h => h === null)) return odtBytes; + + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + + let content = strFromU8(contentBytes); + const styleDefs: { name: string; height: string }[] = []; + const nameByHeight = new Map(); + let counter = 0; + let idx = 0; + + content = content.replace(/]*)>/g, (match, attrs: string) => { + const height = heights[idx++]; + if (!height) return match; + if (/\btable:style-name=/.test(attrs)) return match; + let name = nameByHeight.get(height); + if (!name) { + counter++; + name = `TRH${counter}`; + nameByHeight.set(height, name); + styleDefs.push({ name, height }); + } + return ``; + }); + + if (styleDefs.length === 0) return odtBytes; + + const newStyles = styleDefs.map(({ name, height }) => + ``, + ).join('\n'); + + content = content.replace('', `${newStyles}\n`); + + files['content.xml'] = strToU8(content); + return rezipOdt(files); +} + // odf-kit's ListBuilder doesn't support per-item paragraph options, so list-item // paragraphs always emit with text:style-name="List_20_Bullet" or "List_20_Number". // We rewrite content.xml to point those at custom automatic styles that inherit @@ -612,7 +684,11 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT collectTableCellStyles(raw, cellStyles); const styledCells = applyTableCellStyles(styledLists, cellStyles); - const cleaned = collapseRunWhitespace(styledCells); + const rowHeights: (string | null)[] = []; + collectTableRowHeights(raw, rowHeights); + const styledRows = applyTableRowHeights(styledCells, rowHeights); + + const cleaned = collapseRunWhitespace(styledRows); const finalBytes = rewriteStylesXml(cleaned); const blob = new Blob([finalBytes as Uint8Array], { type: 'application/vnd.oasis.opendocument.text' }); diff --git a/src/styles/editor.css b/src/styles/editor.css index ecd78df..3a478e0 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -253,6 +253,25 @@ user-select: none; } +/* Row resize (tableRowResize.ts). Mirrors the column handle but sits at a cell's + lower edge; one handle per cell forms a continuous line across the active row. + `.row-resize-cursor` is toggled on the whole editor while a row border is active. + Column resize takes precedence at a corner, so the two never fight. */ +.paper .tiptap.row-resize-cursor { + cursor: row-resize; +} + +.paper .tiptap .row-resize-handle { + position: absolute; + left: 0; + right: 0; + bottom: -2px; + height: 4px; + z-index: 20; + background: var(--color-primary); + pointer-events: none; +} + .paper .tiptap p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left;