Files
edentext/src/lib/editor/extensions/tableCellBorders.ts
T
Steffen Becker b7f6328005 fix: a table takes the vertical space LibreOffice gives it
Two ways a table's height was off. Its space above was a margin, so it
collapsed against the paragraph above instead of adding to it — now it rides
--space-before like a paragraph's. And every cell border paints a whole pixel
however thin the file declares it, where LibreOffice reserves the declared
width; the excess now comes off the cell's padding-bottom, 0.25mm a row.
2026-08-10 18:34:32 +00:00

248 lines
9.9 KiB
TypeScript

import { Extension } from '@tiptap/core';
import type { CommandProps } from '@tiptap/core';
import type { EditorState } from '@tiptap/pm/state';
import { selectedRect, isInTable } from '@tiptap/pm/tables';
// Per-side border attrs (`borderTop/Right/Bottom/Left`) on table cells: `null` = the
// table default (0.5pt solid black), `'none'` = no border, else the canonical
// `'<W>pt solid #RRGGBB'`. Round-trips to ODF fo:border-* and DOCX w:tcBorders.
export const DEFAULT_BORDER_WIDTH_PT = 0.5;
export const DEFAULT_BORDER_COLOR = '#000000';
export type BorderPreset =
| 'all' | 'outer' | 'inner' | 'innerH' | 'innerV'
| 'top' | 'bottom' | 'left' | 'right';
// null = no border ('none').
export type BorderSpec = { widthPt: number; color: string } | null;
export const BORDER_SIDES = ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'] as const;
export type BorderSide = (typeof BORDER_SIDES)[number];
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableCellBorders: {
setTableBorders: (preset: BorderPreset, spec: BorderSpec) => ReturnType;
};
}
}
// Canonical attr value for a spec; the editor default collapses to null.
export function borderAttrValue(spec: BorderSpec): string | null {
if (!spec) return 'none';
const w = Math.round(spec.widthPt * 100) / 100;
const color = spec.color.toUpperCase();
if (w === DEFAULT_BORDER_WIDTH_PT && color === DEFAULT_BORDER_COLOR) return null;
return `${w}pt solid ${color}`;
}
// null = table default, 'none' = no border, else width/color of a custom border.
export function parseBorderAttr(
value: string | null | undefined,
): { widthPt: number; color: string } | 'none' | null {
if (value == null) return null;
if (value === 'none') return 'none';
const m = /^([\d.]+)pt solid (#[0-9A-Fa-f]{6})$/.exec(value);
if (!m) return null;
return { widthPt: parseFloat(m[1]), color: m[2].toUpperCase() };
}
// CSS value for a side. The default 0.5pt renders as 1px (editor.css); custom widths
// scale the same way (pt → px, min 1px so thin lines stay crisp).
export function cssBorder(value: string): string | null {
const b = parseBorderAttr(value);
if (b === null) return null;
if (b === 'none') return 'none';
const px = Math.max(1, Math.round((b.widthPt * 96) / 72));
return `${px}px solid ${b.color}`;
}
// What the painted border costs the row beyond the width the file declares: Chromium
// floors every border to a whole pixel, LibreOffice reserves the declared width (a
// 0.05pt hairline is next to nothing). editor.css takes it off the cell's padding.
export function borderOvershootPx(value: string): number {
const b = parseBorderAttr(value);
if (b === null || b === 'none') return 0;
const declared = (b.widthPt * 96) / 72;
return Math.round((Math.max(1, Math.round(declared)) - declared) * 1000) / 1000;
}
const SIDE_META: Record<BorderSide, { data: string; css: string }> = {
borderTop: { data: 'data-border-top', css: 'border-top' },
borderRight: { data: 'data-border-right', css: 'border-right' },
borderBottom: { data: 'data-border-bottom', css: 'border-bottom' },
borderLeft: { data: 'data-border-left', css: 'border-left' },
};
// Walks every boundary a preset targets in the selected rect, reporting both facing
// (cellPos, side) pairs — writing both is what keeps collapsed borders from disagreeing.
// A pos is null at the table edge; boundaries inside a merged cell are skipped.
function forEachBoundary(
rect: ReturnType<typeof selectedRect>,
preset: BorderPreset,
cb: (aPos: number | null, aSide: BorderSide, bPos: number | null, bSide: BorderSide) => void,
): void {
const { map } = rect;
const wants = {
topEdge: preset === 'all' || preset === 'outer' || preset === 'top',
bottomEdge: preset === 'all' || preset === 'outer' || preset === 'bottom',
leftEdge: preset === 'all' || preset === 'outer' || preset === 'left',
rightEdge: preset === 'all' || preset === 'outer' || preset === 'right',
innerH: preset === 'all' || preset === 'inner' || preset === 'innerH',
innerV: preset === 'all' || preset === 'inner' || preset === 'innerV',
};
// Horizontal boundaries: grid line y separates row y-1 (above) from row y (below).
for (let y = rect.top; y <= rect.bottom; y++) {
const isTop = y === rect.top;
const isBottom = y === rect.bottom;
if (isTop && !wants.topEdge) continue;
if (isBottom && !wants.bottomEdge) continue;
if (!isTop && !isBottom && !wants.innerH) continue;
for (let x = rect.left; x < rect.right; x++) {
const below = y < map.height ? map.map[y * map.width + x] : null;
const above = y > 0 ? map.map[(y - 1) * map.width + x] : null;
if (above === below) continue; // grid line runs inside a merged cell
cb(above, 'borderBottom', below, 'borderTop');
}
}
// Vertical boundaries: grid line x separates column x-1 (left) from column x (right).
for (let x = rect.left; x <= rect.right; x++) {
const isLeft = x === rect.left;
const isRight = x === rect.right;
if (isLeft && !wants.leftEdge) continue;
if (isRight && !wants.rightEdge) continue;
if (!isLeft && !isRight && !wants.innerV) continue;
for (let y = rect.top; y < rect.bottom; y++) {
const right = x < map.width ? map.map[y * map.width + x] : null;
const left = x > 0 ? map.map[y * map.width + (x - 1)] : null;
if (left === right) continue;
cb(left, 'borderRight', right, 'borderLeft');
}
}
}
export function setTableBorders(preset: BorderPreset, spec: BorderSpec) {
return ({ state, tr, dispatch }: CommandProps): boolean => {
if (!isInTable(state)) return false;
if (!dispatch) return true;
const rect = selectedRect(state);
const value = borderAttrValue(spec);
// pos (relative to tableStart) → attr changes, deduped across grid slots of a span.
const changes = new Map<number, Record<string, string | null>>();
const set = (pos: number | null, side: BorderSide) => {
if (pos == null) return;
const c = changes.get(pos) ?? {};
c[side] = value;
changes.set(pos, c);
};
forEachBoundary(rect, preset, (aPos, aSide, bPos, bSide) => {
set(aPos, aSide);
set(bPos, bSide);
});
// setNodeMarkup doesn't shift positions, so all cell positions stay valid.
for (const [pos, attrs] of changes) {
const cell = tr.doc.nodeAt(rect.tableStart + pos);
if (!cell) continue;
tr.setNodeMarkup(rect.tableStart + pos, undefined, { ...cell.attrs, ...attrs });
}
dispatch(tr);
return true;
};
}
export const BORDER_PRESETS: BorderPreset[] = [
'all', 'outer', 'inner', 'innerH', 'innerV', 'top', 'bottom', 'left', 'right',
];
// What a boundary renders: the collapse winner of its two facing cell sides
// (a border beats 'none', wider beats narrower; attr null = the table default).
type EffectiveBorder = { widthPt: number; color: string } | 'none';
// Active states for the picker: a preset is active when every boundary it targets renders
// exactly the pen border (width + color), so the default thin pen lights up a fresh table
// and a thicker pen turns it off. 'none' = borderless region; null outside a table.
export function activeBorderPresets(
state: EditorState,
spec: Exclude<BorderSpec, null>,
): Record<BorderPreset | 'none', boolean> | null {
if (!isInTable(state)) return null;
const rect = selectedRect(state);
const sideEff = (pos: number | null, side: BorderSide): EffectiveBorder | null => {
if (pos == null) return null;
const b = parseBorderAttr(state.doc.nodeAt(rect.tableStart + pos)?.attrs[side] as string | null);
return b ?? { widthPt: DEFAULT_BORDER_WIDTH_PT, color: DEFAULT_BORDER_COLOR };
};
const boundaryEff = (aPos: number | null, aSide: BorderSide, bPos: number | null, bSide: BorderSide): EffectiveBorder => {
let best: EffectiveBorder = 'none';
for (const v of [sideEff(aPos, aSide), sideEff(bPos, bSide)]) {
if (v == null || v === 'none') continue;
if (best === 'none' || v.widthPt > best.widthPt) best = v;
}
return best;
};
const matchesPen = (v: EffectiveBorder) =>
v !== 'none' && Math.abs(v.widthPt - spec.widthPt) < 0.01 && v.color === spec.color.toUpperCase();
const out = {} as Record<BorderPreset | 'none', boolean>;
for (const preset of BORDER_PRESETS) {
let n = 0;
let active = true;
forEachBoundary(rect, preset, (aPos, aSide, bPos, bSide) => {
n++;
if (!matchesPen(boundaryEff(aPos, aSide, bPos, bSide))) active = false;
});
out[preset] = n > 0 && active;
}
let n = 0;
let allNone = true;
forEachBoundary(rect, 'all', (aPos, aSide, bPos, bSide) => {
n++;
if (boundaryEff(aPos, aSide, bPos, bSide) !== 'none') allNone = false;
});
out.none = n > 0 && allNone;
return out;
}
export const TableCellBorders = Extension.create({
name: 'tableCellBorders',
addGlobalAttributes() {
return [
{
types: ['tableCell', 'tableHeader'],
attributes: Object.fromEntries(
BORDER_SIDES.map((side) => [
side,
{
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute(SIDE_META[side].data),
renderHTML: (attributes: Record<string, unknown>) => {
const v = attributes[side] as string | null;
if (!v) return {};
const css = cssBorder(v);
if (!css) return {};
// Only the bottom one: with border-collapse a boundary draws a single
// line, so compensating both sides of it would take the excess twice.
const over = side === 'borderBottom' ? borderOvershootPx(v) : 0;
const style = `${SIDE_META[side].css}: ${css}` + (over ? `; --border-over: ${over}px` : '');
return { [SIDE_META[side].data]: v, style };
},
},
]),
),
},
];
},
addCommands() {
return {
setTableBorders: (preset: BorderPreset, spec: BorderSpec) => setTableBorders(preset, spec),
};
},
});