From d14bb33e124d0fbcc5848b7240992a12ca4d536c Mon Sep 17 00:00:00 2001 From: maxrks777 Date: Tue, 31 Mar 2026 01:08:15 +0800 Subject: [PATCH] feat(i18n): add multi-language support and implement app internationalization Add full i18n support for the application, including: 1. Add language selector in settings supporting 10 languages including English and Chinese 2. Add translated text for all UI elements 3. Implement dynamic language switching 4. Add automatic system language detection 5. Enable i18n support for editor, modals, menus and other components 6. Update settings storage to save language preference Refactor hard-coded text across the app to use translation functions. --- src/lib/Installer.svelte | 41 +- src/lib/MarkdownViewer.svelte | 95 +- src/lib/Uninstaller.svelte | 13 +- src/lib/components/Editor.svelte | 76 +- src/lib/components/HomePage.svelte | 12 +- src/lib/components/Modal.svelte | 20 +- src/lib/components/Settings.svelte | 268 ++-- src/lib/components/Tab.svelte | 21 +- src/lib/components/TabList.svelte | 19 +- src/lib/components/TitleBar.svelte | 275 ++-- src/lib/components/Toc.svelte | 13 +- src/lib/components/ZoomOverlay.svelte | 4 +- src/lib/stores/settings.svelte.ts | 62 +- src/lib/stores/tabs.svelte.ts | 9 +- src/lib/utils/i18n.ts | 2127 +++++++++++++++++++++++++ 15 files changed, 2652 insertions(+), 403 deletions(-) create mode 100644 src/lib/utils/i18n.ts diff --git a/src/lib/Installer.svelte b/src/lib/Installer.svelte index 0d85412..6dab851 100644 --- a/src/lib/Installer.svelte +++ b/src/lib/Installer.svelte @@ -4,6 +4,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { onMount } from 'svelte'; import iconUrl from '../assets/icon.png'; + import { t } from './utils/i18n.js'; let installing = $state(false); let error = $state(''); @@ -65,7 +66,7 @@ error = e.toString(); installing = false; if (error.includes('Access is denied') && (isInstalled ? installedAllUsers : allUsers)) { - error = 'Access denied. Please run as Administrator.'; + error = t('installer.accessDenied'); } } } @@ -97,7 +98,7 @@
-
@@ -105,15 +106,15 @@
App Icon -

Markdown Viewer

+

{t('installer.markdownViewer')}

