feat: named paragraph styles with inheritance
This commit is contained in:
1 parent
d461ce09a6
commit
ac9d6cdcff
11 files changed
+428
-135
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user