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

+1 -2
View File
@@ -33,7 +33,7 @@
- Tab stops (real tab character) and manual line breaks (Shift+Enter)
**Insert**
- Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading; a table splits cleanly across page boundaries
- Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header-row toggle (bold + shading); a table splits cleanly across page boundaries
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste
- Special characters picker
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
@@ -65,7 +65,6 @@
### Not yet implemented
Planned word-processor features (to match Word/LibreOffice):
- Table cells: header-row toggle
- Footnotes / endnotes (currently dropped on import)
- Clear formatting (remove all marks)
- Multi-column (newspaper) layout
+27
View File
@@ -1,6 +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 ColorPicker from './ColorPicker.svelte';
let {
@@ -28,6 +29,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));
// 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`).
function cellBg(): string | null {
@@ -205,6 +209,24 @@
</svg>
{/snippet}
</ColorPicker>
<span class="tt-sep"></span>
<button
class="tt-btn"
class:active={isHeaderRow}
title="Header row (bold + shading)"
aria-label="Header row"
aria-pressed={isHeaderRow}
onclick={() => run((c) => c.toggleHeaderRowStyle())}
>
<svg width="16" height="16" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="3" y="3" width="12" height="12" rx="1" stroke="currentColor" stroke-width="1.3"/>
<rect x="3" y="3" width="12" height="4" fill="currentColor" opacity="0.85"/>
<line x1="9" y1="7" x2="9" y2="15" stroke="currentColor" stroke-width="1.1"/>
<line x1="3" y1="11" x2="15" y2="11" stroke="currentColor" stroke-width="1.1"/>
</svg>
</button>
</div>
<style>
@@ -250,6 +272,11 @@
cursor: default;
}
.tt-btn.active {
background: var(--color-btn-hover);
color: var(--color-primary);
}
.tt-danger:hover {
background: #fdecea;
color: #c0392b;
+11 -6
View File
@@ -3,6 +3,7 @@
import HistoryButton from './HistoryButton.svelte';
import AlignButton from './AlignButton.svelte';
import { ORDERED_LIST_TYPES, DEFAULT_ORDERED_TYPE, type OrderedListType } from '../utils/orderedListTypes';
import { isInHeaderCell } from '../editor/extensions/tableHeaderRow';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
@@ -11,10 +12,14 @@
let isH2 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 2 }));
let isH3 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 3 }));
let isHeading = $derived(isH1 || isH2 || isH3);
// fontWeight:'normal' is set explicitly to override heading boldness
// Header-row cells render bold by default (CSS), like headings — so the Bold button
// toggles the fontWeight:'normal' override there too (keeps header bold editable).
let inHeaderCell = $derived(tick >= 0 && !!editor && isInHeaderCell(editor.state));
let boldByDefault = $derived(isHeading || inHeaderCell);
// fontWeight:'normal' is set explicitly to override the default boldness
let hasNormalWeight = $derived(tick >= 0 && !!editor?.isActive('textStyle', { fontWeight: 'normal' }));
// Bold = explicit bold mark, OR inside a heading that hasn't been un-bolded
let isBold = $derived(tick >= 0 && (!!editor?.isActive('bold') || (isHeading && !hasNormalWeight)));
// Bold = explicit bold mark, OR a bold-by-default context that hasn't been un-bolded
let isBold = $derived(tick >= 0 && (!!editor?.isActive('bold') || (boldByDefault && !hasNormalWeight)));
let isItalic = $derived(tick >= 0 && !!editor?.isActive('italic'));
let isUnderline = $derived(tick >= 0 && !!editor?.isActive('underline'));
let isStrike = $derived(tick >= 0 && !!editor?.isActive('strike'));
@@ -59,12 +64,12 @@
<button
class:active={isBold}
onclick={() => {
if (isHeading) {
if (boldByDefault) {
if (hasNormalWeight) {
// Restore heading's natural bold
// Restore the default (heading / header-row) bold
editor?.chain().focus().unsetFontWeight().run();
} else {
// Override heading bold with explicit font-weight: normal
// Override the default bold with explicit font-weight: normal
editor?.chain().focus().setFontWeight('normal').run();
}
} else {
+3
View File
@@ -35,6 +35,7 @@ import { TableColumnResize } from './extensions/tableColumnResize';
import { TableRowResize } from './extensions/tableRowResize';
import { TableSplit } from './extensions/tableSplit';
import { TableCellBackground } from './extensions/tableCellBackground';
import { TableHeaderRow } from './extensions/tableHeaderRow';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
@@ -85,6 +86,8 @@ export const extensions = [
TableSplit,
// Cell shading: backgroundColor attr on tableCell/tableHeader (→ fo:background-color).
TableCellBackground,
// "Header row" styling preset: bold + light shading on the first row (toggle).
TableHeaderRow,
// Guarantees a paragraph after a trailing table so the cursor isn't trapped
// and the user can keep writing below a table at the document's end.
TrailingNode,
@@ -1,4 +1,5 @@
import { Extension } from '@tiptap/core';
import { HEADER_SHADE } from './tableHeaderRow';
// Word/LibreOffice cell shading: a `backgroundColor` attr on table cells, rendered
// as the cell's CSS background and round-tripped to ODF fo:background-color
@@ -19,7 +20,11 @@ export const TableCellBackground = Extension.create({
parseHTML: (element: HTMLElement) => element.style.backgroundColor || null,
renderHTML: (attributes: Record<string, unknown>) => {
const bg = attributes.backgroundColor;
return bg ? { style: `background-color: ${bg}` } : {};
if (!bg) return {};
// The header-row fill doubles as a marker: tag the cell so CSS can render
// its text bold (presentational, covers typed/pasted text). See tableHeaderRow.
const cls = bg === HEADER_SHADE ? { class: 'cell-header' } : {};
return { style: `background-color: ${bg}`, ...cls };
},
},
},
@@ -0,0 +1,91 @@
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';
// 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.
export const HEADER_SHADE = '#F2F2F2';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableHeaderRow: {
toggleHeaderRowStyle: () => ReturnType;
};
}
}
// Distinct cell positions (relative to tableStart) of the table's first row.
function firstRowCellPositions(state: EditorState): { positions: number[]; tableStart: 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];
if (!seen.has(pos)) { seen.add(pos); positions.push(pos); }
}
return { positions, tableStart };
}
// True when the cursor sits in a header-shaded cell (so the Bold button should drive the
// fontWeight override, like a heading, instead of the bold mark — see Toolbar.svelte).
export function isInHeaderCell(state: EditorState): boolean {
const { $from } = state.selection;
for (let d = $from.depth; d > 0; d--) {
const role = $from.node(d).type.spec.tableRole;
if (role === 'cell' || role === 'header_cell') {
return $from.node(d).attrs.backgroundColor === HEADER_SHADE;
}
}
return false;
}
// True when every cell of the first row carries the header fill.
export function isHeaderRowStyled(state: EditorState): boolean {
const info = firstRowCellPositions(state);
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() {
return ({ state, tr, dispatch }: CommandProps): boolean => {
const info = firstRowCellPositions(state);
if (!info) return false;
if (!dispatch) return true;
const isHeader = isHeaderRowStyled(state);
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) {
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.
if (isHeader && bold) tr.removeMark(cellPos + 1, cellPos + cell.nodeSize - 1, bold);
}
dispatch(tr);
return true;
};
}
export const TableHeaderRow = Extension.create({
name: 'tableHeaderRow',
addCommands() {
return {
toggleHeaderRowStyle: () => toggleHeaderRowStyle(),
};
},
});
+12 -4
View File
@@ -3,6 +3,7 @@ import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../utils/orderedListTypes';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -973,10 +974,14 @@ function linkHrefOf(marks: TiptapNode['marks'] = []): string | undefined {
}
// Emit each text node as an odf-kit run; link-marked runs become <text:a> via addLink.
function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []) {
// forceBold bakes bold onto every run regardless of marks — used for header-row cells,
// whose bold is presentational (CSS) in the editor and so isn't stored as a mark.
function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = [], forceBold = false) {
for (const node of content) {
if (node.type !== 'text' || !node.text) continue;
const fmt = formattingFromMarks(node.marks);
// Bake header bold, but respect an explicit un-bold (fontWeight:normal) override.
if (forceBold && fmt.fontWeight !== 'normal') fmt.bold = true;
const f = Object.keys(fmt).length ? fmt : undefined;
const href = linkHrefOf(node.marks);
if (href) p.addLink(node.text, href, f);
@@ -1004,14 +1009,14 @@ function applyHfRuns(b: HeaderFooterBuilder, para: TiptapNode, pageCount: number
// A segment is one paragraph, heading, or list item's paragraph; exactly one SEG
// between consecutive segments, so splitting yields one piece per segment in DFS order.
function buildCellContent(cell: TiptapNode, c: CellBuilder): CellBlock[] {
function buildCellContent(cell: TiptapNode, c: CellBuilder, forceBold = false): CellBlock[] {
const blocks: CellBlock[] = [];
const state = { emitted: false }; // whether any segment has been emitted yet
const emitSegment = (content: TiptapNode[] | undefined) => {
if (state.emitted) c.addText(SEG);
state.emitted = true;
applyRuns(c, content ?? []);
applyRuns(c, content ?? [], forceBold);
};
const walkList = (listNode: TiptapNode): CellListBlock => {
@@ -1109,8 +1114,11 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
const c = normalizeColor(bg);
if (c) opts.backgroundColor = c;
}
// Header-row cells render bold via CSS (presentational), so bake bold into the
// runs on export to keep Word/LibreOffice consistent (incl. freshly-typed text).
const headerBold = bg === HEADER_SHADE;
r.addCell((c: CellBuilder) => {
cellBlocks.push(buildCellContent(cell, c));
cellBlocks.push(buildCellContent(cell, c, headerBold));
}, opts);
}
});
+16 -12
View File
@@ -1,6 +1,7 @@
import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../utils/orderedListTypes';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
@@ -257,21 +258,21 @@ function parseXml(xml: string): Document {
// ---- block conversion -----------------------------------------------------------
function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind): Node[] {
function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
const out: Node[] = [];
for (const el of elements) {
if (el.namespaceURI === NS.text) {
if (el.localName === 'p' || el.localName === 'h') {
out.push(convertParaLike(el, ctx, kind));
out.push(convertParaLike(el, ctx, kind, boldByDefault));
} else if (el.localName === 'list') {
const list = convertList(el, ctx, null, 1);
if (list) out.push(list);
} else if (el.localName === 'section') {
out.push(...convertBlocks(Array.from(el.children), ctx, kind));
out.push(...convertBlocks(Array.from(el.children), ctx, kind, boldByDefault));
} else if (/-index$|^table-of-content$|^bibliography$/.test(el.localName)) {
// Generated indexes (ToC, …): keep the rendered text from index-body.
const indexBody = el.getElementsByTagNameNS(NS.text, 'index-body')[0];
if (indexBody) out.push(...convertBlocks(Array.from(indexBody.children), ctx, kind));
if (indexBody) out.push(...convertBlocks(Array.from(indexBody.children), ctx, kind, boldByDefault));
}
// tracked-changes registry, decls, soft-page-break, … → no visual content
} else if (el.namespaceURI === NS.table && el.localName === 'table') {
@@ -294,7 +295,7 @@ function convertBlocks(elements: Element[], ctx: Ctx, kind: BlockKind): Node[] {
return out;
}
function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind): Node {
function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = false): Node {
const { resolver } = ctx;
const styleName = el.getAttributeNS(NS.text, 'style-name');
const paraProps = resolver.paraProps(styleName);
@@ -308,7 +309,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind): Node {
}
const attrs = blockAttrs(paraProps, baseTextProps, isHeading ? level : null, kind);
const content = convertInline(el, ctx, baseTextProps, isHeading ? level : null);
const content = convertInline(el, ctx, baseTextProps, isHeading ? level : null, false, boldByDefault);
const node: Node = { type: isHeading ? 'heading' : 'paragraph' };
if (isHeading) attrs.level = level;
@@ -380,7 +381,7 @@ function snapPt(v: number): number {
// ---- inline conversion --------------------------------------------------------
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null, hfFields = false): Node[] {
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null, hfFields = false, boldByDefault = false): Node[] {
const out: Node[] = [];
const pushText = (text: string, props: PropMap, linkHref?: string) => {
@@ -393,7 +394,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
clean = clean.replace(/[ \t]*\n[ \t]*/g, ' ');
}
if (!clean) return;
const marks = marksFor(props, ctx.resolver, headingLevel);
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
const node: Node = { type: 'text', text: clean };
if (marks.length) node.marks = marks;
@@ -496,14 +497,15 @@ function mergeAdjacentText(nodes: Node[]): Node[] {
return out;
}
function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number | null): Mark[] {
function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number | null, boldByDefault = false): Mark[] {
const marks: Mark[] = [];
const textStyle: Record<string, unknown> = {};
const weight = props['fo:font-weight'];
const bold = weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : null;
if (headingLevel != null) {
// Headings render bold by default; only a *non*-bold run needs a mark.
// Headings and header-row cells render bold by default (CSS): only a *non*-bold run
// needs a mark (fontWeight:normal), and a bold run needs none.
if (headingLevel != null || boldByDefault) {
if (bold === false) textStyle.fontWeight = 'normal';
} else if (bold === true) {
marks.push({ type: 'bold' });
@@ -668,7 +670,9 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
const rowspan = parseInt(cellEl.getAttributeNS(NS.table, 'number-rows-spanned') ?? '1', 10) || 1;
const rawBg = ctx.resolver.cellBackgroundColor(cellEl.getAttributeNS(NS.table, 'style-name'));
const backgroundColor = rawBg ? normalizeColor(rawBg) ?? rawBg : null;
const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell');
// Header-shaded cells render bold by default (CSS); convert their runs like headings
// so a baked-bold run needs no mark and only an explicitly normal run gets one.
const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell', backgroundColor === HEADER_SHADE);
for (let r = 0; r < repeated; r++) {
const attrs: Record<string, unknown> = { colspan, rowspan };
if (backgroundColor) attrs.backgroundColor = backgroundColor;
+8
View File
@@ -361,6 +361,14 @@
margin-bottom: 0;
}
/* Header-row cells (tableHeaderRow.ts toggle): bold rendered presentationally so all
text — existing, typed, or pasted — is bold. The light fill comes from the cell's
inline background-color; export bakes the bold for Word/LibreOffice consistency. */
.paper .tiptap td.cell-header,
.paper .tiptap th.cell-header {
font-weight: 600;
}
/* Selected-cell highlight while editing (ProseMirror tables add .selectedCell).
A translucent overlay via the ::after avoids disturbing the cell border. */
.paper .tiptap .selectedCell {
+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
});
});