{#if isInstalled}
- Current: v{installedVersion} + {t('installer.current')} v{installedVersion} - Target: v{installerVersion} + {t('installer.target')} v{installerVersion}
{:else} -

A simple markdown viewer v{installerVersion}

+

{t('installer.simpleMarkdownViewer')} v{installerVersion}

{/if}
@@ -123,8 +124,8 @@
{#if !isInstalled}
- - + +
{/if} @@ -134,39 +135,39 @@
{:else}

- Installed for: {installedAllUsers ? 'All Users' : 'Current User'} + {t('installer.installedFor')} {installedAllUsers ? t('installer.allUsers') : 'Current User'}

@@ -181,25 +182,25 @@
{#if isInstalled} - - + + {:else} {/if}
{#if allUsers || (isInstalled && installedAllUsers)} -

Requires Administrator privileges

+

{t('installer.requiresAdmin')}

{/if}
{:else}
-

{isInstalled ? 'Updating' : 'Installing'} Markpad...

+

{t(isInstalled ? 'installer.updating' : 'installer.installing')} {t('installer.markpad')}

{/if}
diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 97cf2f3..202b3c0 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -24,8 +24,9 @@ import { processMarkdownHtml } from './utils/markdown'; import DOMPurify from 'dompurify'; import HomePage from './components/HomePage.svelte'; - import { tabManager } from './stores/tabs.svelte.js'; - import { settings } from './stores/settings.svelte.js'; +import { tabManager } from './stores/tabs.svelte.js'; +import { settings } from './stores/settings.svelte.js'; +import { t } from './utils/i18n.js'; // syntax highlighting & latex let hljs: any = $state(null); @@ -40,6 +41,12 @@ import { processMarkdownHtml } from './utils/markdown'; let showSettings = $state(false); + let uiLanguage = $state(settings.language); + + $effect(() => { + uiLanguage = settings.language; + }); + let recentFiles = $state([]); let isFocused = $state(true); @@ -238,8 +245,8 @@ import { processMarkdownHtml } from './utils/markdown'; if (settings.restoreStateOnReopen) { const hasUnsaved = tabManager.tabs.some((t) => t.isDirty || (t.path === '' && t.rawContent.trim() !== '')); if (hasUnsaved) { - const response = await askCustom(`Are you sure you want to exit? All unsaved tabs and local history will be lost.`, { - title: 'Confirm Exit', + const response = await askCustom(t('modal.exit.unsaved.message'), { + title: t('modal.exit.unsaved.title'), kind: 'warning', showSave: false, }); @@ -1048,8 +1055,8 @@ import { processMarkdownHtml } from './utils/markdown'; if (!tab.isDirty) return true; - const response = await askCustom(`You have unsaved changes in "${tab.title}". Do you want to save them before closing?`, { - title: 'Unsaved Changes', + const response = await askCustom(t('modal.youHaveUnsavedChanges', settings.language).replace('{title}', tab.title), { + title: t('modal.unsavedChanges.title'), kind: 'warning', showSave: true, }); @@ -1070,14 +1077,14 @@ import { processMarkdownHtml } from './utils/markdown'; // Switch back to view if (tab.isDirty && tab.path !== '') { if (autoSave) { - const success = await saveContent(); - if (!success) return; // If save fails, stay in edit mode? - } else { - const response = await askCustom('You have unsaved changes. Do you want to save them before returning to view mode?', { - title: 'Unsaved Changes', - kind: 'warning', - showSave: true, - }); + const success = await saveContent(); + if (!success) return; // If save fails, stay in edit mode? + } else { + const response = await askCustom(t('modal.unsavedChanges.viewMode.message'), { + title: t('modal.unsavedChanges.title'), + kind: 'warning', + showSave: true, + }); if (response === 'cancel') return; if (response === 'save') { @@ -1319,7 +1326,7 @@ import { processMarkdownHtml } from './utils/markdown'; const filename = tab?.path ? tab.path.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') || '' : ''; const ref = filename ? `[[${filename}#${text}]]` : `#${text}`; copyRefItem = [ - { label: 'Copy Reference', onClick: () => invoke('clipboard_write_text', { text: ref }) }, + { label: t('menu.copyReference', uiLanguage), onClick: () => invoke('clipboard_write_text', { text: ref }) }, { separator: true }, ]; } @@ -1328,7 +1335,7 @@ import { processMarkdownHtml } from './utils/markdown'; let mediaItems: any[] = []; if (img) { mediaItems = [ - { label: 'Save Image As...', onClick: () => saveImageAs(img.src) }, + { label: t('menu.saveImageAs', uiLanguage), onClick: () => saveImageAs(img.src) }, { separator: true } ]; } @@ -1336,7 +1343,7 @@ import { processMarkdownHtml } from './utils/markdown'; const mermaidDiag = (e.target as HTMLElement).closest('.mermaid-diagram'); if (mermaidDiag) { mediaItems = [ - { label: 'Save Diagram As SVG...', onClick: () => saveDiagramAs(mermaidDiag as HTMLElement) }, + { label: t('menu.saveDiagramAsSvg', uiLanguage), onClick: () => saveDiagramAs(mermaidDiag as HTMLElement) }, { separator: true } ]; } @@ -1350,16 +1357,16 @@ import { processMarkdownHtml } from './utils/markdown'; ...mediaItems, ...(isEditing && isInsideEditor ? [ - { label: 'Undo', shortcut: 'Ctrl+Z', onClick: () => editorPane?.undo() }, - { label: 'Redo', shortcut: 'Ctrl+Y', onClick: () => editorPane?.redo() }, + { label: t('menu.undo', uiLanguage), shortcut: 'Ctrl+Z', onClick: () => editorPane?.undo() }, + { label: t('menu.redo', uiLanguage), shortcut: 'Ctrl+Y', onClick: () => editorPane?.redo() }, { separator: true } ] : []), - ...(hasSelection ? [{ label: 'Copy', onClick: () => { + ...(hasSelection ? [{ label: t('menu.copy', uiLanguage), onClick: () => { const selection = window.getSelection()?.toString(); if (selection) invoke('clipboard_write_text', { text: selection }); } }] : []), - { label: 'Select All', onClick: () => { + { label: t('menu.selectAll', uiLanguage), onClick: () => { if (!markdownBody) return; const range = document.createRange(); range.selectNodeContents(markdownBody); @@ -1368,10 +1375,10 @@ import { processMarkdownHtml } from './utils/markdown'; selection?.addRange(range); } }, { separator: true }, - { label: 'Open File Location', onClick: openFileLocation, disabled: !currentFile }, - { label: 'Edit', onClick: () => toggleEdit() }, + { label: t('menu.openLocation', uiLanguage), onClick: openFileLocation, disabled: !currentFile }, + { label: t('menu.edit', uiLanguage), onClick: () => toggleEdit() }, { separator: true }, - { label: 'Close File', onClick: closeFile }, + { label: t('menu.closeFile', uiLanguage), onClick: closeFile }, ], }; } @@ -1521,8 +1528,8 @@ import { processMarkdownHtml } from './utils/markdown'; const success = await saveContent(); if (!success) return; } else { - const response = await askCustom('You have unsaved changes. Do you want to save them before closing split view?', { - title: 'Unsaved Changes', + const response = await askCustom(t('modal.unsavedChanges.splitView.message'), { + title: t('modal.unsavedChanges.title'), kind: 'warning', showSave: true, }); @@ -1797,7 +1804,7 @@ import { processMarkdownHtml } from './utils/markdown'; const tab = tabManager.tabs.find((t) => t.id === tabId); if (!tab || !tab.path) return; - const newName = window.prompt('Rename file:', tab.title); + const newName = window.prompt(t('menu.renameFile', settings.language), tab.title); if (newName && newName !== tab.title) { const oldPath = tab.path; const newPath = oldPath.replace(/[/\\][^/\\]+$/, (m) => m.charAt(0) + newName); @@ -1865,12 +1872,12 @@ import { processMarkdownHtml } from './utils/markdown'; console.log('Dirty tabs:', dirtyTabs.length); if (dirtyTabs.length > 0) { console.log('Preventing default close'); - event.preventDefault(); - const response = await askCustom(`You have ${dirtyTabs.length} unsaved file(s). Do you want to save your changes?`, { - title: 'Unsaved Changes', - kind: 'warning', - showSave: true, - }); + event.preventDefault(); + const response = await askCustom(t('modal.youHaveUnsavedFiles', settings.language).replace('{{count}}', dirtyTabs.length.toString()), { + title: t('modal.unsavedChanges', settings.language), + kind: 'warning', + showSave: true, + }); if (response === 'save') { // Attempt to save all dirty tabs @@ -1940,8 +1947,8 @@ import { processMarkdownHtml } from './utils/markdown'; if (ext && ['md', 'markdown', 'txt'].includes(ext)) { loadMarkdown(path); } else { - const filename = path.split(/[/\\]/).pop() || 'File'; - addToast(`Unsupported file type: ${filename}`, 'error'); + const filename = path.split(/[\/\\]/).pop() || 'File'; + addToast(t('toast.unsupportedFile').replace('{{filename}}', filename), 'error'); } }); } @@ -2191,7 +2198,7 @@ import { processMarkdownHtml } from './utils/markdown'; y: e.clientY, items: [ { - label: 'Copy Reference', + label: t('menu.copyReference', uiLanguage), onClick: () => { const tab = tabManager.activeTab; const fn = tab?.path ? tab.path.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') || '' : ''; @@ -2272,17 +2279,17 @@ import { processMarkdownHtml } from './utils/markdown';
{#if isSplit || isEditing}
-
- Drop to Embed -
-
+
+ {t('dragAndDrop.embed')} +
+
{/if} {#if isSplit || !isEditing}
-
- Drop to Open -
-
+
+ {t('dragAndDrop.open')} +
+ {/if} diff --git a/src/lib/Uninstaller.svelte b/src/lib/Uninstaller.svelte index 0b3f7db..1d65f8f 100644 --- a/src/lib/Uninstaller.svelte +++ b/src/lib/Uninstaller.svelte @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import iconUrl from '../assets/icon.png'; + import { t } from './utils/i18n.js'; let uninstalling = $state(false); let error = $state(''); @@ -27,7 +28,7 @@
-
@@ -35,8 +36,8 @@
App Icon -

Uninstall Markpad?

-

This will remove the application and all its shortcuts.

+

{t('uninstaller.uninstallMarkpad')}

+

{t('uninstaller.removeApplication')}

{#if !uninstalling} @@ -46,14 +47,14 @@ {/if}
- - + +
{:else}
-

Removing Markpad...

+

{t('uninstaller.removingMarkpad')}

{/if}
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index be08bb8..e4f7b35 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from "svelte"; import { tabManager } from "../stores/tabs.svelte.js"; import { settings } from "../stores/settings.svelte.js"; + import { t } from '../utils/i18n.js'; import * as monaco from "monaco-editor"; import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; @@ -68,6 +69,11 @@ let wordCount = $state(0); let currentLanguage = $state("markdown"); let currentTabId = $state(tabManager.activeTabId); + let uiLanguage = $state(settings.language); + + $effect(() => { + uiLanguage = settings.language; + }); self.MonacoEnvironment = { getWorker: function (_moduleId: any, label: string) { @@ -218,7 +224,7 @@ editor.addAction({ id: "toggle-minimap", - label: "Toggle Minimap", + label: t('settings.minimap', uiLanguage), run: () => { settings.toggleMinimap(); }, @@ -226,7 +232,7 @@ editor.addAction({ id: "toggle-word-wrap", - label: "Toggle Word Wrap", + label: t('settings.wordWrap', uiLanguage), run: () => { settings.toggleWordWrap(); }, @@ -234,7 +240,7 @@ editor.addAction({ id: "toggle-line-numbers", - label: "Toggle Line Numbers", + label: t('settings.lineNumbers', uiLanguage), run: () => { settings.toggleLineNumbers(); }, @@ -242,7 +248,7 @@ editor.addAction({ id: "toggle-vim-mode", - label: "Toggle Vim Mode", + label: t('settings.vimMode', uiLanguage), run: () => { settings.toggleVimMode(); }, @@ -250,7 +256,7 @@ editor.addAction({ id: "toggle-status-bar", - label: "Toggle Status Bar", + label: t('settings.statusBar', uiLanguage), run: () => { settings.toggleStatusBar(); }, @@ -258,7 +264,7 @@ editor.addAction({ id: "toggle-word-count", - label: "Toggle Word Count", + label: t('settings.wordCount', uiLanguage), run: () => { settings.toggleWordCount(); }, @@ -266,7 +272,7 @@ editor.addAction({ id: "toggle-line-highlight", - label: "Toggle Line Highlight", + label: t('settings.lineHighlight', uiLanguage), run: () => { settings.toggleLineHighlight(); }, @@ -274,7 +280,7 @@ editor.addAction({ id: "toggle-occurrences-highlight", - label: "Toggle Occurrences Highlight", + label: t('settings.showWhitespace', uiLanguage), run: () => { settings.toggleOccurrencesHighlight(); }, @@ -282,7 +288,7 @@ editor.addAction({ id: "toggle-whitespace", - label: "Toggle Show Whitespace", + label: t('settings.showWhitespace', uiLanguage), run: () => { settings.toggleShowWhitespace(); }, @@ -290,7 +296,7 @@ editor.addAction({ id: "toggle-tabs", - label: "Toggle Tabs", + label: t('settings.showTabs', uiLanguage), keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyB, ], @@ -301,7 +307,7 @@ editor.addAction({ id: "toggle-zen-mode", - label: "Toggle Zen Mode", + label: t('settings.zenMode', uiLanguage), keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyZ, ], @@ -431,28 +437,28 @@ editor.addAction({ id: "fmt-bold", - label: "Format: Bold", + label: t('menu.bold', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyB], run: () => toggleFormat("**"), }); editor.addAction({ id: "fmt-italic", - label: "Format: Italic", + label: t('menu.italic', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyI], run: () => toggleFormat("*"), }); editor.addAction({ id: "fmt-underline", - label: "Format: Underline", + label: t('menu.underline', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyU], run: () => toggleFormat("|", "tag"), }); editor.addAction({ id: "insert-table-simple", - label: "Insert Table", + label: t('menu.insertTable', uiLanguage), keybindings: [ monaco.KeyMod.chord( monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, @@ -485,7 +491,7 @@ editor.addAction({ id: "file-new", - label: "New File", + label: t('menu.newFile', uiLanguage), keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyN, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyT, @@ -495,28 +501,28 @@ editor.addAction({ id: "file-open", - label: "Open File", + label: t('menu.openFile', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyO], run: () => onopen?.(), }); editor.addAction({ id: "file-save", - label: "Save File", + label: t('menu.save', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], run: () => onsave?.(), }); editor.addAction({ id: "file-close", - label: "Close File", + label: t('menu.closeFile', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW], run: () => onclose?.(), }); editor.addAction({ id: "file-reveal", - label: "Open File Location", + label: t('menu.openLocation', uiLanguage), keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyR, ], @@ -525,35 +531,35 @@ editor.addAction({ id: "view-toggle-edit", - label: "Toggle Edit Mode", + label: t('menu.toggleEditMode', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyE], run: () => ontoggleEdit?.(), }); editor.addAction({ id: "view-toggle-live", - label: "Toggle Live Mode", + label: t('menu.toggleLiveMode', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL], run: () => ontoggleLive?.(), }); editor.addAction({ id: "view-toggle-split", - label: "Toggle Split View", + label: t('menu.toggleSplitView', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyH], run: () => ontoggleSplit?.(), }); editor.addAction({ id: "tab-next", - label: "Next Tab", + label: t('menu.nextTab', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Tab], run: () => onnextTab?.(), }); editor.addAction({ id: "tab-prev", - label: "Previous Tab", + label: t('menu.previousTab', uiLanguage), keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Tab, ], @@ -562,7 +568,7 @@ editor.addAction({ id: "tab-undo-close", - label: "Undo Close Tab", + label: t('menu.undoCloseTab', uiLanguage), keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyT, ], @@ -571,7 +577,7 @@ editor.addAction({ id: "app-command-palette", - label: "Command Palette", + label: t('menu.commandPalette', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyP], run: (ed) => { ed.trigger("keyboard", "editor.action.quickCommand", {}); @@ -678,7 +684,7 @@ // clipboard handling: override Ctrl+C and Ctrl+V to use Rust backend editor.addAction({ id: "custom-copy", - label: "Copy", + label: t('menu.copy', uiLanguage), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyC], run: async (ed) => { const selection = ed.getSelection(); @@ -1124,20 +1130,20 @@ {#if settings.statusBar}
- Ln {cursorPosition?.lineNumber ?? 1}, Col {cursorPosition?.column ?? 1} -
+ {t('editor.status.lineCol', settings.language).replace('{{line}}', (cursorPosition?.lineNumber ?? 1).toString()).replace('{{col}}', (cursorPosition?.column ?? 1).toString())} +
{#if selectionCount > 0}
- {selectionCount} selected + {t('editor.status.selected', settings.language).replace('{{count}}', selectionCount.toString())}
{:else if cursorCount > 1}
- {cursorCount} selections + {t('editor.status.selections', settings.language).replace('{{count}}', cursorCount.toString())}
{/if} {#if settings.wordCount}
- {wordCount} words + {t('editor.status.words', settings.language).replace('{{count}}', wordCount.toString())}
{/if}
@@ -1146,8 +1152,8 @@
{currentLanguage}
-
CRLF
-
UTF-8
+
{t('editor.status.crlf')}
+
{t('editor.status.utf8')}
{/if} diff --git a/src/lib/components/HomePage.svelte b/src/lib/components/HomePage.svelte index cf04197..0f043fd 100644 --- a/src/lib/components/HomePage.svelte +++ b/src/lib/components/HomePage.svelte @@ -1,6 +1,8 @@
-

Open a Markdown file

+

{t('home.welcomeToMarkpad', settings.language)}

-

Recent Files

+

{t('home.recentFiles', settings.language)}

{#if recentFiles.length > 0}
{#each recentFiles as file} @@ -103,7 +105,7 @@ {/each}
{:else} -

Your recently opened files will appear here.

+

{t('home.noRecentFiles', settings.language)}

{/if}
diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index feb3c03..a4c45ce 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -1,5 +1,7 @@