From fa63a8e67cca87b807bad742a04d03b161cba748 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Fri, 26 Jun 2026 23:17:49 +0800 Subject: [PATCH 1/9] fix(i18n): degrade malformed translations to raw text instead of crashing (#4046) (#4745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): degrade malformed translations to raw text instead of crashing vue-i18n compiles each translation lazily on first use and throws a SyntaxError when a value can't be parsed — e.g. a literal `{{x}}` (nested placeholder) or stray linked-message syntax. A single malformed translation then crashed the renderer; during HTML/PDF export this surfaced as an "Unexpected renderer process error" even though the export itself succeeded (issue #4046). The previous `messageCompiler: { compile }` used the object form, which vue-i18n 11 silently ignores, so it neither special-cased `|` nor prevented the crash. Replace it with the correct function form that reuses vue-i18n's own compiler (`compile` from @intlify/core-base) so well-formed messages keep their `{name}` interpolation, plurals and linked references, and falls back to the raw text when compilation fails. Behavior is byte-identical for every valid message (the old object form was already ignored); only malformed messages change from "throw" to "raw text". Co-Authored-By: Claude Opus 4.8 (1M context) * test(i18n): cover malformed-message resilience (#4046) Reproduce the export crash: a registered translation whose value contains a nested placeholder (`{{type}}`) must not throw from t() and should degrade to its raw text. Also assert well-formed messages still interpolate, guarding the compiler swap from regressing normal translation. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/desktop/package.json | 1 + .../desktop/src/renderer/src/i18n/index.ts | 34 +++++++++------ packages/desktop/test/unit/specs/i18n.spec.ts | 43 +++++++++++++++++++ pnpm-lock.yaml | 3 ++ 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 32271a0ae2..35b982fd13 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -55,6 +55,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@intlify/core-base": "^11.4.6", "@element-plus/icons-vue": "^2.3.2", "@hfelix/electron-localshortcut": "^4.0.1", "@marktext/file-icons": "^1.0.6", diff --git a/packages/desktop/src/renderer/src/i18n/index.ts b/packages/desktop/src/renderer/src/i18n/index.ts index 83af3d0f33..721141b372 100644 --- a/packages/desktop/src/renderer/src/i18n/index.ts +++ b/packages/desktop/src/renderer/src/i18n/index.ts @@ -1,9 +1,28 @@ import { createI18n } from 'vue-i18n' +import { compile, type MessageCompiler } from '@intlify/core-base' import bus from '../bus' - // Directly import translation files import enTranslations from '../../../../static/locales/en.json' +// vue-i18n compiles each translation lazily on first use, and its compiler +// throws a SyntaxError on any value it can't parse — e.g. a literal `{{x}}` +// (nested placeholder) or stray linked-message syntax. A single malformed +// translation then crashed the renderer; during HTML/PDF export this surfaced +// as an "Unexpected renderer process error" even though the export itself +// succeeded (issue #4046). Reuse vue-i18n's own compiler so well-formed +// messages keep their `{name}` interpolation, plurals and linked references, +// and fall back to the raw text when compilation fails. +const safeMessageCompiler: MessageCompiler = (message, context) => { + try { + return compile(message, context) + } catch (err) { + if (typeof message === 'string') { + return () => message + } + throw err + } +} + // vue-i18n's options type intersection between Composition + Legacy modes is // notoriously difficult to satisfy with mixed shapes; we cast the options once // at the call site rather than spreading `any` further. @@ -18,17 +37,8 @@ const i18n = createI18n({ }, // Disable plural parsing pluralRules: {}, - // Custom message compiler to handle '|' characters - messageCompiler: { - compile: (message: unknown) => { - // If the message contains '|', return the raw string without plural parsing - if (typeof message === 'string' && message.includes('|')) { - return () => message - } - // For other messages, use the default compiler - return null - } - } + // Degrade malformed translations to raw text instead of crashing the renderer. + messageCompiler: safeMessageCompiler // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) diff --git a/packages/desktop/test/unit/specs/i18n.spec.ts b/packages/desktop/test/unit/specs/i18n.spec.ts index dec7f53ccf..1b0b916274 100644 --- a/packages/desktop/test/unit/specs/i18n.spec.ts +++ b/packages/desktop/test/unit/specs/i18n.spec.ts @@ -46,3 +46,46 @@ describe('renderer i18n language loading', () => { expect(win.i18nUtils!.loadTranslations).toHaveBeenCalledWith('zh-CN') }) }) + +// Issue #4046: exporting HTML/PDF surfaced an "Unexpected renderer process +// error" — a vue-i18n message-compiler SyntaxError (code 9, +// NOT_ALLOW_NEST_PLACEHOLDER) thrown while lazily compiling a translation whose +// value contained a nested placeholder (e.g. literal `{{type}}`). A single +// malformed translation must degrade to raw text, never crash the renderer. +describe('renderer i18n malformed-message resilience (issue #4046)', () => { + beforeEach(() => { + vi.resetModules() + win.i18nUtils = { loadTranslations: vi.fn() } + }) + + afterEach(() => { + delete win.i18nUtils + }) + + interface TestComposer { + setLocaleMessage: (locale: string, message: Record) => void + locale: { value: string } + t: (key: string, named?: Record) => string + } + + it('does not throw when a registered message contains a nested placeholder', async() => { + const { i18n } = await import('../../../src/renderer/src/i18n') + const composer = i18n.global as unknown as TestComposer + + composer.setLocaleMessage('xx', { export: { failed: 'Failed {{type}} export' } }) + composer.locale.value = 'xx' + + expect(() => composer.t('export.failed', { type: 'PDF' })).not.toThrow() + expect(composer.t('export.failed', { type: 'PDF' })).toBe('Failed {{type}} export') + }) + + it('still interpolates well-formed messages', async() => { + const { i18n } = await import('../../../src/renderer/src/i18n') + const composer = i18n.global as unknown as TestComposer + + composer.setLocaleMessage('xx', { greeting: 'Hello {name}' }) + composer.locale.value = 'xx' + + expect(composer.t('greeting', { name: 'World' })).toBe('Hello World') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97c2ed677f..51b617fcde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@hfelix/electron-localshortcut': specifier: ^4.0.1 version: 4.0.1 + '@intlify/core-base': + specifier: ^11.4.6 + version: 11.4.6 '@marktext/file-icons': specifier: ^1.0.6 version: 1.0.6 From d43df54a740a5aa8f9ee2fd6b379f71877860f03 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Fri, 26 Jun 2026 23:57:24 +0800 Subject: [PATCH 2/9] fix: vertically center task list checkbox with item text (#4748) (#4750) The checkbox marker drifted further out of alignment as the editor font grew. Two causes, both specific to the Chromium the checkbox renders as: 1. The `em`-based `top` offset never tracked the editor font. Form controls do not inherit `font-size` (and the ignores `font-size: inherit`), so `em` resolved against the UA control font (~13.3px) and stayed constant while the text line grew. The offset is now computed from `--mu-font-size`, a custom property that does inherit into the input, times `--mu-line-height`, so it scales with the editor font and line height. 2. The UA `margin: 3px` shifted the absolutely-positioned marker down; it is now zeroed. Verified against the real stylesheet with a live at font sizes 16-40px and line heights 1.4/1.6: marker center vs text center stays within +/-0.9px. Also drops the redundant tight-list override that repeated the old value. Co-authored-by: Claude Opus 4.8 (1M context) --- .../muya/src/assets/styles/blockSyntax.css | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/muya/src/assets/styles/blockSyntax.css b/packages/muya/src/assets/styles/blockSyntax.css index 83a0b392b5..2d7493ba40 100644 --- a/packages/muya/src/assets/styles/blockSyntax.css +++ b/packages/muya/src/assets/styles/blockSyntax.css @@ -490,23 +490,31 @@ li.mu-task-list-item { li.mu-task-list-item > input[type='checkbox'], li.mu-task-list-item > span.mu-task-list-checkbox { position: absolute; - top: 0.3em; - inset-inline-start: -23px; + + /* Center the 18px circle (drawn by ::before at top:-2px, so its center sits + 7px below this box's top) on the first text line, whose vertical midpoint + is `0.5 * line-height * font-size`. The editor font is read from + `--mu-font-size` rather than `em` on purpose: on Chromium the checkbox is + a real , which keeps the UA form-control font size and ignores + `font-size: inherit`, so `em` here would be frozen and the marker would + stop tracking the editor font. Custom properties still inherit into the + input, so this stays aligned at every font size. */ + top: calc(var(--mu-line-height, 1.6) * 0.5 * var(--mu-font-size, 16px) - 7px); width: 12px; height: 12px; + /* Drop the UA margin (3px top), which otherwise pushes the + absolutely-positioned marker below the computed `top`. */ + margin: 0; + transform-origin: center; cursor: pointer; transition: all 0.2s ease; appearance: none; -} - -ul.mu-tight-list > li.mu-task-list-item > input[type='checkbox'], -ul.mu-tight-list > li.mu-task-list-item > span.mu-task-list-checkbox { - top: 0.3em; + inset-inline-start: -23px; } li.mu-task-list-item > input.mu-checkbox-checked ~ *, From 4b46c2a895645927845c3c73bfc00f5d1f5e3506 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 00:01:38 +0800 Subject: [PATCH 3/9] fix(desktop): prefer UTF-8 when a file is valid UTF-8 instead of trusting ced (#3151) (#4739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `guessEncoding` ran the file through `ced` (compact_enc_det), which occasionally misdetects a valid UTF-8 file as a legacy double-byte encoding (notably GBK). `iconv.decode` then mojibakes multi-byte text — e.g. Greek µ/κ/α become CJK 碌/魏/伪 — and the file looks corrupted on reopen. Short-circuit to UTF-8 whenever the buffer is valid UTF-8 and contains no NUL byte (a NUL signals binary / BOM-less UTF-16). Genuinely non-UTF-8 files are still left to ced. Co-authored-by: Claude Opus 4.8 (1M context) --- .../desktop/src/main/filesystem/encoding.ts | 21 +++++++++++ .../desktop/test/unit/specs/encoding.spec.ts | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/desktop/test/unit/specs/encoding.spec.ts diff --git a/packages/desktop/src/main/filesystem/encoding.ts b/packages/desktop/src/main/filesystem/encoding.ts index 6058adf3d2..cebee99838 100644 --- a/packages/desktop/src/main/filesystem/encoding.ts +++ b/packages/desktop/src/main/filesystem/encoding.ts @@ -33,6 +33,22 @@ const checkSequence = (buffer: Buffer, sequence: number[]): boolean => { return sequence.every((v, i) => v === buffer[i]) } +// `ced` occasionally misdetects a valid UTF-8 file as a legacy double-byte +// encoding (notably GBK), mojibaking multi-byte text — e.g. Greek µ/κ/α become +// CJK 碌/魏/伪 (#3151). A NUL byte signals binary / BOM-less UTF-16, not a UTF-8 +// text file. +const isLikelyUtf8 = (buffer: Buffer): boolean => { + if (buffer.includes(0)) { + return false + } + try { + new TextDecoder('utf-8', { fatal: true }).decode(buffer) + return true + } catch { + return false + } +} + /** * Guess the encoding from the buffer. */ @@ -49,6 +65,11 @@ export const guessEncoding = (buffer: Buffer, autoGuessEncoding: boolean): Encod // Auto guess encoding, otherwise use UTF-8. if (autoGuessEncoding) { + // A file that is already valid UTF-8 must be decoded as UTF-8, regardless + // of what `ced` heuristically guesses (#3151). + if (isLikelyUtf8(buffer)) { + return { encoding: 'utf8', isBom } + } encoding = ced(buffer) if (CED_ICONV_ENCODINGS[encoding]) { encoding = CED_ICONV_ENCODINGS[encoding] diff --git a/packages/desktop/test/unit/specs/encoding.spec.ts b/packages/desktop/test/unit/specs/encoding.spec.ts new file mode 100644 index 0000000000..613f0bd506 --- /dev/null +++ b/packages/desktop/test/unit/specs/encoding.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' + +// `ced` (compact_enc_det) occasionally misdetects a valid UTF-8 file as a legacy +// double-byte encoding, mojibaking multi-byte text — e.g. Greek µ/κ/α become CJK +// 碌/魏/伪 (#3151). Simulate that by forcing `ced` to always answer GBK; the fix +// must override it whenever the bytes are valid UTF-8. +vi.mock('ced', () => ({ default: vi.fn(() => 'GB') })) + +const { guessEncoding } = await import('main_renderer/filesystem/encoding') + +describe('guessEncoding — prefer UTF-8 over a ced misdetection (#3151)', () => { + it('returns utf8 for a valid UTF-8 buffer even when ced guesses GBK', () => { + const buffer = Buffer.from('# Notes\n\nµ = 0.5, κ, α — Greek letters.\n', 'utf8') + expect(guessEncoding(buffer, true).encoding).toBe('utf8') + }) + + it('still falls back to ced for a genuinely non-UTF-8 buffer', () => { + // `0xC2` is a UTF-8 lead byte; the following space is not a continuation + // byte, so the buffer is not valid UTF-8 and ced's guess stands. + const buffer = Buffer.from([0x68, 0x69, 0xc2, 0x20, 0x6f, 0x6b]) + expect(guessEncoding(buffer, true).encoding).toBe('gb2312') + }) + + it('does not force utf8 for a buffer containing NUL (binary / BOM-less UTF-16)', () => { + const buffer = Buffer.from([0x68, 0x00, 0x65, 0x00, 0x6c, 0x00]) + expect(guessEncoding(buffer, true).encoding).not.toBe('utf8') + }) + + it('honours a UTF-8 BOM and never reaches ced', () => { + const buffer = Buffer.from([0xef, 0xbb, 0xbf, 0x68, 0x69]) + const result = guessEncoding(buffer, true) + expect(result.encoding).toBe('utf8') + expect(result.isBom).toBe(true) + }) +}) From c6eecd768b42c273a69cc5a8d847dd9908aecc12 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 00:40:04 +0800 Subject: [PATCH 4/9] fix(desktop): scroll the view up when the caret moves above the viewport (#3329) (#4742) The `selection-change` scroll handler only auto-scrolled when the caret neared the bottom edge (#628), so moving the caret up (Arrow-Up) let it slide above the viewport without the document following. Add the symmetric upward scroll when the caret rises within 100px of the top edge. Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/editorWithTabs/editor.vue | 4 ++ .../desktop/test/e2e/scroll-up-arrow.spec.ts | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 packages/desktop/test/e2e/scroll-up-arrow.spec.ts diff --git a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue index 8d2d8ca244..a21609afd9 100644 --- a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue +++ b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue @@ -1912,6 +1912,10 @@ onMounted(() => { // editableHeight is the lowest cursor position(till to top) that editor allowed. const editableHeight = container.clientHeight - 100 animatedScrollTo(container, container.scrollTop + (y - editableHeight), 0) + } else if (y < 100) { + // Symmetric to #628: scroll up when the cursor rises above the top edge + // (e.g. Arrow-Up), otherwise the caret leaves the viewport (#3329). + animatedScrollTo(container, container.scrollTop + (y - 100), 0) } } diff --git a/packages/desktop/test/e2e/scroll-up-arrow.spec.ts b/packages/desktop/test/e2e/scroll-up-arrow.spec.ts new file mode 100644 index 0000000000..57d2c21197 --- /dev/null +++ b/packages/desktop/test/e2e/scroll-up-arrow.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { focusEditor, launchWithMarkdown } from './helpers' + +// #3329 — moving the caret DOWN auto-scrolls the view (the #628 handler), but +// moving it UP did not, so the caret slid above the viewport. The fix adds the +// symmetric upward scroll. +test.describe('Arrow-up scrolls the document (#3329)', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + const md = `${Array.from({ length: 120 }, (_, i) => `Paragraph ${i + 1}`).join('\n\n')}\n` + const launched = await launchWithMarkdown(md) + app = launched.app + page = launched.page + await focusEditor(page) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + const scrollTop = () => + page.evaluate(() => (document.querySelector('.editor-component') as HTMLElement)?.scrollTop ?? -1) + + const pressMany = async(key: string, times: number) => { + for (let i = 0; i < times; i++) { + await page.keyboard.press(key) + await page.waitForTimeout(6) + } + await page.waitForTimeout(120) + } + + test('moving the caret up brings the view back up', async() => { + // Caret to the bottom — the #628 handler scrolls the view down. + await pressMany('ArrowDown', 80) + const bottom = await scrollTop() + expect(bottom).toBeGreaterThan(200) + + // Caret back up — the view must follow it upward. + await pressMany('ArrowUp', 80) + const top = await scrollTop() + expect(top).toBeLessThan(bottom - 200) + }) +}) From 0ff7528d9cc8591b001837ed6060d5ca983c893a Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 27 Jun 2026 00:46:15 +0800 Subject: [PATCH 5/9] fix: scroll the active tab into view on tab switch (#3958) (#4743) * fix: scroll the active tab into view on tab switch (#3958) The tab strip lives in an overflow:hidden container whose scrollLeft was only ever moved by the mouse wheel. Switching tabs by keyboard cycle, switch-by-index, or selecting from the sidebar updated currentFile but never scrolled the newly-active tab into the viewport, so an overflowed tab stayed hidden behind the visible ones. Watch the active file id and scroll the active tab back into view. Co-Authored-By: Claude Opus 4.8 (1M context) * test: cover scrolling an overflowed tab into view (#3958) Open enough untitled tabs to overflow the tab strip, then switch to the far-right (off-screen) tab and assert its box sits inside the scroll viewport. Fails before the fix, passes after. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/editorWithTabs/tabs.vue | 28 +++++++- packages/desktop/test/e2e/tabs.spec.ts | 65 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/renderer/src/components/editorWithTabs/tabs.vue b/packages/desktop/src/renderer/src/components/editorWithTabs/tabs.vue index af48d832dd..787daceedf 100644 --- a/packages/desktop/src/renderer/src/components/editorWithTabs/tabs.vue +++ b/packages/desktop/src/renderer/src/components/editorWithTabs/tabs.vue @@ -42,7 +42,7 @@