feat: add i18n catalog

This commit is contained in:
Steffen Becker committed 2026-07-05 15:36:32 +02:00
1 parent 7faa16c89d
commit 2cbc0edae1
13 files changed
+603 -142

No files matched your search

+2
View File
@@ -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 @@
</div>
{/if}
</div>
<UiLanguagePicker />
<input
bind:this={fileInput}
type="file"
+11 -15
View File
@@ -1,16 +1,12 @@
<script lang="ts">
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<AlignValue | ''>(() => {
@@ -54,7 +50,7 @@
</script>
<div class="align-picker" use:alignPickerClickOutside>
<button class="align-trigger" onclick={openAlignPicker} title="Text alignment">
<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"/>
@@ -86,27 +82,27 @@
</button>
{#if alignOpen}
<div class="align-dropdown">
<div class="align-section-label">Alignment</div>
<div class="align-section-label">{t().align.section}</div>
{#each ALIGNMENTS as a}
<button
class="align-option"
class:active={currentAlign === a.value}
onclick={() => 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'}
<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.value === 'center'}
{: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.value === 'right'}
{: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"/>
@@ -119,7 +115,7 @@
<line x1="2" y1="11" x2="14" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{/if}
<span>{a.label}</span>
<span>{t().align[a]}</span>
</button>
{/each}
</div>
+7 -7
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { historyLog, undoSteps, redoSteps } from '../utils/historyLog.svelte';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
let { editor, tick, direction }: { editor: Editor | null; tick: number; direction: 'undo' | 'redo' } = $props();
@@ -13,8 +15,6 @@
tick >= 0 && (direction === 'undo' ? !!editor?.can().undo() : !!editor?.can().redo()),
);
let verb = $derived(direction === 'undo' ? 'Undo' : 'Redo');
let open = $state(false);
let hoverIndex = $state(-1);
@@ -72,7 +72,7 @@
class="history-main"
onclick={runSingle}
disabled={!canRun}
title={direction === 'undo' ? 'Undo (Ctrl+Z)' : 'Redo (Ctrl+Shift+Z)'}
title={`${direction === 'undo' ? t().history.undo : t().history.redo} (${withShortcut(direction === 'undo' ? 'Ctrl+Z' : 'Ctrl+Shift+Z')})`}
>
{direction === 'undo' ? '↩' : '↪'}
</button>
@@ -82,7 +82,7 @@
disabled={displayed.length === 0}
aria-haspopup="menu"
aria-expanded={open}
title={`${verb} history`}
title={t().history.heading(direction)}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5 L4 5.5 L7 2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
@@ -93,7 +93,7 @@
{#if open && displayed.length > 0}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<div class="history-dropdown">
<div class="history-heading">{verb} history</div>
<div class="history-heading">{t().history.heading(direction)}</div>
<div class="history-list" role="menu" tabindex="-1" onmouseleave={() => (hoverIndex = -1)}>
{#each displayed as entry, i}
<button
@@ -111,9 +111,9 @@
</div>
<div class="history-footer">
{#if hoverIndex >= 0}
{verb} {hoverIndex + 1} action{hoverIndex > 0 ? 's' : ''}
{t().history.revertCount(direction, hoverIndex + 1)}
{:else}
{displayed.length} action{displayed.length !== 1 ? 's' : ''}
{t().history.actionCount(displayed.length)}
{/if}
</div>
</div>
+34 -27
View File
@@ -6,6 +6,13 @@
import { BULLET_TYPES } from '../utils/bulletListTypes';
import { effectiveOrderedTypeAt } from '../editor/extensions/orderedList';
import { isInHeaderCell } from '../editor/extensions/tableHeaderRow';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
// Bullet-marker tooltip by char, with the char itself as fallback.
function bulletName(char: string): string {
return (t().toolbar.bullets as Record<string, string>)[char] ?? char;
}
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
@@ -103,28 +110,28 @@
editor?.chain().focus().toggleBold().run();
}
}}
title="Bold (Ctrl+B)"
title={`${t().toolbar.bold} (${withShortcut('Ctrl+B')})`}
>
<strong>B</strong>
</button>
<button
class:active={isItalic}
onclick={() => editor?.chain().focus().toggleItalic().run()}
title="Italic (Ctrl+I)"
title={`${t().toolbar.italic} (${withShortcut('Ctrl+I')})`}
>
<em>I</em>
</button>
<button
class:active={isUnderline}
onclick={() => editor?.chain().focus().toggleUnderline().run()}
title="Underline (Ctrl+U)"
title={`${t().toolbar.underline} (${withShortcut('Ctrl+U')})`}
>
<u>U</u>
</button>
<button
class:active={isStrike}
onclick={() => editor?.chain().focus().toggleStrike().run()}
title="Strikethrough (Ctrl+Shift+S)"
title={`${t().toolbar.strikethrough} (${withShortcut('Ctrl+Shift+S')})`}
>
<s>S</s>
</button>
@@ -136,21 +143,21 @@
<button
class:active={isH1}
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title="Heading 1"
title={t().toolbar.heading1}
>
H1
</button>
<button
class:active={isH2}
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title="Heading 2"
title={t().toolbar.heading2}
>
H2
</button>
<button
class:active={isH3}
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).unsetFontSize().unsetFontWeight().removeEmptyTextStyle().run()}
title="Heading 3"
title={t().toolbar.heading3}
>
H3
</button>
@@ -165,7 +172,7 @@
class="ol-main"
class:active={isBulletList}
onclick={toggleBulletList}
title="Bullet list"
title={t().toolbar.bulletList}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="2" cy="4" r="1.5" fill="currentColor"/>
@@ -180,7 +187,7 @@
class="ol-chevron"
class:active={isBulletList}
onclick={() => (blMenuOpen = !blMenuOpen)}
title="Bullet symbol"
title={t().toolbar.bulletSymbol}
aria-haspopup="menu"
aria-expanded={blMenuOpen}
>
@@ -191,7 +198,7 @@
</div>
{#if blMenuOpen}
<div class="ol-dropdown bl-dropdown" role="menu">
<div class="ol-section-label">Bullet symbol</div>
<div class="ol-section-label">{t().toolbar.bulletSymbol}</div>
<button
class="ol-option"
class:active={isBulletList && currentBulletChar === null}
@@ -200,18 +207,18 @@
aria-checked={isBulletList && currentBulletChar === null}
>
<span class="ol-option-preview">•</span>
<span class="ol-option-label">Default (by level)</span>
<span class="ol-option-label">{t().toolbar.bulletDefault}</span>
</button>
<div class="bl-grid">
{#each BULLET_TYPES as t}
{#each BULLET_TYPES as b}
<button
class="bl-tile"
class:active={currentBulletChar === t.char}
onclick={() => applyBulletChar(t.char)}
title={t.label}
class:active={currentBulletChar === b.char}
onclick={() => applyBulletChar(b.char)}
title={bulletName(b.char)}
role="menuitemradio"
aria-checked={currentBulletChar === t.char}
>{t.char}</button>
aria-checked={currentBulletChar === b.char}
>{b.char}</button>
{/each}
</div>
</div>
@@ -223,7 +230,7 @@
class="ol-main"
class:active={isOrderedList}
onclick={toggleOrderedList}
title="Ordered list"
title={t().toolbar.orderedList}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="0" y="5.5" font-size="5" font-weight="bold" font-family="sans-serif" fill="currentColor">1.</text>
@@ -238,7 +245,7 @@
class="ol-chevron"
class:active={isOrderedList}
onclick={() => (olMenuOpen = !olMenuOpen)}
title="Numbering style"
title={t().toolbar.numberingStyle}
aria-haspopup="menu"
aria-expanded={olMenuOpen}
>
@@ -249,18 +256,18 @@
</div>
{#if olMenuOpen}
<div class="ol-dropdown" role="menu">
<div class="ol-section-label">Numbering</div>
{#each ORDERED_LIST_TYPES as t}
<div class="ol-section-label">{t().toolbar.numbering}</div>
{#each ORDERED_LIST_TYPES as o}
<button
class="ol-option"
class:active={currentOrderedType === t.key}
onclick={() => applyOrderedType(t.key)}
title={t.label}
class:active={currentOrderedType === o.key}
onclick={() => applyOrderedType(o.key)}
title={o.label}
role="menuitemradio"
aria-checked={currentOrderedType === t.key}
aria-checked={currentOrderedType === o.key}
>
<span class="ol-option-preview">{t.preview}</span>
<span class="ol-option-label">{t.label}</span>
<span class="ol-option-preview">{o.preview}</span>
<span class="ol-option-label">{o.label}</span>
</button>
{/each}
</div>
+89 -93
View File
@@ -16,6 +16,8 @@
import type { Orientation } from '../storage/pageOrientation';
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
import { listContext } from '../editor/extensions/indent';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone, onDebugDump }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void } = $props();
@@ -151,12 +153,14 @@
// Must match line-height in editor.css (.paper .tiptap)
const DEFAULT_LINE_HEIGHT = '1';
const LINE_HEIGHTS = [
{ value: '1', label: 'Single' },
{ value: '1.15', label: '1.15' },
{ value: '1.5', label: '1.5' },
{ value: '2', label: 'Double' },
] as const;
const LINE_HEIGHTS = ['1', '1.15', '1.5', '2'];
// Numeric values show verbatim; the single/double presets are localized.
function lineHeightLabel(value: string): string {
if (value === '1') return t().toolbarExpanded.lineSingle;
if (value === '2') return t().toolbarExpanded.lineDouble;
return value;
}
let currentLineHeight = $derived.by(() => {
if (tick < 0 || !editor) return DEFAULT_LINE_HEIGHT;
@@ -180,7 +184,7 @@
const SPACING_PRESETS: (number | null)[] = [null, 0, 6, 12, 18, 24];
function spacingLabel(v: number | null): string {
return v === null ? 'Default' : `${v} pt`;
return v === null ? t().toolbarExpanded.spacingDefault : t().toolbarExpanded.pt(v);
}
function currentSpacing(attr: 'spaceBefore' | 'spaceAfter'): number | null | '' {
@@ -422,9 +426,9 @@
// Parse a free-text input. Empty → Default (null); otherwise a non-negative pt
// number (capped). Invalid input is ignored.
function applySpaceInput(axis: 'before' | 'after', raw: string) {
const t = raw.trim();
if (t === '') return applySpaceValue(axis, null);
const n = parseFloat(t);
const trimmed = raw.trim();
if (trimmed === '') return applySpaceValue(axis, null);
const n = parseFloat(trimmed);
if (isNaN(n) || n < 0 || n > 200) return;
applySpaceValue(axis, n);
}
@@ -496,12 +500,7 @@
// --- Page layout / margins (cm) ---
type MarginAxis = 'top' | 'bottom' | 'left' | 'right';
const MARGIN_FIELDS: { axis: MarginAxis; label: string }[] = [
{ axis: 'top', label: 'Top' },
{ axis: 'bottom', label: 'Bottom' },
{ axis: 'left', label: 'Left' },
{ axis: 'right', label: 'Right' },
];
const MARGIN_FIELDS: MarginAxis[] = ['top', 'bottom', 'left', 'right'];
const MARGIN_STEP = 0.1;
const MARGIN_MIN = 0;
const MARGIN_MAX = 10;
@@ -563,10 +562,7 @@
// --- Header/footer edge distances (cm) ---
type HfAxis = 'header' | 'footer';
const HF_DIST_FIELDS: { axis: HfAxis; label: string }[] = [
{ axis: 'header', label: 'Header from top' },
{ axis: 'footer', label: 'Footer from bottom' },
];
const HF_DIST_FIELDS: HfAxis[] = ['header', 'footer'];
let hfDistInputs = $state<Record<HfAxis, string>>({ header: '', footer: '' });
function syncHfDistInputs() {
@@ -736,11 +732,11 @@
// Add a scheme/mailto when the user types a bare host or e-mail (like Word/LO).
function normalizeUrl(raw: string): string {
const t = raw.trim();
if (!t) return '';
if (/^(https?:|mailto:|tel:|ftp:|#|\/)/i.test(t)) return t;
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) return `mailto:${t}`;
return `https://${t}`;
const trimmed = raw.trim();
if (!trimmed) return '';
if (/^(https?:|mailto:|tel:|ftp:|#|\/)/i.test(trimmed)) return trimmed;
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return `mailto:${trimmed}`;
return `https://${trimmed}`;
}
function applyLink(rawUrl: string) {
@@ -794,7 +790,7 @@
<div class="toolbar-expanded">
{#if editor}
<div class="font-picker" use:fontPickerClickOutside>
<button class="font-trigger" onclick={openFontPicker} title="Font Name">
<button class="font-trigger" onclick={openFontPicker} title={t().toolbarExpanded.fontName}>
<span class="font-trigger-label" style={currentFont ? `font-family: ${currentFont}` : ''}>{currentFont}</span>
<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"/>
@@ -803,7 +799,7 @@
{#if fontOpen}
<div class="font-dropdown">
{#if recentFonts.length > 0}
<div class="font-section-label">Recent</div>
<div class="font-section-label">{t().toolbarExpanded.recent}</div>
{#each recentFonts as font}
<button
class="font-option"
@@ -814,7 +810,7 @@
{/each}
{/if}
<div class="font-section-label">Web-safe</div>
<div class="font-section-label">{t().toolbarExpanded.webSafe}</div>
{#each WEB_SAFE_FONTS as font}
<button
class="font-option"
@@ -825,7 +821,7 @@
{/each}
{#if extraFontsList.length > 0}
<div class="font-section-label">All fonts</div>
<div class="font-section-label">{t().toolbarExpanded.allFonts}</div>
{#each extraFontsList as font}
<button
class="font-option"
@@ -838,7 +834,7 @@
{#if localFontAccessSupported && !allInstalledFonts}
<button class="font-show-all" onclick={showAllInstalledFonts}>
Load all installed fonts
{t().toolbarExpanded.loadAllFonts}
</button>
{/if}
</div>
@@ -855,9 +851,9 @@
onkeydown={onSizeInputKeydown}
onblur={onSizeInputBlur}
inputmode="numeric"
title="Font size"
title={t().toolbarExpanded.fontSize}
/>
<button class="size-chevron" onclick={openSizePicker} tabindex="-1" title="Font size list">
<button class="size-chevron" onclick={openSizePicker} tabindex="-1" title={t().toolbarExpanded.fontSizeList}>
<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>
@@ -865,7 +861,7 @@
</div>
{#if sizeOpen}
<div class="size-dropdown">
<div class="lh-section-label">Font size</div>
<div class="lh-section-label">{t().toolbarExpanded.fontSize}</div>
{#each FONT_SIZES as size}
<button
class="size-option"
@@ -884,8 +880,8 @@
{editor}
currentColor={currentFontColor}
defaultColor="#C00000"
title="Font color"
chevronTitle="Choose font color"
title={t().toolbarExpanded.fontColor}
chevronTitle={t().toolbarExpanded.chooseFontColor}
bind:open={fontColorOpen}
onOpen={() => onColorPickerOpen('font')}
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setColor(c).run()}
@@ -901,9 +897,9 @@
{editor}
currentColor={currentHighlightColor}
defaultColor="#FFFF00"
title="Highlight color"
chevronTitle="Choose highlight color"
clearLabel="No color"
title={t().toolbarExpanded.highlightColor}
chevronTitle={t().toolbarExpanded.chooseHighlightColor}
clearLabel={t().toolbarExpanded.noColor}
bind:open={highlightColorOpen}
onOpen={() => onColorPickerOpen('highlight')}
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setHighlight({ color: c }).run()}
@@ -924,7 +920,7 @@
<button
class:active={isSuperscript}
onclick={toggleSuperscript}
title="Superscript (Ctrl+.)"
title={`${t().toolbarExpanded.superscript} (${withShortcut('Ctrl+.')})`}
aria-pressed={isSuperscript}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -935,7 +931,7 @@
<button
class:active={isSubscript}
onclick={toggleSubscript}
title="Subscript (Ctrl+,)"
title={`${t().toolbarExpanded.subscript} (${withShortcut('Ctrl+,')})`}
aria-pressed={isSubscript}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -948,7 +944,7 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button onclick={() => changeIndent(-1)} title="Decrease indent">
<button onclick={() => changeIndent(-1)} title={t().toolbarExpanded.decreaseIndent}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="5" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="8" x2="14" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
@@ -956,7 +952,7 @@
<path d="M5.5 8H1.5M3.5 5.5L1.5 8L3.5 10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button onclick={() => changeIndent(1)} title="Increase indent">
<button onclick={() => changeIndent(1)} title={t().toolbarExpanded.increaseIndent}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="5" y1="3" x2="14" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="8" x2="14" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
@@ -969,7 +965,7 @@
<div class="toolbar-separator"></div>
<div class="lh-picker" use:lineHeightPickerClickOutside>
<button class="lh-trigger" onclick={openLineHeightPicker} title="Line &amp; paragraph spacing">
<button class="lh-trigger" onclick={openLineHeightPicker} title={t().toolbarExpanded.lineParagraphSpacing}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<line x1="8" y1="3" x2="16" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
@@ -983,16 +979,16 @@
</button>
{#if lineHeightOpen}
<div class="lh-dropdown">
<div class="lh-section-label">Line spacing</div>
<div class="lh-section-label">{t().toolbarExpanded.lineSpacing}</div>
{#each LINE_HEIGHTS as lh}
<button
class="lh-option"
class:active={currentLineHeight === lh.value}
onclick={() => pickLineHeight(lh.value)}
>{lh.label}</button>
class:active={currentLineHeight === lh}
onclick={() => pickLineHeight(lh)}
>{lineHeightLabel(lh)}</button>
{/each}
<div class="lh-section-label">Space before paragraph (pt)</div>
<div class="lh-section-label">{t().toolbarExpanded.spaceBefore}</div>
<div class="sp-field" use:spacePresetClickOutside={'before'}>
<div class="sp-input-wrap">
<input
@@ -1002,11 +998,11 @@
onfocus={(e) => onSpaceFocus('before', e)}
onkeydown={(e) => onSpaceKeydown('before', e)}
onblur={() => onSpaceBlur('before')}
placeholder="Default"
placeholder={t().toolbarExpanded.spacingDefault}
inputmode="decimal"
title="Space before paragraph (pt)"
title={t().toolbarExpanded.spaceBefore}
/>
<button class="sp-chevron" onclick={() => openSpacePreset('before')} tabindex="-1" title="Space before presets">
<button class="sp-chevron" onclick={() => openSpacePreset('before')} tabindex="-1" title={t().toolbarExpanded.spaceBeforePresets}>
<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>
@@ -1025,7 +1021,7 @@
{/if}
</div>
<div class="lh-section-label">Space after paragraph (pt)</div>
<div class="lh-section-label">{t().toolbarExpanded.spaceAfter}</div>
<div class="sp-field" use:spacePresetClickOutside={'after'}>
<div class="sp-input-wrap">
<input
@@ -1035,11 +1031,11 @@
onfocus={(e) => onSpaceFocus('after', e)}
onkeydown={(e) => onSpaceKeydown('after', e)}
onblur={() => onSpaceBlur('after')}
placeholder="Default"
placeholder={t().toolbarExpanded.spacingDefault}
inputmode="decimal"
title="Space after paragraph (pt)"
title={t().toolbarExpanded.spaceAfter}
/>
<button class="sp-chevron" onclick={() => openSpacePreset('after')} tabindex="-1" title="Space after presets">
<button class="sp-chevron" onclick={() => openSpacePreset('after')} tabindex="-1" title={t().toolbarExpanded.spaceAfterPresets}>
<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>
@@ -1062,67 +1058,67 @@
</div>
<div class="layout-picker right-group" use:layoutClickOutside>
<button class="layout-trigger" onclick={openLayout} title="Page layout &amp; margins" aria-pressed={layoutOpen}>
<button class="layout-trigger" onclick={openLayout} title={t().toolbarExpanded.pageLayoutMargins} aria-pressed={layoutOpen}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2.5" y="1.5" width="11" height="13" rx="1" stroke="currentColor" stroke-width="1.3"/>
<rect x="4.5" y="3.5" width="7" height="9" rx="0.5" stroke="currentColor" stroke-width="1" stroke-dasharray="2 1.5"/>
</svg>
<span class="layout-trigger-label">Layout</span>
<span class="layout-trigger-label">{t().toolbarExpanded.layout}</span>
<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>
</button>
{#if layoutOpen}
<div class="layout-dropdown">
<div class="lh-section-label">Orientation</div>
<div class="lh-section-label">{t().toolbarExpanded.orientation}</div>
<div class="orientation-row">
<button
class="orientation-btn"
class:active={pageOrientation === 'portrait'}
aria-pressed={pageOrientation === 'portrait'}
onclick={() => (pageOrientation = 'portrait')}
title="Portrait"
title={t().toolbarExpanded.portrait}
>
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" aria-hidden="true">
<rect x="2.5" y="1.5" width="9" height="13" rx="1" stroke="currentColor" stroke-width="1.3"/>
</svg>
<span>Portrait</span>
<span>{t().toolbarExpanded.portrait}</span>
</button>
<button
class="orientation-btn"
class:active={pageOrientation === 'landscape'}
aria-pressed={pageOrientation === 'landscape'}
onclick={() => (pageOrientation = 'landscape')}
title="Landscape"
title={t().toolbarExpanded.landscape}
>
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" aria-hidden="true">
<rect x="1.5" y="2.5" width="13" height="9" rx="1" stroke="currentColor" stroke-width="1.3"/>
</svg>
<span>Landscape</span>
<span>{t().toolbarExpanded.landscape}</span>
</button>
</div>
<div class="lh-section-label">Page margins (cm)</div>
<div class="lh-section-label">{t().toolbarExpanded.pageMargins}</div>
<div class="margin-grid">
{#each MARGIN_FIELDS as f}
{#each MARGIN_FIELDS as axis}
<div class="margin-field">
<span class="margin-label">{f.label}</span>
<span class="margin-label">{t().toolbarExpanded.margins[axis]}</span>
<div class="margin-input-wrap">
<input
type="text"
class="margin-input"
bind:value={marginInputs[f.axis]}
onkeydown={(e) => onMarginKeydown(f.axis, e)}
onblur={() => commitMarginInput(f.axis)}
bind:value={marginInputs[axis]}
onkeydown={(e) => onMarginKeydown(axis, e)}
onblur={() => commitMarginInput(axis)}
inputmode="decimal"
title="{f.label} margin (cm)"
title={t().toolbarExpanded.marginField(t().toolbarExpanded.margins[axis])}
/>
<div class="margin-steppers">
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(f.axis, MARGIN_STEP)} title="Increase {f.label.toLowerCase()} margin" aria-label="Increase {f.label.toLowerCase()} margin">
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(axis, MARGIN_STEP)} title={t().toolbarExpanded.increaseMargin(t().toolbarExpanded.margins[axis])} aria-label={t().toolbarExpanded.increaseMargin(t().toolbarExpanded.margins[axis])}>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(f.axis, -MARGIN_STEP)} title="Decrease {f.label.toLowerCase()} margin" aria-label="Decrease {f.label.toLowerCase()} margin">
<button class="margin-step" tabindex="-1" onclick={() => stepMargin(axis, -MARGIN_STEP)} title={t().toolbarExpanded.decreaseMargin(t().toolbarExpanded.margins[axis])} aria-label={t().toolbarExpanded.decreaseMargin(t().toolbarExpanded.margins[axis])}>
<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>
@@ -1133,42 +1129,42 @@
{/each}
</div>
<div class="lh-section-label">Header &amp; footer</div>
<div class="lh-section-label">{t().toolbarExpanded.headerFooter}</div>
<div class="hf-edit-row">
<button
class="hf-edit-btn"
class:active={hfActive === 'header'}
onclick={() => onEditZone?.('header')}
>Edit header</button>
>{t().toolbarExpanded.editHeader}</button>
<button
class="hf-edit-btn"
class:active={hfActive === 'footer'}
onclick={() => onEditZone?.('footer')}
>Edit footer</button>
>{t().toolbarExpanded.editFooter}</button>
</div>
<div class="lh-section-label">Position (cm)</div>
<div class="lh-section-label">{t().toolbarExpanded.position}</div>
<div class="margin-grid">
{#each HF_DIST_FIELDS as f}
{#each HF_DIST_FIELDS as axis}
<div class="margin-field">
<span class="margin-label">{f.label}</span>
<span class="margin-label">{t().toolbarExpanded.hfDist[axis]}</span>
<div class="margin-input-wrap">
<input
type="text"
class="margin-input"
bind:value={hfDistInputs[f.axis]}
onkeydown={(e) => onHfDistKeydown(f.axis, e)}
onblur={() => commitHfDistInput(f.axis)}
bind:value={hfDistInputs[axis]}
onkeydown={(e) => onHfDistKeydown(axis, e)}
onblur={() => commitHfDistInput(axis)}
inputmode="decimal"
title="{f.label} (cm)"
title={t().toolbarExpanded.hfField(t().toolbarExpanded.hfDist[axis])}
/>
<div class="margin-steppers">
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(f.axis, MARGIN_STEP)} title="Increase" aria-label="Increase {f.label.toLowerCase()}">
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(axis, MARGIN_STEP)} title={t().toolbarExpanded.increaseShort} aria-label={t().toolbarExpanded.increase(t().toolbarExpanded.hfDist[axis])}>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(f.axis, -MARGIN_STEP)} title="Decrease" aria-label="Decrease {f.label.toLowerCase()}">
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(axis, -MARGIN_STEP)} title={t().toolbarExpanded.decreaseShort} aria-label={t().toolbarExpanded.decrease(t().toolbarExpanded.hfDist[axis])}>
<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>
@@ -1188,7 +1184,7 @@
<button
class:active={showFormattingMarks}
onclick={() => (showFormattingMarks = !showFormattingMarks)}
title="Formatting marks"
title={t().toolbarExpanded.formattingMarks}
aria-pressed={showFormattingMarks}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -1215,8 +1211,8 @@
<button
onclick={() => imageInput?.click()}
disabled={!!hfActive}
title={hfActive ? 'Images are not available in headers/footers' : 'Insert image'}
aria-label="Insert image"
title={hfActive ? t().toolbarExpanded.imageNotInHf : t().toolbarExpanded.insertImage}
aria-label={t().toolbarExpanded.insertImage}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" stroke="currentColor" />
@@ -1234,8 +1230,8 @@
<button
onclick={() => editor?.chain().focus().insertTextBox().run()}
disabled={!!hfActive}
title={hfActive ? 'Text boxes are not available in headers/footers' : 'Insert text box'}
aria-label="Insert text box"
title={hfActive ? t().toolbarExpanded.textBoxNotInHf : t().toolbarExpanded.insertTextBox}
aria-label={t().toolbarExpanded.insertTextBox}
>
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1.5" y="3.5" width="13" height="9" rx="1" stroke="currentColor" />
@@ -1245,8 +1241,8 @@
<button
onclick={insertToc}
disabled={!!hfActive}
title={hfActive ? 'A table of contents is not available in headers/footers' : 'Insert table of contents'}
aria-label="Insert table of contents"
title={hfActive ? t().toolbarExpanded.tocNotInHf : t().toolbarExpanded.insertToc}
aria-label={t().toolbarExpanded.insertToc}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.5h6M2 6.5h8M2 9.5h5M2 12.5h7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
@@ -1258,8 +1254,8 @@
class:active={isLink}
onclick={openLinkDialog}
disabled={!!hfActive}
title={hfActive ? 'Links are not available in headers/footers' : 'Insert link (Ctrl+K)'}
aria-label="Insert link"
title={hfActive ? t().toolbarExpanded.linkNotInHf : `${t().toolbarExpanded.insertLink} (${withShortcut('Ctrl+K')})`}
aria-label={t().toolbarExpanded.insertLink}
aria-haspopup="dialog"
aria-expanded={linkDialogOpen}
>
@@ -1294,7 +1290,7 @@
.setSpaceAfter(null)
.unsetIndent()
.run()}
title="Clear formatting"
title={t().toolbarExpanded.clearFormatting}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<!-- bold + italic A -->
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import { LOCALES, LOCALE_LABELS, type Locale } from '../i18n/config';
import { t, locale, setLocale } from '../i18n/i18n.svelte';
let open = $state(false);
let currentLocale = $derived(locale());
function pick(l: Locale) {
open = false;
setLocale(l);
}
function clickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) open = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
</script>
<div class="lang-wrap" use:clickOutside>
<button
class="lang-btn"
onclick={() => (open = !open)}
title={t().appearance.language}
aria-haspopup="true"
aria-expanded={open}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.4"/>
<path d="M1.75 8h12.5M8 1.75c1.8 1.7 2.8 3.9 2.8 6.25S9.8 12.55 8 14.25C6.2 12.55 5.2 10.35 5.2 8S6.2 3.45 8 1.75z"
stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
</svg>
</button>
{#if open}
<div class="lang-dropdown" role="menu">
<div class="lang-heading">{t().appearance.language}</div>
{#each LOCALES as l}
<button
class="lang-option"
class:selected={currentLocale === l}
onclick={() => pick(l)}
role="menuitem"
>
{LOCALE_LABELS[l]}
</button>
{/each}
</div>
{/if}
</div>
<style>
.lang-wrap {
position: relative;
}
.lang-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--toolbar-btn-size);
width: var(--toolbar-btn-size);
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.lang-btn:hover {
background: var(--color-btn-hover);
border-color: var(--color-primary);
}
.lang-dropdown {
position: absolute;
top: calc(100% + 3px);
right: 0;
min-width: 130px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 200;
padding: 2px;
display: flex;
flex-direction: column;
}
.lang-heading {
padding: 0.4rem 0.6rem 0.2rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text);
font-family: var(--font-sans);
user-select: none;
}
.lang-option {
display: flex;
align-items: center;
width: 100%;
padding: 0.35rem 0.6rem;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
font-family: var(--font-sans);
text-align: left;
cursor: pointer;
white-space: nowrap;
transition: background 0.1s;
}
.lang-option:hover {
background: var(--color-btn-hover);
}
.lang-option.selected {
background: var(--color-primary);
color: white;
}
</style>
+20
View File
@@ -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<Locale, string> = {
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';
}
+27
View File
@@ -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<Locale, Messages> = { en, de };
let current = $state<Locale>(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);
// <html lang> = UI locale (chrome a11y); document content language is separate.
document.documentElement.lang = next;
}
+124
View File
@@ -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;
+128
View File
@@ -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;
+14
View File
@@ -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);
}
+15
View File
@@ -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);
}
+4
View File
@@ -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 <html lang> (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');