diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts
index 993480d..d9fb27e 100644
--- a/src/lib/editor/extensions.ts
+++ b/src/lib/editor/extensions.ts
@@ -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
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({
diff --git a/src/lib/editor/tableColumnResize.ts b/src/lib/editor/tableColumnResize.ts
new file mode 100644
index 0000000..2ae6cb0
--- /dev/null
+++ b/src/lib/editor/tableColumnResize.ts
@@ -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 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('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();
+ 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 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): 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({
+ 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 => {
+ 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;
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/src/lib/editor/tableView.ts b/src/lib/editor/tableView.ts
new file mode 100644
index 0000000..0cc5f57
--- /dev/null
+++ b/src/lib/editor/tableView.ts
@@ -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 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 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 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;
+ }
+}
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index e33a6b7..cfb7916 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -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: {
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 34bbbbb..ecd78df 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -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 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;