Skip to content
8 changes: 8 additions & 0 deletions packages/desktop/src/renderer/src/codeMirror/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@
.CodeMirror-line > span > span::selection {
background: rgb(178, 215, 254);
}
/* #2372 — make the source-mode selection use the same visible colour as the
WYSIWYG editor. The bundled railscasts theme paints the selection at #272935,
almost identical to its #2b2b2b background, so it was invisible on dark
themes. The higher specificity (+ !important) beats both railscasts' own
!important rule and the one-dark theme. */
.source-code .CodeMirror div.CodeMirror-selected {
background: var(--selection-color, rgb(45 170 219 / 30%)) !important;
}
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,9 @@
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
import bus from '../../bus'
import { loadExportSettings, saveExportSettings } from './persistence'
import Bool from '@/prefComponents/common/bool/index.vue'
import CurSelect from '@/prefComponents/common/select/index.vue'
import FontTextBox from '@/prefComponents/common/fontTextBox/index.vue'
Expand Down Expand Up @@ -338,7 +339,57 @@ const headerFooterFontSize = ref(12)
const tocTitle = ref('')
const tocIncludeTopHeading = ref(true)

// #2287 — persist the chosen export options across sessions. Every option ref
// is registered here; changes are saved to localStorage and restored on mount.
const persistableSettings: Record<string, Ref<unknown>> = {
htmlTitle,
pageSize,
pageSizeWidth,
pageSizeHeight,
isLandscape,
pageMarginTop,
pageMarginRight,
pageMarginBottom,
pageMarginLeft,
fontSettingsOverwrite,
fontFamily,
fontSize,
lineHeight,
autoNumberingHeadings,
showFrontMatter,
theme,
headerType,
headerTextLeft,
headerTextCenter,
headerTextRight,
footerType,
footerTextLeft,
footerTextCenter,
footerTextRight,
headerFooterCustomize,
headerFooterStyled,
headerFooterFontSize,
tocTitle,
tocIncludeTopHeading
}

const restoreExportSettings = () => {
const saved = loadExportSettings()
for (const [key, settingRef] of Object.entries(persistableSettings)) {
if (key in saved) settingRef.value = saved[key]
}
}

watch(Object.values(persistableSettings), () => {
saveExportSettings(
Object.fromEntries(
Object.entries(persistableSettings).map(([key, settingRef]) => [key, settingRef.value])
)
)
})

