feat: header row toggle
This commit is contained in:
1 parent
3cc2e65423
commit
418f152d4b
11 files changed
+312
-25
No files matched your search
+1
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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