feat: expand i18n catalog with submenues of images, table, ...

This commit is contained in:
Steffen Becker committed 2026-07-08 21:05:49 +02:00
1 parent 2cbc0edae1
commit 9dcf0e246c
15 files changed
+461 -157

No files matched your search

+6 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
// Placeholder until the project's repository is public; swap the URL when known.
const REPO_URL = 'https://github.com/your-org/edentext';
@@ -31,10 +32,10 @@
bind:this={dialogEl}
onclose={onClose}
onclick={onClick}
aria-label="About EdenText"
aria-label={t().about.label}
>
<div class="card">
<button class="close" onclick={() => (open = false)} aria-label="Close" title="Close">
<button class="close" onclick={() => (open = false)} aria-label={t().common.close} title={t().common.close}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
@@ -42,15 +43,15 @@
<img class="logo" src="/EdenText.png" alt="EdenText" />
<p class="tagline">A private editor for OpenDocument files — runs entirely in your browser.</p>
<p class="tagline">{t().about.tagline}</p>
<p class="version">Version {__APP_VERSION__}</p>
<p class="version">{t().about.version(__APP_VERSION__)}</p>
<a class="repo" href={REPO_URL} target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 .2a8 8 0 0 0-2.53 15.59c.4.07.55-.17.55-.38l-.01-1.34c-2.23.49-2.7-1.07-2.7-1.07-.36-.93-.89-1.18-.89-1.18-.72-.5.06-.49.06-.49.8.06 1.22.83 1.22.83.71 1.22 1.87.87 2.33.66.07-.52.28-.87.5-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.83-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.22 2.2.82a7.6 7.6 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.52.56.83 1.28.83 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48l-.01 2.19c0 .21.15.46.55.38A8 8 0 0 0 8 .2z" />
</svg>
<span>View on GitHub</span>
<span>{t().about.viewOnGitHub}</span>
</a>
</div>
</dialog>
+9 -8
View File
@@ -2,6 +2,7 @@
import type { Editor } from '@tiptap/core';
import type { Snippet } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import { t } from '../i18n/i18n.svelte';
let {
editor,
@@ -9,7 +10,7 @@
defaultColor,
title,
chevronTitle,
clearLabel = 'Automatic',
clearLabel,
open = $bindable(false),
onApply,
onClear,
@@ -377,7 +378,7 @@
class="color-automatic"
class:active={currentColor === null}
onclick={clearColor}
>{clearLabel}</button>
>{clearLabel ?? t().color.automatic}</button>
<div class="color-grid">
{#each COLOR_PALETTE as row}
<div class="color-row">
@@ -395,12 +396,12 @@
{/each}
</div>
<div class="color-extras">
<button class="color-more-trigger" onclick={openMoreColors}>More colors…</button>
<button class="color-more-trigger" onclick={openMoreColors}>{t().color.moreColors}</button>
<button
class="color-pipette-trigger"
onclick={pickFromPage}
title="Pick a color from the page"
aria-label="Pick a color from the page"
title={t().color.pickFromPage}
aria-label={t().color.pickFromPage}
>
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<g transform="rotate(-45 8 8)" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round" stroke-linecap="round">
@@ -414,7 +415,7 @@
</div>
{/if}
{#if open && view === 'custom'}
<div class="color-window" role="dialog" aria-label="Choose color">
<div class="color-window" role="dialog" aria-label={t().color.chooseColor}>
<div class="color-window-body">
<div
class="sv-square"
@@ -437,8 +438,8 @@
<input class="color-window-hex" type="text" value={stagedColor} oninput={onHexInput} maxlength="7" />
</div>
<div class="color-more-actions">
<button class="color-cancel" onclick={cancelMoreColors}>Cancel</button>
<button class="color-confirm" onclick={confirmMoreColors}>OK</button>
<button class="color-cancel" onclick={cancelMoreColors}>{t().common.cancel}</button>
<button class="color-confirm" onclick={confirmMoreColors}>{t().common.ok}</button>
</div>
</div>
{/if}
+14 -12
View File
@@ -2,6 +2,8 @@
import type { Editor } from '@tiptap/core';
import { onMount } from 'svelte';
import { getSearchState } from '../editor/extensions/searchReplace';
import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
let { editor, tick, mode, focusNonce, onClose }:
{ editor: Editor | null; tick: number; mode: 'find' | 'replace'; focusNonce: number; onClose: () => void } = $props();
@@ -62,8 +64,8 @@
});
</script>
<div class="find-bar" role="dialog" aria-label="Find and replace">
<button class="fb-expand" onclick={() => (showReplace = !showReplace)} title={showReplace ? 'Hide replace' : 'Show replace'} aria-label="Toggle replace">
<div class="find-bar" role="dialog" aria-label={t().findReplace.dialogLabel}>
<button class="fb-expand" onclick={() => (showReplace = !showReplace)} title={showReplace ? t().findReplace.hideReplace : t().findReplace.showReplace} aria-label={t().findReplace.toggleReplace}>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" style="transform: rotate({showReplace ? 90 : 0}deg)">
<path d="M3 1.5l3.5 3.5L3 8.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -77,26 +79,26 @@
oninput={applySearch}
onkeydown={onFindKeydown}
type="text"
placeholder="Find"
placeholder={t().findReplace.findPlaceholder}
spellcheck="false"
autocomplete="off"
/>
<span class="fb-count">
{#if findText && results.count === 0}No results
{#if findText && results.count === 0}{t().findReplace.noResults}
{:else if results.count > 0}{results.current + 1}/{results.count}
{/if}
</span>
<button class="fb-opt" class:on={matchCase} onclick={toggleCase} title="Match case" aria-pressed={matchCase}>Aa</button>
<button class="fb-opt fb-word" class:on={wholeWord} onclick={toggleWord} title="Whole word" aria-pressed={wholeWord}>
<button class="fb-opt" class:on={matchCase} onclick={toggleCase} title={t().findReplace.matchCase} aria-pressed={matchCase}>Aa</button>
<button class="fb-opt fb-word" class:on={wholeWord} onclick={toggleWord} title={t().findReplace.wholeWord} aria-pressed={wholeWord}>
<span>W</span>
</button>
<button class="fb-nav" onclick={prev} disabled={!results.count} title="Previous (Shift+Enter)" aria-label="Previous match">
<button class="fb-nav" onclick={prev} disabled={!results.count} title={`${t().findReplace.previous} (${withShortcut('Shift+Enter')})`} aria-label={t().findReplace.previousAria}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 7.5L6 4l3.5 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="fb-nav" onclick={next} disabled={!results.count} title="Next (Enter)" aria-label="Next match">
<button class="fb-nav" onclick={next} disabled={!results.count} title={`${t().findReplace.next} (Enter)`} aria-label={t().findReplace.nextAria}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 4.5L6 8l3.5-3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="fb-close" onclick={onClose} title="Close (Esc)" aria-label="Close">
<button class="fb-close" onclick={onClose} title={`${t().common.close} (Esc)`} aria-label={t().common.close}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 2.5l7 7M9.5 2.5l-7 7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</div>
@@ -107,12 +109,12 @@
bind:value={replaceText}
onkeydown={onReplaceKeydown}
type="text"
placeholder="Replace"
placeholder={t().findReplace.replacePlaceholder}
spellcheck="false"
autocomplete="off"
/>
<button class="fb-text-btn" onclick={replaceOne} disabled={!results.count} title="Replace current match">Replace</button>
<button class="fb-text-btn" onclick={replaceEvery} disabled={!results.count} title="Replace all matches">All</button>
<button class="fb-text-btn" onclick={replaceOne} disabled={!results.count} title={t().findReplace.replaceCurrent}>{t().findReplace.replace}</button>
<button class="fb-text-btn" onclick={replaceEvery} disabled={!results.count} title={t().findReplace.replaceAllTitle}>{t().findReplace.all}</button>
</div>
{/if}
</div>
+7 -6
View File
@@ -4,6 +4,7 @@
import { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { cmToPx, type PageMargins } from '../storage/pageMargins';
import { PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, type Orientation } from '../storage/pageOrientation';
import { t } from '../i18n/i18n.svelte';
let {
headerDoc = $bindable(),
@@ -130,7 +131,7 @@
liveZone = zone;
const ed = new Editor({
element: mount,
extensions: hfExtensions(zone === 'header' ? 'Header…' : 'Footer…'),
extensions: hfExtensions(zone === 'header' ? t().hf.headerPlaceholder : t().hf.footerPlaceholder),
content: (zoneDoc(zone) ?? emptyDoc()) as Content,
// No autofocus: its scrollIntoView nudges the page so the just-clicked zone
// appears to jump. Focus the zone explicitly without scrolling instead.
@@ -180,7 +181,7 @@
<div
class="hf-zone hf-{zone}"
class:hf-empty={!html}
data-hf-label={zone === 'header' ? 'Double-click to add a header' : 'Double-click to add a footer'}
data-hf-label={zone === 'header' ? t().hf.addHeaderHint : t().hf.addFooterHint}
style={boxStyle(zoneBox(zone, p))}
ondblclick={() => startEdit(zone, p)}
role="button"
@@ -197,11 +198,11 @@
{@const box = zoneBox(hfActive, editingPage)}
<div class="hf-zone hf-{hfActive} hf-active" style={boxStyle(box)} bind:this={liveMount}></div>
<div class="hf-bar" style="top: {box.top}px; left: {box.left + box.width}px;">
<span class="hf-bar-label">Insert</span>
<button class="hf-bar-btn" title="Insert the current page number" onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageNumber')}>Page number</button>
<button class="hf-bar-btn" title="Insert the total page count" onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageCount')}>Page count</button>
<span class="hf-bar-label">{t().hf.insert}</span>
<button class="hf-bar-btn" title={t().hf.pageNumberTitle} onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageNumber')}>{t().hf.pageNumber}</button>
<button class="hf-bar-btn" title={t().hf.pageCountTitle} onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageCount')}>{t().hf.pageCount}</button>
<span class="hf-bar-sep"></span>
<button class="hf-bar-btn hf-bar-done" title="Finish editing the header/footer" onmousedown={(e) => e.preventDefault()} onclick={() => (hfActive = null)}>Done</button>
<button class="hf-bar-btn hf-bar-done" title={t().hf.doneTitle} onmousedown={(e) => e.preventDefault()} onclick={() => (hfActive = null)}>{t().hf.done}</button>
</div>
{/if}
</div>
+17 -15
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import type { WrapMode } from '../editor/extensions/image';
import { t } from '../i18n/i18n.svelte';
let {
editor,
@@ -20,12 +21,13 @@
editor?.chain().focus().setImageWrap(mode).run();
}
const modes: { mode: WrapMode; title: string }[] = [
{ mode: 'inline', title: 'In line with text' },
{ mode: 'left', title: 'Wrap text — image left' },
{ mode: 'right', title: 'Wrap text — image right' },
{ mode: 'topBottom', title: 'Top and bottom' },
];
const modes: WrapMode[] = ['inline', 'left', 'right', 'topBottom'];
function wrapTitle(m: WrapMode): string {
return m === 'inline' ? t().image.wrapInline
: m === 'left' ? t().image.wrapLeft
: m === 'right' ? t().image.wrapRight
: t().image.wrapTopBottom;
}
</script>
<div
@@ -33,19 +35,19 @@
style="top: {top}px; left: {left}px;"
role="toolbar"
tabindex="-1"
aria-label="Image text wrap"
aria-label={t().image.wrapToolbar}
onmousedown={(e) => e.preventDefault()}
>
{#each modes as m}
<button
class="it-btn"
class:active={wrap === m.mode}
title={m.title}
aria-label={m.title}
aria-pressed={wrap === m.mode}
onclick={() => set(m.mode)}
class:active={wrap === m}
title={wrapTitle(m)}
aria-label={wrapTitle(m)}
aria-pressed={wrap === m}
onclick={() => set(m)}
>
{#if m.mode === 'inline'}
{#if m === 'inline'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="12" x2="5.5" y2="12" stroke="currentColor" stroke-width="1.2" />
@@ -53,7 +55,7 @@
<line x1="12.5" y1="12" x2="15.5" y2="12" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else if m.mode === 'left'}
{:else if m === 'left'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<rect x="2.5" y="6" width="6" height="6" rx="1" fill="currentColor" />
@@ -61,7 +63,7 @@
<line x1="10" y1="10" x2="15.5" y2="10" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else if m.mode === 'right'}
{:else if m === 'right'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<rect x="9.5" y="6" width="6" height="6" rx="1" fill="currentColor" />
+5 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
// Small popover to enter/edit a hyperlink URL. The parent owns `open` and decides how
// to apply the URL to the selection (insert vs. set vs. extend); this just collects it.
let {
@@ -40,7 +41,7 @@
</script>
{#if open}
<div class="link-dialog" role="dialog" aria-label="Insert link">
<div class="link-dialog" role="dialog" aria-label={t().link.dialogLabel}>
<input
bind:this={input}
bind:value={url}
@@ -52,11 +53,11 @@
/>
<div class="link-actions">
{#if canRemove}
<button class="link-remove" onclick={onRemove}>Remove</button>
<button class="link-remove" onclick={onRemove}>{t().common.remove}</button>
{/if}
<span class="link-spacer"></span>
<button class="link-cancel" onclick={onClose}>Cancel</button>
<button class="link-apply" onclick={apply} disabled={!url.trim()}>Apply</button>
<button class="link-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="link-apply" onclick={apply} disabled={!url.trim()}>{t().common.apply}</button>
</div>
</div>
{/if}
+13 -7
View File
@@ -1,6 +1,12 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { CHAR_CATEGORIES, type SpecialChar } from '../utils/specialChars';
import { t } from '../i18n/i18n.svelte';
// Category tab label by its English name, with the name itself as fallback.
function categoryLabel(name: string): string {
return (t().special.categories as Record<string, string>)[name] ?? name;
}
let {
editor,
@@ -121,26 +127,26 @@
</script>
<div class="char-picker" use:charPickerClickOutside>
<button class="char-trigger" onclick={openPicker} title="Insert special character" aria-pressed={open}>
<button class="char-trigger" onclick={openPicker} title={t().special.insertChar} aria-pressed={open}>
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="8" y="12.5" font-size="14" font-weight="700" font-family="var(--font-serif, serif)" fill="currentColor" text-anchor="middle">Ω</text>
</svg>
</button>
{#if open}
<div class="char-dropdown" role="dialog" aria-label="Insert special character" tabindex="-1" onkeydown={onKeydown}>
<div class="char-dropdown" role="dialog" aria-label={t().special.insertChar} tabindex="-1" onkeydown={onKeydown}>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="char-search"
bind:value={query}
placeholder="Search by name or code…"
aria-label="Search special characters"
placeholder={t().special.searchPlaceholder}
aria-label={t().special.searchAria}
autofocus
/>
{#if !query && recentChars.length > 0}
<div class="char-section-label">Recent</div>
<div class="char-section-label">{t().special.recent}</div>
<div class="char-grid">
{#each recentChars as c (c)}
<button class="char-cell" title={titleOf(c)} onclick={() => pick(c)}>{c}</button>
@@ -157,7 +163,7 @@
role="tab"
aria-selected={i === activeIndex}
onclick={() => (activeIndex = i)}
>{cat.name}</button>
>{categoryLabel(cat.name)}</button>
{/each}
</div>
{/if}
@@ -169,7 +175,7 @@
{/each}
</div>
{:else}
<div class="char-empty">No matching characters</div>
<div class="char-empty">{t().special.noMatch}</div>
{/if}
</div>
{/if}
+5 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
let {
top,
left,
@@ -42,18 +43,18 @@
style="top: {top}px; left: {left}px;"
role="menu"
tabindex="-1"
aria-label="Spelling suggestions"
aria-label={t().spell.suggestions}
>
{#if suggestions.length}
{#each suggestions as s}
<button class="sm-item sm-suggest" role="menuitem" onclick={() => onReplace(s)}>{s}</button>
{/each}
{:else}
<span class="sm-empty">No suggestions</span>
<span class="sm-empty">{t().spell.noSuggestions}</span>
{/if}
<span class="sm-sep"></span>
<button class="sm-item" role="menuitem" onclick={onAdd}>Add to Dictionary</button>
<button class="sm-item" role="menuitem" onclick={onIgnore}>Ignore All</button>
<button class="sm-item" role="menuitem" onclick={onAdd}>{t().spell.addToDictionary}</button>
<button class="sm-item" role="menuitem" onclick={onIgnore}>{t().spell.ignoreAll}</button>
</div>
<style>
+21 -20
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { activeBorderPresets, type BorderPreset } from '../editor/extensions/tableCellBorders';
import { t } from '../i18n/i18n.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
@@ -24,17 +25,17 @@
h: 'M1 9H17', v: 'M9 1V17',
};
const ALL_SEGS: Seg[] = ['top', 'right', 'bottom', 'left', 'h', 'v'];
const PRESETS: { id: BorderPreset | 'none'; title: string; on: Seg[] }[] = [
{ id: 'all', title: 'All borders', on: ['top', 'right', 'bottom', 'left', 'h', 'v'] },
{ id: 'outer', title: 'Outside borders', on: ['top', 'right', 'bottom', 'left'] },
{ id: 'inner', title: 'Inside borders', on: ['h', 'v'] },
{ id: 'innerH', title: 'Inside horizontal border', on: ['h'] },
{ id: 'innerV', title: 'Inside vertical border', on: ['v'] },
{ id: 'top', title: 'Top border', on: ['top'] },
{ id: 'bottom', title: 'Bottom border', on: ['bottom'] },
{ id: 'left', title: 'Left border', on: ['left'] },
{ id: 'right', title: 'Right border', on: ['right'] },
{ id: 'none', title: 'No border', on: [] },
const PRESETS: { id: BorderPreset | 'none'; on: Seg[] }[] = [
{ id: 'all', on: ['top', 'right', 'bottom', 'left', 'h', 'v'] },
{ id: 'outer', on: ['top', 'right', 'bottom', 'left'] },
{ id: 'inner', on: ['h', 'v'] },
{ id: 'innerH', on: ['h'] },
{ id: 'innerV', on: ['v'] },
{ id: 'top', on: ['top'] },
{ id: 'bottom', on: ['bottom'] },
{ id: 'left', on: ['left'] },
{ id: 'right', on: ['right'] },
{ id: 'none', on: [] },
];
// Word-like button states, re-read per transaction (tick) and pen change: a preset
@@ -65,8 +66,8 @@
<button
class="bp-trigger"
class:active={open}
title="Borders"
aria-label="Borders"
title={t().borders.title}
aria-label={t().borders.title}
aria-expanded={open}
onclick={() => (open = !open)}
>
@@ -82,14 +83,14 @@
{#if open}
<div class="bp-dropdown">
<div class="bp-title">Borders</div>
<div class="bp-title">{t().borders.title}</div>
<div class="bp-presets">
{#each PRESETS as preset}
<button
class="bp-preset"
class:active={!!active?.[preset.id]}
title={preset.title}
aria-label={preset.title}
title={t().borders[preset.id]}
aria-label={t().borders[preset.id]}
aria-pressed={!!active?.[preset.id]}
onclick={() => apply(preset.id)}
>
@@ -118,14 +119,14 @@
{/each}
</div>
<div class="bp-title">Line width</div>
<div class="bp-title">{t().borders.lineWidth}</div>
<div class="bp-widths">
{#each WIDTHS as w}
<button
class="bp-width"
class:active={widthPt === w}
title="{w}pt"
aria-label="{w}pt"
title={t().borders.pt(w)}
aria-label={t().borders.pt(w)}
aria-pressed={widthPt === w}
onclick={() => (widthPt = w)}
>
@@ -134,7 +135,7 @@
{/each}
</div>
<div class="bp-title">Line color</div>
<div class="bp-title">{t().borders.lineColor}</div>
<div class="bp-colors">
{#each COLORS as row}
<div class="bp-color-row">
+11 -10
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { t } from '../i18n/i18n.svelte';
let {
editor,
@@ -84,7 +85,7 @@
</script>
<div class="table-picker" use:tablePickerClickOutside>
<button class="table-trigger" onclick={openPicker} title="Insert table" aria-pressed={open}>
<button class="table-trigger" onclick={openPicker} title={t().table.insertTable} aria-pressed={open}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1.5" y="1.5" width="13" height="13" rx="1" stroke="currentColor" stroke-width="1.5"/>
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" stroke="currentColor" stroke-width="1"/>
@@ -98,9 +99,9 @@
<div class="table-dropdown">
<div class="table-dim-label">
{#if hoverRows > 0 && hoverCols > 0}
{hoverCols} × {hoverRows}
{t().table.dimensions(hoverCols, hoverRows)}
{:else}
Insert table
{t().table.insertTable}
{/if}
</div>
@@ -109,7 +110,7 @@
class="table-grid"
role="grid"
tabindex="-1"
aria-label="Select table size"
aria-label={t().table.selectSize}
onmouseleave={() => { hoverRows = 0; hoverCols = 0; }}
>
{#each ROW_INDICES as r}
@@ -119,7 +120,7 @@
class="table-grid-cell"
class:hot={r <= hoverRows && c <= hoverCols}
role="gridcell"
aria-label={`${c} by ${r}`}
aria-label={t().table.byDimension(c, r)}
onmouseover={() => { hoverRows = r; hoverCols = c; }}
onfocus={() => { hoverRows = r; hoverCols = c; }}
onclick={() => insert(r, c)}
@@ -131,26 +132,26 @@
<div class="table-manual">
<label class="table-manual-field">
<span>Rows</span>
<span>{t().table.rows}</span>
<input
type="text"
inputmode="numeric"
bind:value={rowsInput}
onkeydown={onInputKeydown}
aria-label="Number of rows"
aria-label={t().table.numberOfRows}
/>
</label>
<label class="table-manual-field">
<span>Cols</span>
<span>{t().table.cols}</span>
<input
type="text"
inputmode="numeric"
bind:value={colsInput}
onkeydown={onInputKeydown}
aria-label="Number of columns"
aria-label={t().table.numberOfColumns}
/>
</label>
<button class="table-insert-btn" onclick={insertFromInputs}>Insert</button>
<button class="table-insert-btn" onclick={insertFromInputs}>{t().table.insert}</button>
</div>
</div>
{/if}
+6 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
// Word/LibreOffice "Split Cells…" popover: collects column + row counts. The parent
// owns `open` and runs the splitCellInto command against the saved cell selection
// (focus moves to the inputs, like LinkDialog/TablePicker).
@@ -55,11 +56,11 @@
style="top: {top}px; left: {left}px;"
role="dialog"
tabindex="-1"
aria-label="Split cells"
aria-label={t().table.splitDialogLabel}
>
<div class="split-fields">
<label>
<span>Columns</span>
<span>{t().table.columns}</span>
<input
bind:this={firstField}
bind:value={colsInput}
@@ -70,7 +71,7 @@
/>
</label>
<label>
<span>Rows</span>
<span>{t().table.rowsField}</span>
<input
bind:value={rowsInput}
type="number"
@@ -82,8 +83,8 @@
</div>
<div class="split-actions">
<span class="split-spacer"></span>
<button class="split-cancel" onclick={onClose}>Cancel</button>
<button class="split-apply" onclick={apply}>Split</button>
<button class="split-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="split-apply" onclick={apply}>{t().table.split}</button>
</div>
</div>
{/if}
+25 -24
View File
@@ -4,6 +4,7 @@
import { isHeaderRowStyled } from '../editor/extensions/tableHeaderRow';
import ColorPicker from './ColorPicker.svelte';
import TableBorderPicker from './TableBorderPicker.svelte';
import { t } from '../i18n/i18n.svelte';
let {
editor,
@@ -70,13 +71,13 @@
style="top: {top}px; left: {left}px;"
role="toolbar"
tabindex="-1"
aria-label="Table editing"
aria-label={t().table.editing}
onmousedown={(e) => { if (!(e.target as HTMLElement).closest('.color-picker')) e.preventDefault(); }}
>
<button
class="tt-btn"
title="Insert row above"
aria-label="Insert row above"
title={t().table.insertRowAbove}
aria-label={t().table.insertRowAbove}
onclick={() => run((c) => c.addRowBefore())}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@@ -88,8 +89,8 @@
<button
class="tt-btn"
title="Insert row below"
aria-label="Insert row below"
title={t().table.insertRowBelow}
aria-label={t().table.insertRowBelow}
onclick={() => run((c) => c.addRowAfter())}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@@ -101,8 +102,8 @@
<button
class="tt-btn"
title="Insert column left"
aria-label="Insert column left"
title={t().table.insertColumnLeft}
aria-label={t().table.insertColumnLeft}
onclick={() => run((c) => c.addColumnBefore())}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@@ -114,8 +115,8 @@
<button
class="tt-btn"
title="Insert column right"
aria-label="Insert column right"
title={t().table.insertColumnRight}
aria-label={t().table.insertColumnRight}
onclick={() => run((c) => c.addColumnAfter())}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@@ -129,8 +130,8 @@
<button
class="tt-btn tt-danger"
title="Delete row"
aria-label="Delete row"
title={t().table.deleteRow}
aria-label={t().table.deleteRow}
onclick={() => run((c) => c.deleteRow())}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@@ -142,8 +143,8 @@
<button
class="tt-btn tt-danger"
title="Delete column"
aria-label="Delete column"
title={t().table.deleteColumn}
aria-label={t().table.deleteColumn}
onclick={() => run((c) => c.deleteColumn())}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@@ -155,8 +156,8 @@
<button
class="tt-btn tt-danger"
title="Delete table"
aria-label="Delete table"
title={t().table.deleteTable}
aria-label={t().table.deleteTable}
onclick={() => run((c) => c.deleteTable())}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@@ -168,8 +169,8 @@
<button
class="tt-btn"
title="Merge cells"
aria-label="Merge cells"
title={t().table.mergeCells}
aria-label={t().table.mergeCells}
disabled={!canMerge}
onclick={() => run((c) => c.mergeCells())}
>
@@ -182,8 +183,8 @@
<button
class="tt-btn"
title="Split cells…"
aria-label="Split cells"
title={t().table.splitCells}
aria-label={t().table.splitCellsAria}
onclick={() => onSplit()}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
@@ -199,9 +200,9 @@
{editor}
currentColor={currentCellColor}
defaultColor="#D9D9D9"
title="Cell shading"
chevronTitle="Choose cell color"
clearLabel="No fill"
title={t().table.cellShading}
chevronTitle={t().table.chooseCellColor}
clearLabel={t().table.noFill}
onApply={(c) => applyCellColor(c)}
onClear={() => clearCellColor()}
>
@@ -221,8 +222,8 @@
<button
class="tt-btn"
class:active={isHeaderRow}
title="Header row (bold + shading)"
aria-label="Header row"
title={t().table.headerRow}
aria-label={t().table.headerRowAria}
aria-pressed={isHeaderRow}
onclick={() => run((c) => c.toggleHeaderRowStyle())}
>
+40 -37
View File
@@ -3,6 +3,7 @@
import type { WrapMode } from '../editor/extensions/image';
import type { ShapeKind, TextBoxAttrs } from '../editor/extensions/textBox';
import ColorPicker from './ColorPicker.svelte';
import { t } from '../i18n/i18n.svelte';
let {
editor,
@@ -31,18 +32,20 @@
editor?.chain().focus().setTextBoxAttrs(attrs).run();
}
const wrapModes: { mode: WrapMode; title: string }[] = [
{ mode: 'inline', title: 'In line with text' },
{ mode: 'left', title: 'Wrap text — box left' },
{ mode: 'right', title: 'Wrap text — box right' },
{ mode: 'topBottom', title: 'Top and bottom' },
];
const wrapModes: WrapMode[] = ['inline', 'left', 'right', 'topBottom'];
function wrapTitle(m: WrapMode): string {
return m === 'inline' ? t().textBox.wrapInline
: m === 'left' ? t().textBox.wrapLeft
: m === 'right' ? t().textBox.wrapRight
: t().textBox.wrapTopBottom;
}
const shapes: { kind: ShapeKind; title: string }[] = [
{ kind: 'textbox', title: 'Rectangle' },
{ kind: 'roundRect', title: 'Rounded rectangle' },
{ kind: 'ellipse', title: 'Ellipse' },
];
const shapes: ShapeKind[] = ['textbox', 'roundRect', 'ellipse'];
function shapeTitle(k: ShapeKind): string {
return k === 'textbox' ? t().textBox.shapeRect
: k === 'roundRect' ? t().textBox.shapeRoundRect
: t().textBox.shapeEllipse;
}
const strokeWidths = [0.5, 1, 2.25];
</script>
@@ -52,7 +55,7 @@
style="top: {top}px; left: {left}px;"
role="toolbar"
tabindex="-1"
aria-label="Text box options"
aria-label={t().textBox.toolbar}
onmousedown={(e) => {
// Keep the box selection/caret; buttons work via click.
if ((e.target as HTMLElement).closest('select') === null) e.preventDefault();
@@ -61,13 +64,13 @@
{#each wrapModes as m}
<button
class="tb-btn"
class:active={wrap === m.mode}
title={m.title}
aria-label={m.title}
aria-pressed={wrap === m.mode}
onclick={() => set({ wrap: m.mode })}
class:active={wrap === m}
title={wrapTitle(m)}
aria-label={wrapTitle(m)}
aria-pressed={wrap === m}
onclick={() => set({ wrap: m })}
>
{#if m.mode === 'inline'}
{#if m === 'inline'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="12" x2="5.5" y2="12" stroke="currentColor" stroke-width="1.2" />
@@ -75,7 +78,7 @@
<line x1="12.5" y1="12" x2="15.5" y2="12" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else if m.mode === 'left'}
{:else if m === 'left'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<rect x="2.5" y="6" width="6" height="6" rx="1" fill="currentColor" />
@@ -83,7 +86,7 @@
<line x1="10" y1="10" x2="15.5" y2="10" stroke="currentColor" stroke-width="1.2" />
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else if m.mode === 'right'}
{:else if m === 'right'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
<rect x="9.5" y="6" width="6" height="6" rx="1" fill="currentColor" />
@@ -106,17 +109,17 @@
{#each shapes as s}
<button
class="tb-btn"
class:active={shapeKind === s.kind}
title={s.title}
aria-label={s.title}
aria-pressed={shapeKind === s.kind}
onclick={() => set({ shapeKind: s.kind })}
class:active={shapeKind === s}
title={shapeTitle(s)}
aria-label={shapeTitle(s)}
aria-pressed={shapeKind === s}
onclick={() => set({ shapeKind: s })}
>
{#if s.kind === 'textbox'}
{#if s === 'textbox'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2.5" y="4.5" width="13" height="9" stroke="currentColor" stroke-width="1.3" />
</svg>
{:else if s.kind === 'roundRect'}
{:else if s === 'roundRect'}
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<rect x="2.5" y="4.5" width="13" height="9" rx="3" stroke="currentColor" stroke-width="1.3" />
</svg>
@@ -135,9 +138,9 @@
bind:open={fillOpen}
currentColor={fillColor}
defaultColor="#FFFFFF"
title="Fill color"
chevronTitle="Choose fill color"
clearLabel="No fill"
title={t().textBox.fillColor}
chevronTitle={t().textBox.chooseFillColor}
clearLabel={t().textBox.noFill}
onOpen={() => (strokeOpen = false)}
onApply={(c) => set({ fillColor: c })}
onClear={() => set({ fillColor: null })}
@@ -156,9 +159,9 @@
bind:open={strokeOpen}
currentColor={strokeColor}
defaultColor="#000000"
title="Border color"
chevronTitle="Choose border color"
clearLabel="No border"
title={t().textBox.borderColor}
chevronTitle={t().textBox.chooseBorderColor}
clearLabel={t().textBox.noBorder}
onOpen={() => (fillOpen = false)}
onApply={(c) => set({ strokeColor: c })}
onClear={() => set({ strokeColor: null })}
@@ -173,16 +176,16 @@
<select
class="tb-width"
title="Border width"
aria-label="Border width"
title={t().textBox.borderWidth}
aria-label={t().textBox.borderWidth}
value={String(strokeWidthPt)}
onchange={(e) => set({ strokeWidthPt: parseFloat((e.target as HTMLSelectElement).value) })}
>
{#each strokeWidths as w}
<option value={String(w)}>{w} pt</option>
<option value={String(w)}>{t().textBox.pt(w)}</option>
{/each}
{#if !strokeWidths.includes(strokeWidthPt)}
<option value={String(strokeWidthPt)}>{strokeWidthPt} pt</option>
<option value={String(strokeWidthPt)}>{t().textBox.pt(strokeWidthPt)}</option>
{/if}
</select>
</div>
+141
View File
@@ -119,6 +119,147 @@ const de: Messages = {
linkNotInHf: 'Links sind in Kopf-/Fußzeilen nicht verfügbar',
clearFormatting: 'Formatierung entfernen',
},
image: {
wrapToolbar: 'Bildumbruch',
wrapInline: 'Mit Text in Zeile',
wrapLeft: 'Textumbruch — Bild links',
wrapRight: 'Textumbruch — Bild rechts',
wrapTopBottom: 'Oben und unten',
},
textBox: {
toolbar: 'Textfeld-Optionen',
wrapInline: 'Mit Text in Zeile',
wrapLeft: 'Textumbruch — Feld links',
wrapRight: 'Textumbruch — Feld rechts',
wrapTopBottom: 'Oben und unten',
shapeRect: 'Rechteck',
shapeRoundRect: 'Abgerundetes Rechteck',
shapeEllipse: 'Ellipse',
fillColor: 'Füllfarbe',
chooseFillColor: 'Füllfarbe wählen',
noFill: 'Keine Füllung',
borderColor: 'Rahmenfarbe',
chooseBorderColor: 'Rahmenfarbe wählen',
noBorder: 'Kein Rahmen',
borderWidth: 'Rahmenbreite',
pt: (w) => `${w} pt`,
},
link: {
dialogLabel: 'Link einfügen',
},
color: {
automatic: 'Automatisch',
moreColors: 'Weitere Farben…',
pickFromPage: 'Farbe von der Seite auswählen',
chooseColor: 'Farbe wählen',
},
table: {
editing: 'Tabelle bearbeiten',
insertRowAbove: 'Zeile oberhalb einfügen',
insertRowBelow: 'Zeile unterhalb einfügen',
insertColumnLeft: 'Spalte links einfügen',
insertColumnRight: 'Spalte rechts einfügen',
deleteRow: 'Zeile löschen',
deleteColumn: 'Spalte löschen',
deleteTable: 'Tabelle löschen',
mergeCells: 'Zellen verbinden',
splitCells: 'Zellen teilen…',
splitCellsAria: 'Zellen teilen',
cellShading: 'Zellenschattierung',
chooseCellColor: 'Zellenfarbe wählen',
noFill: 'Keine Füllung',
headerRow: 'Kopfzeile (fett + Schattierung)',
headerRowAria: 'Kopfzeile',
insertTable: 'Tabelle einfügen',
selectSize: 'Tabellengröße wählen',
dimensions: (cols, rows) => `${cols} × ${rows}`,
byDimension: (c, r) => `${c} mal ${r}`,
rows: 'Zeilen',
cols: 'Spalten',
numberOfRows: 'Anzahl der Zeilen',
numberOfColumns: 'Anzahl der Spalten',
insert: 'Einfügen',
splitDialogLabel: 'Zellen teilen',
columns: 'Spalten',
rowsField: 'Zeilen',
split: 'Teilen',
},
borders: {
title: 'Rahmen',
all: 'Alle Rahmen',
outer: 'Außenrahmen',
inner: 'Innenrahmen',
innerH: 'Innerer waagerechter Rahmen',
innerV: 'Innerer senkrechter Rahmen',
top: 'Oberer Rahmen',
bottom: 'Unterer Rahmen',
left: 'Linker Rahmen',
right: 'Rechter Rahmen',
none: 'Kein Rahmen',
lineWidth: 'Linienbreite',
lineColor: 'Linienfarbe',
pt: (w) => `${w}pt`,
},
spell: {
suggestions: 'Rechtschreibvorschläge',
noSuggestions: 'Keine Vorschläge',
addToDictionary: 'Zum Wörterbuch hinzufügen',
ignoreAll: 'Alle ignorieren',
},
about: {
label: 'Über EdenText',
tagline: 'Ein privater Editor für OpenDocument-Dateien — läuft vollständig im Browser.',
version: (v) => `Version ${v}`,
viewOnGitHub: 'Auf GitHub ansehen',
},
findReplace: {
dialogLabel: 'Suchen und ersetzen',
hideReplace: 'Ersetzen ausblenden',
showReplace: 'Ersetzen anzeigen',
toggleReplace: 'Ersetzen umschalten',
findPlaceholder: 'Suchen',
noResults: 'Keine Treffer',
matchCase: 'Groß-/Kleinschreibung beachten',
wholeWord: 'Ganzes Wort',
previous: 'Zurück',
previousAria: 'Vorheriger Treffer',
next: 'Weiter',
nextAria: 'Nächster Treffer',
replacePlaceholder: 'Ersetzen',
replaceCurrent: 'Aktuellen Treffer ersetzen',
replaceAllTitle: 'Alle Treffer ersetzen',
replace: 'Ersetzen',
all: 'Alle',
},
special: {
insertChar: 'Sonderzeichen einfügen',
searchPlaceholder: 'Nach Name oder Code suchen…',
searchAria: 'Sonderzeichen suchen',
recent: 'Zuletzt',
noMatch: 'Keine passenden Zeichen',
categories: {
Punctuation: 'Interpunktion',
Currency: 'Währung',
Math: 'Mathematik',
Arrows: 'Pfeile',
Greek: 'Griechisch',
Latin: 'Lateinisch',
Symbols: 'Symbole',
},
},
hf: {
addHeaderHint: 'Zum Hinzufügen einer Kopfzeile doppelklicken',
addFooterHint: 'Zum Hinzufügen einer Fußzeile doppelklicken',
headerPlaceholder: 'Kopfzeile…',
footerPlaceholder: 'Fußzeile…',
insert: 'Einfügen',
pageNumber: 'Seitenzahl',
pageNumberTitle: 'Aktuelle Seitenzahl einfügen',
pageCount: 'Seitenanzahl',
pageCountTitle: 'Gesamtseitenanzahl einfügen',
done: 'Fertig',
doneTitle: 'Bearbeiten der Kopf-/Fußzeile beenden',
},
};
export default de;
+141
View File
@@ -122,6 +122,147 @@ const en = {
linkNotInHf: 'Links are not available in headers/footers',
clearFormatting: 'Clear formatting',
},
image: {
wrapToolbar: 'Image text wrap',
wrapInline: 'In line with text',
wrapLeft: 'Wrap text — image left',
wrapRight: 'Wrap text — image right',
wrapTopBottom: 'Top and bottom',
},
textBox: {
toolbar: 'Text box options',
wrapInline: 'In line with text',
wrapLeft: 'Wrap text — box left',
wrapRight: 'Wrap text — box right',
wrapTopBottom: 'Top and bottom',
shapeRect: 'Rectangle',
shapeRoundRect: 'Rounded rectangle',
shapeEllipse: 'Ellipse',
fillColor: 'Fill color',
chooseFillColor: 'Choose fill color',
noFill: 'No fill',
borderColor: 'Border color',
chooseBorderColor: 'Choose border color',
noBorder: 'No border',
borderWidth: 'Border width',
pt: (w: number) => `${w} pt`,
},
link: {
dialogLabel: 'Insert link',
},
color: {
automatic: 'Automatic',
moreColors: 'More colors…',
pickFromPage: 'Pick a color from the page',
chooseColor: 'Choose color',
},
table: {
editing: 'Table editing',
insertRowAbove: 'Insert row above',
insertRowBelow: 'Insert row below',
insertColumnLeft: 'Insert column left',
insertColumnRight: 'Insert column right',
deleteRow: 'Delete row',
deleteColumn: 'Delete column',
deleteTable: 'Delete table',
mergeCells: 'Merge cells',
splitCells: 'Split cells…',
splitCellsAria: 'Split cells',
cellShading: 'Cell shading',
chooseCellColor: 'Choose cell color',
noFill: 'No fill',
headerRow: 'Header row (bold + shading)',
headerRowAria: 'Header row',
insertTable: 'Insert table',
selectSize: 'Select table size',
dimensions: (cols: number, rows: number) => `${cols} × ${rows}`,
byDimension: (c: number, r: number) => `${c} by ${r}`,
rows: 'Rows',
cols: 'Cols',
numberOfRows: 'Number of rows',
numberOfColumns: 'Number of columns',
insert: 'Insert',
splitDialogLabel: 'Split cells',
columns: 'Columns',
rowsField: 'Rows',
split: 'Split',
},
borders: {
title: 'Borders',
all: 'All borders',
outer: 'Outside borders',
inner: 'Inside borders',
innerH: 'Inside horizontal border',
innerV: 'Inside vertical border',
top: 'Top border',
bottom: 'Bottom border',
left: 'Left border',
right: 'Right border',
none: 'No border',
lineWidth: 'Line width',
lineColor: 'Line color',
pt: (w: number) => `${w}pt`,
},
spell: {
suggestions: 'Spelling suggestions',
noSuggestions: 'No suggestions',
addToDictionary: 'Add to Dictionary',
ignoreAll: 'Ignore All',
},
about: {
label: 'About EdenText',
tagline: 'A private editor for OpenDocument files — runs entirely in your browser.',
version: (v: string) => `Version ${v}`,
viewOnGitHub: 'View on GitHub',
},
findReplace: {
dialogLabel: 'Find and replace',
hideReplace: 'Hide replace',
showReplace: 'Show replace',
toggleReplace: 'Toggle replace',
findPlaceholder: 'Find',
noResults: 'No results',
matchCase: 'Match case',
wholeWord: 'Whole word',
previous: 'Previous',
previousAria: 'Previous match',
next: 'Next',
nextAria: 'Next match',
replacePlaceholder: 'Replace',
replaceCurrent: 'Replace current match',
replaceAllTitle: 'Replace all matches',
replace: 'Replace',
all: 'All',
},
special: {
insertChar: 'Insert special character',
searchPlaceholder: 'Search by name or code…',
searchAria: 'Search special characters',
recent: 'Recent',
noMatch: 'No matching characters',
categories: {
Punctuation: 'Punctuation',
Currency: 'Currency',
Math: 'Math',
Arrows: 'Arrows',
Greek: 'Greek',
Latin: 'Latin',
Symbols: 'Symbols',
},
},
hf: {
addHeaderHint: 'Double-click to add a header',
addFooterHint: 'Double-click to add a footer',
headerPlaceholder: 'Header…',
footerPlaceholder: 'Footer…',
insert: 'Insert',
pageNumber: 'Page number',
pageNumberTitle: 'Insert the current page number',
pageCount: 'Page count',
pageCountTitle: 'Insert the total page count',
done: 'Done',
doneTitle: 'Finish editing the header/footer',
},
};
export type Messages = typeof en;