feat: paragraph style manager ui

This commit is contained in:
Steffen Becker committed 2026-07-30 19:58:47 +02:00
1 parent ebb76a5104
commit 603f4df905
14 files changed
+773 -76

No files matched your search

+5 -52
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { t } from '../i18n/i18n.svelte';
import AlignIcon from './AlignIcon.svelte';
import type { AlignValue } from './AlignIcon.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
type AlignValue = 'left' | 'center' | 'right' | 'justify';
const ALIGNMENTS: AlignValue[] = ['left', 'center', 'right', 'justify'];
// Empty string when the selection spans paragraphs/headings with different alignments.
@@ -51,31 +52,7 @@
<div class="align-picker" use:alignPickerClickOutside>
<button class="align-trigger" onclick={openAlignPicker} title={t().align.title}>
{#if currentAlign === 'center'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="3" y1="11" x2="13" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else if currentAlign === 'right'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="6" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else if currentAlign === 'justify'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="10" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{/if}
<AlignIcon value={currentAlign || 'left'} />
<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>
@@ -90,31 +67,7 @@
onclick={() => pickAlign(a)}
title={t().align.alignTo(t().align[a])}
>
{#if a === 'left'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="10" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="12" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else if a === 'center'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="7" x2="12" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="3" y1="11" x2="13" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else if a === 'right'}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="6" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="2" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="2" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{/if}
<AlignIcon value={a} />
<span>{t().align[a]}</span>
</button>
{/each}
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
// The four alignment glyphs, shared by the toolbar's align picker and the style
// manager so both show the same control.
export type AlignValue = 'left' | 'center' | 'right' | 'justify';
let { value }: { value: AlignValue } = $props();
// Per line: x1 and x2 of the three rules that make up the glyph.
const LINES: Record<AlignValue, [number, number][]> = {
left: [[2, 14], [2, 10], [2, 12]],
center: [[2, 14], [4, 12], [3, 13]],
right: [[2, 14], [6, 14], [4, 14]],
justify: [[2, 14], [2, 14], [2, 14]],
};
</script>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
{#each LINES[value] as [x1, x2], i}
<line x1={x1} y1={3 + i * 4} x2={x2} y2={3 + i * 4} stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
{/each}
</svg>
@@ -0,0 +1,470 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { t } from '../i18n/i18n.svelte';
import {
DEFAULT_STYLE, HEADING_PARENT, isAbstractStyle, propsFromBlock, resolveStyle, styleDelta,
styleOrder, uniqueStyleName, type Style,
} from '../styles/styleSheet';
import { deleteStyle, putStyle, renameStyle, resetStyle, styleSheet } from '../styles/sheet.svelte';
import { blockStyleName } from '../editor/extensions/paragraphStyle';
import { CANDIDATE_FONTS, detectAvailableFonts } from '../utils/fontDetect';
import AlignIcon, { type AlignValue } from './AlignIcon.svelte';
// LibreOffice's style manager: pick a style, edit its properties, or make a new one
// from the cursor's formatting. Edits apply live — every block using the style follows.
let { open = $bindable(false), editor }: { open?: boolean; editor: Editor | null } = $props();
const ALIGNMENTS: AlignValue[] = ['left', 'center', 'right', 'justify'];
let dialogEl: HTMLDialogElement | null = $state(null);
let selected = $state(DEFAULT_STYLE);
// The fonts installed on this machine, detected once the dialog is first opened
// (same list the font picker uses).
let fonts = $state<string[]>([]);
let sheet = $derived(styleSheet());
// Indented by inheritance depth, so the chain is visible.
let rows = $derived(styleOrder(sheet, true).map((s) => ({ style: s, depth: depthOf(s) })));
let style = $derived(sheet.paragraph[selected] ?? sheet.paragraph[DEFAULT_STYLE]);
let resolved = $derived(resolveStyle(sheet, selected));
// A style can't inherit from itself or from one of its own descendants.
let parentOptions = $derived(
Object.keys(sheet.paragraph).filter((name) => name !== style.name && !descendsFrom(name, style.name)),
);
function depthOf(s: Style): number {
let depth = 0;
let cur = s.parent;
const seen = new Set<string>();
while (cur && sheet.paragraph[cur] && !seen.has(cur)) { seen.add(cur); depth++; cur = sheet.paragraph[cur].parent; }
return depth;
}
function descendsFrom(name: string, ancestor: string): boolean {
let cur: string | null = name;
const seen = new Set<string>();
while (cur && !seen.has(cur)) {
seen.add(cur);
if (cur === ancestor) return true;
cur = sheet.paragraph[cur]?.parent ?? null;
}
return false;
}
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
});
$effect(() => {
if (open && !fonts.length) fonts = detectAvailableFonts(CANDIDATE_FONTS);
});
// Follow the cursor's style when the dialog opens (LibreOffice preselects it too).
$effect(() => {
if (open && editor) selected = blockStyleName(editor.state.selection.$from.parent as never);
});
// An undefined patch value clears the style's own property, so it inherits again.
function edit(patch: { para?: Partial<Style['para']>; text?: Partial<Style['text']> }) {
const merge = <T extends object>(base: T, over: Partial<T>): T => {
const out = { ...base } as T;
for (const [key, value] of Object.entries(over)) {
if (value === undefined) delete out[key as keyof T];
else out[key as keyof T] = value as T[keyof T];
}
return out;
};
putStyle({ ...style, para: merge(style.para, patch.para ?? {}), text: merge(style.text, patch.text ?? {}) });
}
// A style may name a font that isn't installed here; keep it in the list so editing
// another property doesn't silently drop it.
let fontOptions = $derived(
style.text.fontFamily && !fonts.includes(style.text.fontFamily)
? [style.text.fontFamily, ...fonts]
: fonts,
);
// A field left empty clears the style's own value, so it inherits again.
const num = (v: string) => (v.trim() === '' ? undefined : Number(v));
// An empty field shows what the parent chain provides, marked as inherited so it can't
// be mistaken for this style's own value.
const inherited = (value: string | number | undefined) =>
value === undefined || value === '' ? '' : `${value} (${t().styles.inherited})`;
// The cursor's block, with the marks of its first text run.
function blockFormatting() {
if (!editor) return null;
const node = editor.state.selection.$from.parent;
const first = node.firstChild;
return {
node,
current: blockStyleName(node as never),
shown: propsFromBlock(node as never, (first?.marks ?? []) as never),
};
}
function newFromSelection() {
const info = blockFormatting();
if (!info || !editor) return;
const name = uniqueStyleName(sheet, t().styles.newName);
const own = styleDelta(info.shown, resolveStyle(sheet, info.current));
putStyle({ name, parent: info.current, next: info.current, para: own.para, text: own.text });
// The formatting now lives in the style, so the block drops it (LibreOffice's behavior).
editor.commands.setParagraphStyle(name);
editor.commands.clearDirectFormatting();
selected = name;
}
function updateFromSelection() {
const info = blockFormatting();
if (!info || !editor) return;
const own = styleDelta(info.shown, resolveStyle(sheet, style.parent ?? DEFAULT_STYLE));
putStyle({ ...style, para: { ...style.para, ...own.para }, text: { ...style.text, ...own.text } });
editor.commands.setParagraphStyle(style.name);
editor.commands.clearDirectFormatting();
}
function rename() {
const next = prompt(t().styles.renamePrompt, style.name)?.trim();
if (!next || next === style.name || sheet.paragraph[next]) return;
const from = style.name;
renameStyle(from, next);
// Retag every block that referenced the old name.
editor?.commands.command(({ tr, state, dispatch }) => {
state.doc.descendants((node, pos) => {
if (node.attrs?.styleName === from) tr.setNodeAttribute(pos, 'styleName', next);
});
if (dispatch && tr.docChanged) dispatch(tr);
return true;
});
selected = next;
}
function remove() {
const gone = style.name;
const fallback = style.parent ?? DEFAULT_STYLE;
deleteStyle(gone);
editor?.commands.command(({ tr, state, dispatch }) => {
state.doc.descendants((node, pos) => {
if (node.attrs?.styleName === gone) tr.setNodeAttribute(pos, 'styleName', fallback);
});
if (dispatch && tr.docChanged) dispatch(tr);
return true;
});
selected = fallback;
}
</script>
<dialog
class="styles"
bind:this={dialogEl}
onclose={() => (open = false)}
onclick={(e) => { if (e.target === dialogEl) open = false; }}
aria-label={t().styles.title}
>
<div class="card">
<header>
<h2>{t().styles.title}</h2>
<button class="close" onclick={() => (open = false)} aria-label={t().common.close}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</header>
<div class="body">
<ul class="list">
{#each rows as { style: s, depth } (s.name)}
<li>
<button
class="entry"
class:active={selected === s.name}
style="padding-left: {0.6 + depth * 0.8}rem"
onclick={() => (selected = s.name)}
>
{s.name}
{#if isAbstractStyle(s.name)}<span class="badge">{t().styles.abstract}</span>
{:else if !s.builtin}<span class="badge">{t().styles.custom}</span>{/if}
</button>
</li>
{/each}
</ul>
<div class="fields">
<!-- Fallbacks are the page's own defaults, not `inherit`: an inline `inherit`
would beat the .preview rule and pull in the dialog's UI text color. -->
<div class="preview" style="
font-family: {resolved.text.fontFamily ?? 'var(--font-serif)'};
font-size: {Math.min(24, resolved.text.fontSizePt ?? 12)}pt;
font-weight: {resolved.text.bold ? 700 : 400};
font-style: {resolved.text.italic ? 'italic' : 'normal'};
color: {resolved.text.color ?? 'var(--color-page-text)'};
text-decoration: {[resolved.text.underline && 'underline', resolved.text.strike && 'line-through'].filter(Boolean).join(' ') || 'none'};
text-align: {resolved.para.textAlign ?? 'left'};
">{style.name}</div>
<label>{t().styles.parent}
<select value={style.parent ?? ''} onchange={(e) => putStyle({ ...style, parent: e.currentTarget.value || null })}>
<option value="">—</option>
{#each parentOptions as name}<option value={name}>{name}</option>{/each}
</select>
</label>
<label>{t().styles.font}
<select
value={style.text.fontFamily ?? ''}
onchange={(e) => edit({ text: { fontFamily: e.currentTarget.value || undefined } })}
>
<option value="">{inherited(resolved.text.fontFamily) || '—'}</option>
{#each fontOptions as font}
<option value={font} style="font-family: '{font}'">{font}</option>
{/each}
</select>
</label>
<div class="row">
<label>{t().styles.size}
<input type="number" min="1" max="400" step="0.5"
value={style.text.fontSizePt ?? ''}
placeholder={inherited(resolved.text.fontSizePt)}
onchange={(e) => edit({ text: { fontSizePt: num(e.currentTarget.value) } })} />
</label>
<label>{t().styles.color}
<input type="color" value={style.text.color ?? resolved.text.color ?? '#000000'}
onchange={(e) => edit({ text: { color: e.currentTarget.value.toUpperCase() } })} />
</label>
</div>
<div class="row toggles">
<label class="check"><input type="checkbox" checked={!!resolved.text.bold}
onchange={(e) => edit({ text: { bold: e.currentTarget.checked } })} />{t().styles.bold}</label>
<label class="check"><input type="checkbox" checked={!!resolved.text.italic}
onchange={(e) => edit({ text: { italic: e.currentTarget.checked } })} />{t().styles.italic}</label>
<label class="check"><input type="checkbox" checked={!!resolved.text.underline}
onchange={(e) => edit({ text: { underline: e.currentTarget.checked } })} />{t().styles.underline}</label>
<label class="check"><input type="checkbox" checked={!!resolved.text.strike}
onchange={(e) => edit({ text: { strike: e.currentTarget.checked } })} />{t().styles.strike}</label>
</div>
<div class="field">
<span class="field-label">{t().styles.alignment}</span>
<div class="align-row">
{#each ALIGNMENTS as a}
<button
class="align-btn"
class:active={style.para.textAlign === a}
class:inherit={!style.para.textAlign && (resolved.para.textAlign ?? 'left') === a}
title={t().align.alignTo(t().align[a])}
aria-pressed={style.para.textAlign === a}
onclick={() => edit({ para: { textAlign: style.para.textAlign === a ? undefined : a } })}
>
<AlignIcon value={a} />
</button>
{/each}
</div>
</div>
<div class="row">
<label>{t().styles.spaceBefore}
<input type="number" min="0" max="200" step="1" value={style.para.spaceBefore ?? ''}
placeholder={inherited(resolved.para.spaceBefore ?? 0)}
onchange={(e) => edit({ para: { spaceBefore: num(e.currentTarget.value) } })} />
</label>
<label>{t().styles.spaceAfter}
<input type="number" min="0" max="200" step="1" value={style.para.spaceAfter ?? ''}
placeholder={inherited(resolved.para.spaceAfter ?? 0)}
onchange={(e) => edit({ para: { spaceAfter: num(e.currentTarget.value) } })} />
</label>
<label>{t().styles.indent}
<input type="number" min="0" max="20" step="0.25" value={style.para.indent ?? ''}
placeholder={inherited(resolved.para.indent ?? 0)}
onchange={(e) => edit({ para: { indent: num(e.currentTarget.value) } })} />
</label>
</div>
</div>
</div>
<footer>
<button onclick={newFromSelection}>{t().styles.newFromSelection}</button>
<button onclick={updateFromSelection}>{t().styles.updateFromSelection}</button>
<span class="spacer"></span>
{#if style.builtin}
<button onclick={() => resetStyle(style.name)}>{t().styles.reset}</button>
{:else}
<button onclick={rename}>{t().styles.rename}</button>
<button class="danger" onclick={remove} disabled={style.name === HEADING_PARENT}>{t().common.remove}</button>
{/if}
</footer>
</div>
</dialog>
<style>
.styles {
margin: auto;
padding: 0;
border: none;
background: transparent;
max-width: none;
}
.styles::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.card {
width: min(46rem, 92vw);
background: var(--color-surface);
color: var(--color-text);
border-radius: var(--island-radius);
/* The card's own outline: in dark mode a shadow can't separate it from the
backdrop, so the edge has to be a real line. */
border: 1px solid var(--color-border);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
font-family: var(--font-sans);
overflow: hidden;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
}
header h2 { margin: 0; font-size: 0.95rem; }
.close {
border: none;
background: transparent;
color: var(--color-text);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius);
}
.close:hover { background: var(--color-btn-hover); }
.body { display: flex; gap: 0; max-height: min(30rem, 70vh); }
.list {
width: 14rem;
margin: 0;
padding: 0.4rem 0;
list-style: none;
overflow-y: auto;
border-right: 1px solid var(--color-border);
}
.entry {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.35rem 0.6rem;
border: none;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.entry:hover { background: var(--color-btn-hover); }
.entry.active { background: var(--color-primary); color: white; }
.badge {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
}
.fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.8rem 1rem;
overflow-y: auto;
}
.preview {
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-page-bg, #fff);
color: var(--color-page-text);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.row { display: flex; gap: 0.6rem; }
.field { display: flex; flex-direction: column; gap: 0.2rem; }
.field-label { font-size: 0.75rem; color: var(--color-text-muted); }
.align-row { display: flex; gap: 2px; }
.align-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--toolbar-btn-size);
height: var(--toolbar-btn-size);
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.align-btn:hover { background: var(--color-btn-hover); }
.align-btn.active { background: var(--color-primary); color: white; }
/* What the parent chain provides while this style sets none of its own. */
.align-btn.inherit { border-color: var(--color-border); color: var(--color-text-muted); }
.row > label { flex: 1; }
.toggles { align-items: center; gap: 1rem; }
.check {
flex-direction: row;
align-items: center;
gap: 0.35rem;
color: var(--color-text);
font-size: 0.8rem;
}
input, select {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
}
input[type='color'] { padding: 0.1rem; height: 1.9rem; }
footer {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1rem;
border-top: 1px solid var(--color-border);
}
.spacer { flex: 1; }
footer button {
font: inherit;
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
}
footer button:hover:not(:disabled) { border-color: var(--color-primary); }
footer button:disabled { opacity: 0.4; cursor: not-allowed; }
.danger:hover:not(:disabled) { border-color: #c0392b; color: #c0392b; }
</style>
+15
View File
@@ -9,6 +9,7 @@
import { blockStyleName } from '../editor/extensions/paragraphStyle';
import { DEFAULT_STYLE, resolveStyle, styleOrder } from '../styles/styleSheet';
import { styleSheet } from '../styles/sheet.svelte';
import StyleManagerDialog from './StyleManagerDialog.svelte';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
@@ -42,6 +43,7 @@
}
let stylesOpen = $state(false);
let managerOpen = $state(false);
// $derived re-evaluates whenever `tick` changes (i.e. on every TipTap transaction)
let isHeading = $derived(tick >= 0 && !!editor?.isActive('heading'));
@@ -209,6 +211,9 @@
{styleLabel(s.name)}
</button>
{/each}
<button class="ol-option manage" onclick={() => { stylesOpen = false; managerOpen = true; }}>
{t().styles.manage}
</button>
</div>
{/if}
</div>
@@ -341,6 +346,8 @@
{/if}
</div>
<StyleManagerDialog bind:open={managerOpen} {editor} />
<style>
/* Sits inside the header island (which paints the frosted background). Not a
stacking context: its dropdowns (z:200) must join the header's context so they
@@ -430,6 +437,14 @@
line-height: 1.2;
}
.manage {
margin-top: 2px;
border-top: 1px solid var(--color-border);
border-radius: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
/* Ordered-list split button: main toggle + chevron that opens the numbering
style menu, rendered as one joined control. */
.ol-picker {
+3 -1
View File
@@ -129,10 +129,12 @@ export const extensions = [
TrailingNode,
History,
Placeholder.configure({ placeholder: 'Start typing…' }),
// No defaultAlignment: it would put textAlign:'left' on every block, and that inline
// style beats the paragraph style's alignment. Unset = follow the style; an explicit
// 'left' stays direct formatting that overrides it, as in Word/LibreOffice.
TextAlign.configure({
types: ['paragraph', 'heading'],
alignments: ['left', 'center', 'right', 'justify'],
defaultAlignment: 'left',
}),
PageBreaks,
FormattingMarks,
+3 -3
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, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, type HfExport } from './odt';
import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
import { builtinStyleSheet, DEFAULT_STYLE, type Style, type StyleSheet } from '../styles/styleSheet';
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
@@ -862,8 +862,8 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions {
const p = style.para;
const t = style.text;
const run: Writable<IRunStylePropertiesOptions> = {};
// Liberation Serif is the on-screen name; the file declares its metric twin.
if (t.fontFamily) run.font = t.fontFamily === 'Liberation Serif' ? DOC_FONT : t.fontFamily;
// The registry holds the on-screen family; the file declares its metric twin.
if (t.fontFamily) run.font = twinFontName(t.fontFamily);
if (t.fontSizePt != null) run.size = Math.round(t.fontSizePt * 2);
if (t.bold != null) run.bold = t.bold;
if (t.italic != null) run.italics = t.italic;
+10 -2
View File
@@ -148,6 +148,14 @@ export const HEADING_FONT = 'Arial';
export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length;
export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1);
// The bundled screen font → the metric-identical name declared in files, so
// LibreOffice and Word render with the editor's metrics.
export function twinFontName(family: string): string {
if (family === ODFKIT_DEFAULT_FONT) return EXPORT_FONT;
if (family === 'Liberation Sans') return HEADING_FONT;
return family;
}
// ODF encodes spaces in style names as _20_ ("Heading 1" → "Heading_20_1").
export function odfStyleName(name: string): string {
return name.replace(/ /g, '_20_');
@@ -1000,8 +1008,8 @@ function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<stri
}
const text: Record<string, string> = {};
if (t.fontFamily) {
// Liberation Serif is the on-screen name; the file declares its metric twin.
const font = t.fontFamily === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : String(t.fontFamily);
// The registry holds the on-screen family; the file declares its metric twin.
const font = twinFontName(String(t.fontFamily));
text['style:font-name'] = font;
text['style:font-name-asian'] = font;
text['style:font-name-complex'] = font;
+25
View File
@@ -68,6 +68,31 @@ const de: Messages = {
'>': 'Winkel',
},
},
styles: {
title: 'Absatzvorlagen',
manage: 'Vorlagen verwalten…',
custom: 'eigen',
inherited: 'geerbt',
abstract: 'nur Elternstil',
parent: 'Erbt von',
font: 'Schriftart',
size: 'Größe (pt)',
color: 'Farbe',
bold: 'Fett',
italic: 'Kursiv',
underline: 'Unterstrichen',
strike: 'Durchgestrichen',
alignment: 'Ausrichtung',
spaceBefore: 'Abstand oben (pt)',
spaceAfter: 'Abstand unten (pt)',
indent: 'Einzug (cm)',
newFromSelection: 'Neu aus Auswahl',
updateFromSelection: 'Aus Auswahl aktualisieren',
rename: 'Umbenennen…',
reset: 'Auf Standard zurücksetzen',
renamePrompt: 'Neuer Name der Vorlage:',
newName: 'Vorlage',
},
history: {
undo: 'Rückgängig',
redo: 'Wiederholen',
+25
View File
@@ -72,6 +72,31 @@ const en = {
'>': 'Chevron',
},
},
styles: {
title: 'Paragraph styles',
manage: 'Manage styles…',
custom: 'own',
inherited: 'inherited',
abstract: 'parent only',
parent: 'Inherits from',
font: 'Font',
size: 'Size (pt)',
color: 'Color',
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strike: 'Strikethrough',
alignment: 'Alignment',
spaceBefore: 'Space above (pt)',
spaceAfter: 'Space below (pt)',
indent: 'Indent (cm)',
newFromSelection: 'New from selection',
updateFromSelection: 'Update from selection',
rename: 'Rename…',
reset: 'Reset to default',
renamePrompt: 'New style name:',
newName: 'Style',
},
history: {
undo: 'Undo',
redo: 'Redo',
+2 -1
View File
@@ -588,7 +588,8 @@ function styleText(ctx: Ctx, id: string | null): TextProps {
const run = ctx.styles.paragraphRun(id);
const out: TextProps = {};
// Our own export declares the metric twin; keep the registry on the on-screen name.
if (run.font) out.fontFamily = run.font === 'Times New Roman' ? 'Liberation Serif' : run.font;
if (run.font) out.fontFamily = run.font === 'Times New Roman' ? 'Liberation Serif'
: run.font === 'Arial' ? 'Liberation Sans' : run.font;
if (run.sizeHalfPt != null) out.fontSizePt = Math.round((run.sizeHalfPt / 2) * 10) / 10;
if (run.bold != null) out.bold = run.bold;
if (run.italic != null) out.italic = run.italic;
+9 -1
View File
@@ -693,6 +693,14 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
};
}
// The inverse of export/odt.ts twinFontName: a declared metric twin reads back as the
// bundled family the editor renders.
function screenFontName(family: string): string {
if (family === 'Times New Roman') return 'Liberation Serif';
if (family === 'Arial') return 'Liberation Sans';
return family;
}
// ODF encodes spaces as _20_; a style:display-name (when present) is authoritative.
function displayStyleName(odfName: string, display?: string): string {
return display || odfName.replace(/_20_/g, ' ');
@@ -729,7 +737,7 @@ function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps {
const family = resolver.fontFamilyOf(props);
// Our own export declares the metric twin; keep the registry on the on-screen name
// so an export→import loop doesn't drift.
if (family) out.fontFamily = family === 'Times New Roman' ? 'Liberation Serif' : family;
if (family) out.fontFamily = screenFontName(family);
const size = lengthToPt(props['fo:font-size']);
if (size != null) out.fontSizePt = Math.round(size * 10) / 10;
const weight = props['fo:font-weight'];
+39 -6
View File
@@ -2,7 +2,7 @@
// 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';
import { builtinStyleSheet, DEFAULT_STYLE, mergeStoredSheet, STYLE_SHEET_VERSION, type Style, type StyleSheet } from './styleSheet';
const STORAGE_KEY = 'odf-editor-styles';
@@ -10,10 +10,7 @@ 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 } };
return mergeStoredSheet(JSON.parse(raw));
} catch {
return builtinStyleSheet();
}
@@ -27,10 +24,46 @@ export function styleSheet(): StyleSheet {
export function setStyleSheet(next: StyleSheet): void {
current = next;
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: STYLE_SHEET_VERSION, ...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 } });
}
// Rename a style and re-point everything that referenced it (children, next-styles).
// Blocks keep their own `styleName`; the caller retags them.
export function renameStyle(from: string, to: string): void {
const style = current.paragraph[from];
if (!style || from === to || current.paragraph[to]) return;
const paragraph: Record<string, Style> = {};
for (const [name, s] of Object.entries(current.paragraph)) {
if (name === from) continue;
paragraph[name] = { ...s, parent: s.parent === from ? to : s.parent, next: s.next === from ? to : s.next };
}
paragraph[to] = { ...style, name: to, builtin: undefined };
setStyleSheet({ paragraph });
}
// Delete a style; its children re-parent to its own parent, as in LibreOffice.
export function deleteStyle(name: string): void {
const style = current.paragraph[name];
if (!style || style.builtin) return;
const paragraph: Record<string, Style> = {};
for (const [key, s] of Object.entries(current.paragraph)) {
if (key === name) continue;
paragraph[key] = {
...s,
parent: s.parent === name ? style.parent : s.parent,
next: s.next === name ? style.next ?? DEFAULT_STYLE : s.next,
};
}
setStyleSheet({ paragraph });
}
// Restore a built-in to its factory definition.
export function resetStyle(name: string): void {
const factory = builtinStyleSheet().paragraph[name];
if (factory) putStyle(factory);
}
+106 -8
View File
@@ -51,7 +51,7 @@ const BUILTINS: Style[] = [
{ name: DEFAULT_STYLE, parent: null, next: null, builtin: true,
para: { spaceBefore: 0, spaceAfter: 0 }, 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 } },
para: { spaceBefore: 12, spaceAfter: 6 }, text: { fontFamily: 'Liberation Sans', 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,
@@ -65,20 +65,47 @@ const BUILTINS: Style[] = [
para: { indent: 1, spaceAfter: 14 }, text: {} },
];
// Bumped whenever the built-in definitions change: a stored sheet from an older version
// keeps its user styles but takes the new factory built-ins (see mergeStoredSheet).
export const STYLE_SHEET_VERSION = 2;
// A persisted sheet merged onto the current built-ins. Same version: stored entries win
// (a document's own styles, and edits to built-ins). Older: only user styles survive.
export function mergeStoredSheet(stored: unknown): StyleSheet {
const sheet = builtinStyleSheet();
const data = stored as { v?: number; paragraph?: Record<string, Style> } | null;
if (!data?.paragraph || typeof data.paragraph !== 'object') return sheet;
const current = data.v === STYLE_SHEET_VERSION;
for (const [name, style] of Object.entries(data.paragraph)) {
if (!style || typeof style !== 'object') continue;
if (!current && style.builtin) continue;
sheet.paragraph[name] = style;
}
return sheet;
}
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[] {
// The gallery order: built-ins as listed above, user styles after them. `Heading` is an
// abstract parent (LibreOffice's, holding what all levels share) — never assignable, so
// the gallery hides it; the style manager passes withAbstract to edit it.
export function styleOrder(sheet: StyleSheet, withAbstract = false): 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();
const hidden = (n: string) => !withAbstract && n === HEADING_PARENT;
const builtinOrder = BUILTINS.map(b => b.name).filter(n => names.includes(n) && !hidden(n));
const rest = names.filter(n => !builtinOrder.includes(n) && !hidden(n)).sort();
return [...builtinOrder, ...rest].map(n => sheet.paragraph[n]);
}
// Styles that exist only to be inherited from.
export function isAbstractStyle(name: string): boolean {
return name === HEADING_PARENT;
}
export function headingStyleName(level: number): string {
return `Heading ${level}`;
}
@@ -99,9 +126,16 @@ export function resolveStyle(sheet: StyleSheet, name: string | null | undefined)
cur = style.parent ?? '';
}
const out: ResolvedStyle = { para: {}, text: {} };
// Only defined values layer: a key present as undefined means "not set here", so it
// must not wipe what the parent provides.
const layer = <T extends object>(target: T, source: T) => {
for (const key of Object.keys(source) as (keyof T)[]) {
if (source[key] !== undefined) target[key] = source[key];
}
};
for (const style of chain.reverse()) {
Object.assign(out.para, style.para);
Object.assign(out.text, style.text);
layer(out.para, style.para);
layer(out.text, style.text);
}
return out;
}
@@ -109,7 +143,7 @@ export function resolveStyle(sheet: StyleSheet, name: string | null | undefined)
// 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)';
if (name === 'Liberation Sans' || name === 'Arial') return 'var(--font-heading)';
return `'${name.replace(/'/g, "\\'")}', var(--font-serif)`;
}
@@ -153,3 +187,67 @@ export function styleCss(sheet: StyleSheet): string {
}
return rules.join('\n\n');
}
// ---- block ⇄ style ------------------------------------------------------------------
type BlockNode = { type?: { name: string }; attrs?: Record<string, unknown> };
type BlockMark = { type: { name: string } | string; attrs?: Record<string, unknown> };
// The formatting a block currently shows, read off its attrs and the marks of its text —
// the raw material for "new style from selection".
export function propsFromBlock(node: BlockNode, marks: BlockMark[] = []): ResolvedStyle {
const a = node.attrs ?? {};
const para: ParaProps = {};
// Every block carries textAlign:'left' (the TextAlign extension's default), so only a
// real choice counts as the block's own alignment.
const ta = a.textAlign;
if (ta === 'center' || ta === 'right' || ta === 'justify') para.textAlign = ta;
if (typeof a.lineHeight === 'string' && a.lineHeight) para.lineHeight = a.lineHeight;
if (typeof a.spaceBefore === 'number') para.spaceBefore = a.spaceBefore;
if (typeof a.spaceAfter === 'number') para.spaceAfter = a.spaceAfter;
if (typeof a.indent === 'number' && a.indent > 0) para.indent = a.indent;
if (typeof a.backgroundColor === 'string' && a.backgroundColor) para.backgroundColor = a.backgroundColor;
for (const side of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'] as const) {
const v = a[side];
if (typeof v === 'string' && v && v !== 'none') para[side] = v;
}
const text: TextProps = {};
// The paragraph-mark size sizes the block itself; a run's own size wins below.
if (typeof a.fontSize === 'string' && a.fontSize) text.fontSizePt = parseFloat(a.fontSize);
const nameOf = (m: BlockMark) => (typeof m.type === 'string' ? m.type : m.type.name);
for (const mark of marks) {
const name = nameOf(mark);
if (name === 'bold') text.bold = true;
else if (name === 'italic') text.italic = true;
else if (name === 'underline') text.underline = true;
else if (name === 'strike') text.strike = true;
else if (name === 'textStyle') {
const attrs = mark.attrs ?? {};
if (typeof attrs.fontFamily === 'string' && attrs.fontFamily) text.fontFamily = attrs.fontFamily;
if (typeof attrs.fontSize === 'string' && attrs.fontSize) text.fontSizePt = parseFloat(attrs.fontSize);
if (typeof attrs.color === 'string' && attrs.color) text.color = attrs.color;
if (attrs.fontWeight === 'normal') text.bold = false;
}
}
return { para, text };
}
// What `shown` declares beyond `base` — a style's own properties (LibreOffice's
// "new style from selection" stores exactly this against the parent).
export function styleDelta(shown: ResolvedStyle, base: ResolvedStyle): ResolvedStyle {
const pick = <T extends object>(a: T, b: T): T => {
const out = {} as T;
for (const key of Object.keys(a) as (keyof T)[]) {
if (a[key] !== undefined && a[key] !== b[key]) out[key] = a[key];
}
return out;
};
return { para: pick(shown.para, base.para), text: pick(shown.text, base.text) };
}
// A name that isn't taken yet ("Style", "Style 2", …).
export function uniqueStyleName(sheet: StyleSheet, base: string): string {
if (!sheet.paragraph[base]) return base;
for (let i = 2; ; i++) if (!sheet.paragraph[`${base} ${i}`]) return `${base} ${i}`;
}
+40 -2
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
builtinStyleSheet, resolveStyle, styleCss, styleOrder, DEFAULT_STYLE, type StyleSheet,
builtinStyleSheet, mergeStoredSheet, resolveStyle, styleCss, styleOrder, DEFAULT_STYLE,
STYLE_SHEET_VERSION, type StyleSheet,
} from '../../src/lib/styles/styleSheet';
import { HEADING_STYLE_OVERRIDES } from '../../src/lib/export/odt';
@@ -12,13 +13,22 @@ describe('named paragraph styles', () => {
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.text.fontFamily).toBe('Liberation Sans'); // 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).toBe(0);
});
it('treats an undefined own value as "not set" (the parent still applies)', () => {
const sheet = builtinStyleSheet();
// What the style manager stores when a field is cleared.
sheet.paragraph['Heading 2'] = { ...sheet.paragraph['Heading 2'], text: { fontSizePt: undefined } };
const h2 = resolveStyle(sheet, 'Heading 2');
expect(h2.text.fontFamily).toBe('Liberation Sans'); // still inherited from Heading
expect(h2.text.fontSizePt).toBe(12); // falls back to Standard's size
});
it('falls back to Standard for unknown names and survives a parent cycle', () => {
const sheet = builtinStyleSheet();
expect(resolveStyle(sheet, 'Nope').text.fontSizePt).toBe(12);
@@ -58,3 +68,31 @@ describe('named paragraph styles', () => {
expect(names).toContain('Quotations');
});
});
// The sheet lives in localStorage, so a released change to the built-ins has to reach
// installations that already stored the old ones.
describe('persisted style sheet', () => {
const own = { name: 'Merksatz', parent: 'Standard', next: null, para: {}, text: { bold: true } };
it('re-seeds built-ins from an older version but keeps user styles', () => {
const stale = { v: 1, paragraph: {
Heading: { name: 'Heading', parent: 'Standard', next: 'Standard', builtin: true, para: {}, text: { fontFamily: 'Arial' } },
Merksatz: own,
} };
const sheet = mergeStoredSheet(stale);
expect(sheet.paragraph['Heading'].text.fontFamily).toBe('Liberation Sans'); // factory again
expect(sheet.paragraph['Merksatz']).toEqual(own); // user style kept
});
it('keeps stored built-ins of the current version (edits, imported documents)', () => {
const edited = { v: STYLE_SHEET_VERSION, paragraph: {
Heading: { name: 'Heading', parent: 'Standard', next: 'Standard', builtin: true, para: {}, text: { fontFamily: 'Georgia' } },
} };
expect(mergeStoredSheet(edited).paragraph['Heading'].text.fontFamily).toBe('Georgia');
});
it('falls back to the built-ins for junk', () => {
expect(mergeStoredSheet(null).paragraph['Standard'].text.fontSizePt).toBe(12);
expect(mergeStoredSheet({ v: 2 }).paragraph['Heading 1'].text.fontSizePt).toBe(18);
});
});