diff --git a/packages/desktop/src/renderer/src/components/sideBar/index.vue b/packages/desktop/src/renderer/src/components/sideBar/index.vue index fd178e4ee1..753c080d59 100644 --- a/packages/desktop/src/renderer/src/components/sideBar/index.vue +++ b/packages/desktop/src/renderer/src/components/sideBar/index.vue @@ -116,8 +116,12 @@ onMounted(() => { const handleLeftIconClick = (name: string): void => { if (rightColumn.value === name) { + // Capture the expanded width BEFORE collapsing: once rightColumn is '', + // finalSideBarWidth evaluates to the 45px icon strip and would overwrite + // the user's real width with the clamped 220px minimum (#2421). + const widthToPersist = finalSideBarWidth.value layoutStore.SET_LAYOUT({ rightColumn: '' }) - layoutStore.CHANGE_SIDE_BAR_WIDTH(finalSideBarWidth.value) + layoutStore.CHANGE_SIDE_BAR_WIDTH(widthToPersist) } else { const needDispatch = rightColumn.value === '' layoutStore.SET_LAYOUT({ rightColumn: name }) diff --git a/packages/desktop/src/renderer/src/components/sideBar/tree.vue b/packages/desktop/src/renderer/src/components/sideBar/tree.vue index a7d435f0d9..0be7487e18 100644 --- a/packages/desktop/src/renderer/src/components/sideBar/tree.vue +++ b/packages/desktop/src/renderer/src/components/sideBar/tree.vue @@ -176,8 +176,15 @@ const props = defineProps<{ }>() const depth = 0 -const showDirectories = ref(true) -const showOpenedFiles = ref(true) +// Persist the section collapse state (#2421). The tree is rendered under a +// v-if and is destroyed when the sidebar collapses to its icon strip, so local +// refs reset to expanded on re-open. Back them with localStorage (like the +// sidebar width) so the state survives a re-mount and app restart. +const SHOW_DIRECTORIES_KEY = 'side-bar-show-directories' +const SHOW_OPENED_FILES_KEY = 'side-bar-show-opened-files' +const readSectionExpanded = (key: string): boolean => localStorage.getItem(key) !== 'false' +const showDirectories = ref(readSectionExpanded(SHOW_DIRECTORIES_KEY)) +const showOpenedFiles = ref(readSectionExpanded(SHOW_OPENED_FILES_KEY)) const createName = ref('') const input = ref(null) @@ -219,10 +226,12 @@ const handleRootContextMenu = (event: MouseEvent): void => { const toggleOpenedFiles = (): void => { showOpenedFiles.value = !showOpenedFiles.value + localStorage.setItem(SHOW_OPENED_FILES_KEY, String(showOpenedFiles.value)) } const toggleDirectories = (): void => { showDirectories.value = !showDirectories.value + localStorage.setItem(SHOW_DIRECTORIES_KEY, String(showDirectories.value)) } // From createFileOrDirectoryMixins diff --git a/packages/desktop/test/e2e/issue-2421-sidebar-state.spec.ts b/packages/desktop/test/e2e/issue-2421-sidebar-state.spec.ts new file mode 100644 index 0000000000..b50559f9e3 --- /dev/null +++ b/packages/desktop/test/e2e/issue-2421-sidebar-state.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from '@playwright/test' +import type { ElectronApplication, Page } from 'playwright' +import { launchWithMarkdown } from './helpers' + +// #2421 — toggling the sidebar via its left-column icons must not lose state. +// Two bugs: (1) collapsing to the icon strip persisted the clamped 220px width +// instead of the real width, so re-expanding shrank the sidebar; (2) the tree's +// collapsed sections (Opened files / Directories) are local refs under a v-if, +// so collapsing the sidebar destroyed the tree and reset them on re-expand. +// These drive the real built app. + +const filesIcon = (page: Page) => + page.locator('.side-bar .left-column > ul').first().locator('li').nth(0) + +const sideBarWidth = (page: Page) => + page.evaluate(() => { + const el = document.querySelector('.side-bar') as HTMLElement | null + return el ? Math.round(el.getBoundingClientRect().width) : 0 + }) + +test.describe('#2421 sidebar state survives icon toggle', () => { + let app: ElectronApplication + let page: Page + + test.beforeAll(async() => { + const launched = await launchWithMarkdown('# Doc\n\n## A\n\n## B\n') + app = launched.app + page = launched.page + // The files panel is the default right column; make sure it is open + wide. + await page.waitForFunction(() => { + const el = document.querySelector('.side-bar') as HTMLElement | null + return !!(el && el.offsetParent !== null && el.getBoundingClientRect().width > 220) + }, null, { timeout: 5000 }) + }) + + test.afterAll(async() => { + if (app) await app.close() + }) + + test('collapsing then re-expanding preserves a widened sidebar width', async() => { + // Widen the sidebar past the 220px minimum by dragging the drag-bar, so a + // width loss on collapse is observable (the default already sits at 220). + const dragBar = page.locator('.side-bar .drag-bar') + const box = await dragBar.boundingBox() + expect(box).not.toBeNull() + await page.mouse.move(box!.x + box!.width / 2, box!.y + 80) + await page.mouse.down() + await page.mouse.move(box!.x + box!.width / 2 + 120, box!.y + 80, { steps: 8 }) + await page.mouse.up() + await page.waitForFunction(() => { + const el = document.querySelector('.side-bar') as HTMLElement | null + return !!el && el.getBoundingClientRect().width >= 300 + }, null, { timeout: 5000 }) + + const widened = await sideBarWidth(page) + expect(widened).toBeGreaterThanOrEqual(300) + + await filesIcon(page).click() // collapse to icon strip + await page.waitForFunction(() => { + const el = document.querySelector('.side-bar') as HTMLElement | null + return !!el && el.getBoundingClientRect().width <= 50 + }, null, { timeout: 5000 }) + + await filesIcon(page).click() // re-expand + await page.waitForFunction(() => { + const el = document.querySelector('.side-bar') as HTMLElement | null + return !!el && el.getBoundingClientRect().width > 50 + }, null, { timeout: 5000 }) + + const reExpanded = await sideBarWidth(page) + // The widened width must survive the collapse round-trip (it was reset to + // the clamped 220px before the fix). + expect(Math.abs(reExpanded - widened)).toBeLessThanOrEqual(3) + }) + + test('a collapsed tree section stays collapsed after toggling the sidebar', async() => { + const arrow = page.locator('.side-bar .opened-files > .title .icon-arrow').first() + await expect(arrow).toBeVisible() + + // Collapse the "Opened files" section. + await arrow.click() + await page.waitForFunction(() => { + const a = document.querySelector('.side-bar .opened-files .icon-arrow') + return !!(a && a.classList.contains('fold')) + }, null, { timeout: 5000 }) + + // Toggle the whole sidebar off and back on via its icon. + await filesIcon(page).click() + await page.waitForTimeout(250) + await filesIcon(page).click() + await page.waitForFunction(() => { + const el = document.querySelector('.side-bar .opened-files') as HTMLElement | null + return !!(el && el.offsetParent !== null) + }, null, { timeout: 5000 }) + + const stillCollapsed = await page.evaluate(() => { + const a = document.querySelector('.side-bar .opened-files .icon-arrow') + return !!(a && a.classList.contains('fold')) + }) + expect(stillCollapsed).toBe(true) + }) +}) diff --git a/packages/muya/src/assets/styles/inlineSyntax.css b/packages/muya/src/assets/styles/inlineSyntax.css index cb4e3e1099..c27236e250 100644 --- a/packages/muya/src/assets/styles/inlineSyntax.css +++ b/packages/muya/src/assets/styles/inlineSyntax.css @@ -220,12 +220,25 @@ div .mu-math-error, font-size: 14px; font-family: monospace; font-style: italic; +} + +/* Inline math shows a short error label: keep it on one line in the narrow + popup, and let it overflow VISIBLE rather than be clipped — the default + `overflow: auto` on .mu-math-render both clips the label and takes the + inline-block's baseline from its bottom edge, lifting it off the text. */ +.mu-math > .mu-math-render.mu-math-error { + overflow: visible; - /* Keep the KaTeX parse-error message on one line instead of wrapping - across the narrow inline-math popup. */ white-space: nowrap; } +/* Block math shows the full KaTeX parse-error message, which can be long; wrap + it within the block instead of overflowing horizontally (#2220). */ +.mu-math-preview .mu-math-error { + white-space: normal; + overflow-wrap: break-word; +} + .mu-math > .mu-math-render .katex-display { margin: 0; } @@ -266,14 +279,6 @@ div .mu-math-error, user-select: auto; } -/* A parse-error message is short and never needs scrolling, but `overflow: auto` - makes the inline-block take its baseline from its bottom edge, pushing it a few - px above the surrounding text. `visible` keeps it on the baseline. (A long - valid formula keeps `overflow: auto` so it stays scrollable, not truncated.) */ -.mu-hide.mu-math > .mu-math-render.mu-math-error { - overflow: visible; -} - .mu-ruby:not(.mu-hide) > .mu-ruby-render, .mu-math:not(.mu-hide) > .mu-math-render { z-index: 100; diff --git a/packages/muya/src/block/extra/math/__tests__/mathErrorMessage.spec.ts b/packages/muya/src/block/extra/math/__tests__/mathErrorMessage.spec.ts new file mode 100644 index 0000000000..0097763175 --- /dev/null +++ b/packages/muya/src/block/extra/math/__tests__/mathErrorMessage.spec.ts @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom + +import type { Muya as MuyaType } from '../../../../muya'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Muya } from '../../../../muya'; + +// #2220 — "How to debug ?". The live editor +// caught KaTeX's parse error and replaced it with an opaque generic message, +// so the user had no idea WHAT was wrong. Surface KaTeX's actual parse-error +// reason: inline math keeps the compact baseline-aligned label but exposes the +// message via the title (a long message inline would break the text baseline — +// #4100 / inline-math-align); block math shows the message text directly. + +const bootedHosts: HTMLElement[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + document.getSelection()?.removeAllRanges(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): MuyaType { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +describe('#2220 — invalid math surfaces the KaTeX parse error, not a generic message', () => { + it('inline math `$\\frac{1}{$` carries the parse reason in the .mu-math-error title (compact label kept)', () => { + const muya = bootMuya('$\\frac{1}{$\n'); + const errorEl = muya.domNode.querySelector('.mu-math-error'); + expect(errorEl).not.toBeNull(); + // The visible label stays compact (baseline-safe); the reason is on the title. + expect(errorEl!.getAttribute('title') ?? '').toMatch(/parse error/i); + expect(errorEl!.textContent ?? '').toContain('Invalid Mathematical Formula'); + }); + + it('block math `$$\\frac{1}{$$` shows the parse reason in .mu-math-error', () => { + const muya = bootMuya('$$\n\\frac{1}{\n$$\n'); + const errorEl = muya.domNode.querySelector('.mu-math-error'); + expect(errorEl).not.toBeNull(); + expect(errorEl!.textContent ?? '').toMatch(/parse error/i); + expect(muya.domNode.textContent ?? '').not.toContain('Invalid Mathematical Formula'); + }); +}); diff --git a/packages/muya/src/block/extra/math/mathPreview.ts b/packages/muya/src/block/extra/math/mathPreview.ts index 6f081b1da2..8922468329 100644 --- a/packages/muya/src/block/extra/math/mathPreview.ts +++ b/packages/muya/src/block/extra/math/mathPreview.ts @@ -3,6 +3,7 @@ import type { IMathBlockState, TState } from '../../../state/types'; import katex from 'katex'; import { fromEvent } from 'rxjs'; import { CLASS_NAMES } from '../../../config'; +import { escapeHTML } from '../../../utils'; import logger from '../../../utils/logger'; import Parent from '../../base/parent'; import 'katex/dist/contrib/mhchem.mjs'; @@ -70,10 +71,9 @@ class MathPreview extends Parent { }); this.domNode!.innerHTML = html; } - catch { - this.domNode!.innerHTML = `
< ${i18n.t( - 'Invalid Mathematical Formula', - )} >
`; + catch (err) { + const message = err instanceof Error ? err.message : i18n.t('Invalid Mathematical Formula'); + this.domNode!.innerHTML = `
${escapeHTML(message)}
`; } } else { diff --git a/packages/muya/src/inlineRenderer/renderer/inlineMath.ts b/packages/muya/src/inlineRenderer/renderer/inlineMath.ts index d1dd16b19a..bccf254cbb 100644 --- a/packages/muya/src/inlineRenderer/renderer/inlineMath.ts +++ b/packages/muya/src/inlineRenderer/renderer/inlineMath.ts @@ -48,6 +48,9 @@ export default function inlineMath(this: Renderer, { const key = `${math}_${type}`; let mathVnode = null; let previewSelector = `span.${CLASS_NAMES.MU_MATH_RENDER}`; + // Inline math errors stay compact to keep the surrounding text baseline + // (#4100, inline-math-align); surface the parse reason via the title. + let errorTitle = ''; if (loadMathMap.has(key)) { mathVnode = loadMathMap.get(key); } @@ -59,9 +62,10 @@ export default function inlineMath(this: Renderer, { mathVnode = htmlToVNode(html); loadMathMap.set(key, mathVnode); } - catch { + catch (err) { mathVnode = `<${i18n.t('Invalid Mathematical Formula')}>`; previewSelector += `.${CLASS_NAMES.MU_MATH_ERROR}`; + errorTitle = err instanceof Error ? err.message : ''; } } @@ -78,7 +82,9 @@ export default function inlineMath(this: Renderer, { h( previewSelector, { - attrs: { contenteditable: 'false' }, + attrs: errorTitle + ? { contenteditable: 'false', title: errorTitle } + : { contenteditable: 'false' }, dataset: { start: String(start + 1), // '$'.length end: String(end - 1), // '$'.length