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;