feat: named paragraph styles with inheritance

This commit is contained in:
Steffen Becker committed 2026-07-28 23:53:00 +02:00
1 parent d461ce09a6
commit ac9d6cdcff
11 files changed
+428 -135

No files matched your search

+24
View File
@@ -23,6 +23,8 @@
import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { styleCss } from '../styles/styleSheet';
import { styleSheet } from '../styles/sheet.svelte';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
import '../../styles/editor.css';
@@ -125,6 +127,28 @@
});
});
// The document stylesheet: the named paragraph styles rendered as CSS rules in one
// element that is rewritten whenever a style changes. Appended at runtime, so it also
// wins over editor.css on equal specificity.
let styleEl: HTMLStyleElement | null = null;
let styleRecalcRaf = 0;
$effect(() => {
const css = styleCss(styleSheet());
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'document-styles';
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
const ed = editor;
if (!ed) return;
// Type sizes/margins changed under the layout, so page positions must be re-measured.
cancelAnimationFrame(styleRecalcRaf);
styleRecalcRaf = requestAnimationFrame(() => {
ed.view.dispatch(ed.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true));
});
});
// Bundled fonts (Liberation Serif, Carlito, …) load with font-display:swap, so text
// can reflow after pagination first runs (e.g. imported content in a not-yet-loaded
// font). Re-paginate when fonts finish so page positions and the HF band settle.
+35 -69
View File
@@ -6,7 +6,9 @@
import { BULLET_TYPES } from '../utils/bulletListTypes';
import { effectiveOrderedTypeAt } from '../editor/extensions/orderedList';
import { isInHeaderCell } from '../editor/extensions/tableHeaderRow';
import { HEADING_FONT } from '../export/odt';
import { blockStyleName } from '../editor/extensions/paragraphStyle';
import { DEFAULT_STYLE, resolveStyle, styleOrder } from '../styles/styleSheet';
import { styleSheet } from '../styles/sheet.svelte';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
@@ -17,42 +19,26 @@
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)
fontFamily?: string;
align?: 'center';
bold?: boolean;
italic?: boolean;
indent?: number; // cm
spaceAfter?: number; // pt
preview: string; // menu-entry font size
};
// The document's named paragraph styles (LibreOffice model): the gallery lists the
// registry, assigning one only sets the style — hard formatting stays, as in Word/LO.
let sheet = $derived(styleSheet());
let galleryStyles = $derived(styleOrder(sheet));
// LibreOffice's values: Title/Subtitle sans off the Heading style, Quotations
// indented with space below (LO also indents 1cm on the right — we have no such attr).
const PARAGRAPH_STYLES: ParagraphStyle[] = [
{ key: 'default', preview: '0.85rem' },
{ key: 'title', fontSize: '28pt', fontFamily: HEADING_FONT, align: 'center', bold: true, preview: '1.35rem' },
{ key: 'subtitle', fontSize: '18pt', fontFamily: HEADING_FONT, align: 'center', 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', indent: 1, spaceAfter: 14, preview: '0.85rem' },
];
function styleLabel(key: string): string {
// Built-in names are translated; user styles show their own name.
function styleLabel(name: 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;
Standard: s.default, Title: s.docTitle, Subtitle: s.subtitle, Quotations: s.quote,
'Heading 1': t().toolbar.heading1, 'Heading 2': t().toolbar.heading2,
'Heading 3': t().toolbar.heading3, 'Heading 4': t().toolbar.heading4,
'Heading 5': t().toolbar.heading5,
} as Record<string, string>)[name] ?? name;
}
// Menu entries preview their own style, clamped so the list stays compact.
function previewSize(name: string): string {
const pt = resolveStyle(sheet, name).text.fontSizePt ?? 12;
return `${Math.min(1.35, Math.max(0.85, pt / 20))}rem`;
}
let stylesOpen = $state(false);
@@ -60,42 +46,21 @@
// $derived re-evaluates whenever `tick` changes (i.e. on every TipTap transaction)
let isHeading = $derived(tick >= 0 && !!editor?.isActive('heading'));
// The style whose formatting the cursor's block currently carries.
// The style assigned to the cursor's block (headings without one fall back to their level).
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) return 'quote';
return 'default';
if (tick < 0 || !editor) return DEFAULT_STYLE;
return blockStyleName(editor.state.selection.$from.parent as never);
});
// Applies to the whole block (Word applies a style without a selection); the
// caret is restored afterwards.
function applyStyle(s: ParagraphStyle) {
function applyStyle(name: string) {
stylesOpen = false;
if (!editor) return;
const level = sheet.paragraph[name]?.outlineLevel;
// 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().unsetFontFamily().removeEmptyTextStyle()
.setNode(type, s.level ? { level: s.level } : {})
.setBlockFontSize(s.fontSize ?? null)
.setTextAlign(s.align ?? 'left')
.updateAttributes(type, { indent: s.indent ?? 0, spaceAfter: s.spaceAfter ?? null });
if (s.fontFamily) chain.setFontFamily(s.fontFamily);
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();
if (level && !editor.schema.nodes.heading) return;
editor.commands.setParagraphStyle(name);
}
// 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));
@@ -231,16 +196,17 @@
{#if stylesOpen}
<div class="ol-dropdown" role="menu">
<div class="ol-section-label">{t().toolbar.styles.title}</div>
{#each PARAGRAPH_STYLES as s}
{#each galleryStyles as s}
{@const preview = resolveStyle(sheet, s.name)}
<button
class="ol-option style-option"
class:active={currentStyle === s.key}
onclick={() => applyStyle(s)}
class:active={currentStyle === s.name}
onclick={() => applyStyle(s.name)}
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'}"
aria-checked={currentStyle === s.name}
style="font-size: {previewSize(s.name)}; font-weight: {preview.text.bold ? 600 : 400}; font-style: {preview.text.italic ? 'italic' : 'normal'}"
>
{styleLabel(s.key)}
{styleLabel(s.name)}
</button>
{/each}
</div>
+2 -11
View File
@@ -1565,17 +1565,8 @@
<div class="toolbar-group">
<button
onclick={() =>
editor?.chain().focus()
.unsetAllMarks()
.clearNodes()
.unsetLineHeight()
.unsetTextAlign()
.setSpaceBefore(null)
.setSpaceAfter(null)
.unsetIndent()
.run()}
title={t().toolbarExpanded.clearFormatting}
onclick={() => editor?.commands.clearDirectFormatting()}
title={`${t().toolbarExpanded.clearFormatting} (${withShortcut('Ctrl+M')})`}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<!-- bold + italic A -->
+5
View File
@@ -27,6 +27,7 @@ import { LineHeight } from './extensions/lineHeight';
import { ParagraphSpacing } from './extensions/paragraphSpacing';
import { ParagraphBox } from './extensions/paragraphBox';
import { BlockFontSize } from './extensions/blockFontSize';
import { ParagraphStyle } from './extensions/paragraphStyle';
import { PageBreak } from './extensions/pageBreak';
import { Indent } from './extensions/indent';
import { FormattingMarks } from './extensions/formattingMarks';
@@ -46,6 +47,7 @@ import { TextBox } from './extensions/textBox';
import { Columns } from './extensions/columns';
import { ColumnsFlow } from './extensions/columnsFlow';
import { TableOfContents } from './extensions/tableOfContents';
import { styleSheet } from '../styles/sheet.svelte';
export const extensions = [
// textBox and columns have their own groups so only the document (not
@@ -96,6 +98,9 @@ export const extensions = [
ParagraphBox,
// Paragraph-mark font size: sizes empty lines (and text typed into them).
BlockFontSize,
// Named paragraph style (LibreOffice style:style / Word w:pStyle); the document
// stylesheet in Editor.svelte renders it, direct formatting still wins.
ParagraphStyle.configure({ sheet: styleSheet }),
PageBreak,
Indent,
Heading.configure({ levels: [1, 2, 3, 4, 5] }),
@@ -0,0 +1,88 @@
import { Extension } from '@tiptap/core';
import { headingStyleName, type StyleSheet } from '../../styles/styleSheet';
// The named paragraph style a block uses (LibreOffice's style:style / Word's w:pStyle).
// Rendered as data-style so the generated document stylesheet (styleCss) can target it;
// direct formatting (attrs/marks) still overrides it, as in Word/LibreOffice.
// Block attrs the styles govern — reset by clearDirectFormatting, kept by setParagraphStyle.
const DIRECT_ATTRS = [
'textAlign', 'lineHeight', 'spaceBefore', 'spaceAfter', 'indent', 'fontSize',
'backgroundColor', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft',
];
declare module '@tiptap/core' {
interface Commands<ReturnType> {
paragraphStyle: {
setParagraphStyle: (name: string) => ReturnType;
clearDirectFormatting: () => ReturnType;
};
}
}
export const ParagraphStyle = Extension.create<{ types: string[]; sheet: () => StyleSheet }>({
name: 'paragraphStyle',
addOptions() {
return { types: ['paragraph', 'heading'], sheet: () => ({ paragraph: {} }) };
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
styleName: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-style') || null,
renderHTML: (attributes: Record<string, unknown>) =>
attributes.styleName ? { 'data-style': String(attributes.styleName) } : {},
},
},
},
];
},
addCommands() {
return {
// A heading style also switches the node type (its outline level); any other
// style turns a heading back into a paragraph.
setParagraphStyle: (name: string) => ({ editor, chain }) => {
const style = this.options.sheet().paragraph[name];
const level = style?.outlineLevel;
const run = chain().focus();
if (level && editor.schema.nodes.heading) run.setNode('heading', { level });
else if (!level) run.setNode('paragraph');
return run.updateAttributes(level ? 'heading' : 'paragraph', { styleName: name }).run();
},
// Word/LibreOffice Ctrl+M: drop hard formatting, keep the style assignment.
// A bare cursor clears the whole block (LibreOffice) — marks need a range —
// and the caret is restored afterwards. Links survive: they carry the URL.
clearDirectFormatting: () => ({ editor, state, chain }) => {
const { from, to } = state.selection;
const start = state.selection.$from.start();
const end = state.selection.$to.end();
const run = chain().focus().setTextSelection({ from: start, to: end });
for (const mark of Object.keys(editor.schema.marks)) {
if (mark !== 'link') run.unsetMark(mark);
}
for (const type of this.options.types) run.resetAttributes(type, DIRECT_ATTRS);
if (start !== end) run.setTextSelection({ from, to });
return run.run();
},
};
},
addKeyboardShortcuts() {
return { 'Mod-m': () => this.editor.commands.clearDirectFormatting() };
},
});
// The style a block renders with: its own, else the heading level's, else Standard.
export function blockStyleName(node: { type: { name: string }; attrs?: Record<string, unknown> } | null): string {
const own = node?.attrs?.styleName;
if (typeof own === 'string' && own) return own;
if (node?.type.name === 'heading') return headingStyleName(Number(node.attrs?.level) || 1);
return 'Standard';
}
+36
View File
@@ -0,0 +1,36 @@
// The document's style sheet as a reactive singleton (same shape as i18n.svelte.ts):
// components read styleSheet() in a template or $derived and re-render when it is
// replaced. Persisted to localStorage like the other document side-cars.
import { builtinStyleSheet, type Style, type StyleSheet } from './styleSheet';
const STORAGE_KEY = 'odf-editor-styles';
function load(): StyleSheet {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return builtinStyleSheet();
try {
const parsed = JSON.parse(raw) as StyleSheet;
if (!parsed?.paragraph || typeof parsed.paragraph !== 'object') return builtinStyleSheet();
// Built-ins are re-seeded so a new release's additions show up; stored entries win.
return { paragraph: { ...builtinStyleSheet().paragraph, ...parsed.paragraph } };
} catch {
return builtinStyleSheet();
}
}
let current = $state<StyleSheet>(load());
export function styleSheet(): StyleSheet {
return current;
}
export function setStyleSheet(next: StyleSheet): void {
current = next;
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
// Replace one style (add or edit) — every block using it re-renders via styleCss.
export function putStyle(style: Style): void {
setStyleSheet({ paragraph: { ...current.paragraph, [style.name]: style } });
}
+155
View File
@@ -0,0 +1,155 @@
// Named paragraph styles with inheritance, modelled on LibreOffice: a style has a
// name, a parent it inherits from, a follow-on style, and two property groups
// (paragraph layout + text). Resolution walks the parent chain, nearer wins; direct
// formatting on the block (attrs/marks) still overrides the result.
export type ParaProps = {
textAlign?: 'left' | 'center' | 'right' | 'justify';
lineHeight?: string;
spaceBefore?: number; // pt
spaceAfter?: number; // pt
indent?: number; // cm
backgroundColor?: string;
borderTop?: string;
borderRight?: string;
borderBottom?: string;
borderLeft?: string;
};
export type TextProps = {
fontFamily?: string;
fontSizePt?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
color?: string;
};
export type Style = {
name: string;
parent: string | null;
// Style applied to the paragraph created by Enter at its end (ODF next-style-name,
// DOCX w:next). Not honored yet — stored so it round-trips.
next: string | null;
// 1–5 makes it a heading style: assigning it switches the node type (ODF outline level).
outlineLevel?: number;
builtin?: boolean;
para: ParaProps;
text: TextProps;
};
export type StyleSheet = { paragraph: Record<string, Style> };
export const DEFAULT_STYLE = 'Standard';
export const HEADING_PARENT = 'Heading';
// LibreOffice's defaults. Heading sizes/margins mirror HEADING_STYLE_OVERRIDES in
// export/odt.ts, which still drives the ODF/DOCX side (tests/unit/style-resolve.test.ts
// asserts the two agree).
const BUILTINS: Style[] = [
{ name: DEFAULT_STYLE, parent: null, next: null, builtin: true,
para: {}, text: { fontFamily: 'Liberation Serif', fontSizePt: 12 } },
{ name: HEADING_PARENT, parent: DEFAULT_STYLE, next: DEFAULT_STYLE, builtin: true,
para: { spaceBefore: 12, spaceAfter: 6 }, text: { fontFamily: 'Arial', bold: true } },
...[18, 16, 14, 13, 12].map((size, i) => ({
name: `Heading ${i + 1}`, parent: HEADING_PARENT, next: DEFAULT_STYLE,
outlineLevel: i + 1, builtin: true,
para: {}, text: { fontSizePt: size },
})),
{ name: 'Title', parent: HEADING_PARENT, next: DEFAULT_STYLE, builtin: true,
para: { textAlign: 'center' }, text: { fontSizePt: 28 } },
{ name: 'Subtitle', parent: HEADING_PARENT, next: DEFAULT_STYLE, builtin: true,
para: { textAlign: 'center' }, text: { fontSizePt: 18 } },
{ name: 'Quotations', parent: DEFAULT_STYLE, next: DEFAULT_STYLE, builtin: true,
para: { indent: 1, spaceAfter: 14 }, text: {} },
];
export function builtinStyleSheet(): StyleSheet {
const paragraph: Record<string, Style> = {};
for (const s of BUILTINS) paragraph[s.name] = structuredClone(s);
return { paragraph };
}
// The gallery/manager order: built-ins as listed above, user styles after them.
export function styleOrder(sheet: StyleSheet): Style[] {
const names = Object.keys(sheet.paragraph);
const builtinOrder = BUILTINS.map(b => b.name).filter(n => names.includes(n) && n !== HEADING_PARENT);
const rest = names.filter(n => !builtinOrder.includes(n) && n !== HEADING_PARENT).sort();
return [...builtinOrder, ...rest].map(n => sheet.paragraph[n]);
}
export function headingStyleName(level: number): string {
return `Heading ${level}`;
}
export type ResolvedStyle = { para: ParaProps; text: TextProps };
// Flattened props of a style: the parent chain applied root-first, so the nearest
// definition wins. Cycles and missing parents end the walk.
export function resolveStyle(sheet: StyleSheet, name: string | null | undefined): ResolvedStyle {
const chain: Style[] = [];
const seen = new Set<string>();
let cur = name && sheet.paragraph[name] ? name : DEFAULT_STYLE;
while (cur && !seen.has(cur)) {
seen.add(cur);
const style = sheet.paragraph[cur];
if (!style) break;
chain.push(style);
cur = style.parent ?? '';
}
const out: ResolvedStyle = { para: {}, text: {} };
for (const style of chain.reverse()) {
Object.assign(out.para, style.para);
Object.assign(out.text, style.text);
}
return out;
}
// The bundled fonts are exposed as CSS variables; anything else renders by name.
function cssFontFamily(name: string): string {
if (name === 'Liberation Serif') return 'var(--font-serif)';
if (name === 'Arial') return 'var(--font-heading)';
return `'${name.replace(/'/g, "\\'")}', var(--font-serif)`;
}
function declarations(r: ResolvedStyle): string[] {
const out: string[] = [];
const { para: p, text: t } = r;
if (t.fontFamily) out.push(`font-family: ${cssFontFamily(t.fontFamily)}`);
if (t.fontSizePt != null) out.push(`font-size: ${t.fontSizePt}pt`);
if (t.bold != null) out.push(`font-weight: ${t.bold ? 700 : 400}`);
if (t.italic != null) out.push(`font-style: ${t.italic ? 'italic' : 'normal'}`);
if (t.underline || t.strike) {
out.push(`text-decoration: ${[t.underline && 'underline', t.strike && 'line-through'].filter(Boolean).join(' ')}`);
}
if (t.color) out.push(`color: ${t.color}`);
if (p.textAlign) out.push(`text-align: ${p.textAlign}`);
if (p.lineHeight) out.push(`line-height: ${p.lineHeight}`);
if (p.spaceBefore != null) out.push(`margin-top: ${p.spaceBefore}pt`);
if (p.spaceAfter != null) out.push(`margin-bottom: ${p.spaceAfter}pt`);
if (p.indent != null) out.push(`margin-left: ${p.indent}cm`);
if (p.backgroundColor) out.push(`background-color: ${p.backgroundColor}`);
for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
const v = p[key];
if (v && v !== 'none') out.push(`border-${side}: ${v}`);
}
return out;
}
// The document stylesheet: one rule per style, keyed by the block's data-style attr,
// plus per-level fallbacks for headings that carry no style name (imported documents).
// Specificity beats editor.css's `.paper .tiptap hN`; inline attrs/marks still win.
export function styleCss(sheet: StyleSheet): string {
const rules: string[] = [];
for (const style of Object.values(sheet.paragraph)) {
const decls = declarations(resolveStyle(sheet, style.name));
if (!decls.length) continue;
const attr = `[data-style="${style.name.replace(/"/g, '\\"')}"]`;
const selectors = [`.paper .tiptap ${attr}`];
if (style.outlineLevel) selectors.push(`.paper .tiptap h${style.outlineLevel}:not([data-style])`);
if (style.name === DEFAULT_STYLE) selectors.push('.paper .tiptap p:not([data-style])');
rules.push(`${selectors.join(',\n')} {\n ${decls.join(';\n ')};\n}`);
}
return rules.join('\n\n');
}
+6 -8
View File
@@ -1,19 +1,17 @@
import { HEADING_STYLE_OVERRIDES } from '../export/odt';
import { resolveStyle } from '../styles/styleSheet';
import { styleSheet } from '../styles/sheet.svelte';
import { blockStyleName } from '../editor/extensions/paragraphStyle';
// The size a block renders its unmarked text at — what the toolbar's size box must show.
// Keep in sync with editor.css (tests/unit/font-size-display.test.ts asserts it).
export const DEFAULT_FONT_SIZE = '12pt';
export const HEADING_SIZES: Record<number, string> =
Object.fromEntries(HEADING_STYLE_OVERRIDES.map((h, i) => [i + 1, h.fontSize]));
export type SizedBlock = { type: { name: string }; attrs?: Record<string, unknown> } | null;
// Paragraph-mark size (blockFontSize attr) wins, else the heading/body default.
// Paragraph-mark size (blockFontSize attr) wins, else the block's named style.
export function blockFontSize(block: SizedBlock): string {
const explicit = block?.attrs?.fontSize;
if (typeof explicit === 'string' && explicit) return explicit;
if (block?.type.name === 'heading') return HEADING_SIZES[Number(block.attrs?.level)] ?? DEFAULT_FONT_SIZE;
return DEFAULT_FONT_SIZE;
const size = resolveStyle(styleSheet(), blockStyleName(block)).text.fontSizePt;
return size != null ? `${size}pt` : DEFAULT_FONT_SIZE;
}
+4 -28
View File
@@ -384,35 +384,11 @@
.paper .tiptap h3,
.paper .tiptap h4,
.paper .tiptap h5 {
/* LibreOffice's Heading style: sans, bold, one margin pair for every level.
Keep the sizes below in sync with HEADING_STYLE_OVERRIDES
(tests/unit/font-size-display.test.ts checks it). */
font-family: var(--font-heading);
font-weight: 700;
/* Neutral base: font, size and margins come from the named paragraph styles
(styles/styleSheet.ts → the #document-styles element Editor.svelte writes). */
font: inherit;
margin: 0;
line-height: 1.15;
margin-top: 0.423cm;
margin-bottom: 0.212cm;
}
.paper .tiptap h1 {
font-size: 18pt;
}
.paper .tiptap h2 {
font-size: 16pt;
}
.paper .tiptap h3 {
font-size: 14pt;
}
.paper .tiptap h4 {
font-size: 13pt;
}
.paper .tiptap h5 {
font-size: 12pt;
}
.paper .tiptap ul,
+13 -19
View File
@@ -1,32 +1,26 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { blockFontSize, DEFAULT_FONT_SIZE, HEADING_SIZES } from '../../src/lib/utils/fontSize';
import { blockFontSize, DEFAULT_FONT_SIZE } from '../../src/lib/utils/fontSize';
import { builtinStyleSheet, resolveStyle } from '../../src/lib/styles/styleSheet';
// The toolbar's size box shows blockFontSize() for text without its own size mark, while
// the page renders what editor.css says — so the two tables must not drift apart.
const css = readFileSync(resolve(process.cwd(), 'src/styles/editor.css'), 'utf8');
function cssFontSize(selector: string): string | null {
const re = new RegExp(`${selector.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\s*\\{[^}]*?font-size:\\s*([^;]+);`);
return css.match(re)?.[1].trim() ?? null;
}
// the page renders the block's named style — the two must not drift apart.
describe('toolbar font size vs. rendered font size', () => {
it('shows the paragraph-mark size, else the block default', () => {
const sheet = builtinStyleSheet();
it('shows the paragraph-mark size, else the block style default', () => {
const para = { type: { name: 'paragraph' }, attrs: {} };
expect(blockFontSize(para)).toBe(DEFAULT_FONT_SIZE);
expect(blockFontSize({ ...para, attrs: { fontSize: '22pt' } })).toBe('22pt');
expect(blockFontSize({ type: { name: 'heading' }, attrs: { level: 5 } })).toBe(HEADING_SIZES[5]);
// An explicit paragraph-mark size beats the heading default (style gallery, imports).
expect(blockFontSize({ type: { name: 'heading' }, attrs: { level: 1, fontSize: '9pt' } })).toBe('9pt');
expect(blockFontSize(null)).toBe(DEFAULT_FONT_SIZE);
});
it('matches the sizes editor.css renders', () => {
expect(cssFontSize('.paper .tiptap')).toBe(DEFAULT_FONT_SIZE);
for (const [level, size] of Object.entries(HEADING_SIZES)) {
expect(cssFontSize(`.paper .tiptap h${level}`), `h${level}`).toBe(size);
it('reads a heading from its style, by name or by level', () => {
for (const level of [1, 2, 3, 4, 5]) {
const expected = `${resolveStyle(sheet, `Heading ${level}`).text.fontSizePt}pt`;
expect(blockFontSize({ type: { name: 'heading' }, attrs: { level } })).toBe(expected);
expect(blockFontSize({ type: { name: 'paragraph' }, attrs: { styleName: `Heading ${level}` } })).toBe(expected);
}
// An explicit paragraph-mark size beats the style (direct formatting wins).
expect(blockFontSize({ type: { name: 'heading' }, attrs: { level: 1, fontSize: '9pt' } })).toBe('9pt');
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import {
builtinStyleSheet, resolveStyle, styleCss, styleOrder, DEFAULT_STYLE, type StyleSheet,
} from '../../src/lib/styles/styleSheet';
import { HEADING_STYLE_OVERRIDES } from '../../src/lib/export/odt';
const ptOf = (len: string) => (len.endsWith('cm') ? (parseFloat(len) / 2.54) * 72 : parseFloat(len));
describe('named paragraph styles', () => {
it('inherits along the parent chain, nearest wins', () => {
const sheet = builtinStyleSheet();
const h1 = resolveStyle(sheet, 'Heading 1');
expect(h1.text.fontSizePt).toBe(18); // own
expect(h1.text.bold).toBe(true); // from Heading
expect(h1.text.fontFamily).toBe('Arial'); // from Heading, shadowing Standard's serif
expect(h1.para.spaceBefore).toBe(12); // from Heading
const standard = resolveStyle(sheet, DEFAULT_STYLE);
expect(standard.text.fontFamily).toBe('Liberation Serif');
expect(standard.para.spaceBefore).toBeUndefined();
});
it('falls back to Standard for unknown names and survives a parent cycle', () => {
const sheet = builtinStyleSheet();
expect(resolveStyle(sheet, 'Nope').text.fontSizePt).toBe(12);
const cyclic: StyleSheet = { paragraph: {
A: { name: 'A', parent: 'B', next: null, para: {}, text: { fontSizePt: 9 } },
B: { name: 'B', parent: 'A', next: null, para: {}, text: { bold: true } },
} };
const a = resolveStyle(cyclic, 'A');
expect(a.text.fontSizePt).toBe(9);
expect(a.text.bold).toBe(true);
});
it('matches the heading sizes/margins the exporters write', () => {
const sheet = builtinStyleSheet();
HEADING_STYLE_OVERRIDES.forEach((o, i) => {
const r = resolveStyle(sheet, `Heading ${i + 1}`);
expect(r.text.fontSizePt, o.name).toBe(ptOf(o.fontSize));
expect(r.para.spaceBefore!, o.name).toBeCloseTo(ptOf(o.marginTop), 1);
expect(r.para.spaceAfter!, o.name).toBeCloseTo(ptOf(o.marginBottom), 1);
});
});
it('renders CSS keyed by data-style, with a fallback for unnamed headings', () => {
const css = styleCss(builtinStyleSheet());
expect(css).toContain('.paper .tiptap [data-style="Heading 1"]');
expect(css).toContain('.paper .tiptap h1:not([data-style])');
expect(css).toContain('.paper .tiptap p:not([data-style])');
const h1Rule = css.slice(css.indexOf('[data-style="Heading 1"]'));
expect(h1Rule.slice(0, h1Rule.indexOf('}'))).toContain('font-size: 18pt');
});
it('lists the gallery order without the abstract Heading parent', () => {
const names = styleOrder(builtinStyleSheet()).map((s) => s.name);
expect(names[0]).toBe(DEFAULT_STYLE);
expect(names).not.toContain('Heading');
expect(names).toContain('Heading 5');
expect(names).toContain('Quotations');
});
});