feat: header row toggle

This commit is contained in:
Steffen Becker committed 2026-06-28 12:23:41 +02:00
1 parent 3cc2e65423
commit 418f152d4b
11 files changed
+312 -25

No files matched your search

+36
View File
@@ -8,6 +8,7 @@ import { Node as PMNode } from '@tiptap/pm/model';
import { buildOdt } from '../src/lib/export/odt';
import { importOdt } from '../src/lib/import/odt';
import { hfExtensions } from '../src/lib/editor/extensions/headerFooter';
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
type N = any;
@@ -281,6 +282,41 @@ describe('Leg 1b: merged table cells (colspan/rowspan)', () => {
});
});
describe('Leg 1c: header-row cells (bold-by-default, editable)', () => {
// Header-shaded cells render bold via CSS; bold is editable via fontWeight:normal.
// Round-trip: a default-bold run carries no mark (CSS bolds), an un-bolded run keeps
// fontWeight:normal, and export bakes bold so Word/LibreOffice match.
const hcell = (...content: N[]): N =>
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [100], backgroundColor: HEADER_SHADE }, content: [P(null, ...content)] });
const FW_NORMAL = { type: 'textStyle', attrs: { fontWeight: 'normal' } };
const doc: N = { type: 'doc', content: [
{ type: 'table', content: [
ROW(hcell(T('Bold')), hcell(T('Plain', FW_NORMAL))),
ROW(CELLM(1, 1, [100], 'x'), CELLM(1, 1, [100], 'y')),
] },
] };
it('bakes header bold on export and round-trips an un-bolded run', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const xml = strFromU8(unzipSync(bytes)['content.xml']);
check('export bakes bold on the default-bold header run', /fo:font-weight="bold"/.test(xml), xml.match(/fo:font-weight="[^"]*"/g));
check('export emits the un-bold override', /fo:font-weight="normal"/.test(xml));
const res = importOdt(bytes);
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
const row0 = table?.content?.[0]?.content ?? [];
const marksOf = (cell: N) => cell?.content?.[0]?.content?.[0]?.marks ?? [];
// Both cells keep the header fill.
check('header cells keep the fill', row0[0]?.attrs?.backgroundColor === HEADER_SHADE && row0[1]?.attrs?.backgroundColor === HEADER_SHADE, row0.map((c: N) => c?.attrs?.backgroundColor));
// Default-bold run carries no mark (CSS provides bold).
check('default-bold run has no bold mark', !marksOf(row0[0]).some((m: N) => m.type === 'bold'), marksOf(row0[0]));
// Un-bolded run keeps fontWeight:normal.
const fw = marksOf(row0[1]).find((m: N) => m.type === 'textStyle')?.attrs?.fontWeight;
check('un-bolded run keeps fontWeight:normal', fw === 'normal', marksOf(row0[1]));
});
});
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"?>
+101
View File
@@ -0,0 +1,101 @@
// Unit test for the header-row styling toggle: drives toggleHeaderRowStyle against a
// ProseMirror EditorState and checks the first row gains/loses bold + the header fill.
import { describe, it, expect } from 'vitest';
import { getSchema } from '@tiptap/core';
import { EditorState, TextSelection } from '@tiptap/pm/state';
import type { Node as PMNode } from '@tiptap/pm/model';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import Bold from '@tiptap/extension-bold';
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
import { TableCellBackground } from '../../src/lib/editor/extensions/tableCellBackground';
import {
HEADER_SHADE,
isHeaderRowStyled,
toggleHeaderRowStyle,
} from '../../src/lib/editor/extensions/tableHeaderRow';
type N = any;
const schema = getSchema([
Document, Paragraph, Text, Bold, Table, ResizableTableRow, TableCell, TableHeader,
TableCellBackground,
]);
const cell = (text: string): N => ({
type: 'tableCell',
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
const row = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
function makeState(): EditorState {
const doc = schema.nodeFromJSON({
type: 'doc',
content: [{ type: 'table', content: [row(cell('A'), cell('B')), row(cell('c'), cell('d'))] }],
});
// Cursor in the first cell.
let pos = -1;
doc.descendants((n: PMNode, p: number) => { if (pos < 0 && n.isText) pos = p; });
return EditorState.create({ schema, doc, selection: TextSelection.create(doc, pos) });
}
// Apply the command factory to a state, returning the next state.
function run(state: EditorState): EditorState {
let next: EditorState | null = null;
toggleHeaderRowStyle()({ state, tr: state.tr, dispatch: (tr: any) => { next = state.apply(tr); } } as any);
return next ?? state;
}
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');
describe('toggleHeaderRowStyle', () => {
it('toggles the header fill on the first row (bold is presentational/CSS)', () => {
let state = makeState();
expect(isHeaderRowStyled(state)).toBe(false);
// Toggle ON: only the fill marker is set (bold comes from the .cell-header CSS).
state = run(state);
expect(isHeaderRowStyled(state)).toBe(true);
for (const c of firstRow(state).content) {
expect(c.attrs.backgroundColor).toBe(HEADER_SHADE);
expect(boldMarks(c)).toBe(false); // no stored bold mark — CSS handles it
}
// Second row untouched.
expect(state.doc.toJSON().content[0].content[1].content[0].attrs.backgroundColor == null).toBe(true);
// Toggle OFF: fill cleared.
state = run(state);
expect(isHeaderRowStyled(state)).toBe(false);
for (const c of firstRow(state).content) {
expect(c.attrs.backgroundColor == null).toBe(true);
}
});
it('strips baked-in bold marks when turning the header off (import round-trip)', () => {
// Simulate an imported header: first row has the fill + bold runs (export bakes bold).
const doc = schema.nodeFromJSON({
type: 'doc',
content: [{ type: 'table', content: [
row(
{ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: null, backgroundColor: HEADER_SHADE },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'H', marks: [{ type: 'bold' }] }] }] },
),
row(cell('x')),
] }],
});
let pos = -1;
doc.descendants((n: PMNode, p: number) => { if (pos < 0 && n.isText) pos = p; });
let state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, pos) });
expect(isHeaderRowStyled(state)).toBe(true);
state = run(state); // toggle OFF
expect(isHeaderRowStyled(state)).toBe(false);
const c = firstRow(state).content[0];
expect(c.attrs.backgroundColor == null).toBe(true);
expect(boldMarks(c)).toBe(false); // baked bold removed
});
});