From e989dcc70a90430d3ddf954195d460e32bdd18f5 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 20:40:18 +0800 Subject: [PATCH 1/9] fix(desktop): make Paragraph/Format commands inert in source mode (grey out menus) (#4810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): suppress paragraph/format edit commands in source mode The paragraph edit commands (Insert Table wizard, duplicate/create/delete paragraph) and inline format commands still ran in source-code mode, where they operate on the hidden WYSIWYG engine rather than the visible CodeMirror source — e.g. the Insert Table wizard opened and wrote to the invisible editor. Guard handleEditParagraph, handleParagraph and handleInlineFormat on sourceCode, mirroring handleUndo/handleSelectAll. Fixes #3531 Co-Authored-By: Claude Opus 4.8 (1M context) * feat(desktop): grey out Paragraph/Format menus in source-code mode The Paragraph and Format menu commands act on the hidden WYSIWYG engine, so in source-code mode they either silently mutate the discarded engine or (for the table wizard) spawn an inert invisible dialog. Disable the whole Paragraph and Format menu entries when entering source mode and re-enable them on return, so they are clearly unavailable there. The renderer-side handler guards remain as defense-in-depth. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(desktop): re-apply cursor-context menu state when exiting source mode On returning to WYSIWYG, re-apply the Paragraph/Format menu state for the current cursor (a code block or table still disables some items) instead of blanket-enabling everything. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/desktop/src/main/menu/index.ts | 12 +++ .../src/components/editorWithTabs/editor.vue | 31 +++++++ packages/desktop/src/shared/types/ipc.ts | 1 + .../source-mode-menu-disabled-3531.spec.ts | 84 +++++++++++++++++++ .../e2e/source-mode-table-wizard-3531.spec.ts | 52 ++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 packages/desktop/test/e2e/source-mode-menu-disabled-3531.spec.ts create mode 100644 packages/desktop/test/e2e/source-mode-table-wizard-3531.spec.ts diff --git a/packages/desktop/src/main/menu/index.ts b/packages/desktop/src/main/menu/index.ts index db818946cd..cd80af460e 100644 --- a/packages/desktop/src/main/menu/index.ts +++ b/packages/desktop/src/main/menu/index.ts @@ -511,6 +511,18 @@ class AppMenu { updateSelectionMenus(this.getWindowMenuById(windowId), changes) }) + // In source-code mode the Paragraph and Format commands act on the hidden + // WYSIWYG engine, so grey them out; on return to WYSIWYG they are re-enabled + // and the next selection change refines them (#3531). + ipcMain.on('mt::set-editor-format-menus-enabled', (_e, windowId: number, enabled: boolean) => { + if (!this.has(windowId)) return + const menu = this.getWindowMenuById(windowId) + for (const id of ['paragraphMenuEntry', 'formatMenuItem']) { + const entry = menu.getMenuItemById(id) + entry?.submenu?.items.forEach((item) => (item.enabled = enabled)) + } + }) + onInternalChannel('menu-add-recently-used', (pathname: string) => { this.addRecentlyUsedDocument(pathname) }) diff --git a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue index 7a0ad03e49..de80df4eae 100644 --- a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue +++ b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue @@ -546,6 +546,25 @@ watch(focus, (value) => { } }) +// In source-code mode the Paragraph and Format menus operate on the hidden +// WYSIWYG engine, so grey them out. On return to WYSIWYG, re-apply the menu +// state for the CURRENT cursor context (a code block/table still disables some +// items) rather than blanket-enabling everything (#3531). +watch(sourceCode, (isSource) => { + const windowId = window.marktext?.env?.windowId ?? -1 + if (isSource) { + window.electron.ipcRenderer.send('mt::set-editor-format-menus-enabled', windowId, false) + return + } + nextTick(() => { + if (selectionChange.value) { + pushSelectionMenuState(selectionChange.value as MuyaChange) + } else { + window.electron.ipcRenderer.send('mt::set-editor-format-menus-enabled', windowId, true) + } + }) +}) + watch(fontSize, (value, oldValue) => { if (value !== oldValue && editor.value) { editor.value.setOptions({ fontSize: value }) @@ -1371,6 +1390,12 @@ const pushSelectionMenuState = (changes: MuyaChange) => { } const handleEditParagraph = (type: unknown) => { + // These commands act on the hidden WYSIWYG engine, so block them in + // source-code mode (mirrors handleUndo/handleSelectAll) — otherwise e.g. the + // Insert Table wizard opens and writes to the invisible editor (#3531). + if (sourceCode.value) { + return + } if (type === 'table') { tableChecker.rows = 4 tableChecker.columns = 3 @@ -1391,6 +1416,9 @@ const handleEditParagraph = (type: unknown) => { // handle `duplicate`, `delete`, `create paragraph below` const handleParagraph = (type: unknown) => { + if (sourceCode.value) { + return + } if (editor.value) { switch (type) { case 'duplicate': { @@ -1409,6 +1437,9 @@ const handleParagraph = (type: unknown) => { } const handleInlineFormat = (type: unknown) => { + if (sourceCode.value) { + return + } editor.value && editor.value.format(type) } diff --git a/packages/desktop/src/shared/types/ipc.ts b/packages/desktop/src/shared/types/ipc.ts index 6aee9e9519..43fd0d003d 100644 --- a/packages/desktop/src/shared/types/ipc.ts +++ b/packages/desktop/src/shared/types/ipc.ts @@ -132,6 +132,7 @@ export interface IpcSendChannels { 'mt::open-setting-window': [] 'mt::rename': [payload: { id: string; pathname: string; newPathname: string; currentFile?: unknown }] 'mt::request-keybindings': [] + 'mt::set-editor-format-menus-enabled': [windowId: number, enabled: boolean] 'mt::response-export': [ payload: { type: ExportType diff --git a/packages/desktop/test/e2e/source-mode-menu-disabled-3531.spec.ts b/packages/desktop/test/e2e/source-mode-menu-disabled-3531.spec.ts new file mode 100644 index 0000000000..7d536e8370 --- /dev/null +++ b/packages/desktop/test/e2e/source-mode-menu-disabled-3531.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { launchWithMarkdown, focusEditor, enterSourceMode, exitSourceMode } from './helpers' + +// #3531 — Paragraph and Format menu commands act on the hidden WYSIWYG engine, +// so they must be greyed out in source-code mode and re-enabled on return. + +const readEnabled = (app: ElectronApplication) => + app.evaluate(({ Menu }) => { + const m = Menu.getApplicationMenu() + const get = (id: string) => { + const i = m?.getMenuItemById(id) + return i ? i.enabled : null + } + return { + table: get('tableMenuItem'), + heading1: get('heading1MenuItem'), + strong: get('strongMenuItem'), + emphasis: get('emphasisMenuItem') + } + }) + +test.describe('paragraph/format menus disabled in source mode (#3531)', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + const launched = await launchWithMarkdown('# Doc\n\nhello world\n', { suppressErrorDialog: true }) + app = launched.app + page = launched.page + await focusEditor(page) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + test('menus are enabled in WYSIWYG, disabled in source mode, restored on exit', async() => { + await expect.poll(() => readEnabled(app)).toEqual({ + table: true, heading1: true, strong: true, emphasis: true + }) + + await enterSourceMode(page, app) + await expect.poll(() => readEnabled(app)).toEqual({ + table: false, heading1: false, strong: false, emphasis: false + }) + + await exitSourceMode(page, app) + await expect.poll(() => readEnabled(app)).toEqual({ + table: true, heading1: true, strong: true, emphasis: true + }) + }) +}) + +test.describe('menus reflect cursor context after exiting source mode (#3531)', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + const launched = await launchWithMarkdown('```js\nconst x = 1\n```\n\n# Heading\n', { + suppressErrorDialog: true + }) + app = launched.app + page = launched.page + await focusEditor(page) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + test('a cursor in a code block keeps Format items disabled after a source-mode round-trip', async() => { + // Put the caret inside the fenced code block; its context disables the Format menu. + await page.locator('.editor-component pre.mu-code-block .mu-codeblock-content').first().click() + await expect.poll(() => readEnabled(app).then((s) => s.strong)).toBe(false) + + await enterSourceMode(page, app) + await expect.poll(() => readEnabled(app).then((s) => s.strong)).toBe(false) + + await exitSourceMode(page, app) + // The fix: the menu is re-applied for the code-block cursor, NOT blanket-enabled. + await expect.poll(() => readEnabled(app).then((s) => s.strong)).toBe(false) + }) +}) diff --git a/packages/desktop/test/e2e/source-mode-table-wizard-3531.spec.ts b/packages/desktop/test/e2e/source-mode-table-wizard-3531.spec.ts new file mode 100644 index 0000000000..30414eca7d --- /dev/null +++ b/packages/desktop/test/e2e/source-mode-table-wizard-3531.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { + launchWithMarkdown, + focusEditor, + enterSourceMode, + exitSourceMode, + sendIpcToRenderer +} from './helpers' + +// #3531 — the paragraph edit commands (e.g. the "Insert Table" wizard) still +// fired in source-code mode, where they operate on the hidden WYSIWYG engine +// instead of the visible CodeMirror source. Like undo/redo/selectAll, these +// must be blocked while in source mode. + +const TABLE_DIALOG = '.ag-insert-table-dialog' + +test.describe('paragraph edit commands are suppressed in source mode (#3531)', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + const launched = await launchWithMarkdown('# Doc\n\nsome text\n', { + suppressErrorDialog: true + }) + app = launched.app + page = launched.page + await focusEditor(page) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + test('the Insert Table wizard does not open while in source-code mode', async() => { + await enterSourceMode(page, app) + + // Fire the "insert table" paragraph action (menu / shortcut path). + await sendIpcToRenderer(app, 'mt::editor-paragraph-action', { type: 'table' }) + await page.waitForTimeout(400) + + // The table wizard dialog must NOT appear in source mode. + await expect(page.locator(TABLE_DIALOG)).toHaveCount(0) + + await exitSourceMode(page, app) + + // Sanity: in WYSIWYG mode the same action DOES open the wizard. + await focusEditor(page) + await sendIpcToRenderer(app, 'mt::editor-paragraph-action', { type: 'table' }) + await expect(page.locator(TABLE_DIALOG)).toBeVisible({ timeout: 5000 }) + }) +}) From 9ca5ccf81b5cb9bd05f72dcda7d25da9781aec4d Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 21:17:49 +0800 Subject: [PATCH 2/9] fix(desktop): New File on a collapsed sidebar folder expands it (#4809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoking "New File" on a collapsed folder did nothing. The create input only renders inside the folder's expanded contents, but handleInputFocus guarded the expand (isCollapsed = false) behind `if (input.value)` — which is null while collapsed — so the folder never expanded and no input appeared. Scope the handler to the create-target folder and expand it first, then focus the input on the next tick once it has rendered. Fixes #3439 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/sideBar/treeFolder.vue | 10 ++- .../new-file-collapsed-folder-3439.spec.ts | 90 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 packages/desktop/test/e2e/new-file-collapsed-folder-3439.spec.ts diff --git a/packages/desktop/src/renderer/src/components/sideBar/treeFolder.vue b/packages/desktop/src/renderer/src/components/sideBar/treeFolder.vue index 04f035fe3d..422d4f1433 100644 --- a/packages/desktop/src/renderer/src/components/sideBar/treeFolder.vue +++ b/packages/desktop/src/renderer/src/components/sideBar/treeFolder.vue @@ -91,13 +91,17 @@ const { activeItem } = storeToRefs(projectStore) const { clipboard } = storeToRefs(projectStore) const handleInputFocus = (): void => { + // Only the folder that is the create target reacts. Expand it FIRST so the + // create input renders even when the folder was collapsed, then focus it on + // the next tick — previously the expand sat behind `if (input.value)`, which + // is null while collapsed, so New File on a collapsed folder did nothing + // (#3439). + if (createCache.value.dirname !== props.folder.pathname) return + isCollapsed.value = false nextTick(() => { if (input.value) { input.value.focus() createName.value = '' - if (props.folder) { - isCollapsed.value = false - } } }) } diff --git a/packages/desktop/test/e2e/new-file-collapsed-folder-3439.spec.ts b/packages/desktop/test/e2e/new-file-collapsed-folder-3439.spec.ts new file mode 100644 index 0000000000..5414768977 --- /dev/null +++ b/packages/desktop/test/e2e/new-file-collapsed-folder-3439.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { launchElectron } from './helpers' + +// #3439 — invoking "New File" (sidebar context menu) on a COLLAPSED folder did +// nothing: the create only renders inside the folder's expanded +// contents, and handleInputFocus guarded the expand behind `if (input.value)`, +// which is null while collapsed — so the folder never expanded and no input +// appeared. + +// A collapsed sidebar folder's create-input is visible once the folder expands. +const visibleNewInput = (page: Page): Promise => + page.evaluate( + () => + Array.from( + document.querySelectorAll('.side-bar-folder .folder-contents input.new-input') + ).filter((el) => (el as HTMLElement).offsetParent !== null).length + ) + +test.describe('New File on a collapsed folder (#3439)', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + // launchElectron opens the desktop package folder in the sidebar (its + // sub-folders render as collapsed tree-folders). + const launched = await launchElectron() + app = launched.app + page = launched.page + await page.waitForSelector('.side-bar-folder .folder-name', { timeout: 10000 }) + + // Replace the sidebar context-menu popup handler so it does NOT open a real + // native menu (which would hang the headless run); instead resolve the + // template to the "New File" item and dispatch its click straight back to + // the renderer, driving the real context-menu → bus → store → tree-folder + // path. + await app.evaluate(({ ipcMain }) => { + const findId = (items: Array<{ id?: string, submenu?: unknown }>): string | null => { + for (const it of items || []) { + if (it?.id && String(it.id).startsWith('newFileMenuItem')) return it.id + if (it?.submenu) { + const r = findId(it.submenu as Array<{ id?: string }>) + if (r) return r + } + } + return null + } + ipcMain.removeAllListeners('mt::menu::popup') + ipcMain.on('mt::menu::popup', (event, template) => { + const id = findId(template as Array<{ id?: string }>) + if (id) { + setTimeout(() => { + try { + event.sender.send('mt::menu::click', { id }) + } catch { /* window gone */ } + }, 30) + } + }) + }) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + test('the create input appears when New File targets a collapsed folder', async() => { + // No tree-folder create input is rendered initially. + expect(await visibleNewInput(page)).toBe(0) + + // Right-click the first (collapsed) sub-folder → sets it active + pops the + // context menu, which the main-side hook resolves to "New File". + await page.evaluate(() => { + const fn = document.querySelector('.side-bar-folder .folder-name') as HTMLElement | null + if (!fn) throw new Error('no sub-folder in sidebar') + const r = fn.getBoundingClientRect() + fn.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + clientX: r.left + 5, + clientY: r.top + 5 + }) + ) + }) + + // The targeted folder must expand and reveal its create input. + await expect.poll(() => visibleNewInput(page), { timeout: 6000 }).toBeGreaterThanOrEqual(1) + + await page.keyboard.press('Escape') + }) +}) From 3a76a0aa74fd383520b8a0c3ae6f054bb3f19c4e Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 21:22:40 +0800 Subject: [PATCH 3/9] fix(desktop): select the query when re-opening the Find bar (#4807) Re-pressing Find (Cmd/Ctrl-F) while the bar was already open only re-focused the input, leaving the caret after the existing term so the next keystroke appended to it. Select the input's contents after focusing so the current term is highlighted and a keystroke types over it. Fixes #3458 Co-authored-by: Claude Opus 4.8 (1M context) --- .../renderer/src/components/search/index.vue | 3 ++ .../test/e2e/find-reopen-select-3458.spec.ts | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packages/desktop/test/e2e/find-reopen-select-3458.spec.ts diff --git a/packages/desktop/src/renderer/src/components/search/index.vue b/packages/desktop/src/renderer/src/components/search/index.vue index 6ae87b26cc..a9f5d5624c 100644 --- a/packages/desktop/src/renderer/src/components/search/index.vue +++ b/packages/desktop/src/renderer/src/components/search/index.vue @@ -249,6 +249,9 @@ const listenFind = () => { type.value = 'search' nextTick(() => { search.value?.focus() + // Select the existing term so re-opening Find types over it instead of + // appending to the previous query (#3458). + search.value?.select() if (searchValue.value) { searchFn() } diff --git a/packages/desktop/test/e2e/find-reopen-select-3458.spec.ts b/packages/desktop/test/e2e/find-reopen-select-3458.spec.ts new file mode 100644 index 0000000000..772e6de2cb --- /dev/null +++ b/packages/desktop/test/e2e/find-reopen-select-3458.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { launchWithMarkdown, sendIpcToRenderer, focusEditor } from './helpers' + +// #3458 — re-pressing Find (Cmd/Ctrl-F) while the bar is already open just +// re-focused the input without selecting it, so the caret landed after the +// existing term and the next keystroke appended ("accretion"). Re-opening +// should highlight the current term so it can be typed over. + +const FIND_INPUT = '.search-bar .search input' + +test.describe('Find bar reopen selects the existing term (#3458)', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + const launched = await launchWithMarkdown('apple banana apple cherry\n') + app = launched.app + page = launched.page + await focusEditor(page) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + test('re-opening Find highlights the existing query so it can be typed over', async() => { + await sendIpcToRenderer(app, 'mt::editor-edit-action', 'find') + await expect(page.locator('.search-bar')).toBeVisible({ timeout: 5000 }) + await page.locator(FIND_INPUT).fill('apple') + // Let the search settle (active match selected) before re-opening. + await expect.poll(() => page.locator('.search-bar .search-result').innerText()) + .toContain('/ 2') + + // Re-trigger Find while the bar is already open with a term present. + await sendIpcToRenderer(app, 'mt::editor-edit-action', 'find') + + // The whole term must be selected (so a keystroke replaces it), not left + // with a collapsed caret at the end. + await expect + .poll(() => + page.evaluate((sel) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (!el) return null + return { start: el.selectionStart, end: el.selectionEnd, len: el.value.length } + }, FIND_INPUT) + ) + .toEqual({ start: 0, end: 5, len: 5 }) + + // Typing over the selection replaces the term rather than appending to it. + await page.keyboard.type('cherry') + await expect.poll(() => page.locator(FIND_INPUT).inputValue()).toBe('cherry') + }) +}) From d61df0c452da6908e3a8b4391669ea7c74d6ed74 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 21:54:08 +0800 Subject: [PATCH 4/9] fix(desktop): keep diagram content when exporting with Header & Footer (#4805) The Header & Footer export branch re-sanitized the whole assembled document with the export DOMPurify config. That config strips the mermaid renders its node labels into, so enabling Header & Footer silently dropped diagram content; exporting the same document without Header & Footer was fine because that branch never re-sanitized. The article is already sanitized by the engine during render, so re-sanitizing it is both redundant and lossy. Sanitize only the user-supplied header/footer text fields instead, and leave the rendered article untouched. Fixes #3359 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/renderer/src/util/exportHtml.ts | 32 +++++++----- .../export-header-footer-diagram.spec.ts | 49 +++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 packages/desktop/test/unit/specs/export-header-footer-diagram.spec.ts diff --git a/packages/desktop/src/renderer/src/util/exportHtml.ts b/packages/desktop/src/renderer/src/util/exportHtml.ts index 053aefa30b..1c9eab6680 100644 --- a/packages/desktop/src/renderer/src/util/exportHtml.ts +++ b/packages/desktop/src/renderer/src/util/exportHtml.ts @@ -81,26 +81,36 @@ const styledClass = (value: boolean | undefined): string => { return value ? ' styled' : ' simple' } +// Header/footer left/center/right are user-supplied, so sanitize them here. +// The article body is NOT sanitized again (it was already sanitized by the +// engine during render); re-sanitizing it strips diagram +// labels and drops mermaid content from the export (#3359). +const hf = (value: string): string => sanitize(value, EXPORT_DOMPURIFY_CONFIG) as string + const createTableHeader = (header: HeaderFooterPart, headerFooterStyled?: boolean): string => { const { type, left = '', center = '', right = '' } = header - const headerClass = (type === 1 ? 'single' : '') + styledClass(headerFooterStyled) - return ` + const headerClass = `page-header ${(type === 1 ? 'single' : '') + styledClass(headerFooterStyled)}` + .replace(/\s+/g, ' ') + .trim() + return `
-
${left}
-
${center}
-
${right}
+
${hf(left)}
+
${hf(center)}
+
${hf(right)}
` } const createRealFooter = (footer: HeaderFooterPart, headerFooterStyled?: boolean): string => { const { type, left = '', center = '', right = '' } = footer - const footerClass = (type === 1 ? 'single' : '') + styledClass(headerFooterStyled) - return `