feat: add table header column

This commit is contained in:
Steffen Becker committed 2026-08-03 22:47:44 +02:00
1 parent 359915d6b2
commit 82699e653b
5 files changed
+81 -26

No files matched your search

+21 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Editor, ChainedCommands } from '@tiptap/core';
import { CellSelection } from '@tiptap/pm/tables';
import { isHeaderRowStyled } from '../editor/extensions/tableHeaderRow';
import { isHeaderStyled } from '../editor/extensions/tableHeaderRow';
import ColorPicker from './ColorPicker.svelte';
import TableBorderPicker from './TableBorderPicker.svelte';
import { t } from '../i18n/i18n.svelte';
@@ -31,8 +31,9 @@
// Merge needs a multi-cell selection; re-evaluated per transaction via `tick`.
const canMerge = $derived(tick >= 0 && !!editor && editor.can().mergeCells());
// Whether the first row is styled as a header (bold + light shading).
const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderRowStyled(editor.state));
// Whether the first row / first column is styled as a header (bold + light shading).
const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'row'));
const isHeaderCol = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'column'));
// Background colour of the selected cell(s): the uniform value, '' if mixed, null if
// none. Drives the ColorPicker's "current" swatch (re-evaluated per `tick`).
@@ -235,6 +236,23 @@
<line x1="9" y1="6.5" x2="9" y2="16.5" stroke="currentColor" stroke-width="1.1"/>
</svg>
</button>
<button
class="tt-btn"
class:active={isHeaderCol}
title={t().table.headerColumn}
aria-label={t().table.headerColumnAria}
aria-pressed={isHeaderCol}
onclick={() => run((c) => c.toggleHeaderColumnStyle())}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="1.5" y="1.5" width="15" height="15" rx="1" stroke="currentColor" stroke-width="1.3"/>
<rect x="1.5" y="1.5" width="5" height="15" fill="currentColor" opacity="0.85"/>
<line x1="6.5" y1="1.5" x2="6.5" y2="16.5" stroke="currentColor" stroke-width="1.1"/>
<line x1="11.5" y1="1.5" x2="11.5" y2="16.5" stroke="currentColor" stroke-width="1.1"/>
<line x1="6.5" y1="9" x2="16.5" y2="9" stroke="currentColor" stroke-width="1.1"/>
</svg>
</button>
</div>
<style>
+28 -19
View File
@@ -3,34 +3,38 @@ import type { CommandProps } from '@tiptap/core';
import type { EditorState } from '@tiptap/pm/state';
import { selectedRect, isInTable } from '@tiptap/pm/tables';
// Word/LibreOffice-style "header row" as a styling preset: the first row gets bold text
// + a light grey fill. This is real formatting (bold mark + the backgroundColor cell
// attr from tableCellBackground.ts), both of which already round-trip to/from ODF — so
// the editor and the exported .odt look identical, with no page repetition (we
// deliberately don't emit <table:table-header-rows>, which is what makes Word repeat it).
// The shading doubles as the toggle's marker, so the active state survives save/reopen.
// Word/LibreOffice "header row"/"header column" as a styling preset: the first row (or
// column) gets bold text + a light grey fill, both of which round-trip to ODF/DOCX. The
// fill doubles as the marker, so no <table:table-header-rows> and no page repetition.
export const HEADER_SHADE = '#F2F2F2';
export type HeaderAxis = 'row' | 'column';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableHeaderRow: {
toggleHeaderRowStyle: () => ReturnType;
toggleHeaderColumnStyle: () => ReturnType;
};
}
}
// Distinct cell positions (relative to tableStart) of the table's first row.
function firstRowCellPositions(state: EditorState): { positions: number[]; tableStart: number } | null {
// Distinct cell positions (relative to tableStart) of the table's first row / first column.
function headerCellPositions(
state: EditorState,
axis: HeaderAxis,
): { positions: number[]; tableStart: number; corner: number } | null {
if (!isInTable(state)) return null;
const { map, tableStart } = selectedRect(state);
const seen = new Set<number>();
const positions: number[] = [];
for (let c = 0; c < map.width; c++) {
const pos = map.map[c];
const n = axis === 'row' ? map.width : map.height;
for (let i = 0; i < n; i++) {
const pos = map.map[axis === 'row' ? i : i * map.width];
if (!seen.has(pos)) { seen.add(pos); positions.push(pos); }
}
return { positions, tableStart };
return { positions, tableStart, corner: map.map[0] };
}
// True when the cursor sits in a header-shaded cell (so the Bold button should drive the
@@ -46,33 +50,37 @@ export function isInHeaderCell(state: EditorState): boolean {
return false;
}
// True when every cell of the first row carries the header fill.
export function isHeaderRowStyled(state: EditorState): boolean {
const info = firstRowCellPositions(state);
// True when every cell of the first row / first column carries the header fill.
export function isHeaderStyled(state: EditorState, axis: HeaderAxis): boolean {
const info = headerCellPositions(state, axis);
if (!info || info.positions.length === 0) return false;
return info.positions.every(
(p) => state.doc.nodeAt(info.tableStart + p)?.attrs.backgroundColor === HEADER_SHADE,
);
}
export function toggleHeaderRowStyle() {
export function toggleHeaderStyle(axis: HeaderAxis) {
return ({ state, tr, dispatch }: CommandProps): boolean => {
const info = firstRowCellPositions(state);
const info = headerCellPositions(state, axis);
if (!info) return false;
if (!dispatch) return true;
const isHeader = isHeaderRowStyled(state);
const isHeader = isHeaderStyled(state, axis);
// The corner cell belongs to both headers, so turning one off must leave it alone
// while the other is still on.
const keepCorner = isHeader && isHeaderStyled(state, axis === 'row' ? 'column' : 'row');
const bold = state.schema.marks.bold;
// The fill is the header marker; bold is presentational (CSS on `.cell-header`, keyed
// off this fill) so it covers existing, typed, and pasted text alike. setNodeMarkup/
// removeMark don't shift positions, so the original cell positions stay valid.
for (const p of info.positions) {
if (keepCorner && p === info.corner) continue;
const cellPos = info.tableStart + p;
const cell = tr.doc.nodeAt(cellPos);
if (!cell) continue;
tr.setNodeMarkup(cellPos, undefined, { ...cell.attrs, backgroundColor: isHeader ? null : HEADER_SHADE });
// Turning off: strip any bold marks (incl. ones baked into the runs on export and
// read back on import) so the row truly returns to normal.
// read back on import) so the cells truly return to normal.
if (isHeader && bold) tr.removeMark(cellPos + 1, cellPos + cell.nodeSize - 1, bold);
}
dispatch(tr);
@@ -85,7 +93,8 @@ export const TableHeaderRow = Extension.create({
addCommands() {
return {
toggleHeaderRowStyle: () => toggleHeaderRowStyle(),
toggleHeaderRowStyle: () => toggleHeaderStyle('row'),
toggleHeaderColumnStyle: () => toggleHeaderStyle('column'),
};
},
});
+2
View File
@@ -240,6 +240,8 @@ const de: Messages = {
noFill: 'Keine Füllung',
headerRow: 'Kopfzeile (fett + Schattierung)',
headerRowAria: 'Kopfzeile',
headerColumn: 'Kopfspalte (fett + Schattierung)',
headerColumnAria: 'Kopfspalte',
insertTable: 'Tabelle einfügen',
selectSize: 'Tabellengröße wählen',
dimensions: (cols, rows) => `${cols} × ${rows}`,
+2
View File
@@ -244,6 +244,8 @@ const en = {
noFill: 'No fill',
headerRow: 'Header row (bold + shading)',
headerRowAria: 'Header row',
headerColumn: 'Header column (bold + shading)',
headerColumnAria: 'Header column',
insertTable: 'Insert table',
selectSize: 'Select table size',
dimensions: (cols: number, rows: number) => `${cols} × ${rows}`,
+28 -4
View File
@@ -13,8 +13,9 @@ import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
import { TableCellBackground } from '../../src/lib/editor/extensions/tableCellBackground';
import {
HEADER_SHADE,
isHeaderRowStyled,
toggleHeaderRowStyle,
isHeaderStyled,
toggleHeaderStyle,
type HeaderAxis,
} from '../../src/lib/editor/extensions/tableHeaderRow';
type N = any;
@@ -43,12 +44,14 @@ function makeState(): EditorState {
}
// Apply the command factory to a state, returning the next state.
function run(state: EditorState): EditorState {
function run(state: EditorState, axis: HeaderAxis = 'row'): EditorState {
let next: EditorState | null = null;
toggleHeaderRowStyle()({ state, tr: state.tr, dispatch: (tr: any) => { next = state.apply(tr); } } as any);
toggleHeaderStyle(axis)({ state, tr: state.tr, dispatch: (tr: any) => { next = state.apply(tr); } } as any);
return next ?? state;
}
const isHeaderRowStyled = (s: EditorState) => isHeaderStyled(s, 'row');
const firstRow = (state: EditorState): N => state.doc.toJSON().content[0].content[0];
const boldMarks = (c: N): boolean => (c.content[0].content[0]?.marks ?? []).some((m: N) => m.type === 'bold');
@@ -98,4 +101,25 @@ describe('toggleHeaderRowStyle', () => {
expect(c.attrs.backgroundColor == null).toBe(true);
expect(boldMarks(c)).toBe(false); // baked bold removed
});
it('toggles the first column, and the shared corner survives turning one off', () => {
let state = makeState();
state = run(state, 'column');
expect(isHeaderStyled(state, 'column')).toBe(true);
const col = (s: EditorState) => s.doc.toJSON().content[0].content.map((r: N) => r.content[0]);
for (const c of col(state)) expect(c.attrs.backgroundColor).toBe(HEADER_SHADE);
// Cell (0,1) untouched.
expect(state.doc.toJSON().content[0].content[0].content[1].attrs.backgroundColor == null).toBe(true);
// Both headers on: turning the row off keeps the corner (still the column header).
state = run(state, 'row');
expect(isHeaderStyled(state, 'row')).toBe(true);
state = run(state, 'row');
expect(isHeaderStyled(state, 'row')).toBe(false);
expect(isHeaderStyled(state, 'column')).toBe(true);
state = run(state, 'column');
expect(isHeaderStyled(state, 'column')).toBe(false);
for (const c of col(state)) expect(c.attrs.backgroundColor == null).toBe(true);
});
});