feat: add character style editor

This commit is contained in:
Steffen Becker committed 2026-07-30 23:34:36 +02:00
1 parent 3dcae6f640
commit ec0cfb76fd
8 files changed
+213 -43

No files matched your search

+11 -5
View File
@@ -27,7 +27,15 @@
- Font family picker (lists only fonts installed on the machine; remembers recently used fonts)
- Font size
- Font color and highlight color (color picker with custom colors)
- Headings H1–H3, including un-bolding a heading
- Headings H1–H5, including un-bolding a heading
**Styles**
- Named paragraph styles with LibreOffice's inheritance model: a style has a parent, a follow-on style and its own properties; changing a style updates every paragraph using it. Built-ins Default text → Heading → Heading 1–5 / Title / Subtitle / Quote, with LibreOffice's values (18/16/14/13/12 pt headings)
- Named character styles (Emphasis / Strong Emphasis / Source Text + own ones) as a mark on a text run
- Style gallery in the toolbar lists both families; assigning a style keeps hard formatting (Word/LibreOffice behavior), Ctrl+M clears direct formatting but keeps the style
- Style manager ("Manage styles…"): inheritance tree, live property editing (empty field = inherit again), new / update from selection, rename, delete, reset a built-in — for paragraph and character styles alike
- Round-trips as real styles, not baked formatting: ODF `style:style` (`style:family="paragraph"`/`"text"`, `style:parent-style-name`, `style:next-style-name`) and DOCX `w:pStyle`/`w:rStyle` with `w:basedOn`/`w:next`; import adopts the file's used styles plus their parent chains, so only formatting beyond the style stays direct
- A new document starts from the built-in styles, like Word's/LibreOffice's default template
**Paragraphs & lists**
- Text alignment: left, center, right, justify
@@ -45,7 +53,7 @@
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste
- Special characters picker
- Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF `text:date`/`text:time` (minted `number:date/time-style`) and DOCX `DATE`/`TIME` fields; the field carries the surrounding font
- Table of contents: generated from headings (H1–H3) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
- Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
- Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before`
- Text boxes and basic shapes (rectangle / rounded rectangle / ellipse): editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; floating toolbar for wrap, shape kind and colors. Round-trips to ODF `draw:frame`/`draw:text-box` + `draw:custom-shape` and DOCX DrawingML `wps:wsp`/`wps:txbx` (imports Word's `mc:AlternateContent` and legacy VML text boxes too)
@@ -85,17 +93,15 @@
Planned word-processor features (to match Word/LibreOffice):
- Footnotes / endnotes (currently dropped on import)
- Clear formatting (remove all marks)
- Comments / annotations (currently dropped on import)
- Horizontal rule
- Page numbering options (start value / format) + different first-page header
- Heading levels H4–H6 (currently clamped to H3)
- Heading level H6 (currently clamped to H5)
- Paragraph borders / shading
- Track changes (revisions)
- Lines / arrows / connectors / freeform and other shape presets (currently dropped on import with a warning; text boxes + rect/round-rect/ellipse are supported)
- Equations / formulas
- Cross-references / bookmarks
- Named paragraph / character styles (style gallery)
Other:
- Custom right-click context menu (formatting, cut/copy)
+3
View File
@@ -24,6 +24,7 @@
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte';
import { builtinStyleSheet } from './lib/styles/styleSheet';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName';
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
@@ -391,6 +392,8 @@
hfDistances = { ...DEFAULT_HF_DISTANCES };
documentName = '';
fileHandle = null;
// Styles live in the document, so a new one starts from the built-ins
setStyleSheet(builtinStyleSheet());
clearEmbeddedFonts();
void clearEmbeddedFontStore();
editor.commands.focus();
+142 -29
View File
@@ -3,10 +3,11 @@
import { t } from '../i18n/i18n.svelte';
import {
DEFAULT_STYLE, HEADING_PARENT, isAbstractStyle, propsFromBlock, resolveStyle, styleDelta,
styleOrder, uniqueStyleName, type Style,
styleOrder, uniqueStyleName, type Style, type StyleFamily,
} from '../styles/styleSheet';
import { deleteStyle, putStyle, renameStyle, resetStyle, styleSheet } from '../styles/sheet.svelte';
import { deleteCharacterStyle, deleteStyle, putStyle, renameStyle, resetStyle, styleSheet } from '../styles/sheet.svelte';
import { blockStyleName } from '../editor/extensions/paragraphStyle';
import { activeCharacterStyle } from '../editor/extensions/characterStyle';
import { CANDIDATE_FONTS, detectAvailableFonts } from '../utils/fontDetect';
import AlignIcon, { type AlignValue } from './AlignIcon.svelte';
@@ -17,26 +18,40 @@
const ALIGNMENTS: AlignValue[] = ['left', 'center', 'right', 'justify'];
let dialogEl: HTMLDialogElement | null = $state(null);
let family = $state<StyleFamily>('paragraph');
let selected = $state(DEFAULT_STYLE);
let selectedChar = $state('');
// 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());
let isChar = $derived(family === 'character');
let styles = $derived(isChar ? sheet.character : sheet.paragraph);
// 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));
let rows = $derived(
isChar
? Object.values(sheet.character).map((s) => ({ style: s, depth: depthOf(s) }))
: styleOrder(sheet, true).map((s) => ({ style: s, depth: depthOf(s) })),
);
let current = $derived(isChar ? selectedChar : selected);
let style = $derived(styles[current] ?? Object.values(styles)[0]);
let resolved = $derived(resolveStyle(sheet, style?.name, family));
// 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)),
Object.keys(styles).filter((name) => name !== style?.name && !descendsFrom(name, style?.name ?? '')),
);
function select(name: string) {
if (isChar) selectedChar = name;
else selected = 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; }
while (cur && styles[cur] && !seen.has(cur)) { seen.add(cur); depth++; cur = styles[cur].parent; }
return depth;
}
@@ -46,7 +61,7 @@
while (cur && !seen.has(cur)) {
seen.add(cur);
if (cur === ancestor) return true;
cur = sheet.paragraph[cur]?.parent ?? null;
cur = styles[cur]?.parent ?? null;
}
return false;
}
@@ -64,7 +79,9 @@
// 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);
if (!open || !editor) return;
selected = blockStyleName(editor.state.selection.$from.parent as never);
selectedChar = activeCharacterStyle(editor.state as never) ?? Object.keys(sheet.character)[0] ?? '';
});
// An undefined patch value clears the style's own property, so it inherits again.
@@ -77,7 +94,7 @@
}
return out;
};
putStyle({ ...style, para: merge(style.para, patch.para ?? {}), text: merge(style.text, patch.text ?? {}) });
putStyle({ ...style, para: merge(style.para, patch.para ?? {}), text: merge(style.text, patch.text ?? {}) }, family);
}
// A style may name a font that isn't installed here; keep it in the list so editing
@@ -108,9 +125,43 @@
};
}
// The marks of the selection's first run — the raw material for a character style.
function runFormatting() {
if (!editor) return null;
const { from, to, empty } = editor.state.selection;
const marks = empty
? editor.state.storedMarks ?? editor.state.selection.$head.marks()
: (editor.state.doc.nodeAt(from) ?? editor.state.doc.nodeAt(Math.max(0, to - 1)))?.marks ?? [];
return propsFromBlock({ attrs: {} }, marks as never).text;
}
// Drop the direct marks the new style now carries, then apply it — as in LibreOffice.
function applyCharStyle(name: string, text: ReturnType<typeof runFormatting>) {
if (!editor || !text) return;
const chain = editor.chain().focus();
if (text.bold) chain.unsetBold();
if (text.italic) chain.unsetItalic();
if (text.underline) chain.unsetUnderline();
if (text.strike) chain.unsetStrike();
if (text.fontFamily) chain.unsetFontFamily();
if (text.fontSizePt != null) chain.unsetFontSize();
if (text.color) chain.unsetColor();
chain.removeEmptyTextStyle().setCharacterStyle(name).run();
}
function newFromSelection() {
if (!editor) return;
if (isChar) {
const text = runFormatting();
if (!text || !Object.keys(text).length) return;
const name = uniqueStyleName(sheet, t().styles.newName, 'character');
putStyle({ name, parent: null, next: null, para: {}, text }, 'character');
applyCharStyle(name, text);
selectedChar = name;
return;
}
const info = blockFormatting();
if (!info || !editor) return;
if (!info) 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 });
@@ -121,41 +172,68 @@
}
function updateFromSelection() {
if (!editor || !style) return;
if (isChar) {
const text = runFormatting();
if (!text) return;
putStyle({ ...style, text: { ...style.text, ...text } }, 'character');
applyCharStyle(style.name, text);
return;
}
const info = blockFormatting();
if (!info || !editor) return;
if (!info) 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();
}
// A character style with nothing selected yet: start from the built-in defaults.
function newEmptyStyle() {
const name = uniqueStyleName(sheet, t().styles.newName, 'character');
putStyle({ name, parent: null, next: null, para: {}, text: {} }, 'character');
selectedChar = name;
}
function rename() {
const next = prompt(t().styles.renamePrompt, style.name)?.trim();
if (!next || next === style.name || sheet.paragraph[next]) return;
if (!next || next === style.name || styles[next]) return;
const from = style.name;
renameStyle(from, next);
renameStyle(from, next, family);
// Retag every block that referenced the old name.
retag(from, next);
select(next);
}
// Re-point every block (or run) that referenced `from`; null drops the reference.
function retag(from: string, to: string | null) {
editor?.commands.command(({ tr, state, dispatch }) => {
state.doc.descendants((node, pos) => {
if (node.attrs?.styleName === from) tr.setNodeAttribute(pos, 'styleName', next);
if (isChar) {
const mark = node.marks.find((m) => m.type.name === 'charStyle' && m.attrs.name === from);
if (!mark) return;
tr.removeMark(pos, pos + node.nodeSize, mark);
if (to) tr.addMark(pos, pos + node.nodeSize, mark.type.create({ name: to }));
} else if (node.attrs?.styleName === from && to) {
tr.setNodeAttribute(pos, 'styleName', to);
}
});
if (dispatch && tr.docChanged) dispatch(tr);
return true;
});
selected = next;
}
function remove() {
const gone = style.name;
if (isChar) {
deleteCharacterStyle(gone);
retag(gone, null);
selectedChar = Object.keys(sheet.character)[0] ?? '';
return;
}
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;
});
retag(gone, fallback);
selected = fallback;
}
</script>
@@ -178,14 +256,19 @@
</header>
<div class="body">
<div class="pane">
<div class="family">
<button class:active={!isChar} onclick={() => (family = 'paragraph')}>{t().styles.paragraphStyles}</button>
<button class:active={isChar} onclick={() => (family = 'character')}>{t().styles.characterStyles}</button>
</div>
<ul class="list">
{#each rows as { style: s, depth } (s.name)}
<li>
<button
class="entry"
class:active={selected === s.name}
class:active={current === s.name}
style="padding-left: {0.6 + depth * 0.8}rem"
onclick={() => (selected = s.name)}
onclick={() => select(s.name)}
>
{s.name}
{#if isAbstractStyle(s.name)}<span class="badge">{t().styles.abstract}</span>
@@ -194,6 +277,7 @@
</li>
{/each}
</ul>
</div>
<div class="fields">
<!-- Fallbacks are the page's own defaults, not `inherit`: an inline `inherit`
@@ -209,7 +293,7 @@
">{style.name}</div>
<label>{t().styles.parent}
<select value={style.parent ?? ''} onchange={(e) => putStyle({ ...style, parent: e.currentTarget.value || null })}>
<select value={style.parent ?? ''} onchange={(e) => putStyle({ ...style, parent: e.currentTarget.value || null }, family)}>
<option value="">—</option>
{#each parentOptions as name}<option value={name}>{name}</option>{/each}
</select>
@@ -251,6 +335,7 @@
onchange={(e) => edit({ text: { strike: e.currentTarget.checked } })} />{t().styles.strike}</label>
</div>
{#if !isChar}
<div class="field">
<span class="field-label">{t().styles.alignment}</span>
<div class="align-row">
@@ -286,15 +371,17 @@
onchange={(e) => edit({ para: { indent: num(e.currentTarget.value) } })} />
</label>
</div>
{/if}
</div>
</div>
<footer>
{#if isChar}<button onclick={newEmptyStyle}>{t().styles.newStyle}</button>{/if}
<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>
<button onclick={() => resetStyle(style.name, family)}>{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>
@@ -349,13 +436,39 @@
.body { display: flex; gap: 0; max-height: min(30rem, 70vh); }
.list {
.pane {
display: flex;
flex-direction: column;
width: 14rem;
border-right: 1px solid var(--color-border);
}
.family {
display: flex;
padding: 0.4rem 0.4rem 0;
gap: 2px;
}
.family button {
flex: 1;
padding: 0.3rem 0.2rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text-muted);
font: inherit;
font-size: 0.72rem;
cursor: pointer;
}
.family button.active {
background: var(--color-btn-hover);
color: var(--color-text);
}
.list {
width: 100%;
margin: 0;
padding: 0.4rem 0;
list-style: none;
overflow-y: auto;
border-right: 1px solid var(--color-border);
}
.entry {
display: flex;
+33 -3
View File
@@ -211,7 +211,7 @@
</svg>
</button>
{#if stylesOpen}
<div class="ol-dropdown" role="menu">
<div class="ol-dropdown style-dropdown" role="menu">
<div class="ol-section-label">{t().toolbar.styles.title}</div>
{#each galleryStyles as s}
{@const preview = resolveStyle(sheet, s.name)}
@@ -243,6 +243,12 @@
{/each}
{/if}
<button class="ol-option manage" onclick={() => { stylesOpen = false; managerOpen = true; }}>
<!-- Sliders, as on the Tools button: this opens a settings surface -->
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 4.5h8M13 4.5h1M2 11.5h1M6 11.5h8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<circle cx="11.5" cy="4.5" r="1.6" stroke="currentColor" stroke-width="1.4"/>
<circle cx="4.5" cy="11.5" r="1.6" stroke="currentColor" stroke-width="1.4"/>
</svg>
{t().styles.manage}
</button>
</div>
@@ -468,17 +474,41 @@
line-height: 1.2;
}
/* The gallery lists every registered style, so it scrolls inside itself like the
font picker instead of growing past the viewport. */
.style-dropdown {
max-height: 360px;
overflow-y: auto;
}
.char-label {
margin-top: 2px;
border-top: 1px solid var(--color-border);
}
/* Pinned to the scrolling gallery's bottom edge (bottom cancels the dropdown's
padding) so it is reachable without scrolling to the last style. */
.manage {
position: sticky;
bottom: -2px;
margin-top: 2px;
padding: 0.45rem 0.6rem calc(0.45rem + 2px);
gap: 0.45rem;
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border-top: 1px solid var(--color-border);
border-radius: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 600;
white-space: nowrap;
/* Mixed toward the text color so it stays legible on the dark surfaces too. */
color: color-mix(in srgb, var(--color-primary) 55%, var(--color-text));
}
.manage svg {
flex-shrink: 0;
}
.manage:hover {
background: color-mix(in srgb, var(--color-primary) 16%, var(--color-surface));
}
/* Ordered-list split button: main toggle + chevron that opens the numbering
+3 -1
View File
@@ -69,9 +69,11 @@ const de: Messages = {
},
},
styles: {
title: 'Absatzvorlagen',
title: 'Formatvorlagen',
manage: 'Vorlagen verwalten…',
characterStyles: 'Zeichenvorlagen',
paragraphStyles: 'Absatzvorlagen',
newStyle: 'Neu',
custom: 'eigen',
inherited: 'geerbt',
abstract: 'nur Elternstil',
+3 -1
View File
@@ -73,9 +73,11 @@ const en = {
},
},
styles: {
title: 'Paragraph styles',
title: 'Styles',
manage: 'Manage styles…',
characterStyles: 'Character styles',
paragraphStyles: 'Paragraph styles',
newStyle: 'New',
custom: 'own',
inherited: 'inherited',
abstract: 'parent only',
+14 -1
View File
@@ -35,7 +35,8 @@ export function putStyle(style: Style, family: StyleFamily = 'paragraph'): void
// 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 {
export function renameStyle(from: string, to: string, family: StyleFamily = 'paragraph'): void {
if (family === 'character') return renameCharacterStyle(from, to);
const style = current.paragraph[from];
if (!style || from === to || current.paragraph[to]) return;
const paragraph: Record<string, Style> = {};
@@ -77,3 +78,15 @@ export function deleteCharacterStyle(name: string): void {
delete character[name];
setStyleSheet({ ...current, character });
}
function renameCharacterStyle(from: string, to: string): void {
const style = current.character[from];
if (!style || from === to || current.character[to]) return;
const character: Record<string, Style> = {};
for (const [name, s] of Object.entries(current.character)) {
if (name === from) continue;
character[name] = { ...s, parent: s.parent === from ? to : s.parent };
}
character[to] = { ...style, name: to, builtin: undefined };
setStyleSheet({ ...current, character });
}
+4 -3
View File
@@ -271,7 +271,8 @@ export function styleDelta(shown: ResolvedStyle, base: ResolvedStyle): ResolvedS
}
// 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}`;
export function uniqueStyleName(sheet: StyleSheet, base: string, family: StyleFamily = 'paragraph'): string {
const styles = family === 'character' ? sheet.character : sheet.paragraph;
if (!styles[base]) return base;
for (let i = 2; ; i++) if (!styles[`${base} ${i}`]) return `${base} ${i}`;
}