feat: i18n migration for App.svelte

This commit is contained in:
Steffen Becker committed 2026-07-08 19:15:51 +00:00
1 parent 9dcf0e246c
commit 2b8b9d71c4
8 files changed
+283 -71

No files matched your search

+2 -1
View File
@@ -62,6 +62,8 @@
- Primary toolbar plus an expandable extended toolbar (horizontal scroll on narrow windows)
- Status bar with page number, word count, language and zoom controls
- About dialog
- UI internationalization (English / German): language picker, auto-detects the browser
language on first load, persisted; covers toolbar, dialogs, menus, status bar and warnings
**File & export**
- ODT export via odf-kit with extensive content.xml / styles.xml post-processing (custom node types, tables with borders, images, fonts and colors); filename derived from the first heading (fallback `document.odt`)
@@ -91,7 +93,6 @@ Other:
- Custom right-click context menu (formatting, cut/copy)
- Multi-document management
- PWA / offline support
- i18n
- ...
### Known issues / deferred
+73 -60
View File
@@ -24,6 +24,9 @@
import LanguagePicker from './lib/components/LanguagePicker.svelte';
import UiLanguagePicker from './lib/components/UiLanguagePicker.svelte';
import AboutDialog from './lib/components/AboutDialog.svelte';
import { t } from './lib/i18n/i18n.svelte';
import { withShortcut } from './lib/i18n/shortcut';
import { localizeImportMessage } from './lib/i18n/importMessages';
let editor: Editor | null = $state(null);
let tick: number = $state(0);
@@ -96,9 +99,9 @@
// Only computed while the field is blank (otherwise the placeholder is hidden,
// so we skip the per-transaction getJSON).
let namePlaceholder = $derived.by(() => {
if (documentName.trim() || tick < 0 || !editor) return 'Untitled document';
if (documentName.trim() || tick < 0 || !editor) return t().app.untitled;
const base = stripOdtExtension(deriveFilename(editor.getJSON() as Parameters<typeof buildOdt>[0]));
return base === 'document' ? 'Untitled document' : base;
return base === 'document' ? t().app.untitled : base;
});
function suggestedFilename(json: Parameters<typeof buildOdt>[0]): string {
@@ -298,7 +301,7 @@
function handleNew() {
if (!editor) return;
if (isDocNonEmpty() && !confirm('Start a new document? Any unsaved changes will be lost.')) return;
if (isDocNonEmpty() && !confirm(t().dialogs.confirmNew)) return;
editor.commands.setContent('<p></p>'); // onUpdate fires → autosave
// Reset everything to defaults; the $effects persist these.
hfActive = null;
@@ -321,7 +324,7 @@
const result = isDocx ? importDocx(bytes) : importOdt(bytes);
const hasContent = editor.state.doc.textContent.length > 0 || editor.state.doc.childCount > 1;
if (hasContent && !confirm('Opening this file will replace the current document. Continue?')) {
if (hasContent && !confirm(t().dialogs.confirmReplace)) {
return;
}
@@ -347,11 +350,11 @@
if (result.warnings.length) {
console.warn('[import] Unsupported content in opened file:', result.warnings);
alert(`Opened with limitations:\n• ${result.warnings.join('\n• ')}`);
alert(t().dialogs.openedWithLimitations(result.warnings.map(localizeImportMessage).join('\n• ')));
}
} catch (err) {
console.error('[import] Failed to open file:', err);
alert(err instanceof Error ? err.message : 'Could not open this file.');
alert(err instanceof Error ? localizeImportMessage(err.message) : t().dialogs.couldNotOpen);
}
}
@@ -364,7 +367,7 @@
} catch (err) {
if ((err as DOMException)?.name !== 'AbortError') {
console.error('[open] Failed to open file:', err);
alert('Could not open this file.');
alert(t().dialogs.couldNotOpen);
}
}
} else {
@@ -392,7 +395,7 @@
// A stored handle may have lost permission; re-prompt via Save As.
if (fileHandle) { fileHandle = null; await handleSaveAs(); return; }
console.error('[save] Failed to save file:', err);
alert('Could not save this file.');
alert(t().dialogs.couldNotSave);
}
}
@@ -406,7 +409,7 @@
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
console.error('[save] Failed to save file:', err);
alert('Could not save this file.');
alert(t().dialogs.couldNotSave);
}
}
@@ -424,7 +427,7 @@
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
console.error('[docx] Export failed:', err);
alert('Could not export to Word (.docx).');
alert(t().dialogs.couldNotExportDocx);
} finally {
docxBusy = false;
}
@@ -447,7 +450,7 @@
});
} catch (err) {
console.error('[pdf] Export failed:', err);
alert('Could not export to PDF.');
alert(t().dialogs.couldNotExportPdf);
} finally {
pdfBusy = false;
}
@@ -471,7 +474,7 @@
});
} catch (err) {
console.error('[pdf] Print failed:', err);
alert('Could not print.');
alert(t().dialogs.couldNotPrint);
} finally {
pdfBusy = false;
}
@@ -494,7 +497,7 @@
});
} catch (err) {
console.error('[pdf] Print failed:', err);
alert('Could not print to PDF.');
alert(t().dialogs.couldNotPrintPdf);
}
}
@@ -592,7 +595,7 @@
<div class="toolbar-clip" id="primary-toolbar" bind:this={toolbarClipEl} onwheel={onToolbarWheel}>
<div class="toolbar-stack" bind:this={toolbarStackEl} style="transform: translateX(-{tbScroll}px);">
<header class:expanded={toolbarExpanded}>
<button class="logo-btn" onclick={() => (aboutOpen = true)} aria-label="About EdenText" title="About EdenText">
<button class="logo-btn" onclick={() => (aboutOpen = true)} aria-label={t().about.label} title={t().about.label}>
<img src="/EdenText.png" alt="EdenText" class="app-logo" />
</button>
<Toolbar editor={activeEditor} tick={activeTick} />
@@ -618,13 +621,13 @@
type="text"
bind:value={documentName}
placeholder={namePlaceholder}
title="Document name"
title={t().app.documentName}
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
onblur={() => (documentName = documentName.trim())}
/>
</div>
<div class="file-actions">
<button class="file-action-btn" onclick={handleNew} disabled={!editor} title="New document">
<button class="file-action-btn" onclick={handleNew} disabled={!editor} title={t().app.newDocument}>
<!-- Page with folded corner + plus -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M9 1.75H4.5A1.25 1.25 0 0 0 3.25 3v10A1.25 1.25 0 0 0 4.5 14.25h7A1.25 1.25 0 0 0 12.75 13V5.5L9 1.75z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
@@ -632,7 +635,7 @@
<path d="M8 8v3.5M6.25 9.75h3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</button>
<button class="file-action-btn" onclick={handleOpen} disabled={!editor} title="Open .odt…">
<button class="file-action-btn" onclick={handleOpen} disabled={!editor} title={t().app.openOdt}>
<!-- Folder -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M1.75 12.5V4a1 1 0 0 1 1-1h3.2a1 1 0 0 1 .8.4l.7.95a1 1 0 0 0 .8.4h4.2a1 1 0 0 1 1 1v6.75a1 1 0 0 1-1 1H2.75a1 1 0 0 1-1-1z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
@@ -641,14 +644,14 @@
<!-- Single Save / Export button → ODT, Raster PDF, or Vector PDF (beta). -->
<div class="save-split" use:exportMenuClickOutside>
<div class="save-control">
<button class="file-action-btn save-main" onclick={handleSave} disabled={!editor || pdfBusy} title="Save .odt (Ctrl+S)">
<button class="file-action-btn save-main" onclick={handleSave} disabled={!editor || pdfBusy} title={`${t().app.save} (${withShortcut('Ctrl+S')})`}>
{@render saveIcon()}
</button>
<button
class="save-chevron"
onclick={() => (exportMenuOpen = !exportMenuOpen)}
disabled={!editor || pdfBusy}
title="Save / Export"
title={t().app.saveExport}
aria-haspopup="menu"
aria-expanded={exportMenuOpen}
>
@@ -659,27 +662,27 @@
</div>
{#if exportMenuOpen}
<div class="theme-dropdown" role="menu">
<div class="theme-heading">Save / Export</div>
<div class="theme-heading">{t().app.saveExport}</div>
<button class="theme-option" onclick={handleSave} role="menuitem">
<span>ODT</span>
<span class="theme-option-hint">OpenDocument</span>
<span>{t().app.odt}</span>
<span class="theme-option-hint">{t().app.openDocument}</span>
</button>
<button class="theme-option" onclick={handleSaveDocx} disabled={docxBusy} role="menuitem">
<span>{docxBusy ? 'Exporting…' : 'Word (.docx)'}</span>
<span class="theme-option-hint">Microsoft Word</span>
<span>{docxBusy ? t().app.exporting : t().app.wordDocx}</span>
<span class="theme-option-hint">{t().app.microsoftWord}</span>
</button>
<button class="theme-option" onclick={handleExportPdf} disabled={pdfBusy} role="menuitem">
<span>{pdfBusy ? 'Exporting…' : 'Raster PDF'}</span>
<span class="theme-option-hint">Exact copy of the editor</span>
<span>{pdfBusy ? t().app.exporting : t().app.rasterPdf}</span>
<span class="theme-option-hint">{t().app.rasterHint}</span>
</button>
<button class="theme-option" onclick={handlePrintPdf} role="menuitem">
<span>Vector PDF (beta)</span>
<span class="theme-option-hint">Sharp, but problems with tables</span>
<span>{t().app.vectorPdf}</span>
<span class="theme-option-hint">{t().app.vectorHint}</span>
</button>
</div>
{/if}
</div>
<button class="file-action-btn" onclick={handlePrint} disabled={!editor || pdfBusy} title="Print (Ctrl+P)">
<button class="file-action-btn" onclick={handlePrint} disabled={!editor || pdfBusy} title={`${t().app.print} (${withShortcut('Ctrl+P')})`}>
<!-- Printer -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4.5 6V2.25h7V6" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
@@ -692,7 +695,7 @@
<button
class="theme-btn"
onclick={() => (themeOpen = !themeOpen)}
title="Appearance"
title={t().appearance.title}
aria-haspopup="true"
aria-expanded={themeOpen}
>
@@ -726,17 +729,17 @@
</button>
{#if themeOpen}
<div class="theme-dropdown" role="menu">
<div class="theme-heading">Appearance</div>
{#each ([['light', 'Light'], ['dark', 'Dark'], ['allBlack', 'AllBlack'], ['auto', 'Auto']] as const) as [m, label]}
<div class="theme-heading">{t().appearance.title}</div>
{#each (['light', 'dark', 'allBlack', 'auto'] as const) as m}
<button
class="theme-option"
class:selected={themeMode === m}
onclick={() => selectTheme(m)}
role="menuitem"
>
<span>{label}</span>
<span>{t().appearance[m]}</span>
{#if m === 'allBlack'}
<span class="theme-option-hint">font colors forced white</span>
<span class="theme-option-hint">{t().appearance.allBlackHint}</span>
{/if}
</button>
{/each}
@@ -761,7 +764,7 @@
class="expand-toggle"
class:active={toolbarExpanded}
onclick={toggleToolbar}
title={toolbarExpanded ? 'Hide extra tools' : 'Show extra tools'}
title={toolbarExpanded ? t().app.hideExtraTools : t().app.showExtraTools}
aria-expanded={toolbarExpanded}
>
<!-- Sliders icon -->
@@ -773,7 +776,7 @@
<circle cx="10" cy="8" r="1.5" fill="var(--color-surface)" stroke="currentColor" stroke-width="1.2"/>
<circle cx="7" cy="12" r="1.5" fill="var(--color-surface)" stroke="currentColor" stroke-width="1.2"/>
</svg>
<span class="expand-label">Tools</span>
<span class="expand-label">{t().app.tools}</span>
<svg class="chevron" width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M2.5 1l3 3-3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -821,37 +824,37 @@
{/if}
<footer class="statusbar">
<div class="sb-left">
<span>Page {currentPage} of {numPages}</span>
<span>{t().status.pageOf(currentPage, numPages)}</span>
<div class="wordcount-wrap" use:wordCountClickOutside>
<button
class="wordcount-btn"
onclick={() => (wordCountOpen = !wordCountOpen)}
title="Statistics"
title={t().status.statistics}
aria-haspopup="dialog"
aria-expanded={wordCountOpen}
>
{#if selStats}
{selStats.words.toLocaleString()} of {docStats.words.toLocaleString()} words
{t().status.selectedOf(selStats.words, docStats.words)}
{:else}
{docStats.words.toLocaleString()} {docStats.words === 1 ? 'Word' : 'Words'}
{t().status.words(docStats.words)}
{/if}
</button>
{#if wordCountOpen}
<div class="wordcount-popup" role="dialog" aria-label="Statistics">
<div class="wc-heading">Statistics</div>
<div class="wordcount-popup" role="dialog" aria-label={t().status.statistics}>
<div class="wc-heading">{t().status.statistics}</div>
{#if selStats}
<div class="wc-section">Selection</div>
<div class="wc-row"><span>Words</span><span>{selStats.words.toLocaleString()}</span></div>
<div class="wc-row"><span>Characters (with spaces)</span><span>{selStats.charsWithSpaces.toLocaleString()}</span></div>
<div class="wc-row"><span>Characters (no spaces)</span><span>{selStats.charsNoSpaces.toLocaleString()}</span></div>
<div class="wc-section">{t().status.selection}</div>
<div class="wc-row"><span>{t().status.wordsLabel}</span><span>{t().status.num(selStats.words)}</span></div>
<div class="wc-row"><span>{t().status.charsWithSpaces}</span><span>{t().status.num(selStats.charsWithSpaces)}</span></div>
<div class="wc-row"><span>{t().status.charsNoSpaces}</span><span>{t().status.num(selStats.charsNoSpaces)}</span></div>
<div class="wc-divider"></div>
<div class="wc-section">Document</div>
<div class="wc-section">{t().status.document}</div>
{/if}
<div class="wc-row"><span>Words</span><span>{docStats.words.toLocaleString()}</span></div>
<div class="wc-row"><span>Characters (with spaces)</span><span>{docStats.charsWithSpaces.toLocaleString()}</span></div>
<div class="wc-row"><span>Characters (no spaces)</span><span>{docStats.charsNoSpaces.toLocaleString()}</span></div>
<div class="wc-row"><span>Paragraphs</span><span>{docStats.paragraphs.toLocaleString()}</span></div>
<div class="wc-row"><span>Pages</span><span>{numPages.toLocaleString()}</span></div>
<div class="wc-row"><span>{t().status.wordsLabel}</span><span>{t().status.num(docStats.words)}</span></div>
<div class="wc-row"><span>{t().status.charsWithSpaces}</span><span>{t().status.num(docStats.charsWithSpaces)}</span></div>
<div class="wc-row"><span>{t().status.charsNoSpaces}</span><span>{t().status.num(docStats.charsNoSpaces)}</span></div>
<div class="wc-row"><span>{t().status.paragraphs}</span><span>{t().status.num(docStats.paragraphs)}</span></div>
<div class="wc-row"><span>{t().status.pages}</span><span>{t().status.num(numPages)}</span></div>
</div>
{/if}
</div>
@@ -861,7 +864,7 @@
</div>
<div class="sb-right">
<div class="zoom-controls">
<button class="zoom-btn" onclick={() => setZoom(zoom - 10)} disabled={zoom <= 20} title="Zoom out">−</button>
<button class="zoom-btn" onclick={() => setZoom(zoom - 10)} disabled={zoom <= 20} title={t().status.zoomOut}>−</button>
<input
type="range"
class="zoom-slider"
@@ -870,10 +873,10 @@
step="1"
value={zoom}
oninput={(e) => setZoom(parseInt((e.target as HTMLInputElement).value, 10))}
title="Zoom"
title={t().status.zoom}
/>
<button class="zoom-btn" onclick={() => setZoom(zoom + 10)} disabled={zoom >= 300} title="Zoom in">+</button>
<button class="zoom-pct" onclick={() => setZoom(100)} title="Reset to 100%">{zoom}%</button>
<button class="zoom-btn" onclick={() => setZoom(zoom + 10)} disabled={zoom >= 300} title={t().status.zoomIn}>+</button>
<button class="zoom-pct" onclick={() => setZoom(100)} title={t().status.resetZoom}>{zoom}%</button>
</div>
</div>
</footer>
@@ -1204,10 +1207,10 @@
top, but below the basic toolbar (200) so its dropdowns open over this row;
the header menus sit above everything via a higher z-index. */
z-index: 160;
/* The toggle is absolute (adds no height): collapsed the bar is 0-height so the
document fills up to here; expanded it grows with the extended toolbar.
padding-left reserves the tab's column. */
padding: 0 1rem 0 6.5rem;
/* The toggle is absolute while collapsed (adds no height) so the bar is 0-height
and the document fills up to here; expanded, the toggle joins the flex flow
(rule below) and the extended toolbar follows it — no reserved column needed. */
padding: 0 1rem;
background: transparent;
border: 1px solid transparent;
border-top: none;
@@ -1265,6 +1268,16 @@
box-shadow: var(--shadow);
}
/* Expanded: the toggle sits in the flex flow so its width is content-driven (any
language) and the extended toolbar starts right after it, never overlapping. */
.toolbar-secondary.expanded .expand-toggle {
position: relative;
left: auto;
top: auto;
flex: none;
margin: 0 0.6rem 0 0.35rem;
}
.expand-toggle:hover {
border-color: var(--color-primary);
}
+3 -3
View File
@@ -22,6 +22,8 @@
import { DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
import '../../styles/editor.css';
const DEFAULT_EDITOR_FONT = 'Georgia'; // must match ToolbarExpanded.svelte
@@ -159,8 +161,6 @@
// --- Link hover hint (Word/LibreOffice style) ---
// Hovering a link shows its URL + a modifier-click hint; the link only follows on
// Ctrl/Cmd+click (handleClick in editorProps), so a plain click can edit the text.
const linkHintKey =
typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform) ? '⌘' : 'Ctrl';
let linkTip = $state<{ top: number; left: number; href: string } | null>(null);
function showLinkTip(a: HTMLAnchorElement) {
@@ -733,7 +733,7 @@
{#if linkTip}
<div class="link-tooltip" style="top: {linkTip.top}px; left: {linkTip.left}px;">
<span class="link-tooltip-url">{linkTip.href}</span>
<span class="link-tooltip-hint">{linkHintKey}+Click to open link</span>
<span class="link-tooltip-hint">{t().link.openHint(withShortcut('Ctrl'))}</span>
</div>
{/if}
</div>
+4 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { LANGUAGES, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import { t } from '../i18n/i18n.svelte';
let {
value,
@@ -10,19 +11,19 @@
} = $props();
</script>
<label class="lang-picker" title="Spell-check language">
<label class="lang-picker" title={t().spellPicker.label}>
<svg width="13" height="13" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 13l3-8 3 8M3.2 10.5h3.6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 14.5l1.6-1.6 1.6 1.6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<select
aria-label="Spell-check language"
aria-label={t().spellPicker.label}
onchange={(e) => onChange((e.currentTarget as HTMLSelectElement).value)}
>
{#each LANGUAGES as l}
<option value={l.code} selected={l.code === value}>{l.label}</option>
{/each}
<option value={NO_LANGUAGE} selected={value === NO_LANGUAGE}>No spell check</option>
<option value={NO_LANGUAGE} selected={value === NO_LANGUAGE}>{t().spellPicker.noSpellCheck}</option>
</select>
</label>
+24
View File
@@ -0,0 +1,24 @@
// Localizes a canonical English import message (warning or thrown error) at display
// time. The parsers stay locale-agnostic and unit-testable — they emit the English
// text, and the UI maps it to the current locale here. Unknown text passes through.
import en from './locales/en';
import { t } from './i18n.svelte';
// The dictionary warning carries a language tag, so it's matched separately.
const DICT_RE = /^Spell-check language "(.+)" has no bundled dictionary/;
export function localizeImportMessage(message: string): string {
const dict = DICT_RE.exec(message);
if (dict) return t().importWarn.noDictionary(dict[1]);
for (const [key, value] of Object.entries(en.importWarn)) {
if (typeof value === 'string' && value === message) {
return (t().importWarn as unknown as Record<string, string>)[key];
}
}
for (const [key, value] of Object.entries(en.importError)) {
if (value === message) return (t().importError as Record<string, string>)[key];
}
return message;
}
+86
View File
@@ -13,6 +13,12 @@ const de: Messages = {
},
appearance: {
language: 'Sprache',
title: 'Darstellung',
light: 'Hell',
dark: 'Dunkel',
allBlack: 'Ganz schwarz',
auto: 'Automatisch',
allBlackHint: 'Schriftfarben werden weiß erzwungen',
},
shortcut: {
ctrl: 'Strg',
@@ -146,6 +152,11 @@ const de: Messages = {
},
link: {
dialogLabel: 'Link einfügen',
openHint: (mod) => `${mod}+Klick, um den Link zu öffnen`,
},
spellPicker: {
label: 'Rechtschreibsprache',
noSpellCheck: 'Keine Rechtschreibprüfung',
},
color: {
automatic: 'Automatisch',
@@ -260,6 +271,81 @@ const de: Messages = {
done: 'Fertig',
doneTitle: 'Bearbeiten der Kopf-/Fußzeile beenden',
},
app: {
documentName: 'Dokumentname',
untitled: 'Unbenanntes Dokument',
newDocument: 'Neues Dokument',
openOdt: '.odt öffnen…',
save: '.odt speichern',
saveExport: 'Speichern / Exportieren',
odt: 'ODT',
openDocument: 'OpenDocument',
wordDocx: 'Word (.docx)',
exporting: 'Wird exportiert…',
microsoftWord: 'Microsoft Word',
rasterPdf: 'Raster-PDF',
rasterHint: 'Exakte Kopie des Editors',
vectorPdf: 'Vektor-PDF (Beta)',
vectorHint: 'Scharf, aber Probleme mit Tabellen',
print: 'Drucken',
tools: 'Werkzeuge',
showExtraTools: 'Weitere Werkzeuge anzeigen',
hideExtraTools: 'Weitere Werkzeuge ausblenden',
},
status: {
pageOf: (current, total) => `Seite ${current} von ${total}`,
words: (n) => `${n.toLocaleString('de-DE')} ${n === 1 ? 'Wort' : 'Wörter'}`,
selectedOf: (sel, total) =>
`${sel.toLocaleString('de-DE')} von ${total.toLocaleString('de-DE')} Wörtern`,
num: (n) => n.toLocaleString('de-DE'),
statistics: 'Statistik',
selection: 'Auswahl',
document: 'Dokument',
wordsLabel: 'Wörter',
charsWithSpaces: 'Zeichen (mit Leerzeichen)',
charsNoSpaces: 'Zeichen (ohne Leerzeichen)',
paragraphs: 'Absätze',
pages: 'Seiten',
zoomOut: 'Verkleinern',
zoomIn: 'Vergrößern',
zoom: 'Zoom',
resetZoom: 'Auf 100 % zurücksetzen',
},
dialogs: {
confirmNew: 'Neues Dokument beginnen? Nicht gespeicherte Änderungen gehen verloren.',
confirmReplace: 'Beim Öffnen dieser Datei wird das aktuelle Dokument ersetzt. Fortfahren?',
openedWithLimitations: (list) => `Mit Einschränkungen geöffnet:\n• ${list}`,
couldNotOpen: 'Diese Datei konnte nicht geöffnet werden.',
couldNotSave: 'Diese Datei konnte nicht gespeichert werden.',
couldNotExportDocx: 'Export nach Word (.docx) nicht möglich.',
couldNotExportPdf: 'PDF-Export nicht möglich.',
couldNotPrint: 'Drucken nicht möglich.',
couldNotPrintPdf: 'Druck als PDF nicht möglich.',
autosaveQuota:
'Das Dokument ist zu groß, um automatisch gespeichert zu werden (Speicherlimit des Browsers erreicht). Das passiert meist bei großen eingebetteten Bildern. Speichere es als .odt-Datei, um deine Arbeit zu sichern.',
},
importWarn: {
imagesSkipped: 'Einige Bilder konnten nicht gelesen werden und wurden übersprungen',
shapesRemoved: 'Nicht unterstützte Formen wurden entfernt',
drawingsRemoved: 'Zeichnungen wurden entfernt',
perPageHf: 'Seitenabhängige Kopf-/Fußzeilen-Varianten (erste/gerade Seiten) werden nicht unterstützt — die Standardvariante wurde verwendet',
noDictionary: (tag) => `Für die Rechtschreibsprache „${tag}" ist kein Wörterbuch enthalten — die Rechtschreibprüfung wurde deaktiviert`,
hfFlattened: 'Listen/Tabellen in Kopf- oder Fußzeilen wurden in Text umgewandelt',
textBoxFlattened: 'In Tabellenzellen oder anderen Textfeldern verschachtelte Textfelder wurden aufgelöst',
nestedTables: 'Verschachtelte Tabellen wurden in Absätze umgewandelt',
footnotes: 'Fuß- und Endnoten wurden entfernt',
comments: 'Kommentare wurden entfernt',
},
importError: {
odtArchive: 'Keine gültige .odt-Datei (Archiv konnte nicht gelesen werden).',
odtContentMissing: 'Keine gültige .odt-Datei (content.xml fehlt).',
odtNoBody: 'Kein Textdokument (kein office:text-Inhalt).',
odtMalformed: 'Keine gültige .odt-Datei (fehlerhaftes XML).',
docxMalformed: 'Keine gültige .docx-Datei (fehlerhaftes XML).',
docxArchive: 'Keine gültige .docx-Datei (Archiv konnte nicht gelesen werden).',
docxMissing: 'Keine gültige .docx-Datei (word/document.xml fehlt).',
docxNoBody: 'Kein Word-Dokument (kein w:body).',
},
};
export default de;
+88
View File
@@ -14,6 +14,12 @@ const en = {
},
appearance: {
language: 'Language',
title: 'Appearance',
light: 'Light',
dark: 'Dark',
allBlack: 'AllBlack',
auto: 'Auto',
allBlackHint: 'font colors forced white',
},
shortcut: {
ctrl: 'Ctrl',
@@ -149,6 +155,11 @@ const en = {
},
link: {
dialogLabel: 'Insert link',
openHint: (mod: string) => `${mod}+Click to open link`,
},
spellPicker: {
label: 'Spell-check language',
noSpellCheck: 'No spell check',
},
color: {
automatic: 'Automatic',
@@ -263,6 +274,83 @@ const en = {
done: 'Done',
doneTitle: 'Finish editing the header/footer',
},
app: {
documentName: 'Document name',
untitled: 'Untitled document',
newDocument: 'New document',
openOdt: 'Open .odt…',
save: 'Save .odt',
saveExport: 'Save / Export',
odt: 'ODT',
openDocument: 'OpenDocument',
wordDocx: 'Word (.docx)',
exporting: 'Exporting…',
microsoftWord: 'Microsoft Word',
rasterPdf: 'Raster PDF',
rasterHint: 'Exact copy of the editor',
vectorPdf: 'Vector PDF (beta)',
vectorHint: 'Sharp, but problems with tables',
print: 'Print',
tools: 'Tools',
showExtraTools: 'Show extra tools',
hideExtraTools: 'Hide extra tools',
},
status: {
pageOf: (current: number, total: number) => `Page ${current} of ${total}`,
words: (n: number) => `${n.toLocaleString('en-US')} ${n === 1 ? 'Word' : 'Words'}`,
selectedOf: (sel: number, total: number) =>
`${sel.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} words`,
num: (n: number) => n.toLocaleString('en-US'),
statistics: 'Statistics',
selection: 'Selection',
document: 'Document',
wordsLabel: 'Words',
charsWithSpaces: 'Characters (with spaces)',
charsNoSpaces: 'Characters (no spaces)',
paragraphs: 'Paragraphs',
pages: 'Pages',
zoomOut: 'Zoom out',
zoomIn: 'Zoom in',
zoom: 'Zoom',
resetZoom: 'Reset to 100%',
},
dialogs: {
confirmNew: 'Start a new document? Any unsaved changes will be lost.',
confirmReplace: 'Opening this file will replace the current document. Continue?',
openedWithLimitations: (list: string) => `Opened with limitations:\n• ${list}`,
couldNotOpen: 'Could not open this file.',
couldNotSave: 'Could not save this file.',
couldNotExportDocx: 'Could not export to Word (.docx).',
couldNotExportPdf: 'Could not export to PDF.',
couldNotPrint: 'Could not print.',
couldNotPrintPdf: 'Could not print to PDF.',
autosaveQuota:
'The document is too large to save automatically (browser storage limit reached). This usually happens with large embedded images. Save it as an .odt file to keep your work.',
},
// Values mirror the canonical English messages the import parsers emit; the UI
// maps a parser message to its localized form at display time (importMessages.ts).
importWarn: {
imagesSkipped: 'Some images could not be read and were skipped',
shapesRemoved: 'Unsupported shapes were removed',
drawingsRemoved: 'Drawings were removed',
perPageHf: 'Per-page header/footer variants (first/even pages) are not supported — the default one was used',
noDictionary: (tag: string) => `Spell-check language "${tag}" has no bundled dictionary — spell check was turned off`,
hfFlattened: 'Lists/tables in headers or footers were flattened to text',
textBoxFlattened: 'Text boxes nested in table cells or other text boxes were flattened',
nestedTables: 'Nested tables were flattened to paragraphs',
footnotes: 'Footnotes and endnotes were removed',
comments: 'Comments were removed',
},
importError: {
odtArchive: 'Not a valid .odt file (could not read the archive).',
odtContentMissing: 'Not a valid .odt file (content.xml is missing).',
odtNoBody: 'Not a text document (no office:text body).',
odtMalformed: 'Not a valid .odt file (malformed XML).',
docxMalformed: 'Not a valid .docx file (malformed XML).',
docxArchive: 'Not a valid .docx file (could not read the archive).',
docxMissing: 'Not a valid .docx file (word/document.xml is missing).',
docxNoBody: 'Not a Word document (no w:body).',
},
};
export type Messages = typeof en;
+3 -4
View File
@@ -1,3 +1,5 @@
import { t } from '../i18n/i18n.svelte';
const STORAGE_KEY = 'odf-editor-doc';
const DEBOUNCE_MS = 1000;
@@ -16,10 +18,7 @@ export function saveDocument(json: object): void {
console.error('[autosave] Could not save the document:', err);
if (!quotaWarned) {
quotaWarned = true;
alert(
'The document is too large to save automatically (browser storage limit reached). ' +
'This usually happens with large embedded images. Save it as an .odt file to keep your work.',
);
alert(t().dialogs.autosaveQuota);
}
}
}, DEBOUNCE_MS);