feat: table margin edge resize
This commit is contained in:
1 parent
f7c0e1ed1e
commit
ca4fbd422b
11 files changed
+494
-78
No files matched your search
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"?>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user