feat: dropdown for text styles

This commit is contained in:
Steffen Becker committed 2026-07-27 20:35:55 +02:00
1 parent 18a8609926
commit 56f884a412
14 files changed
+267 -61

No files matched your search

+127 -25
View File
@@ -16,11 +16,80 @@
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
// Word/LibreOffice-style style gallery. We have no named-style model, so a style
// is a preset of direct formatting (node type + block attrs + marks).
type ParagraphStyle = {
key: string;
level?: 1 | 2 | 3 | 4 | 5;
fontSize?: string; // block font size (paragraph mark + inherited by its runs)
align?: 'center';
bold?: boolean;
italic?: boolean;
indent?: number; // cm
preview: string; // menu-entry font size
};
const PARAGRAPH_STYLES: ParagraphStyle[] = [
{ key: 'default', preview: '0.85rem' },
{ key: 'title', fontSize: '28pt', align: 'center', bold: true, preview: '1.35rem' },
{ key: 'subtitle', fontSize: '18pt', align: 'center', italic: true, preview: '1.05rem' },
{ key: 'h1', level: 1, preview: '1.25rem' },
{ key: 'h2', level: 2, preview: '1.1rem' },
{ key: 'h3', level: 3, preview: '0.95rem' },
{ key: 'h4', level: 4, preview: '0.9rem' },
{ key: 'h5', level: 5, preview: '0.85rem' },
{ key: 'quote', italic: true, indent: 1.25, preview: '0.85rem' },
];
function styleLabel(key: string): string {
const s = t().toolbar.styles;
return ({
default: s.default, title: s.docTitle, subtitle: s.subtitle, quote: s.quote,
h1: t().toolbar.heading1, h2: t().toolbar.heading2, h3: t().toolbar.heading3,
h4: t().toolbar.heading4, h5: t().toolbar.heading5,
} as Record<string, string>)[key] ?? key;
}
let stylesOpen = $state(false);
// $derived re-evaluates whenever `tick` changes (i.e. on every TipTap transaction)
let isH1 = $derived(tick >= 0 && !!editor?.isActive('heading', { level: 1 }));
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);
let isHeading = $derived(tick >= 0 && !!editor?.isActive('heading'));
// The style whose formatting the cursor's block currently carries.
let currentStyle = $derived.by<string>(() => {
if (tick < 0 || !editor) return 'default';
if (editor.isActive('heading')) return `h${editor.getAttributes('heading').level}`;
const { fontSize, indent } = editor.getAttributes('paragraph');
const sized = PARAGRAPH_STYLES.find((s) => s.fontSize && s.fontSize === fontSize);
if (sized) return sized.key;
if ((indent ?? 0) >= 1.25 && editor.isActive('italic')) return 'quote';
return 'default';
});
// Applies to the whole block (Word applies a style without a selection); the
// caret is restored afterwards.
function applyStyle(s: ParagraphStyle) {
stylesOpen = false;
if (!editor) return;
// The header/footer schema has no heading node.
if (s.level && !editor.schema.nodes.heading) return;
const { from, to } = editor.state.selection;
const start = editor.state.selection.$from.start();
const end = editor.state.selection.$to.end();
const type = s.level ? 'heading' : 'paragraph';
const chain = editor.chain().focus().setTextSelection({ from: start, to: end })
// A style replaces conflicting direct formatting, as in Word/LibreOffice.
.unsetBold().unsetItalic().unsetFontSize().unsetFontWeight().removeEmptyTextStyle()
.setNode(type, s.level ? { level: s.level } : {})
.setBlockFontSize(s.fontSize ?? null)
.setTextAlign(s.align ?? 'left')
.updateAttributes(type, { indent: s.indent ?? 0 });
if (s.bold) chain.setBold();
if (s.italic) chain.setItalic();
// On an empty block the marks are stored marks — restoring the selection would drop them.
if (start !== end) chain.setTextSelection({ from, to });
chain.run();
}
// 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));
@@ -140,27 +209,37 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
class:active={isH1}
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title={t().toolbar.heading1}
>
H1
</button>
<button
class:active={isH2}
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title={t().toolbar.heading2}
>
H2
</button>
<button
class:active={isH3}
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title={t().toolbar.heading3}
>
H3
</button>
<div class="ol-picker" use:menuClickOutside={() => (stylesOpen = false)}>
<button
class="style-button"
onclick={() => (stylesOpen = !stylesOpen)}
title={t().toolbar.styles.title}
aria-haspopup="menu"
aria-expanded={stylesOpen}
>
<span class="style-current">{styleLabel(currentStyle)}</span>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{#if stylesOpen}
<div class="ol-dropdown" role="menu">
<div class="ol-section-label">{t().toolbar.styles.title}</div>
{#each PARAGRAPH_STYLES as s}
<button
class="ol-option style-option"
class:active={currentStyle === s.key}
onclick={() => applyStyle(s)}
role="menuitemradio"
aria-checked={currentStyle === s.key}
style="font-size: {s.preview}; font-weight: {s.bold || s.level ? 600 : 400}; font-style: {s.italic ? 'italic' : 'normal'}"
>
{styleLabel(s.key)}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="toolbar-separator"></div>
@@ -356,6 +435,29 @@
border-color: var(--color-primary);
}
/* Style gallery: a combobox-style button showing the block's current style. */
.style-button {
justify-content: space-between;
gap: 0.4rem;
min-width: 8.5rem;
padding: 0 0.45rem;
border: 1px solid var(--color-border);
}
.style-button:hover:not(:disabled) {
border-color: var(--color-primary);
}
.style-current {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.style-option {
line-height: 1.2;
}
/* Ordered-list split button: main toggle + chevron that opens the numbering
style menu, rendered as one joined control. */
.ol-picker {
+29 -10
View File
@@ -18,6 +18,7 @@
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, PAGE_FORMAT_CM, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
import { HEADING_STYLE_OVERRIDES } from '../export/odt';
import { listContext } from '../editor/extensions/indent';
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../editor/extensions/columns';
import { t } from '../i18n/i18n.svelte';
@@ -128,18 +129,29 @@
return mixed ? '' : (font ?? DEFAULT_EDITOR_FONT);
});
// Must match the common document default and editor.css heading sizes (in pt)
// Must match the common document default; heading sizes come from the export overrides
// (the single source editor.css and both importers agree with).
const DEFAULT_FONT_SIZE = '12pt';
const HEADING_SIZES: Record<number, string> = { 1: '20pt', 2: '16pt', 3: '14pt' };
const HEADING_SIZES: Record<number, string> =
Object.fromEntries(HEADING_STYLE_OVERRIDES.map((h, i) => [i + 1, h.fontSize]));
const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48, 72];
function effectiveSize(node: { isText: boolean; marks: readonly { type: { name: string }; attrs: Record<string, string> }[] }, parent: { type: { name: string }; attrs: Record<string, number> } | null): string {
const explicit = node.marks.find(m => m.type.name === 'textStyle')?.attrs.fontSize;
if (explicit) return explicit;
if (parent?.type.name === 'heading') return HEADING_SIZES[parent.attrs.level] ?? DEFAULT_FONT_SIZE;
type SizedBlock = { type: { name: string }; attrs: Record<string, unknown> } | null;
// What a block renders its unmarked text at: its paragraph-mark size (blockFontSize,
// set by the style gallery and by imported sized lines), else the heading/body default.
function blockSize(parent: SizedBlock): string {
const block = parent?.attrs?.fontSize;
if (typeof block === 'string' && block) return block;
if (parent?.type.name === 'heading') return HEADING_SIZES[Number(parent.attrs.level)] ?? DEFAULT_FONT_SIZE;
return DEFAULT_FONT_SIZE;
}
function effectiveSize(node: { isText: boolean; marks: readonly { type: { name: string }; attrs: Record<string, string> }[] }, parent: SizedBlock): string {
const explicit = node.marks.find(m => m.type.name === 'textStyle')?.attrs.fontSize;
return explicit || blockSize(parent);
}
let currentFontSize = $derived.by(() => {
if (tick < 0 || !editor) return '';
const { from, to, empty } = editor.state.selection;
@@ -148,9 +160,7 @@
const marks = editor.state.storedMarks ?? head.marks();
const explicit = marks.find(m => m.type.name === 'textStyle')?.attrs.fontSize;
if (explicit) return explicit;
const parent = head.parent;
if (parent.type.name === 'heading') return HEADING_SIZES[parent.attrs.level] ?? DEFAULT_FONT_SIZE;
return DEFAULT_FONT_SIZE;
return blockSize(head.parent as SizedBlock);
}
let size: string | undefined;
let mixed = false;
@@ -364,7 +374,16 @@
const to = savedTo ?? editor.state.selection.to;
savedFrom = null;
savedTo = null;
editor.chain().focus().setTextSelection({ from, to }).setFontSize(`${size}pt`).run();
const chain = editor.chain().focus().setTextSelection({ from, to }).setFontSize(`${size}pt`);
// An empty line or a fully covered block also gets its paragraph-mark size, so what
// the block renders can't diverge from the shown size (and empty lines keep it).
const rFrom = editor.state.doc.resolve(from);
const rTo = editor.state.doc.resolve(to);
const wholeBlock = from === to
? rFrom.parent.content.size === 0
: rFrom.parentOffset === 0 && rTo.parentOffset === rTo.parent.content.size;
if (wholeBlock) chain.setBlockFontSize(`${size}pt`);
chain.run();
}
function onSizeInputFocus(e: FocusEvent) {
+1 -1
View File
@@ -98,7 +98,7 @@ export const extensions = [
BlockFontSize,
PageBreak,
Indent,
Heading.configure({ levels: [1, 2, 3] }),
Heading.configure({ levels: [1, 2, 3, 4, 5] }),
BulletList,
OrderedList,
ListItem,
+4 -3
View File
@@ -1,8 +1,9 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { MAX_HEADING_LEVEL } from '../../export/odt';
// A generated table of contents: a block atom listing every heading (levels 1–3) with its
// A generated table of contents: a block atom listing every heading (levels 1–5) with its
// live page number. The node view regenerates entries from the headings + pagination and
// caches them in `entries` (persisted like a Word/LO TOC field); round-trips to ODF/DOCX.
@@ -104,13 +105,13 @@ class TocView {
});
}
// Every heading (level ≤ 3) with non-empty text, in document order.
// Every heading (level ≤ MAX_HEADING_LEVEL) with non-empty text, in document order.
private headings(): HeadingRef[] {
const out: HeadingRef[] = [];
this.editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
const text = node.textContent.trim();
if (text) out.push({ text, level: Math.min(3, (node.attrs.level as number) ?? 1), pos });
if (text) out.push({ text, level: Math.min(MAX_HEADING_LEVEL, (node.attrs.level as number) ?? 1), pos });
}
});
return out;
+7 -4
View File
@@ -22,7 +22,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, HEADING_STYLE_OVERRIDES, mergeJoinedParagraphsJson, type HfExport } from './odt';
import { normalizeColor, HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, type HfExport } from './odt';
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
// BCP-47 tag for rendering a fixed field's cached text; set at buildDocx start from
@@ -407,7 +407,7 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st
const attrs = node.attrs ?? {};
const pPr: string[] = [];
if (node.type === 'heading') {
const lvl = Math.min(3, Math.max(1, Number(attrs.level) || 1));
const lvl = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(attrs.level) || 1));
pPr.push(`<w:pStyle w:val="Heading${lvl}"/>`);
}
if (indentTwip) pPr.push(`<w:ind w:left="${indentTwip}"/>`);
@@ -575,6 +575,7 @@ function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined {
const HEADING_LEVEL: Record<number, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4, 5: HeadingLevel.HEADING_5,
};
type ParaOpts = { numbering?: { reference: string; level: number }; indentLeftTwip?: number; forceBold?: boolean };
@@ -601,7 +602,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
if (typeof attrs.indent === 'number' && attrs.indent > 0) indent.left = cmToTwip(attrs.indent);
else if (opts.indentLeftTwip) indent.left = opts.indentLeftTwip;
}
const heading = node.type === 'heading' ? HEADING_LEVEL[Math.min(3, Math.max(1, Number(attrs.level) || 1))] : undefined;
const heading = node.type === 'heading' ? HEADING_LEVEL[Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(attrs.level) || 1))] : undefined;
// Paragraph-mark run props carry an empty line's font size (see import/docx.ts).
const markSize = typeof attrs.fontSize === 'string' ? fontSizeToHalfPoints(attrs.fontSize) : undefined;
return new Paragraph({
@@ -768,7 +769,7 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
// + link it on field update (features.updateFields does this on open). Title is a
// plain bold paragraph so it isn't itself listed; our importer regenerates the node.
out.push(new Paragraph({ children: [new TextRun({ text: 'Table of Contents', bold: true, size: 32 })], spacing: { after: cmToTwip(0.3) } }));
out.push(new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: '1-3' }));
out.push(new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: `1-${MAX_HEADING_LEVEL}` }));
}
}
return out;
@@ -841,6 +842,8 @@ function buildStyles(language?: { language: string; country: string } | null) {
heading1: headingStyle(HEADING_STYLE_OVERRIDES[0]),
heading2: headingStyle(HEADING_STYLE_OVERRIDES[1]),
heading3: headingStyle(HEADING_STYLE_OVERRIDES[2]),
heading4: headingStyle(HEADING_STYLE_OVERRIDES[3]),
heading5: headingStyle(HEADING_STYLE_OVERRIDES[4]),
},
};
}
+10 -6
View File
@@ -128,8 +128,14 @@ export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTo
{ name: 'Heading_20_1', fontSize: '20pt', marginTop: '1.058cm', marginBottom: '0.353cm' },
{ name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.847cm', marginBottom: '0.282cm' },
{ name: 'Heading_20_3', fontSize: '14pt', marginTop: '0.741cm', marginBottom: '0.247cm' },
{ name: 'Heading_20_4', fontSize: '12pt', marginTop: '0.635cm', marginBottom: '0.212cm' },
{ name: 'Heading_20_5', fontSize: '11pt', marginTop: '0.582cm', marginBottom: '0.194cm' },
];
// Highest heading level the editor offers (extensions.ts, both importers, TOC).
export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length;
export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1);
function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (!attrs) return false;
if (attrs.lineHeight) return true;
@@ -464,7 +470,7 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode
.filter(e => e && typeof e.text === 'string')
.map(e => ({
text: String(e.text),
level: Math.min(3, Math.max(1, Number(e.level) || 1)),
level: Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(e.level) || 1)),
page: Math.max(1, Number(e.page) || 1),
}));
tocs.push({ entries });
@@ -2129,9 +2135,9 @@ function tocXml(toc: TocExport, index: number): string {
const title = 'Table of Contents';
const name = `${title}${index + 1}`;
const source =
`<text:table-of-content-source text:outline-level="3" text:use-index-marks="false" text:use-index-source-styles="false">` +
`<text:table-of-content-source text:outline-level="${MAX_HEADING_LEVEL}" text:use-index-marks="false" text:use-index-source-styles="false">` +
`<text:index-title-template text:style-name="Contents_20_Heading">${escapeXml(title)}</text:index-title-template>` +
[1, 2, 3]
HEADING_LEVELS
.map(
l =>
`<text:table-of-content-entry-template text:outline-level="${l}" text:style-name="Contents_20_${l}">` +
@@ -2180,9 +2186,7 @@ function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: numbe
const tabPosCm = Math.max(1, Math.round(contentWidthCm * 1000) / 1000);
const styles =
contentsHeadingStyle() +
contentsEntryStyle('Contents_20_1', 1, tabPosCm) +
contentsEntryStyle('Contents_20_2', 2, tabPosCm) +
contentsEntryStyle('Contents_20_3', 3, tabPosCm);
HEADING_LEVELS.map(l => contentsEntryStyle(`Contents_20_${l}`, l, tabPosCm)).join('');
content = injectAutomaticStyles(content, styles);
files['content.xml'] = strToU8(content);
+2 -2
View File
@@ -189,7 +189,7 @@ function materializeListMarkers(root: HTMLElement): void {
const glyph = listMarkerGlyph(li, root);
if (!glyph) continue;
li.style.listStyleType = 'none';
const target = li.querySelector(':scope > p, :scope > h1, :scope > h2, :scope > h3') ?? li;
const target = li.querySelector(':scope > p, :scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5') ?? li;
const slot = document.createElement('span');
slot.style.cssText = 'display:inline-block;width:0;overflow:visible;vertical-align:baseline';
const label = document.createElement('span');
@@ -402,7 +402,7 @@ function buildBodyHtml(json: Json): string {
});
// generateHTML omits ProseMirror's trailing <br>, so empty textblocks collapse to zero
// height and blank lines vanish in print. Re-add a break so each keeps a line.
host.querySelectorAll('p, h1, h2, h3').forEach((el) => {
host.querySelectorAll('p, h1, h2, h3, h4, h5').forEach((el) => {
if (!el.firstChild) el.appendChild(document.createElement('br'));
});
return host.innerHTML;
+9
View File
@@ -41,6 +41,15 @@ const de: Messages = {
heading1: 'Überschrift 1',
heading2: 'Überschrift 2',
heading3: 'Überschrift 3',
heading4: 'Überschrift 4',
heading5: 'Überschrift 5',
styles: {
title: 'Formatvorlagen',
default: 'Standard',
docTitle: 'Titel',
subtitle: 'Untertitel',
quote: 'Zitat',
},
bulletList: 'Aufzählung',
bulletSymbol: 'Aufzählungszeichen',
bulletDefault: 'Standard (nach Ebene)',
+10
View File
@@ -43,6 +43,16 @@ const en = {
heading1: 'Heading 1',
heading2: 'Heading 2',
heading3: 'Heading 3',
heading4: 'Heading 4',
heading5: 'Heading 5',
// Style gallery (Word/LibreOffice "Styles"): presets of direct formatting.
styles: {
title: 'Styles',
default: 'Default text',
docTitle: 'Title',
subtitle: 'Subtitle',
quote: 'Quote',
},
bulletList: 'Bullet list',
bulletSymbol: 'Bullet symbol',
bulletDefault: 'Default (by level)',
+2 -2
View File
@@ -1,7 +1,7 @@
import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
@@ -530,7 +530,7 @@ function emptyLineFontSize(ppr: Element | null, ctx: Ctx, baseRun: RunProps, lev
// files / English Word), then the style's resolved outline level (locale-independent,
// catches LibreOffice/localized heading styles), then a direct paragraph outline level.
function headingLevelOf(ppr: Element | null, ctx: Ctx): number | null {
const clamp = (n: number) => Math.min(3, Math.max(1, n));
const clamp = (n: number) => Math.min(MAX_HEADING_LEVEL, Math.max(1, n));
if (!ppr) return null;
const ps = fc(ppr, 'pStyle');
const id = ps ? wVal(ps) : null;
+3 -3
View File
@@ -1,6 +1,6 @@
import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
@@ -588,7 +588,7 @@ function convertToc(el: Element): Node {
if (p.namespaceURI !== NS.text || p.localName !== 'p') continue; // skip index-title
const style = p.getAttributeNS(NS.text, 'style-name') ?? '';
const m = /Contents_20_(\d+)/.exec(style);
const level = m ? Math.min(3, Math.max(1, parseInt(m[1], 10))) : 1;
const level = m ? Math.min(MAX_HEADING_LEVEL, Math.max(1, parseInt(m[1], 10))) : 1;
const { text, page } = tocEntryTextAndPage(p);
if (text) entries.push({ text, level, page: Math.max(1, parseInt(page, 10) || 1) });
}
@@ -625,7 +625,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
let level = 1;
if (isHeading) {
const raw = parseInt(el.getAttributeNS(NS.text, 'outline-level') ?? '1', 10);
level = Math.min(3, Math.max(1, Number.isFinite(raw) ? raw : 1));
level = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number.isFinite(raw) ? raw : 1));
}
const attrs = blockAttrs(paraProps, baseTextProps, isHeading ? level : null, kind);
+26 -4
View File
@@ -204,6 +204,8 @@
}
.paper .tiptap .toc-level-2 { padding-left: 0.6cm; }
.paper .tiptap .toc-level-3 { padding-left: 1.2cm; }
.paper .tiptap .toc-level-4 { padding-left: 1.8cm; }
.paper .tiptap .toc-level-5 { padding-left: 2.4cm; }
.paper .tiptap .toc-text {
white-space: nowrap;
overflow: hidden;
@@ -379,7 +381,9 @@
.paper .tiptap h1,
.paper .tiptap h2,
.paper .tiptap h3 {
.paper .tiptap h3,
.paper .tiptap h4,
.paper .tiptap h5 {
font-weight: 700;
line-height: 1.15;
margin-top: 1.5em;
@@ -399,6 +403,14 @@
font-size: 14pt;
}
.paper .tiptap h4 {
font-size: 12pt;
}
.paper .tiptap h5 {
font-size: 11pt;
}
.paper .tiptap ul,
.paper .tiptap ol {
padding-left: 1.5rem;
@@ -579,7 +591,9 @@
.paper .tiptap p::after,
.paper .tiptap h1::after,
.paper .tiptap h2::after,
.paper .tiptap h3::after {
.paper .tiptap h3::after,
.paper .tiptap h4::after,
.paper .tiptap h5::after {
content: '\00A0¶';
display: inline-block;
width: 0;
@@ -598,7 +612,9 @@
.paper.show-formatting-marks .tiptap p::after,
.paper.show-formatting-marks .tiptap h1::after,
.paper.show-formatting-marks .tiptap h2::after,
.paper.show-formatting-marks .tiptap h3::after {
.paper.show-formatting-marks .tiptap h3::after,
.paper.show-formatting-marks .tiptap h4::after,
.paper.show-formatting-marks .tiptap h5::after {
color: var(--color-primary);
}
@@ -609,6 +625,8 @@
.paper .tiptap h1:has(> br.ProseMirror-trailingBreak:only-child),
.paper .tiptap h2:has(> br.ProseMirror-trailingBreak:only-child),
.paper .tiptap h3:has(> br.ProseMirror-trailingBreak:only-child),
.paper .tiptap h4:has(> br.ProseMirror-trailingBreak:only-child),
.paper .tiptap h5:has(> br.ProseMirror-trailingBreak:only-child),
.paper .tiptap li > p:has(> br.ProseMirror-trailingBreak:only-child) {
position: relative;
}
@@ -617,6 +635,8 @@
.paper .tiptap h1:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper .tiptap h2:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper .tiptap h3:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper .tiptap h4:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper .tiptap h5:has(> br.ProseMirror-trailingBreak:only-child)::after,
.paper .tiptap li > p:has(> br.ProseMirror-trailingBreak:only-child)::after {
position: absolute;
top: 0;
@@ -722,7 +742,9 @@
.paper.show-formatting-marks .tiptap p::after,
.paper.show-formatting-marks .tiptap h1::after,
.paper.show-formatting-marks .tiptap h2::after,
.paper.show-formatting-marks .tiptap h3::after {
.paper.show-formatting-marks .tiptap h3::after,
.paper.show-formatting-marks .tiptap h4::after,
.paper.show-formatting-marks .tiptap h5::after {
color: transparent;
}
}
+9
View File
@@ -60,6 +60,8 @@ describe('DOCX export → import round trip', () => {
{ type: 'tableRow', attrs: { rowHeight: 40 }, content: [cell('Widget', { backgroundColor: '#FFFF00', rowspan: 2 }), cell('1', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })] },
{ type: 'tableRow', content: [cell('2')] },
] },
heading(4, 'Fourth'),
heading(5, 'Fifth'),
],
} as any;
@@ -159,6 +161,13 @@ describe('DOCX export → import round trip', () => {
expect(p.attrs.indent).toBe(1);
});
it('round-trips heading levels 4 and 5', () => {
const heads = walk(doc, 'heading').filter((h) => h.attrs.level >= 4);
expect(heads.map((h) => h.attrs.level)).toEqual([4, 5]);
// Their style sizes are the level defaults, so no explicit size mark survives.
expect(heads.every((h) => !(h.content![0].marks ?? []).length)).toBe(true);
});
it('round-trips an empty line\'s font size (paragraph-mark size)', () => {
const empty = doc.content!.find((n) => n.type === 'paragraph' && !n.content && n.attrs?.fontSize);
expect(empty).toBeTruthy();
+28 -1
View File
@@ -1212,7 +1212,7 @@ describe('Leg 5: table of contents (text:table-of-content)', () => {
const bytes = await buildOdt(tocDoc, margins, 'portrait');
const content = strFromU8(unzipSync(bytes)['content.xml']);
check('emits <text:table-of-content>', content.includes('<text:table-of-content '), content.slice(0, 200));
check('source spans outline-level 3', content.includes('text:outline-level="3"'));
check('source spans all heading levels', content.includes('<text:table-of-content-source text:outline-level="5"'));
check('mints Contents_20_1 style', content.includes('style:name="Contents_20_1"'));
check('cached entry carries a tab + page', /Contents_20_1">Introduction<text:tab\/>1<\/text:p>/.test(content));
check('ampersand in entry text is escaped', content.includes('Background &amp; Aims'));
@@ -1278,3 +1278,30 @@ describe('Leg 10: date/time fields (text:date / text:time)', () => {
(res.content.content ?? [])[0]?.content?.[0]?.text === 'Signed on ');
});
});
describe('Leg 11: heading levels 4 and 5', () => {
const doc: N = {
type: 'doc',
content: [H({ level: 4 }, T('Fourth')), H({ level: 5 }, T('Fifth'))],
};
it('exports Heading_20_4/5 at the editor sizes and re-imports the levels', async () => {
const bytes = await buildOdt(doc, margins, 'portrait');
const files = unzipSync(bytes);
const content = strFromU8(files['content.xml']);
const styles = strFromU8(files['styles.xml']);
check('h4 outline level', content.includes('text:outline-level="4"'), content.slice(0, 400));
check('h5 outline level', content.includes('text:outline-level="5"'));
check('Heading_20_4 sized 12pt',
/style:name="Heading_20_4"[\s\S]*?fo:font-size="12pt"/.test(styles));
check('Heading_20_5 sized 11pt',
/style:name="Heading_20_5"[\s\S]*?fo:font-size="11pt"/.test(styles));
const blocks = importOdt(bytes).content.content ?? [];
check('h4 round-trips', blocks[0]?.type === 'heading' && blocks[0]?.attrs?.level === 4, blocks[0]);
check('h5 round-trips', blocks[1]?.type === 'heading' && blocks[1]?.attrs?.level === 5, blocks[1]);
// Sizes equal to the level defaults must not land as explicit fontSize marks.
check('no explicit size mark on h4', !(blocks[0]?.content?.[0]?.marks ?? []).length, blocks[0]?.content);
check('no explicit size mark on h5', !(blocks[1]?.content?.[0]?.marks ?? []).length, blocks[1]?.content);
});
});