Skip to content
Merged
12 changes: 1 addition & 11 deletions packages/desktop/src/main/app/env.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/desktop/src/main/app/envPath.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {
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)
}
10 changes: 9 additions & 1 deletion packages/desktop/src/main/menu/actions/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,24 @@
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)
await writeFile(filePath, data, extension!, 'binary')

Check warning on line 121 in packages/desktop/src/main/menu/actions/file.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
} else {
if (!content) {
throw new Error('No HTML content found.')
}
await writeFile(filePath, content, extension!, 'utf8')

Check warning on line 126 in packages/desktop/src/main/menu/actions/file.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
}
win.webContents.send('mt::export-success', { type, filePath })
} catch (err) {
Expand Down Expand Up @@ -201,7 +209,7 @@
ipcMain.emit('window-add-file-path', win.id, filePath)
ipcMain.emit('menu-add-recently-used', filePath)

const newFilename = path.basename(filePath!)

Check warning on line 212 in packages/desktop/src/main/menu/actions/file.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
win.webContents.send('mt::set-pathname', { id, pathname: filePath, filename: newFilename })
} else {
ipcMain.emit('window-file-saved', win.id, filePath)
Expand Down Expand Up @@ -366,7 +374,7 @@
ipcMain.emit('window-add-file-path', win.id, filePath)
ipcMain.emit('menu-add-recently-used', filePath)

const newFilename = path.basename(filePath!)

Check warning on line 377 in packages/desktop/src/main/menu/actions/file.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
win.webContents.send('mt::set-pathname', {
id,
pathname: filePath,
Expand Down
12 changes: 12 additions & 0 deletions packages/desktop/src/main/menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
5 changes: 4 additions & 1 deletion packages/desktop/src/main/menu/templates/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Expand All @@ -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': {
Expand All @@ -1409,6 +1437,9 @@ const handleParagraph = (type: unknown) => {
}

const handleInlineFormat = (type: unknown) => {
if (sourceCode.value) {
return
}
editor.value && editor.value.format(type)
}

Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/src/renderer/src/components/search/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
})
</script>

Expand Down
32 changes: 21 additions & 11 deletions packages/desktop/src/renderer/src/util/exportHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <foreignObject>
// 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 `<thead class="page-header ${headerClass}"><tr><th>
const headerClass = `page-header ${(type === 1 ? 'single' : '') + styledClass(headerFooterStyled)}`
.replace(/\s+/g, ' ')
.trim()
return `<thead class="${headerClass}"><tr><th>
<div class="hf-container">
<div class="header-content-left">${left}</div>
<div class="header-content">${center}</div>
<div class="header-content-right">${right}</div>
<div class="header-content-left">${hf(left)}</div>
<div class="header-content">${hf(center)}</div>
<div class="header-content-right">${hf(right)}</div>
</div>
</th></tr></thead>`
}

const createRealFooter = (footer: HeaderFooterPart, headerFooterStyled?: boolean): string => {
const { type, left = '', center = '', right = '' } = footer
const footerClass = (type === 1 ? 'single' : '') + styledClass(headerFooterStyled)
return `<div class="page-footer ${footerClass}">
const footerClass = `page-footer ${(type === 1 ? 'single' : '') + styledClass(headerFooterStyled)}`
.replace(/\s+/g, ' ')
.trim()
return `<div class="${footerClass}">
<div class="hf-container">
<div class="footer-content-left">${left}</div>
<div class="footer-content">${center}</div>
<div class="footer-content-right">${right}</div>
<div class="footer-content-left">${hf(left)}</div>
<div class="footer-content">${hf(center)}</div>
<div class="footer-content-right">${hf(right)}</div>
</div>
</div>`
}
Expand Down Expand Up @@ -216,7 +226,7 @@ export const exportStyledHTML = async(
}
output += createTableBody(`<article class="markdown-body">${article}</article>`)
output += HF_TABLE_END
bodyHtml = sanitize(output, EXPORT_DOMPURIFY_CONFIG) as string
bodyHtml = output
}

// Re-emit the engine document shell with the (possibly augmented) body.
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/src/renderer/src/util/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export const getCssForOptions = async(options: PdfCssOptions): Promise<string> =
if (isPrintable) {
output += `@media print{@page{
margin: ${pageMarginTop}mm ${pageMarginRight}mm ${pageMarginBottom}mm ${pageMarginLeft}mm;}`
// Keep a heading with the content that follows it, so a page never breaks
// immediately after a heading, and never split a multi-line heading (#3039).
output += 'h1,h2,h3,h4,h5,h6{break-after:avoid;break-inside:avoid;}'
}

// Auto numbering headings via CSS
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/src/shared/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions packages/desktop/test/e2e/find-reopen-select-3458.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading