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