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,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 {
|
||||
|
||||
Reference in new issue
Block a user