feat: header row toggle
This commit is contained in:
1 parent
3cc2e65423
commit
418f152d4b
11 files changed
+312
-25
No files matched your search
@@ -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"?>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user