diff --git a/src/App.svelte b/src/App.svelte index d03a20b..eab6fbd 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -22,6 +22,7 @@ import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage'; import { spellController } from './lib/spell/controller'; import LanguagePicker from './lib/components/LanguagePicker.svelte'; + import UiLanguagePicker from './lib/components/UiLanguagePicker.svelte'; import AboutDialog from './lib/components/AboutDialog.svelte'; let editor: Editor | null = $state(null); @@ -742,6 +743,7 @@ {/if} + import type { Editor } from '@tiptap/core'; + import { t } from '../i18n/i18n.svelte'; let { editor, tick }: { editor: Editor | null; tick: number } = $props(); type AlignValue = 'left' | 'center' | 'right' | 'justify'; - const ALIGNMENTS: { value: AlignValue; label: string }[] = [ - { value: 'left', label: 'Left' }, - { value: 'center', label: 'Center' }, - { value: 'right', label: 'Right' }, - { value: 'justify', label: 'Justify' }, - ]; + const ALIGNMENTS: AlignValue[] = ['left', 'center', 'right', 'justify']; // Empty string when the selection spans paragraphs/headings with different alignments. let currentAlign = $derived.by(() => { @@ -54,7 +50,7 @@ - + {#if currentAlign === 'center'} @@ -86,27 +82,27 @@ {#if alignOpen} - Alignment + {t().align.section} {#each ALIGNMENTS as a} pickAlign(a.value)} - title="Align {a.label.toLowerCase()}" + class:active={currentAlign === a} + onclick={() => pickAlign(a)} + title={t().align.alignTo(t().align[a])} > - {#if a.value === 'left'} + {#if a === 'left'} - {:else if a.value === 'center'} + {:else if a === 'center'} - {:else if a.value === 'right'} + {:else if a === 'right'} @@ -119,7 +115,7 @@ {/if} - {a.label} + {t().align[a]} {/each} diff --git a/src/lib/components/HistoryButton.svelte b/src/lib/components/HistoryButton.svelte index 014d34e..03ef9b2 100644 --- a/src/lib/components/HistoryButton.svelte +++ b/src/lib/components/HistoryButton.svelte @@ -1,6 +1,8 @@ + + + (open = !open)} + title={t().appearance.language} + aria-haspopup="true" + aria-expanded={open} + > + + + + + + {#if open} + + {t().appearance.language} + {#each LOCALES as l} + pick(l)} + role="menuitem" + > + {LOCALE_LABELS[l]} + + {/each} + + {/if} + + + diff --git a/src/lib/i18n/config.ts b/src/lib/i18n/config.ts new file mode 100644 index 0000000..5773f2e --- /dev/null +++ b/src/lib/i18n/config.ts @@ -0,0 +1,20 @@ +// UI locale configuration. Plain module (no runes), safe to import anywhere — +// keeps appLanguage.ts and i18n.svelte.ts free of circular runes imports. + +export const LOCALES = ['en', 'de'] as const; +export type Locale = (typeof LOCALES)[number]; + +export const LOCALE_LABELS: Record = { + en: 'English', + de: 'Deutsch', +}; + +export function isLocale(value: string | null): value is Locale { + return !!value && (LOCALES as readonly string[]).includes(value); +} + +// navigator.language ('de-AT', 'en-GB', …) → a supported base locale, default 'en'. +export function resolveBrowserLocale(): Locale { + const base = (navigator.language || 'en').slice(0, 2).toLowerCase(); + return isLocale(base) ? base : 'en'; +} diff --git a/src/lib/i18n/i18n.svelte.ts b/src/lib/i18n/i18n.svelte.ts new file mode 100644 index 0000000..7d4abce --- /dev/null +++ b/src/lib/i18n/i18n.svelte.ts @@ -0,0 +1,27 @@ +// Reactive i18n core. `current` is a $state rune; any component reading t() in a +// template or $derived re-renders when setLocale() reassigns it — no store bridge. + +import en, { type Messages } from './locales/en'; +import de from './locales/de'; +import { loadAppLanguage, saveAppLanguage } from '../storage/appLanguage'; +import type { Locale } from './config'; + +const catalogs: Record = { en, de }; + +let current = $state(loadAppLanguage()); + +// The active catalog. Reading this tracks `current`, so callers stay reactive. +export function t(): Messages { + return catalogs[current]; +} + +export function locale(): Locale { + return current; +} + +export function setLocale(next: Locale): void { + current = next; + saveAppLanguage(next); + // = UI locale (chrome a11y); document content language is separate. + document.documentElement.lang = next; +} diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts new file mode 100644 index 0000000..229bf25 --- /dev/null +++ b/src/lib/i18n/locales/de.ts @@ -0,0 +1,124 @@ +// German catalog. Typed as `Messages`, so a missing or mistyped key — or an +// interpolation function with the wrong signature — is a svelte-check error. + +import type { Messages } from './en'; + +const de: Messages = { + common: { + ok: 'OK', + cancel: 'Abbrechen', + close: 'Schließen', + apply: 'Übernehmen', + remove: 'Entfernen', + }, + appearance: { + language: 'Sprache', + }, + shortcut: { + ctrl: 'Strg', + shift: 'Umschalt', + }, + align: { + title: 'Textausrichtung', + section: 'Ausrichtung', + left: 'Links', + center: 'Zentriert', + right: 'Rechts', + justify: 'Blocksatz', + alignTo: (label: string) => `${label} ausrichten`, + }, + toolbar: { + bold: 'Fett', + italic: 'Kursiv', + underline: 'Unterstrichen', + strikethrough: 'Durchgestrichen', + heading1: 'Überschrift 1', + heading2: 'Überschrift 2', + heading3: 'Überschrift 3', + bulletList: 'Aufzählung', + bulletSymbol: 'Aufzählungszeichen', + bulletDefault: 'Standard (nach Ebene)', + orderedList: 'Nummerierte Liste', + numberingStyle: 'Nummerierungsstil', + numbering: 'Nummerierung', + bullets: { + '•': 'Punkt', + '◦': 'Kreis', + '▪': 'Quadrat', + '❖': 'Raute', + '➢': 'Pfeilspitze', + '⇨': 'Pfeil', + '✓': 'Haken', + '–': 'Strich', + '>': 'Winkel', + }, + }, + history: { + undo: 'Rückgängig', + redo: 'Wiederholen', + heading: (dir) => (dir === 'undo' ? 'Rückgängig-Verlauf' : 'Wiederherstellungsverlauf'), + revertCount: (dir, n) => + `${n} ${n === 1 ? 'Aktion' : 'Aktionen'} ${dir === 'undo' ? 'rückgängig machen' : 'wiederholen'}`, + actionCount: (n) => `${n} ${n === 1 ? 'Aktion' : 'Aktionen'}`, + }, + toolbarExpanded: { + fontName: 'Schriftart', + recent: 'Zuletzt', + webSafe: 'Web-sicher', + allFonts: 'Alle Schriften', + loadAllFonts: 'Alle installierten Schriften laden', + fontSize: 'Schriftgröße', + fontSizeList: 'Schriftgrößenliste', + fontColor: 'Schriftfarbe', + chooseFontColor: 'Schriftfarbe wählen', + highlightColor: 'Hervorhebungsfarbe', + chooseHighlightColor: 'Hervorhebungsfarbe wählen', + noColor: 'Keine Farbe', + superscript: 'Hochgestellt', + subscript: 'Tiefgestellt', + decreaseIndent: 'Einzug verkleinern', + increaseIndent: 'Einzug vergrößern', + lineParagraphSpacing: 'Zeilen- & Absatzabstand', + lineSpacing: 'Zeilenabstand', + lineSingle: 'Einfach', + lineDouble: 'Doppelt', + spaceBefore: 'Abstand vor Absatz (pt)', + spaceAfter: 'Abstand nach Absatz (pt)', + spaceBeforePresets: 'Voreinstellungen Abstand davor', + spaceAfterPresets: 'Voreinstellungen Abstand danach', + spacingDefault: 'Standard', + pt: (v) => `${v} pt`, + pageLayoutMargins: 'Seitenlayout & Ränder', + layout: 'Layout', + orientation: 'Ausrichtung', + portrait: 'Hochformat', + landscape: 'Querformat', + pageMargins: 'Seitenränder (cm)', + margins: { top: 'Oben', bottom: 'Unten', left: 'Links', right: 'Rechts' }, + marginField: (label) => `Rand ${label.toLowerCase()} (cm)`, + increaseMargin: (label) => `Rand ${label.toLowerCase()} vergrößern`, + decreaseMargin: (label) => `Rand ${label.toLowerCase()} verkleinern`, + headerFooter: 'Kopf- & Fußzeile', + editHeader: 'Kopfzeile bearbeiten', + editFooter: 'Fußzeile bearbeiten', + position: 'Position (cm)', + hfDist: { header: 'Kopfzeile von oben', footer: 'Fußzeile von unten' }, + hfField: (label) => `${label} (cm)`, + increaseShort: 'Vergrößern', + decreaseShort: 'Verkleinern', + increase: (label) => `${label} vergrößern`, + decrease: (label) => `${label} verkleinern`, + formattingMarks: 'Formatierungszeichen', + insertImage: 'Bild einfügen', + imageNotInHf: 'Bilder sind in Kopf-/Fußzeilen nicht verfügbar', + insertTextBox: 'Textfeld einfügen', + textBoxNotInHf: 'Textfelder sind in Kopf-/Fußzeilen nicht verfügbar', + insertToc: 'Inhaltsverzeichnis einfügen', + tocNotInHf: 'Ein Inhaltsverzeichnis ist in Kopf-/Fußzeilen nicht verfügbar', + insertLink: 'Link einfügen', + linkNotInHf: 'Links sind in Kopf-/Fußzeilen nicht verfügbar', + clearFormatting: 'Formatierung entfernen', + }, +}; + +export default de; diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts new file mode 100644 index 0000000..07b252f --- /dev/null +++ b/src/lib/i18n/locales/en.ts @@ -0,0 +1,128 @@ +// English catalog — the source of truth. `type Messages = typeof en` is the +// contract every other locale must satisfy, so a missing key fails svelte-check. +// Interpolated/pluralized entries are functions; everything else is a string. + +type HistoryDir = 'undo' | 'redo'; + +const en = { + common: { + ok: 'OK', + cancel: 'Cancel', + close: 'Close', + apply: 'Apply', + remove: 'Remove', + }, + appearance: { + language: 'Language', + }, + shortcut: { + ctrl: 'Ctrl', + shift: 'Shift', + }, + align: { + title: 'Text alignment', + section: 'Alignment', + left: 'Left', + center: 'Center', + right: 'Right', + justify: 'Justify', + // e.g. "Align left" from the translated alignment label. + alignTo: (label: string) => `Align ${label.toLowerCase()}`, + }, + toolbar: { + bold: 'Bold', + italic: 'Italic', + underline: 'Underline', + strikethrough: 'Strikethrough', + heading1: 'Heading 1', + heading2: 'Heading 2', + heading3: 'Heading 3', + bulletList: 'Bullet list', + bulletSymbol: 'Bullet symbol', + bulletDefault: 'Default (by level)', + orderedList: 'Ordered list', + numberingStyle: 'Numbering style', + numbering: 'Numbering', + // Bullet-marker tooltips, keyed by the marker char (bulletListTypes.ts). + bullets: { + '•': 'Disc', + '◦': 'Circle', + '▪': 'Square', + '❖': 'Diamond', + '➢': 'Arrowhead', + '⇨': 'Arrow', + '✓': 'Check', + '–': 'Dash', + '>': 'Chevron', + }, + }, + history: { + undo: 'Undo', + redo: 'Redo', + heading: (dir: HistoryDir) => `${dir === 'undo' ? 'Undo' : 'Redo'} history`, + revertCount: (dir: HistoryDir, n: number) => + `${dir === 'undo' ? 'Undo' : 'Redo'} ${n} action${n === 1 ? '' : 's'}`, + actionCount: (n: number) => `${n} action${n === 1 ? '' : 's'}`, + }, + toolbarExpanded: { + fontName: 'Font Name', + recent: 'Recent', + webSafe: 'Web-safe', + allFonts: 'All fonts', + loadAllFonts: 'Load all installed fonts', + fontSize: 'Font size', + fontSizeList: 'Font size list', + fontColor: 'Font color', + chooseFontColor: 'Choose font color', + highlightColor: 'Highlight color', + chooseHighlightColor: 'Choose highlight color', + noColor: 'No color', + superscript: 'Superscript', + subscript: 'Subscript', + decreaseIndent: 'Decrease indent', + increaseIndent: 'Increase indent', + lineParagraphSpacing: 'Line & paragraph spacing', + lineSpacing: 'Line spacing', + lineSingle: 'Single', + lineDouble: 'Double', + spaceBefore: 'Space before paragraph (pt)', + spaceAfter: 'Space after paragraph (pt)', + spaceBeforePresets: 'Space before presets', + spaceAfterPresets: 'Space after presets', + spacingDefault: 'Default', + pt: (v: number) => `${v} pt`, + pageLayoutMargins: 'Page layout & margins', + layout: 'Layout', + orientation: 'Orientation', + portrait: 'Portrait', + landscape: 'Landscape', + pageMargins: 'Page margins (cm)', + margins: { top: 'Top', bottom: 'Bottom', left: 'Left', right: 'Right' }, + marginField: (label: string) => `${label} margin (cm)`, + increaseMargin: (label: string) => `Increase ${label.toLowerCase()} margin`, + decreaseMargin: (label: string) => `Decrease ${label.toLowerCase()} margin`, + headerFooter: 'Header & footer', + editHeader: 'Edit header', + editFooter: 'Edit footer', + position: 'Position (cm)', + hfDist: { header: 'Header from top', footer: 'Footer from bottom' }, + hfField: (label: string) => `${label} (cm)`, + increaseShort: 'Increase', + decreaseShort: 'Decrease', + increase: (label: string) => `Increase ${label.toLowerCase()}`, + decrease: (label: string) => `Decrease ${label.toLowerCase()}`, + formattingMarks: 'Formatting marks', + insertImage: 'Insert image', + imageNotInHf: 'Images are not available in headers/footers', + insertTextBox: 'Insert text box', + textBoxNotInHf: 'Text boxes are not available in headers/footers', + insertToc: 'Insert table of contents', + tocNotInHf: 'A table of contents is not available in headers/footers', + insertLink: 'Insert link', + linkNotInHf: 'Links are not available in headers/footers', + clearFormatting: 'Clear formatting', + }, +}; + +export type Messages = typeof en; +export default en; diff --git a/src/lib/i18n/shortcut.ts b/src/lib/i18n/shortcut.ts new file mode 100644 index 0000000..0a0e1aa --- /dev/null +++ b/src/lib/i18n/shortcut.ts @@ -0,0 +1,14 @@ +// Platform- and locale-aware keyboard-shortcut hints. Catalog messages store only +// the label; the shortcut suffix is composed at render. Mac always shows ⌘/⇧; other +// platforms use the locale's modifier names (Ctrl/Shift, Strg/Umschalt). + +import { t } from './i18n.svelte'; + +const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform); + +// 'Ctrl+Shift+S' → '⌘+⇧+S' on Mac, 'Strg+Umschalt+S' in German, etc. +export function withShortcut(combo: string): string { + const mod = IS_MAC ? '⌘' : t().shortcut.ctrl; + const shift = IS_MAC ? '⇧' : t().shortcut.shift; + return combo.replace('Ctrl', mod).replace('Shift', shift); +} diff --git a/src/lib/storage/appLanguage.ts b/src/lib/storage/appLanguage.ts new file mode 100644 index 0000000..41f7d54 --- /dev/null +++ b/src/lib/storage/appLanguage.ts @@ -0,0 +1,15 @@ +// The UI language, persisted to localStorage. Distinct from the per-document +// spell-check language (documentLanguage.ts): this drives only the app chrome. + +import { isLocale, resolveBrowserLocale, type Locale } from '../i18n/config'; + +const KEY = 'odf-editor-app-language'; + +export function loadAppLanguage(): Locale { + const saved = localStorage.getItem(KEY); + return isLocale(saved) ? saved : resolveBrowserLocale(); +} + +export function saveAppLanguage(locale: Locale): void { + localStorage.setItem(KEY, locale); +} diff --git a/src/main.ts b/src/main.ts index 059206a..b01bddf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,10 +2,14 @@ import { mount } from 'svelte'; import App from './App.svelte'; import './styles/global.css'; import { loadTheme, applyTheme } from './lib/storage/theme'; +import { locale } from './lib/i18n/i18n.svelte'; // Apply saved theme before mount to prevent flash of wrong theme applyTheme(loadTheme()); +// Resolve the UI locale before mount and reflect it on (chrome a11y) +document.documentElement.lang = locale(); + // Keep 'auto' in sync when system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (loadTheme() === 'auto') applyTheme('auto');