feat: add i18n catalog
This commit is contained in:
1 parent
7faa16c89d
commit
2cbc0edae1
13 files changed
+603
-142
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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 -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user