feat: move table column bars by mouse

This commit is contained in:
Steffen Becker committed 2026-06-07 16:42:16 +02:00
1 parent d5aa63cd77
commit dafca8de2b
5 files changed
+494 -11

No files matched your search

+10 -4
View File
@@ -20,6 +20,8 @@ import { PageBreaks } from './pageBreaks';
import { LineHeight } from './lineHeight';
import { ParagraphSpacing } from './paragraphSpacing';
import { FormattingMarks } from './formattingMarks';
import { TableView } from './tableView';
import { TableColumnResize } from './tableColumnResize';
export const extensions = [
Document,
@@ -44,13 +46,17 @@ export const extensions = [
BulletList,
OrderedList,
ListItem,
// resizable:false → equal-width, full-width columns. Kept non-resizable so the
// editor's column layout matches the ODT export (odf-kit distributes columns
// evenly when no column widths are emitted — see export/odt.ts CUST_TABLE path).
Table.configure({ resizable: false }),
// 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
// 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.
Table.configure({ resizable: false, View: TableView }),
TableRow,
TableHeader,
TableCell,
TableColumnResize,
History,
Placeholder.configure({ placeholder: 'Start typing…' }),
TextAlign.configure({
+301
View File
@@ -0,0 +1,301 @@
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';
// Word-style column resizing.
//
// TipTap's built-in columnResizing resizes a single column and lets the table
// width grow (it has no neighbour-trade mode). Instead, this plugin keeps the
// table exactly the full text width: dragging the border between column i and i+1
// only trades width between those two columns (their sum stays constant). Live
// feedback pokes the <col> elements of the custom TableView (tableView.ts); on
// release a single transaction writes the new proportional weights into the
// `colwidth` attribute of every cell, so the document fully describes the layout
// (→ exact, deterministic ODT export).
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;
interface ResizeState {
activeHandle: number; // doc pos of the cell whose RIGHT edge is active, or -1
dragging: boolean;
}
type ResizeAction =
| { setHandle: number }
| { setDragging: boolean };
export const tableColumnResizeKey = new PluginKey<ResizeState>('tableColumnResize');
// ─── DOM / geometry helpers (mirrors prosemirror-tables) ───────────────────
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 cell adjacent to a column border near the pointer. For "right" the
// returned pos is the cell on the left of the border; for "left" it's the cell
// left of the border before the pointer's cell. Returns -1 at the outer edges.
function edgeCell(view: EditorView, event: MouseEvent, side: 'left' | 'right'): number {
const offset = side === 'right' ? -HANDLE_WIDTH : HANDLE_WIDTH;
const found = view.posAtCoords({ left: event.clientX + offset, top: event.clientY });
if (!found) return -1;
const $cell = cellAround(view.state.doc.resolve(found.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];
}
// The rightmost column index covered by the cell at `cellPos`.
function rightColOf(state: EditorState, cellPos: number): { col: number; width: number } {
const $cell = state.doc.resolve(cellPos);
const table = $cell.node(-1);
const map = TableMap.get(table);
const start = $cell.start(-1);
const colspan = ($cell.nodeAfter?.attrs.colspan as number) ?? 1;
return { col: map.colCount($cell.pos - start) + colspan - 1, width: map.width };
}
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;
if (target) {
const { left, right } = target.getBoundingClientRect();
if (event.clientX - left <= HANDLE_WIDTH) cell = edgeCell(view, event, 'left');
else if (right - event.clientX <= HANDLE_WIDTH) cell = edgeCell(view, event, 'right');
}
// Only internal borders are draggable — the outer right edge would change the
// table width, which we never want (the table stays full text width).
if (cell !== -1) {
const { col, width } = rightColOf(view.state, cell);
if (col >= width - 1) cell = -1;
}
if (cell !== state.activeHandle) {
view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: cell } as ResizeAction));
}
}
// Write the final per-column weights into every cell's `colwidth` attribute.
function commitColumnWidths(view: EditorView, activeHandle: number, finalPercents: 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<number>();
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 (tr.docChanged) view.dispatch(tr);
}
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;
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;
if (rightCol > map.width - 1) return false; // outer edge, never resizable
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 <colgroup> element but replaces its <col>
// 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<HTMLElement>;
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): 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 = 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 (right) or merge into the row above.
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 cellNode = table.nodeAt(cellPos);
if (!cellNode) continue;
const pos = start + cellPos + cellNode.nodeSize - 1;
const dom = document.createElement('div');
dom.className = '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',
addProseMirrorPlugins() {
return [
new Plugin<ResizeState>({
key: tableColumnResizeKey,
state: {
init: (): ResizeState => ({ activeHandle: -1, 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, dragging: false };
if (action && 'setDragging' in action) return { activeHandle: prev.activeHandle, 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, dragging: prev.dragging };
}
return prev;
},
},
props: {
// Whole-editor cursor while a border is active (matches the handle).
attributes: (state): Record<string, string> => {
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 } 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);
return null;
},
},
}),
];
},
});
+107
View File
@@ -0,0 +1,107 @@
import type { Node as PMNode } from '@tiptap/pm/model';
import type { ViewMutationRecord } from '@tiptap/pm/view';
// Custom table node view.
//
// Unlike TipTap's built-in TableView (which renders absolute px column widths and
// sets the table's own width to their sum), this view renders the <colgroup> with
// *percentage* widths and keeps the table at width:100%. Consequences:
// • the table is always exactly the full text width (matches the ODT export,
// where odf-kit emits table:align="margins"), and
// • it stays responsive to page-margin / orientation changes (Layout panel)
// without rewriting the document.
//
// The per-column values stored in the `colwidth` cell attribute are treated as
// proportional *weights* (their absolute scale is irrelevant — only ratios matter).
// `null` means "no explicit width" → equal share. The column-resize plugin
// (tableColumnResize.ts) writes these weights and drives live drag feedback by
// poking the <col> elements this view creates.
// Expand the first row's cells into a per-column weight array, honouring colspan.
// A cell with colspan k contributes k entries (its colwidth slice, or nulls).
export function columnWeightsFromRow(row: PMNode | null | undefined): (number | null)[] {
const weights: (number | null)[] = [];
if (!row) return weights;
for (let i = 0; i < row.childCount; i++) {
const cell = row.child(i);
const colspan = (cell.attrs.colspan as number) ?? 1;
const colwidth = cell.attrs.colwidth as number[] | null;
for (let j = 0; j < colspan; j++) {
const w = colwidth && colwidth[j] ? colwidth[j] : null;
weights.push(w);
}
}
return weights;
}
// Convert column weights into percentages that sum to 100. All-null → equal
// columns; otherwise null entries take the average present weight so they render
// as "normal" columns until the user resizes them (after which all are explicit).
export function columnPercents(weights: (number | null)[]): number[] {
const n = weights.length;
if (n === 0) return [];
const present = weights.filter((w): w is number => w != null && w > 0);
if (present.length === 0) return weights.map(() => 100 / n);
const avg = present.reduce((a, b) => a + b, 0) / present.length;
const filled = weights.map((w) => (w != null && w > 0 ? w : avg));
const total = filled.reduce((a, b) => a + b, 0);
return filled.map((w) => (w / total) * 100);
}
function buildColgroup(node: PMNode, colgroup: HTMLElement): void {
while (colgroup.firstChild) colgroup.removeChild(colgroup.firstChild);
const percents = columnPercents(columnWeightsFromRow(node.firstChild));
for (const p of percents) {
const col = document.createElement('col');
col.style.width = `${p}%`;
colgroup.appendChild(col);
}
}
export class TableView {
node: PMNode;
cellMinWidth: number;
dom: HTMLElement;
table: HTMLTableElement;
colgroup: HTMLElement;
contentDOM: HTMLElement;
constructor(node: PMNode, cellMinWidth: number) {
this.node = node;
this.cellMinWidth = cellMinWidth;
this.dom = document.createElement('div');
this.dom.className = 'tableWrapper';
this.table = this.dom.appendChild(document.createElement('table'));
// Percentage columns are authoritative only under fixed layout; the actual
// table-layout/border-collapse come from editor.css. width:100% keeps the
// table the full text width regardless of the stored weights.
this.table.style.width = '100%';
this.colgroup = this.table.appendChild(document.createElement('colgroup'));
buildColgroup(node, this.colgroup);
this.contentDOM = this.table.appendChild(document.createElement('tbody'));
}
update(node: PMNode): boolean {
if (node.type !== this.node.type) return false;
this.node = node;
buildColgroup(node, this.colgroup);
return true;
}
// Ignore DOM mutations the view makes to its own chrome (colgroup/table chrome),
// including the live <col> width pokes from the resize plugin — only the tbody
// content is ProseMirror-managed.
ignoreMutation(mutation: ViewMutationRecord): boolean {
const target = mutation.target as Node;
const isInsideWrapper = this.dom.contains(target);
const isInsideContent = this.contentDOM.contains(target);
if (isInsideWrapper && !isInsideContent) {
return (
mutation.type === 'attributes' ||
mutation.type === 'childList' ||
mutation.type === 'characterData'
);
}
return false;
}
}
+45 -5
View File
@@ -437,14 +437,49 @@ function addListToCell(c: CellBuilder, listNode: TiptapNode, depth: number, stat
}
}
// Derive per-column widths (in cm) from the table's first row. The editor stores
// proportional *weights* in each cell's `colwidth` attr (see tableView.ts); we turn
// those into absolute cm widths that sum exactly to the text width. Combined with
// odf-kit's table:align="margins", LibreOffice/Word render the columns at exactly
// these proportions across the full text width — identical to the editor preview.
// Returns undefined when no column has an explicit width (fresh/legacy tables) so
// odf-kit falls back to even distribution.
function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[] | undefined {
const firstRow = (node.content ?? []).find(r => r.type === 'tableRow');
if (!firstRow) return undefined;
const weights: (number | null)[] = [];
for (const cell of firstRow.content ?? []) {
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
const colspan = (cell.attrs?.colspan as number) ?? 1;
const cw = cell.attrs?.colwidth as number[] | null | undefined;
for (let k = 0; k < colspan; k++) weights.push(cw && cw[k] ? cw[k] : null);
}
if (weights.length === 0 || weights.every(w => w == null)) return undefined;
const present = weights.filter((w): w is number => w != null);
const avg = present.reduce((a, b) => a + b, 0) / present.length;
const filled = weights.map(w => (w != null ? w : avg));
const total = filled.reduce((a, b) => a + b, 0);
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const cm = filled.map(w => round3((w / total) * contentWidthCm));
// Absorb the rounding remainder into the last column so the widths sum exactly
// to the text width (table:align="margins" scales otherwise).
const sum = cm.reduce((a, b) => a + b, 0);
cm[cm.length - 1] = round3(cm[cm.length - 1] + (contentWidthCm - sum));
return cm.map(v => `${v}cm`);
}
// Build an ODF table from a CUST_TABLE node (renamed from "table" in
// injectCustomTypes). We bypass odf-kit's native walkTable so we can pass an
// explicit cell border — the native path emits none, so the table would be
// invisible in LibreOffice/Word. No columnWidths are passed, so odf-kit
// distributes columns evenly, matching the editor's equal-width fixed layout.
function exportTable(node: TiptapNode, doc: OdtDocument): void {
// invisible in LibreOffice/Word. Column widths come from the editor's per-column
// weights (tableColumnWidthsCm); when absent odf-kit distributes columns evenly.
function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number): void {
const rows = (node.content ?? []).filter(r => r.type === 'tableRow');
if (rows.length === 0) return;
const columnWidths = tableColumnWidthsCm(node, contentWidthCm);
doc.addTable((t: TableBuilder) => {
for (const row of rows) {
t.addRow((r: RowBuilder) => {
@@ -482,7 +517,7 @@ function exportTable(node: TiptapNode, doc: OdtDocument): void {
}
});
}
}, { border: TABLE_BORDER });
}, columnWidths ? { border: TABLE_BORDER, columnWidths } : { border: TABLE_BORDER });
}
// odf-kit's XML builder serializes a multi-child element's children on separate
@@ -515,6 +550,11 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT
const raw = editor.getJSON() as TiptapNode;
const json = injectCustomTypes(raw);
// Text width = A4 page width (portrait 21cm / landscape 29.7cm) minus the L/R
// margins. Table column widths are scaled to fill exactly this width.
const pageWidthCm = orientation === 'landscape' ? 29.7 : 21;
const contentWidthCm = pageWidthCm - margins.left - margins.right;
const odt = await tiptapToOdt(json, {
// Orientation comes from the Layout panel; odf-kit swaps the A4 dimensions
// automatically (29.7×21cm) and writes style:print-orientation accordingly.
@@ -527,7 +567,7 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT
marginRight: `${margins.right}cm`,
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
if (node.type === CUST_TABLE) {
exportTable(node, doc);
exportTable(node, doc, contentWidthCm);
return;
}
const opts: {
+31 -2
View File
@@ -189,8 +189,10 @@
.paper .tiptap table {
width: 100%;
/* fixed layout + width:100% → equal-width columns regardless of content,
matching odf-kit's even column distribution on export. */
/* fixed layout → the per-column <col> percentage widths from the custom node
view (tableView.ts) are authoritative; width:100% keeps the table the full
text width, matching the ODT export (export/odt.ts emits per-column widths
summing to the text width with table:align="margins"). */
table-layout: fixed;
border-collapse: collapse;
}
@@ -200,6 +202,9 @@
border: 1px solid #000;
padding: 0.1cm;
vertical-align: top;
/* Positioning context for the column-resize handle widget (a child div the
resize plugin places at the cell's right edge). */
position: relative;
/* Cells hold paragraphs; suppress their bottom margin so a single-line cell
isn't taller than its text (matches the list-item paragraph treatment). */
}
@@ -224,6 +229,30 @@
pointer-events: none;
}
/* Column resize (tableColumnResize.ts). The handle is a widget the plugin places
at a cell's right edge when the pointer is near an internal column border; the
plugin reads the pointer position itself, so the handle ignores pointer events.
`.resize-cursor` is toggled on the whole editor while a border is active. */
.paper .tiptap.resize-cursor {
cursor: col-resize;
}
.paper .tiptap .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
z-index: 20;
background: var(--color-primary);
pointer-events: none;
}
.paper .tiptap .column-resize-dragging {
/* Suppress text-selection flicker while dragging a column border. */
user-select: none;
}
.paper .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;