diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e66899..5f813b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/App.svelte b/src/App.svelte index 2731023..d70d1e1 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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(); diff --git a/src/lib/components/StyleManagerDialog.svelte b/src/lib/components/StyleManagerDialog.svelte index d735edb..1209108 100644 --- a/src/lib/components/StyleManagerDialog.svelte +++ b/src/lib/components/StyleManagerDialog.svelte @@ -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('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([]); 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(); - 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) { + 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; } @@ -178,14 +256,19 @@
+
+
+ + +
    {#each rows as { style: s, depth } (s.name)}
  • {/each}
+
+ {t().styles.manage}
@@ -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 diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 5d352bc..a21e65f 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index a8cbbed..7ca4ecf 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/src/lib/styles/sheet.svelte.ts b/src/lib/styles/sheet.svelte.ts index 45f2694..e95fc75 100644 --- a/src/lib/styles/sheet.svelte.ts +++ b/src/lib/styles/sheet.svelte.ts @@ -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 = {}; @@ -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 = {}; + 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 }); +} diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 13d43dc..27700ca 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -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}`; }