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

+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 {