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;