feat: table margin edge resize

This commit is contained in:
Steffen Becker committed 2026-07-31 17:46:26 +02:00
1 parent f7c0e1ed1e
commit ca4fbd422b
11 files changed
+494 -78

No files matched your search

+198 -64
View File
@@ -5,25 +5,49 @@ 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';
import { PX_PER_CM } from '../../storage/pageMargins';
// Word-style column resizing: dragging the border between columns i and i+1 trades
// width between only those two (their sum stays constant, table stays full width).
// Live drag pokes the TableView <col> elements; release writes `colwidth` weights.
// Word-style column resizing: an inner border trades width between its two columns
// (table width fixed); an outer edge moves the table's `marginLeft`/`marginRight` (cm)
// and the adjacent column. Live drag pokes the DOM; release writes the attrs.
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;
// Which border of the active cell is hovered: a column border, or the table's own
// left/right edge (the first/last column's outer side).
type EdgeSide = 'inner' | 'left' | 'right';
interface ResizeState {
activeHandle: number; // doc pos of the cell whose RIGHT edge is active, or -1
activeHandle: number; // doc pos of the cell owning the active border, or -1
edge: EdgeSide;
dragging: boolean;
}
type ResizeAction =
| { setHandle: number }
| { setHandle: number; edge: EdgeSide }
| { setDragging: boolean };
const round3 = (v: number) => Math.round(v * 1000) / 1000;
// A CSS margin (our own output is cm, pasted HTML may be px) as cm; 0 when absent.
function marginCm(value: string): number {
const n = parseFloat(value);
if (!Number.isFinite(n) || n <= 0) return 0;
return value.trim().endsWith('px') ? round3(n / PX_PER_CM) : round3(n);
}
// Inline style for the static HTML paths: the wrapper-less <table> carries the margins
// itself, so its width must shrink by them (editor.css has it at width:100%).
function tableMarginStyle(attrs: Record<string, unknown>): Record<string, string> {
const ml = Number(attrs.marginLeft) || 0;
const mr = Number(attrs.marginRight) || 0;
if (!ml && !mr) return {};
return { style: `margin-left: ${ml}cm; margin-right: ${mr}cm; width: calc(100% - ${round3(ml + mr)}cm)` };
}
export const tableColumnResizeKey = new PluginKey<ResizeState>('tableColumnResize');
// ─── DOM / geometry helpers (mirrors prosemirror-tables) ───────────────────
@@ -36,30 +60,28 @@ export function domCellAround(target: HTMLElement | null): 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 {
// Resolve the border near the pointer: the cell that owns it plus which border it is.
// For "right" that's the cell left of the border, for "left" the cell right of it —
// unless the border is the table's own outer edge (first/last column). cell -1 = none.
function borderAt(view: EditorView, event: MouseEvent, side: 'left' | 'right'): { cell: number; edge: EdgeSide } {
const none = { cell: -1, edge: 'inner' as EdgeSide };
const offset = side === 'right' ? -HANDLE_WIDTH : HANDLE_WIDTH;
const found = view.posAtCoords({ left: event.clientX + offset, top: event.clientY });
if (!found) return -1;
if (!found) return none;
const $cell = cellAround(view.state.doc.resolve(found.pos));
if (!$cell) return -1;
if (side === 'right') return $cell.pos;
if (!$cell) return none;
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);
if (side === 'left') {
const index = map.map.indexOf($cell.pos - start);
if (index < 0) return none;
return index % map.width === 0
? { cell: $cell.pos, edge: 'left' }
: { cell: start + map.map[index - 1], edge: 'inner' };
}
const colspan = ($cell.nodeAfter?.attrs.colspan as number) ?? 1;
return { col: map.colCount($cell.pos - start) + colspan - 1, width: map.width };
const col = map.colCount($cell.pos - start) + colspan - 1;
return { cell: $cell.pos, edge: col >= map.width - 1 ? 'right' : 'inner' };
}
function tableDOM(view: EditorView, tableStart: number): HTMLTableElement | null {
@@ -77,26 +99,52 @@ function handleMouseMove(view: EditorView, event: MouseEvent): void {
const target = domCellAround(event.target as HTMLElement | null);
let cell = -1;
let edge: EdgeSide = 'inner';
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');
if (event.clientX - left <= HANDLE_WIDTH) ({ cell, edge } = borderAt(view, event, 'left'));
else if (right - event.clientX <= HANDLE_WIDTH) ({ cell, edge } = borderAt(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));
if (cell !== state.activeHandle || edge !== state.edge) {
view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: cell, edge } as ResizeAction));
}
}
// Write the final per-column weights into every cell's `colwidth` attribute.
function commitColumnWidths(view: EditorView, activeHandle: number, finalPercents: number[]): void {
// Outer-edge drag, Word-style: only the adjacent column gives up width, so every
// other gridline stays put. All lengths in unscaled document px; the edge stops at
// the text margin (margins never go negative) and at the column's minimum width.
export function edgeResize(
percents: number[],
tableWidth: number,
marginLeftPx: number,
marginRightPx: number,
side: 'left' | 'right',
dx: number,
): { marginLeftPx: number; marginRightPx: number; percents: number[] } {
const abs = percents.map(p => (p / 100) * tableWidth);
const i = side === 'left' ? 0 : abs.length - 1;
const delta = side === 'left'
? Math.max(-marginLeftPx, Math.min(abs[i] - CELL_MIN_PX, dx))
: Math.max(CELL_MIN_PX - abs[i], Math.min(marginRightPx, dx));
abs[i] += side === 'left' ? -delta : delta;
const total = abs.reduce((a, b) => a + b, 0);
return {
marginLeftPx: side === 'left' ? marginLeftPx + delta : marginLeftPx,
marginRightPx: side === 'right' ? marginRightPx - delta : marginRightPx,
percents: abs.map(w => (w / total) * 100),
};
}
// Write the final per-column weights into every cell's `colwidth` attribute, plus the
// table's own margins when an outer edge was dragged (sizes are unchanged, so both
// markups can share one transaction).
function commitColumnWidths(
view: EditorView,
activeHandle: number,
finalPercents: number[],
tableMargins?: { marginLeft: number; marginRight: number },
): void {
const $cell = view.state.doc.resolve(activeHandle);
const table = $cell.node(-1);
const map = TableMap.get(table);
@@ -121,13 +169,76 @@ function commitColumnWidths(view: EditorView, activeHandle: number, finalPercent
tr.setNodeMarkup(start + pos, undefined, { ...cellNode.attrs, colwidth });
}
}
if (tableMargins) tr.setNodeMarkup(start - 1, undefined, { ...table.attrs, ...tableMargins });
if (tr.docChanged) view.dispatch(tr);
}
// Drag of the table's left/right edge: moves that edge only (margin + adjacent column).
function startEdgeDrag(view: EditorView, event: MouseEvent, cellPos: number, side: 'left' | 'right'): boolean {
const $cell = view.state.doc.resolve(cellPos);
const table = $cell.node(-1);
const tableStart = $cell.start(-1);
const startPercents = columnPercents(columnWeightsFromRow(table.firstChild));
if (startPercents.length !== TableMap.get(table).width) return false;
const dom = tableDOM(view, tableStart);
const wrapper = dom?.parentElement;
const colgroup = dom?.querySelector(':scope > colgroup') as HTMLElement | null;
if (!dom || !wrapper || !colgroup) return false;
const tableWidth = dom.offsetWidth; // unscaled document px
if (!tableWidth) return false;
// offsetWidth is unscaled, getBoundingClientRect is scaled by the .paper zoom:
// their ratio converts the mouse delta back to document px (mirrors tableRowResize).
const zoom = dom.getBoundingClientRect().width / tableWidth || 1;
const ml0 = (((table.attrs.marginLeft as number) || 0)) * PX_PER_CM;
const mr0 = (((table.attrs.marginRight as number) || 0)) * PX_PER_CM;
const startX = event.clientX;
let last = { marginLeftPx: ml0, marginRightPx: mr0, percents: startPercents };
let moved = false;
const win = view.dom.ownerDocument.defaultView ?? window;
view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setDragging: true } as ResizeAction));
function move(e: MouseEvent): void {
if (!e.buttons) {
finish();
return;
}
last = edgeResize(startPercents, tableWidth, ml0, mr0, side, (e.clientX - startX) / zoom);
wrapper!.style.marginLeft = `${last.marginLeftPx}px`;
wrapper!.style.marginRight = `${last.marginRightPx}px`;
const cols = colgroup!.children as HTMLCollectionOf<HTMLElement>;
last.percents.forEach((p, i) => { if (cols[i]) cols[i].style.width = `${p}%`; });
moved = true;
}
function finish(): void {
win.removeEventListener('mouseup', finish);
win.removeEventListener('mousemove', move);
const st = tableColumnResizeKey.getState(view.state);
if (st?.dragging) {
if (moved) {
commitColumnWidths(view, cellPos, last.percents, {
marginLeft: round3(last.marginLeftPx / PX_PER_CM),
marginRight: round3(last.marginRightPx / PX_PER_CM),
});
}
view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setDragging: false } as ResizeAction));
}
}
win.addEventListener('mouseup', finish);
win.addEventListener('mousemove', move);
event.preventDefault();
return true;
}
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;
if (state.edge !== 'inner') return startEdgeDrag(view, event, state.activeHandle, state.edge);
const activeHandle = state.activeHandle;
const $cell = view.state.doc.resolve(activeHandle);
@@ -137,7 +248,6 @@ function handleMouseDown(view: EditorView, event: MouseEvent): boolean {
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;
@@ -202,7 +312,7 @@ function handleMouseDown(view: EditorView, event: MouseEvent): boolean {
// ─── Decorations: the visible handle at the active column border ─────────────
function handleDecorations(state: EditorState, cell: number): DecorationSet {
function handleDecorations(state: EditorState, cell: number, edge: EdgeSide): DecorationSet {
const decorations: Decoration[] = [];
const $cell = state.doc.resolve(cell);
const table = $cell.node(-1);
@@ -210,31 +320,32 @@ function handleDecorations(state: EditorState, cell: number): DecorationSet {
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 col = edge === 'left' ? 0 : 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));
// Skip cells that span across this border (irrelevant at the left edge) or that
// merge into the row above.
const spansBorder = edge !== 'left' && col < map.width - 1 && map.map[index] === map.map[index + 1];
const mergedUp = row > 0 && map.map[index] === map.map[index - map.width];
if (spansBorder || mergedUp) continue;
const cellPos = map.map[index];
const cellNode = table.nodeAt(cellPos);
if (!cellNode) continue;
// Widgets sit INSIDE the cell (which is the handle's positioning context).
const pos = edge === 'left' ? start + cellPos + 1 : start + cellPos + cellNode.nodeSize - 1;
const dom = document.createElement('div');
dom.className = edge === 'left' ? 'column-resize-handle left' : '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);
}
@@ -244,20 +355,43 @@ function handleDecorations(state: EditorState, cell: number): DecorationSet {
export const TableColumnResize = Extension.create({
name: 'tableColumnResize',
// The table's own margins (cm, 0 = flush with the text area), set by the outer-edge
// drag. Rendered on the wrapper by TableView; the inline style here serves the
// static HTML paths (generateHTML → PDF export, clipboard round trip).
addGlobalAttributes() {
return [
{
types: ['table'],
attributes: {
marginLeft: {
default: 0,
parseHTML: (element: HTMLElement) => marginCm(element.style.marginLeft),
renderHTML: (attributes: Record<string, unknown>) => tableMarginStyle(attributes),
},
marginRight: {
default: 0,
parseHTML: (element: HTMLElement) => marginCm(element.style.marginRight),
renderHTML: () => ({}),
},
},
},
];
},
addProseMirrorPlugins() {
return [
new Plugin<ResizeState>({
key: tableColumnResizeKey,
state: {
init: (): ResizeState => ({ activeHandle: -1, dragging: false }),
init: (): ResizeState => ({ activeHandle: -1, edge: 'inner', 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 (action && 'setHandle' in action) return { activeHandle: action.setHandle, edge: action.edge, dragging: false };
if (action && 'setDragging' in action) return { ...prev, 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 { activeHandle: handle, edge: prev.edge, dragging: prev.dragging };
}
return prev;
},
@@ -276,7 +410,7 @@ export const TableColumnResize = Extension.create({
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));
view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: -1, edge: 'inner' } as ResizeAction));
}
return false;
},
@@ -284,7 +418,7 @@ export const TableColumnResize = Extension.create({
},
decorations: (state) => {
const s = tableColumnResizeKey.getState(state);
if (s && s.activeHandle > -1) return handleDecorations(state, s.activeHandle);
if (s && s.activeHandle > -1) return handleDecorations(state, s.activeHandle, s.edge);
return null;
},
},
+11
View File
@@ -36,6 +36,15 @@ export function columnPercents(weights: (number | null)[]): number[] {
return filled.map((w) => (w / total) * 100);
}
// The table's own margins (cm) go on the wrapper; the table stays 100% of it, so a
// dragged outer edge (tableColumnResize.ts) narrows the table without touching layout.
function applyMargins(node: PMNode, wrapper: HTMLElement): void {
const ml = (node.attrs.marginLeft as number) || 0;
const mr = (node.attrs.marginRight as number) || 0;
wrapper.style.marginLeft = ml ? `${ml}cm` : '';
wrapper.style.marginRight = mr ? `${mr}cm` : '';
}
function buildColgroup(node: PMNode, colgroup: HTMLElement): void {
while (colgroup.firstChild) colgroup.removeChild(colgroup.firstChild);
const percents = columnPercents(columnWeightsFromRow(node.firstChild));
@@ -59,6 +68,7 @@ export class TableView {
this.cellMinWidth = cellMinWidth;
this.dom = document.createElement('div');
this.dom.className = 'tableWrapper';
applyMargins(node, this.dom);
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
@@ -72,6 +82,7 @@ export class TableView {
update(node: PMNode): boolean {
if (node.type !== this.node.type) return false;
this.node = node;
applyMargins(node, this.dom);
buildColgroup(node, this.colgroup);
return true;
}
+7 -2
View File
@@ -700,9 +700,13 @@ function cellBlocksToDocx(content: TiptapNode[] = [], headerBold: boolean, num:
function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): Table {
const rows = (node.content ?? []).filter((r) => r.type === 'tableRow');
const colsCm = columnWidthsCm(node, contentWidthCm);
// A dragged table edge (tableColumnResize.ts) → w:tblInd + the narrower grid.
let ml = Math.max(0, Number(node.attrs?.marginLeft) || 0);
let mr = Math.max(0, Number(node.attrs?.marginRight) || 0);
if (ml + mr > contentWidthCm - 1) { ml = 0; mr = 0; }
const colsCm = columnWidthsCm(node, contentWidthCm - ml - mr);
const colsTwip = colsCm?.map(cmToTwip);
const totalTwip = colsTwip ? colsTwip.reduce((a, b) => a + b, 0) : cmToTwip(contentWidthCm);
const totalTwip = colsTwip ? colsTwip.reduce((a, b) => a + b, 0) : cmToTwip(contentWidthCm - ml - mr);
const tableRows = rows.map((row) => {
const rh = row.attrs?.rowHeight;
@@ -740,6 +744,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
rows: tableRows,
columnWidths: colsTwip,
width: { size: totalTwip, type: WidthType.DXA },
indent: ml ? { size: cmToTwip(ml), type: WidthType.DXA } : undefined,
layout: TableLayoutType.FIXED,
margins: { marginUnitType: WidthType.DXA, top: cmToTwip(0.05), bottom: cmToTwip(0.05), left: cmToTwip(0.1), right: cmToTwip(0.1) },
borders: {
+46 -4
View File
@@ -645,6 +645,8 @@ type CellBlock =
| { kind: 'paragraph'; style: ParaStyle }
| { kind: 'heading'; level: number; style: ParaStyle }
| CellListBlock;
// A table's own margins in cm (0/0 tables are recorded as null).
type TableMargins = { ml: number; mr: number };
// Collect the alignment + paragraph spacing of each listItem's first paragraph,
// in DFS order — matching the order that odf-kit emits <text:list-item> elements
@@ -730,6 +732,30 @@ function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]):
return rezipOdt(files);
}
// A dragged table edge → the table style's fo:margin-left/-right + style:width (with
// odf-kit's table:align="margins" the table fills exactly what's left). odf-kit names
// table styles Table1, Table2, … in document order, matching the descriptor list.
function applyTableMargins(odtBytes: Uint8Array, margins: (TableMargins | null)[], contentWidthCm: number): Uint8Array {
if (margins.every(m => m === null)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
margins.forEach((m, i) => {
if (!m) return;
const width = Math.round((contentWidthCm - m.ml - m.mr) * 1000) / 1000;
content = content.replace(
new RegExp(`(<style:style[^>]*style:name="Table${i + 1}"[^>]*>\\s*<style:table-properties)`),
`$1 style:width="${width}cm" fo:margin-left="${m.ml}cm" fo:margin-right="${m.mr}cm"`,
);
});
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// odf-kit's ListBuilder has no per-item paragraph options, so list-item paragraphs all
// emit as List_20_Bullet/Number. Rewrite content.xml to point those at automatic styles
// that inherit the list style and add fo:text-align / fo:margin-top / fo:margin-bottom.
@@ -2078,13 +2104,26 @@ function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[]
return cm.map(v => `${v}cm`);
}
// A table's own left/right margins (cm, from the outer-edge drag), clamped into the
// text width. null = the table spans the full text width (the common case).
function tableMarginsCm(node: TiptapNode, contentWidthCm: number): TableMargins | null {
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const ml = Math.max(0, Number(node.attrs?.marginLeft) || 0);
const mr = Math.max(0, Number(node.attrs?.marginRight) || 0);
if (!ml && !mr) return null;
if (ml + mr > contentWidthCm - 1) return null;
return { ml: round3(ml), mr: round3(mr) };
}
// Build an ODF table from a CUST_TABLE node, bypassing odf-kit's native walkTable to
// pass an explicit cell border (the native path emits none → invisible). Column widths
// come from tableColumnWidthsCm; when absent odf-kit distributes columns evenly.
function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][]): void {
function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][], tableMargins: (TableMargins | null)[]): void {
const rows = (node.content ?? []).filter(r => r.type === 'tableRow');
if (rows.length === 0) return;
const columnWidths = tableColumnWidthsCm(node, contentWidthCm);
const margins = tableMarginsCm(node, contentWidthCm);
tableMargins.push(margins);
const columnWidths = tableColumnWidthsCm(node, contentWidthCm - (margins ? margins.ml + margins.mr : 0));
doc.addTable((t: TableBuilder) => {
for (const row of rows) {
t.addRow((r: RowBuilder) => {
@@ -2624,6 +2663,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// Filled by exportTable, in document order, one CellBlock[] per table cell.
// applyCellBlocks consumes it to rebuild real <text:h>/<text:p>/<text:list>.
const cellBlocks: CellBlock[][] = [];
// One entry per table, same order — consumed by applyTableMargins.
const tableMargins: (TableMargins | null)[] = [];
const odt = await tiptapToOdt(json, {
// Orientation sets style:print-orientation; rewriteStylesXml overrides the exact
@@ -2643,7 +2684,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
return;
}
if (node.type === CUST_TABLE) {
exportTable(node, doc, contentWidthCm, cellBlocks);
exportTable(node, doc, contentWidthCm, cellBlocks, tableMargins);
return;
}
const opts: {
@@ -2735,8 +2776,9 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const rowHeights: (string | null)[] = [];
collectTableRowHeights(raw, rowHeights);
const styledRows = applyTableRowHeights(styledCells, rowHeights);
const withTableMargins = applyTableMargins(styledRows, tableMargins, contentWidthCm);
const cleaned = collapseRunWhitespace(styledRows);
const cleaned = collapseRunWhitespace(withTableMargins);
const withBreaks = applyInlineSentinels(cleaned);
const withImages = applyImages(withBreaks, images);
const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
+36 -3
View File
@@ -41,6 +41,8 @@ type Ctx = {
convertedImages: ConvertedImages;
pendingBlocks: Node[];
listCounters: Map<number, Map<number, number>>; // numId → ilvl → last number used
// Text width (cm) of the file's page setup; a table's margins are relative to it.
contentWidthCm: number;
};
// ---- units & editor defaults to suppress -----------------------------------
@@ -115,11 +117,12 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const defaultStyleId = styles.defaultParagraphStyle();
for (const [id, def] of styles.namedParagraphStyles()) styleNames.set(id, registryName(id, def.name, id === defaultStyleId));
const charStyleNames = styles.namedCharacterStyles();
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map() };
const body = docDoc.getElementsByTagNameNS(W, 'body')[0];
if (!body) throw new Error('Not a Word document (no w:body).');
const contentWidthCm = sectionContentWidthCm(fc(body, 'sectPr'));
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm };
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
// more than one column becomes a columns node (the trailing group is described
// by the body-final sectPr, covering whole-document multi-column files).
@@ -1296,7 +1299,28 @@ function convertTable(tbl: Element, ctx: Ctx): Node | null {
if (h && h > 0) row.attrs = { rowHeight: Math.round(twipToPx(h)) };
rows.push(row);
}
return rows.length ? { type: 'table', content: rows } : null;
if (rows.length === 0) return null;
const margins = tableMargins(tbl, useWeights, ctx);
return margins ? { type: 'table', attrs: margins, content: rows } : { type: 'table', content: rows };
}
// A table narrower than the text width: w:tblInd is its left indent, the grid (or
// w:tblW) its width — the rest becomes the editor's right margin.
function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx): { marginLeft: number; marginRight: number } | null {
const tblPr = fc(tbl, 'tblPr');
const content = ctx.contentWidthCm;
const dxa = (el: Element | null) => (el?.getAttributeNS(W, 'type') ?? 'dxa') === 'dxa' ? intAttr(el, W, 'w') : null;
const left = Math.max(0, twipToCm(dxa(fc(tblPr, 'tblInd')) ?? 0));
const declared = dxa(fc(tblPr, 'tblW'));
const width = declared && declared > 0
? twipToCm(declared)
: weights ? twipToCm(weights.reduce((a, b) => a + b, 0)) : null;
const right = width != null ? Math.max(0, content - left - width) : 0;
if (left < 0.05 && right < 0.05) return null;
if (left + right > content - 1) return null;
const round2 = (v: number) => Math.round(v * 100) / 100;
return { marginLeft: round2(left), marginRight: round2(right) };
}
function flattenTable(tbl: Element, ctx: Ctx): Node[] {
@@ -1306,6 +1330,15 @@ function flattenTable(tbl: Element, ctx: Ctx): Node[] {
}
// ---- section: page geometry + headers/footers ------------------------------
// Text width of a section (A4 with Word's 2.54cm margins when it declares none).
function sectionContentWidthCm(sect: Element | null): number {
const w = intAttr(fc(sect, 'pgSz'), W, 'w') ?? 11906;
const pgMar = fc(sect, 'pgMar');
const left = intAttr(pgMar, W, 'left') ?? 1440;
const right = intAttr(pgMar, W, 'right') ?? 1440;
return Math.max(1, twipToCm(w - left - right));
}
// Split body children into sections: a <w:p> whose pPr carries a sectPr ends the
// group before it (dropped unless it has run content). The trailing group has
// sectPr null — the caller pairs it with the body-final <w:sectPr>.
+40 -4
View File
@@ -9,7 +9,7 @@ import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
import { imageDataUrl, type ConvertedImages } from './imageFormats';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import type { PageFormat } from '../storage/pageFormat';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import type { HfDoc } from '../storage/headerFooter';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
@@ -80,6 +80,8 @@ type Ctx = {
imageCache: Map<string, string>;
convertedImages: ConvertedImages;
pendingBlocks: Node[];
// Text width (cm) of the file's page setup; a table's margins are relative to it.
contentWidthCm: number;
};
// Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format
@@ -349,7 +351,13 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
for (const [name, def] of resolver.namedParagraphStyles()) styleNames.set(name, displayStyleName(name, def.display));
const charStyleNames = new Map<string, string>();
for (const [name, def] of resolver.namedTextStyles()) charStyleNames.set(name, displayStyleName(name, def.display));
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [] };
// Text width of the file's own page setup — the reference a table's margins are
// measured against (falls back to A4 with the ODF default margins).
const geo = resolver.pageGeometry();
const contentWidthCm = geo
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
@@ -364,7 +372,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const hf = resolver.masterPageHF();
const geometry = resolver.pageGeometry();
const geometry = geo;
const edge = resolver.edgeDistancesCm();
const fonts: EmbeddedFont[] = [];
@@ -1496,7 +1504,35 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
}
}
return rows.length ? { type: 'table', content: rows } : null;
if (rows.length === 0) return null;
const margins = tableMargins(el, ctx);
return margins ? { type: 'table', attrs: margins, content: rows } : { type: 'table', content: rows };
}
// A table narrower than the text width → the editor's marginLeft/marginRight attrs.
// LibreOffice states the width plus one margin (per table:align), the other follows.
function tableMargins(el: Element, ctx: Ctx): { marginLeft: number; marginRight: number } | null {
const props = ctx.resolver.tableProps(el.getAttributeNS(NS.table, 'style-name'));
const content = ctx.contentWidthCm;
const rel = parseFloat(props['style:rel-width'] ?? '');
const width = lengthToCm(props['style:width'])
?? (Number.isFinite(rel) && rel > 0 ? (Math.min(rel, 100) / 100) * content : null);
let left = lengthToCm(props['fo:margin-left']);
let right = lengthToCm(props['fo:margin-right']);
if (left == null && width != null) {
const align = props['table:align'];
left = align === 'center' ? (content - width) / 2 : align === 'right' ? content - width : 0;
}
left = Math.max(0, left ?? 0);
if (right == null) right = width != null ? content - left - width : 0;
right = Math.max(0, right);
// Rounding noise from the producer, or a table that would be left with no room.
if (left < 0.05 && right < 0.05) return null;
if (left + right > content - 1) return null;
const round2 = (v: number) => Math.round(v * 100) / 100;
return { marginLeft: round2(left), marginRight: round2(right) };
}
// Per-column proportional weights for the colwidth cell attr (tableView.ts uses
+6
View File
@@ -115,6 +115,7 @@ function entryFromStyleElement(el: Element): StyleEntry {
};
collectProps(el, 'text-properties', entry.text);
collectProps(el, 'paragraph-properties', entry.para);
collectProps(el, 'table-properties', entry.misc);
collectProps(el, 'table-column-properties', entry.misc);
collectProps(el, 'table-row-properties', entry.misc);
collectProps(el, 'table-cell-properties', entry.misc);
@@ -318,6 +319,11 @@ export class StyleResolver {
return null;
}
// A table style's raw <style:table-properties> (style:width, fo:margin-*, table:align).
tableProps(styleName: string | null): PropMap {
return styleName ? this.merged('table', styleName).misc : {};
}
columnWidthCm(styleName: string | null): number | null {
if (!styleName) return null;
return lengthToCm(this.merged('table-column', styleName).misc['style:column-width']);
+8 -1
View File
@@ -459,7 +459,8 @@
• padding → export emits fo:padding 0.1cm per cell (CELL_PADDING).
Tables are wrapped in <div class="tableWrapper"> by TipTap's node view. */
.paper .tiptap .tableWrapper {
/* The exported table style carries no margin (nor do Word/LO tables). */
/* Default is flush with the text area; a dragged outer edge sets an inline
margin-left/-right here (tableView.ts), which is what the export writes. */
margin: 0;
overflow-x: auto;
}
@@ -533,6 +534,12 @@
pointer-events: none;
}
/* The table's outer left edge: same handle, mirrored to the cell's left side. */
.paper .tiptap .column-resize-handle.left {
left: -2px;
right: auto;
}
.paper .tiptap .column-resize-dragging {
/* Suppress text-selection flicker while dragging a column border. */
user-select: none;
+26
View File
@@ -999,3 +999,29 @@ describe('DOCX named character styles', () => {
expect(res.styles.character['Signal'].text).toEqual({ bold: true, color: '#CC0000' });
});
});
describe('DOCX table margins (dragged outer edges)', () => {
// Default Word geometry here: 21 - 2.12 - 2.12 = 16.76cm of text width, so the
// table is 16.76 - 2 - 3 = 11.76cm wide and indented 2cm.
const fixture = {
type: 'doc',
content: [
{ type: 'table', attrs: { marginLeft: 2, marginRight: 3 }, content: [
{ type: 'tableRow', content: [cell('A', { colwidth: [200] }), cell('B', { colwidth: [100] })] },
] },
],
} as any;
it('writes w:tblInd + the narrower grid and round-trips the margins', async () => {
const bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait');
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
expect(xml).toContain(`<w:tblInd w:type="dxa" w:w="${Math.round((2 / 2.54) * 1440)}"/>`);
const res = await importDocx(bytes);
const table = walk(res.content as N, 'table')[0];
expect(table.attrs.marginLeft).toBeCloseTo(2, 1);
expect(table.attrs.marginRight).toBeCloseTo(3, 1);
const weights = (table.content?.[0].content ?? []).map((c: N) => c.attrs.colwidth[0]);
expect(weights[0] / weights[1]).toBeCloseTo(2, 2);
});
});
+37
View File
@@ -484,6 +484,43 @@ describe('Leg 1d: table cell borders (per-side, fo:border-*)', () => {
});
});
describe('Leg 1e: table margins (dragged outer edges)', () => {
// Text width here is 21 - 2.5 - 1.5 = 17cm, so the table is 17 - 2 - 3 = 12cm wide.
const doc: N = { type: 'doc', content: [
{ type: 'table', attrs: { marginLeft: 2, marginRight: 3 }, content: [
ROW(CELL([200], P(null, T('A'))), CELL([100], P(null, T('B')))),
] },
] };
it('exports fo:margin-* + style:width and re-imports the attrs', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
const tableStyle = xml.match(/<style:style[^>]*style:family="table"[^>]*>\s*<style:table-properties[^>]*>/)?.[0] ?? '';
check('export emits the left margin', tableStyle.includes('fo:margin-left="2cm"'), tableStyle);
check('export emits the right margin', tableStyle.includes('fo:margin-right="3cm"'), tableStyle);
check('export emits the remaining width', tableStyle.includes('style:width="12cm"'), tableStyle);
// Columns keep their 2:1 ratio inside the narrowed table.
check('columns fill the narrowed table', xml.includes('style:column-width="8cm"') && xml.includes('style:column-width="4cm"'), xml.match(/style:column-width="[^"]*"/g));
const res = importOdt(bytes);
check('no warnings on own export', res.warnings.length === 0, res.warnings);
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
check('left margin round-trips', table?.attrs?.marginLeft === 2, table?.attrs);
check('right margin round-trips', table?.attrs?.marginRight === 3, table?.attrs);
const weights = (table?.content?.[0]?.content ?? []).map((c: N) => c.attrs?.colwidth?.[0]);
check('column ratio survives', weights[0] === 2 * weights[1], weights);
});
it('leaves a full-width table without margin attrs', async () => {
const plain: N = { type: 'doc', content: [
{ type: 'table', content: [ROW(CELL([100], P(null, T('A'))), CELL([100], P(null, T('B'))))] },
] };
const res = importOdt(await buildOdt(plain, margins, 'portrait'));
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
check('no margins on a full-width table', !table?.attrs?.marginLeft && !table?.attrs?.marginRight, table?.attrs);
});
});
describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => {
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
+79
View File
@@ -0,0 +1,79 @@
// The outer-edge drag math (tableColumnResize.ts): Word semantics — only the adjacent
// column gives up width, every other gridline stays put, and the edge stops at the
// text margin / the column minimum.
import { describe, it, expect } from 'vitest';
import { generateHTML } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
import { edgeResize, TableColumnResize } from '../../src/lib/editor/extensions/tableColumnResize';
type Result = { marginLeftPx: number; marginRightPx: number; percents: number[] };
// A 3-column table at 300/200/100 px.
const PERCENTS = [50, 100 / 3, 100 / 6];
// Resulting column widths in px: the table width changes by whatever the margins gave up.
const cols = (r: Result, tableWidth: number, ml = 0, mr = 0): number[] => {
const width = tableWidth - (r.marginLeftPx - ml) - (r.marginRightPx - mr);
return r.percents.map((p) => (p / 100) * width);
};
describe('edgeResize', () => {
it('left edge: only column 1 shrinks, the others keep their width', () => {
const r = edgeResize(PERCENTS, 600, 0, 0, 'left', 100);
expect(r.marginLeftPx).toBe(100);
expect(r.marginRightPx).toBe(0);
const [a, b, c] = cols(r, 600);
expect(a).toBeCloseTo(200);
expect(b).toBeCloseTo(200);
expect(c).toBeCloseTo(100);
});
it('right edge: only the last column grows, reclaiming its own margin', () => {
const r = edgeResize(PERCENTS, 450, 0, 150, 'right', 100);
expect(r.marginRightPx).toBe(50);
const [a, b, c] = cols(r, 450, 0, 150);
expect(a).toBeCloseTo(225);
expect(b).toBeCloseTo(150);
expect(c).toBeCloseTo(175);
});
it('stops at the text margin and never goes negative', () => {
const r = edgeResize(PERCENTS, 600, 0, 0, 'right', 100); // no right margin to reclaim
expect(r.marginRightPx).toBe(0);
expect(r.percents).toEqual(PERCENTS);
});
it('keeps the adjacent column above the minimum width', () => {
const r = edgeResize(PERCENTS, 600, 0, 0, 'left', 500); // column 1 is only 300px
expect(r.marginLeftPx).toBe(270); // 300 - CELL_MIN_PX
expect(cols(r, 600)[0]).toBeCloseTo(30);
});
});
// The static HTML path (generateHTML) feeds the PDF export, which has no node view.
describe('table margins in rendered HTML', () => {
const exts = [Document, Paragraph, Text, Table, TableRow, TableCell, TableHeader, TableColumnResize];
const table = (attrs: Record<string, number>) => ({
type: 'doc',
content: [{ type: 'table', attrs, content: [
{ type: 'tableRow', content: [{ type: 'tableCell', content: [{ type: 'paragraph' }] }] },
] }],
});
it('renders the margins and the matching width', () => {
const html = generateHTML(table({ marginLeft: 2, marginRight: 3 }), exts);
expect(html).toContain('margin-left: 2cm');
expect(html).toContain('margin-right: 3cm');
// jsdom's CSS parser normalizes absolute lengths in `width` to px (browsers don't).
const [, value, unit] = html.match(/width: calc\(100% - ([\d.]+)(cm|px)\)/) ?? [];
expect(unit).toBeTruthy();
expect(unit === 'px' ? Number(value) / (96 / 2.54) : Number(value)).toBeCloseTo(5, 3);
});
it('leaves a full-width table unstyled', () => {
expect(generateHTML(table({ marginLeft: 0, marginRight: 0 }), exts)).not.toContain('margin-left');
});
});