diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte index 3beca90..7d10445 100644 --- a/src/lib/components/Toolbar.svelte +++ b/src/lib/components/Toolbar.svelte @@ -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)[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(() => { + 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 @@
- - - +
(stylesOpen = false)}> + + {#if stylesOpen} + + {/if} +
@@ -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 { diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index 0fe9d90..c39e4cc 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -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 = { 1: '20pt', 2: '16pt', 3: '14pt' }; + const HEADING_SIZES: Record = + 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 }[] }, parent: { type: { name: string }; attrs: Record } | 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 } | 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 }[] }, 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) { diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 034cb09..4d4f430 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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, diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index a527a33..b1d9ca1 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -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; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index fa40add..004296c 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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(``); } if (indentTwip) pPr.push(``); @@ -575,6 +575,7 @@ function spacingOf(attrs: TiptapNode['attrs']): ISpacingProperties | undefined { const HEADING_LEVEL: Record = { 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]), }, }; } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 4f43628..c766480 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 = - `` + + `` + `${escapeXml(title)}` + - [1, 2, 3] + HEADING_LEVELS .map( 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); diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index eea53ce..476a731 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -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
, 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; diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 0759e23..9cbdb6d 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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)', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index f9fd8f1..c8ae83d 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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)', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 667d57d..e9241d8 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 8c375bb..9e7973d 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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); diff --git a/src/styles/editor.css b/src/styles/editor.css index 3219d19..4499a5a 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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; } } diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 0f0ba33..88b553a 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -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(); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 34f744b..334d515 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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 ', content.includes('Introduction1<\/text:p>/.test(content)); check('ampersand in entry text is escaped', content.includes('Background & 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); + }); +});