diff --git a/packages/desktop/src/main/app/env.ts b/packages/desktop/src/main/app/env.ts index 2f88613959..aa5d2f0962 100644 --- a/packages/desktop/src/main/app/env.ts +++ b/packages/desktop/src/main/app/env.ts @@ -1,18 +1,8 @@ -import path from 'path' import AppPaths, { ensureAppDirectoriesSync } from './paths' +import { patchEnvPath } from './envPath' let envId = 0 -const patchEnvPath = (): void => { - if (process.platform === 'darwin') { - const currentPath = process.env.PATH ?? '' - process.env.PATH = - currentPath + - (currentPath.endsWith(path.delimiter) ? '' : path.delimiter) + - '/Library/TeX/texbin' - } -} - export interface AppEnvironmentOptions { userDataPath?: string debug?: boolean diff --git a/packages/desktop/src/main/app/envPath.ts b/packages/desktop/src/main/app/envPath.ts new file mode 100644 index 0000000000..69f0299227 --- /dev/null +++ b/packages/desktop/src/main/app/envPath.ts @@ -0,0 +1,21 @@ +import path from 'path' + +// GUI-launched apps on macOS/Linux don't inherit the user's login-shell PATH, +// so Homebrew (/opt/homebrew/bin, /usr/local/bin) and standard bin dirs are +// absent and CLI tools like pandoc can't be found (#2751). These mirror the +// picgo uploader's PATH handling (ipc/uploader.ts). +const EXTRA_PATH_DIRS: Record = { + darwin: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/Library/TeX/texbin'], + linux: ['/usr/local/bin', '/usr/bin', '/bin'] +} + +export const patchEnvPath = (): void => { + const extras = EXTRA_PATH_DIRS[process.platform] + if (!extras) return + + const current = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean) + for (const dir of extras) { + if (!current.includes(dir)) current.push(dir) + } + process.env.PATH = current.join(path.delimiter) +} diff --git a/packages/desktop/src/main/menu/actions/file.ts b/packages/desktop/src/main/menu/actions/file.ts index 4f2393d689..e91b9b97d5 100644 --- a/packages/desktop/src/main/menu/actions/file.ts +++ b/packages/desktop/src/main/menu/actions/file.ts @@ -106,7 +106,15 @@ const handleResponseForExport = async(e: IpcMainEvent, payload: ExportPayload): if (filePath && !canceled) { try { if (type === 'pdf') { - const options: Electron.PrintToPDFOptions = { printBackground: true } + // Build a clickable bookmark/outline tree from the document's h1-h6 + // headings so exported PDFs have a navigation pane (#2989). The outline + // is derived from the tagged-PDF structure tree, so generateTaggedPDF is + // required — generateDocumentOutline alone produces no outline. + const options: Electron.PrintToPDFOptions = { + printBackground: true, + generateTaggedPDF: true, + generateDocumentOutline: true + } Object.assign(options, getPdfPageOptions(pageOptions)) const data = await win.webContents.printToPDF(options) removePrintServiceFromWindow(win) 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/main/menu/templates/edit.ts b/packages/desktop/src/main/menu/templates/edit.ts index 4a461421a6..a0bb30fdeb 100755 --- a/packages/desktop/src/main/menu/templates/edit.ts +++ b/packages/desktop/src/main/menu/templates/edit.ts @@ -159,7 +159,10 @@ export default function(keybindings: Keybindings): MenuItemConstructorOptions { } }, { - type: 'separator' + // Screenshot is macOS-only; hide its trailing separator too so + // Windows/Linux don't show a doubled divider here (#2997). + type: 'separator', + visible: isOsx }, { // TODO: Remove this menu entry and add it to the command palette (#1408). 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/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/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/src/renderer/src/prefComponents/common/fontTextBox/bundledFonts.ts b/packages/desktop/src/renderer/src/prefComponents/common/fontTextBox/bundledFonts.ts new file mode 100644 index 0000000000..b10c9229da --- /dev/null +++ b/packages/desktop/src/renderer/src/prefComponents/common/fontTextBox/bundledFonts.ts @@ -0,0 +1,11 @@ +// Fonts MarkText bundles via @font-face (packages/muya/src/assets/styles/index.css). +// They are the editor/code defaults but are NOT installed system fonts, so the +// OS font-list IPC never returns them and the picker couldn't reselect them (#3021). +export const BUNDLED_PROPORTIONAL_FONTS = ['Open Sans'] +export const BUNDLED_MONOSPACE_FONTS = ['DejaVu Sans Mono'] + +export const withBundledFonts = (systemFonts: string[], onlyMonospace = false): string[] => { + const bundled = onlyMonospace ? BUNDLED_MONOSPACE_FONTS : BUNDLED_PROPORTIONAL_FONTS + const missing = bundled.filter(font => !systemFonts.includes(font)) + return [...missing, ...systemFonts] +} diff --git a/packages/desktop/src/renderer/src/prefComponents/common/fontTextBox/index.vue b/packages/desktop/src/renderer/src/prefComponents/common/fontTextBox/index.vue index 3844c2a9a9..9d3ccd3087 100644 --- a/packages/desktop/src/renderer/src/prefComponents/common/fontTextBox/index.vue +++ b/packages/desktop/src/renderer/src/prefComponents/common/fontTextBox/index.vue @@ -43,6 +43,7 @@ import { ArrowDown } from '@element-plus/icons-vue' import LinkIcon from '@/components/icons/LinkIcon.vue' import { useI18n } from 'vue-i18n' import type { PrefControlProps } from '../types' +import { withBundledFonts } from './bundledFonts' const { t } = useI18n() @@ -96,7 +97,10 @@ const handleMoreClick = () => { onMounted(async () => { // font-list is a native module; it runs in the main process and is reached via IPC. const fonts = await window.fonts.list() - fontFamilies.value = (fonts || []).map((f) => f.replace(/"/g, '').trim()) + const systemFonts = (fonts || []).map((f) => f.replace(/"/g, '').trim()) + // System fonts don't include the bundled defaults (Open Sans / DejaVu Sans + // Mono), so surface them in the picker too (#3021). + fontFamilies.value = withBundledFonts(systemFonts, props.onlyMonospace) }) 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 `