feat: move table row bars by mouse

This commit is contained in:
Steffen Becker committed 2026-06-08 20:35:57 +02:00
1 parent e28c399639
commit bb281c60c1
7 files changed
+416 -15

No files matched your search

+11 -5
View File
@@ -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 <colgroup> 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({
+13 -8
View File
@@ -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 <div class="tableWrapper"> node
// view (see extensions.ts Table config), so the top-level child is
+1 -1
View File
@@ -35,7 +35,7 @@ export const tableColumnResizeKey = new PluginKey<ResizeState>('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);
+37
View File
@@ -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 <tr>. 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 <tr style="height: …px">. 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` } : {};
},
},
};
},
});
+258
View File
@@ -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 <tr>, 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<RowResizeState>('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 <tr> 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<RowResizeState>({
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<string, string> => {
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;
},
},
}),
];
},
});
+77 -1
View File
@@ -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 <table:table-row> 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 <table:table-row> 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 <table:table-row> 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<string, string>();
let counter = 0;
let idx = 0;
content = content.replace(/<table:table-row\b([^>]*)>/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 `<table:table-row table:style-name="${name}"${attrs}>`;
});
if (styleDefs.length === 0) return odtBytes;
const newStyles = styleDefs.map(({ name, height }) =>
`<style:style style:name="${name}" style:family="table-row"><style:table-row-properties style:min-row-height="${height}" style:use-optimal-row-height="false"/></style:style>`,
).join('\n');
content = content.replace('</office:automatic-styles>', `${newStyles}\n</office:automatic-styles>`);
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<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
+19
View File
@@ -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;