onMounted(() => {
restoreExportSettings()
bus.on('showExportDialog', showDialog)
bus.on('language-changed', updateTranslations)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Persists the export dialog's options across sessions (#2287). The dialog's
// options were component-local refs with hardcoded defaults, so every restart
// (and every dialog open) reset them. We store the chosen values in
// localStorage — the same renderer-side persistence the sidebar width uses —
// and restore them when the dialog opens.

export const EXPORT_SETTINGS_STORAGE_KEY = 'export-settings'

export function loadExportSettings(): Record<string, unknown> {
try {
const raw = localStorage.getItem(EXPORT_SETTINGS_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
} catch {
return {}
}
}

export function saveExportSettings(settings: Record<string, unknown>): void {
try {
localStorage.setItem(EXPORT_SETTINGS_STORAGE_KEY, JSON.stringify(settings))
} catch {
// Storage can be unavailable (private mode / quota); persisting export
// options is best-effort and must never break the export flow.
}
}
8 changes: 8 additions & 0 deletions packages/desktop/src/renderer/src/store/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1658,6 +1658,14 @@ export const useEditorStore = defineStore('editor', {
}
case 'add':
case 'change': {
// Only the file's metadata changed on disk (e.g. a git checkout
// that left the content byte-identical) — there is nothing to
// reload and no reason to warn the user (#1861).
const newMarkdown = (change as unknown as FileChangePayload).data?.markdown
if (typeof newMarkdown === 'string' && newMarkdown === tab.markdown) {
break
}

const { autoSave } = preferencesStore
if (autoSave) {
if (autoSaveTimers.has(id)) {
Expand Down
14 changes: 13 additions & 1 deletion packages/desktop/src/renderer/src/store/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export const useProjectStore = defineStore('project', () => {
})
}

function CREATE_FILE_DIRECTORY(name: string): void {
async function CREATE_FILE_DIRECTORY(name: string): Promise<void> {
const cache = createCache.value as CreateCacheEntry
const { dirname, type } = cache

Expand All @@ -279,6 +279,18 @@ export const useProjectStore = defineStore('project', () => {

const fullName = `${dirname}/${name}`

// Creating over an existing path would silently overwrite it (outputFile
// truncates). Refuse instead of destroying the existing file (#1946).
if (await window.fileUtils.pathExists(fullName)) {
createCache.value = {}
notice.notify({
title: 'Error in Side Bar',
type: 'error',
message: `A ${type} named "${name}" already exists in this folder.`
})
return
}

create(fullName, type as FileCreateType)
.then(() => {
createCache.value = {}
Expand Down
21 changes: 21 additions & 0 deletions packages/desktop/src/renderer/src/util/exportHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Muya } from '@muyajs/core'
import { MarkdownToHtml } from '@muyajs/core'
import { sanitize, EXPORT_DOMPURIFY_CONFIG } from './dompurify'
import { resolveLocalImageSrc } from './resolveImageSrc'
import { resolveLocalLinkHref } from './resolveLinkHref'

export interface HeaderFooterPart {
type?: number
Expand Down Expand Up @@ -134,6 +135,23 @@ const rewriteImageSrcs = (html: string): string =>
return resolved === src ? match : `${pre}${resolved}${post}`
})

// Match the `href="…"` of an <a> tag in the (already sanitized, double-quoted)
// engine output, so relative local links are rewritten to absolute `file://`
// URLs the same way images are.
const ANCHOR_HREF_REG = /(<a\b[^>]*?\shref=")([^"]*)(")/gi

/**
* Rewrite relative / absolute-local `<a href>` to absolute `file://` URLs so a
* link to a local file still resolves after the saved document is moved out of
* the source folder (#1688). Remote URLs, `mailto:`/`data:` schemes and in-page
* fragment anchors are left untouched.
*/
const rewriteAnchorHrefs = (html: string): string =>
html.replace(ANCHOR_HREF_REG, (match, pre: string, href: string, post: string) => {
const resolved = resolveLocalLinkHref(href)
return resolved === href ? match : `${pre}${resolved}${post}`
})

/**
* Build a styled, standalone HTML document equivalent to legacy muyajs
* `exportStyledHTML`. Renders markdown through the new engine, injects the TOC
Expand Down Expand Up @@ -170,6 +188,9 @@ export const exportStyledHTML = async(
// Resolve relative image paths to absolute file:// URLs so the saved document
// still shows its images when opened from a different folder (issue 230).
article = rewriteImageSrcs(article)
// Same for relative local links so they still resolve after the document is
// moved out of the source folder (#1688).
article = rewriteAnchorHrefs(article)

// Inject the TOC at the `[TOC]` marker (legacy behaviour: only appears when
// the document explicitly contains `[TOC]`). The marker is rendered as a
Expand Down
20 changes: 20 additions & 0 deletions packages/desktop/src/renderer/src/util/resolveLinkHref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Resolve an <a>'s href for export / static print (#1688): a relative local
// path is resolved to an absolute `file://` URL against the current document
// directory so a link to a local file still works after the exported HTML / PDF
// is moved out of the source folder. In-page fragments, any URL scheme, and
// already-absolute paths are left untouched.
export function resolveLocalLinkHref(href: string): string {
if (!href) return href
// In-page fragment anchor (#heading) — never a filesystem path.
if (href.startsWith('#')) return href
// Windows drive-absolute path (C:\… / C:/…) → file://. Checked before the
// scheme test, since `C:` otherwise reads as a URL scheme.
if (/^[a-z]:[\\/]/i.test(href)) return `file://${href}`
// POSIX / UNC absolute path → file://.
if (/^(?:\/|\\\\)/.test(href)) return `file://${href}`
// Any URL scheme (http:, https:, file:, mailto:, tel:, data:…) — leave as-is.
if (/^[a-z][a-z\d+.-]*:/i.test(href)) return href
// Relative local path — resolve against the document directory.
if (window.DIRNAME) return `file://${window.path.resolve(window.DIRNAME, href)}`
return href
}
40 changes: 40 additions & 0 deletions packages/desktop/test/e2e/issue-1861-filechange.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
import fs from 'fs'
import { launchWithMarkdown, waitForMenuReady } from './helpers'

// #1861 — rewriting the open file on disk with byte-identical content (e.g. a
// git checkout that left it unchanged) fires a watcher 'change', but must NOT
// mark the tab unsaved or show the "file changed on disk" banner. A genuinely
// different on-disk content must still warn.
//
// This drives the REAL watcher: it writes the actual file and lets the
// main-process chokidar watcher -> loadMarkdownFile -> renderer handler run,
// rather than hand-crafting the IPC payload (which would tautologically match).

const isDirty = (page: Page) =>
page.evaluate(() => !!document.querySelector('.editor-tabs li.unsaved'))

// macOS uses polling + awaitWriteFinish (stabilityThreshold 1000ms), so a disk
// write surfaces ~1–2s later.
const WATCH_SETTLE = 2500

test.describe('Issue #1861 — content-identical file change', () => {
test('an identical on-disk rewrite stays clean; a real change warns', async() => {
const { app, page, filePath } = await launchWithMarkdown('hello\nworld\n')
await waitForMenuReady(app)
await page.waitForTimeout(500)
expect(await isDirty(page)).toBe(false)

// Identical bytes — the watcher fires, but the tab must stay clean.
fs.writeFileSync(filePath, 'hello\nworld\n', 'utf-8')
await page.waitForTimeout(WATCH_SETTLE)
expect(await isDirty(page)).toBe(false)

// A genuine content change still marks the tab unsaved.
fs.writeFileSync(filePath, 'hello\nworld\nchanged\n', 'utf-8')
await expect.poll(() => isDirty(page), { timeout: 8000 }).toBe(true)

await app.close()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test'
import type { ElectronApplication, Page } from 'playwright'
import { launchWithMarkdown, clickMenuById, enterSourceMode } from './helpers'

// #2372 — in source-code mode the dark themes (railscasts) rendered the
// selection at #272935, almost identical to the #2b2b2b editor background, so a
// selection was effectively invisible. The selection should use the same
// visible colour as the WYSIWYG editor (--selection-color). Drives the real
// built app: switch to the "dark" theme, enter source mode, select all, and
// read the rendered selection background.

test.describe('#2372 source-mode selection colour', () => {
let app: ElectronApplication
let page: Page

test.beforeAll(async() => {
const launched = await launchWithMarkdown('# Selection\n\nalpha bravo charlie\n\ndelta echo foxtrot\n')
app = launched.app
page = launched.page
await clickMenuById(app, 'dark') // a railscasts dark theme
await page.waitForFunction(() => document.body.classList.contains('dark'), null, { timeout: 5000 })
await enterSourceMode(page, app)
await page.waitForFunction(() => !!document.querySelector('.source-code .CodeMirror.cm-s-railscasts'), null, {
timeout: 5000
})
})

test.afterAll(async() => {
if (app) await app.close()
})

test('selection background is the visible editor selection colour, not near-background', async() => {
// Select all via the real CodeMirror instance so it renders .CodeMirror-selected.
await page.evaluate(() => {
const cm = (document.querySelector('.source-code .CodeMirror') as Element & { CodeMirror?: { focus: () => void; execCommand: (c: string) => void } }).CodeMirror
cm!.focus()
cm!.execCommand('selectAll')
})
await page.waitForSelector('.source-code .CodeMirror-selected', { state: 'attached', timeout: 5000 })

const { selBg, selectionColor } = await page.evaluate(() => {
const sel = document.querySelector('.source-code .CodeMirror-selected') as HTMLElement
// Resolve --selection-color (what the WYSIWYG editor uses) to its computed
// rgb form so we can compare against the rendered selection background.
const probe = document.createElement('div')
probe.style.background = 'var(--selection-color)'
document.body.appendChild(probe)
const selectionColor = getComputedStyle(probe).backgroundColor
probe.remove()
return { selBg: getComputedStyle(sel).backgroundColor, selectionColor }
})

// Source-mode selection now matches the editor's --selection-color
// (consistent with WYSIWYG mode), and is no longer the near-invisible
// railscasts colour rgb(39, 41, 53) (#272935).
expect(selBg).toBe(selectionColor)
expect(selBg).not.toBe('rgb(39, 41, 53)')
})
})
39 changes: 39 additions & 0 deletions packages/desktop/test/unit/specs/export-settings-persist.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom

import { beforeEach, describe, expect, it } from 'vitest'
import {
EXPORT_SETTINGS_STORAGE_KEY,
loadExportSettings,
saveExportSettings
} from '@/components/exportSettings/persistence'

// #2287 — export options (font, margins, page size, theme, …) reset to default
// on every restart because the dialog never persisted them. These pin the
// persistence layer: a save→load round-trip survives, missing/corrupt storage
// falls back to {} (so the component keeps its defaults) and never throws.

describe('#2287 — export settings persistence', () => {
beforeEach(() => {
localStorage.clear()
})

it('round-trips saved settings through localStorage', () => {
saveExportSettings({ pageSize: 'A3', fontSize: 18, pageMarginTop: 30, theme: 'liber' })
expect(loadExportSettings()).toEqual({ pageSize: 'A3', fontSize: 18, pageMarginTop: 30, theme: 'liber' })
})

it('returns an empty object when nothing was saved', () => {
expect(loadExportSettings()).toEqual({})
})

it('returns an empty object (no throw) when stored JSON is corrupt', () => {
localStorage.setItem(EXPORT_SETTINGS_STORAGE_KEY, '{not json')
expect(() => loadExportSettings()).not.toThrow()
expect(loadExportSettings()).toEqual({})
})

it('persists under a stable, namespaced key', () => {
saveExportSettings({ fontSize: 12 })
expect(localStorage.getItem(EXPORT_SETTINGS_STORAGE_KEY)).toContain('fontSize')
})
})
24 changes: 24 additions & 0 deletions packages/desktop/test/unit/specs/exportHtml.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,27 @@ describe('exportStyledHTML — relative image paths', () => {
expect(out).not.toContain('file://')
})
})

describe('exportStyledHTML — relative link paths (#1688)', () => {
it('rewrites a relative <a href> to an absolute file:// URL', async() => {
// window.DIRNAME is stubbed to '/docs', so `./my_file.pdf` resolves against it.
const out = await exportStyledHTML(NO_MUYA, '[doc](./my_file.pdf)', {})

expect(out).toMatch(/<a[^>]+href="file:\/\/\/docs\/my_file\.pdf"/)
expect(out).not.toContain('href="./my_file.pdf"')
})

it('leaves a remote http(s) link untouched', async() => {
const out = await exportStyledHTML(NO_MUYA, '[site](https://example.com/p)', {})

expect(out).toMatch(/<a[^>]+href="https:\/\/example\.com\/p"/)
expect(out).not.toContain('file://')
})

it('leaves an in-page fragment anchor untouched', async() => {
const out = await exportStyledHTML(NO_MUYA, '# Heading\n\n[jump](#heading)', {})

expect(out).toContain('href="#heading"')
expect(out).not.toContain('file://')
})
})
Loading