Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions packages/desktop/src/main/filesystem/encoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1044,12 +1044,20 @@ const replaceMisspelling = (payload: unknown) => {
}

const handleUndo = () => {
if (sourceCode.value) {
return
}

if (editor.value) {
editor.value.undo()
}
}

const handleRedo = () => {
if (sourceCode.value) {
return
}

if (editor.value) {
editor.value.redo()
}
Expand Down Expand Up @@ -1912,6 +1920,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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,26 @@ const handleSelectAll = () => {
}
}

const handleUndo = () => {
if (!sourceCode.value) {
return
}

if (editor.value) {
editor.value.execCommand('undo')
}
}

const handleRedo = () => {
if (!sourceCode.value) {
return
}

if (editor.value) {
editor.value.execCommand('redo')
}
}

interface ImageActionPayload {
id: string
result: string
Expand Down Expand Up @@ -340,6 +360,8 @@ onMounted(() => {
bus.on('invalidate-image-cache', handleInvalidateImageCache)
bus.on('file-changed', handleFileChange)
bus.on('selectAll', handleSelectAll)
bus.on('undo', handleUndo)
bus.on('redo', handleRedo)
bus.on('image-action', handleImageAction)
bus.on('scroll-to-header', handleScrollToHeader)

Expand Down Expand Up @@ -378,6 +400,8 @@ onBeforeUnmount(() => {
bus.off('invalidate-image-cache', handleInvalidateImageCache)
bus.off('file-changed', handleFileChange)
bus.off('selectAll', handleSelectAll)
bus.off('undo', handleUndo)
bus.off('redo', handleRedo)
bus.off('image-action', handleImageAction)
bus.off('scroll-to-header', handleScrollToHeader)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useEditorStore } from '@/store/editor'
import { useLayoutStore } from '@/store/layout'
import { storeToRefs } from 'pinia'
Expand Down Expand Up @@ -91,6 +91,25 @@ const newFile = () => {
editorStore.NEW_UNTITLED_TAB({})
}

// Keep the active tab visible when the selection changes by something other
// than a direct click on a visible tab (keyboard cycle, switch-by-index, open
// from the sidebar): the strip has `overflow: hidden` and only scrolls on the
// wheel, so an off-screen tab would otherwise stay hidden (#3958).
const scrollActiveTabIntoView = () => {
const container = tabContainer.value
if (!container) return
const activeTab = container.querySelector<HTMLElement>('li.active')
if (!activeTab) return

const containerRect = container.getBoundingClientRect()
const tabRect = activeTab.getBoundingClientRect()
if (tabRect.left < containerRect.left) {
container.scrollLeft -= containerRect.left - tabRect.left
} else if (tabRect.right > containerRect.right) {
container.scrollLeft += tabRect.right - containerRect.right
}
}

const handleTabScroll = (event: WheelEvent) => {
// Use mouse wheel value first but prioritize X value more (e.g. touchpad input).
let delta = event.deltaY
Expand Down Expand Up @@ -157,6 +176,13 @@ const handleContextMenu = (event: MouseEvent, tab: IFileState) => {
}
}

watch(
() => currentFile.value?.id,
() => {
nextTick(scrollActiveTabIntoView)
}
)

onMounted(() => {
bus.on('TABS::close-this', closeTab)
bus.on('TABS::close-others', closeOthers)
Expand Down
12 changes: 11 additions & 1 deletion packages/desktop/src/renderer/src/components/sideBar/tree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@
v-if="projectTree"
class="project-tree"
>
<div class="title">
<div
class="title"
@contextmenu.prevent="handleRootContextMenu"
>
<el-icon
class="icon-arrow"
:class="{ fold: !showDirectories }"
Expand Down Expand Up @@ -155,6 +158,7 @@ import Folder from './treeFolder.vue'
import File from './treeFile.vue'
import OpenedFile from './treeOpenedTab.vue'
import bus from '../../bus'
import { showContextMenu } from '../../contextMenu/sideBar'
import { useI18n } from 'vue-i18n'
import { ArrowRight } from '@element-plus/icons-vue'
import type { TreeNode, TabDescriptor } from './types'
Expand Down Expand Up @@ -183,6 +187,7 @@ const preferencesStore = usePreferencesStore()

// Computed properties
const { createCache } = storeToRefs(projectStore)
const { clipboard } = storeToRefs(projectStore)
const { openedFilesInSidebar } = storeToRefs(preferencesStore)

// The createCache state is `{ dirname, type }` while an input is shown, and
Expand All @@ -207,6 +212,11 @@ const createFile = (): void => {
bus.emit('SIDEBAR::new', 'file')
}

const handleRootContextMenu = (event: MouseEvent): void => {
projectStore.CHANGE_ACTIVE_ITEM(props.projectTree)
showContextMenu(event, !!clipboard.value)
}

const toggleOpenedFiles = (): void => {
showOpenedFiles.value = !showOpenedFiles.value
}
Expand Down
34 changes: 22 additions & 12 deletions packages/desktop/src/renderer/src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)

Expand Down
71 changes: 71 additions & 0 deletions packages/desktop/test/e2e/issue-781-source-undo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect, test } from '@playwright/test'
import type { Page } from 'playwright'
import {
launchWithMarkdown,
waitForMenuReady,
enterSourceMode,
sendIpcToRenderer
} from './helpers'

// Issue #781 — Undo/redo while in Source Code mode must act on the CodeMirror
// editor, not the hidden WYSIWYG (muya) engine. The Edit › Undo menu item and
// Cmd/Ctrl+Z both route through `mt::editor-edit-action` → bus `undo`. Before the
// fix only editor.vue subscribed and called muya.undo() (invisible in source
// mode), so undo did nothing. sourceCode.vue now also subscribes and runs
// CodeMirror's `undo`/`redo` command, and editor.vue's handler bails out while
// source mode is active.

const cmValue = (page: Page): Promise<string> =>
page.evaluate(() => {
const cm = (
document.querySelector('.source-code .CodeMirror') as Element & {
CodeMirror: { getValue(): string }
}
).CodeMirror
return cm.getValue()
})

const typeInCm = (page: Page, text: string): Promise<void> =>
page.evaluate((t) => {
const cm = (
document.querySelector('.source-code .CodeMirror') as Element & {
CodeMirror: {
focus(): void
setCursor(p: { line: number; ch: number }): void
replaceSelection(text: string): void
}
}
).CodeMirror
cm.focus()
cm.setCursor({ line: 0, ch: 'saved baseline'.length })
cm.replaceSelection(t)
}, text)

const undo = (app: Parameters<typeof sendIpcToRenderer>[0]): Promise<void> =>
sendIpcToRenderer(app, 'mt::editor-edit-action', 'undo')
const redo = (app: Parameters<typeof sendIpcToRenderer>[0]): Promise<void> =>
sendIpcToRenderer(app, 'mt::editor-edit-action', 'redo')

test.describe('Issue #781 — undo/redo in source code mode', () => {
test('undo reverts a source-mode edit; redo re-applies it', async() => {
const { app, page } = await launchWithMarkdown('saved baseline\n')
await waitForMenuReady(app)

await enterSourceMode(page, app)
const baseline = await cmValue(page)

// A single CodeMirror edit (one undo step).
await typeInCm(page, ' SRCKEY')
expect(await cmValue(page)).toContain('saved baseline SRCKEY')

// Undo through the same IPC the Edit › Undo menu uses — must hit CodeMirror.
await undo(app)
await expect.poll(() => cmValue(page)).toBe(baseline)

// Redo restores the edit.
await redo(app)
await expect.poll(() => cmValue(page)).toContain('saved baseline SRCKEY')

await app.close()
})
})
46 changes: 46 additions & 0 deletions packages/desktop/test/e2e/scroll-up-arrow.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading