feat: i18n migration for App.svelte
This commit is contained in:
1 parent
9dcf0e246c
commit
2b8b9d71c4
8 files changed
+283
-71
No files matched your search
+2
-1
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user