Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
13 changes: 11 additions & 2 deletions packages/desktop/src/renderer/src/components/sideBar/tree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement | null>(null)

Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions packages/desktop/test/e2e/issue-2421-sidebar-state.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
25 changes: 15 additions & 10 deletions packages/muya/src/assets/styles/inlineSyntax.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <Invalid Mathematical Formula>?". 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<Window>).MUYA_VERSION;
});

function bootMuya(markdown: string): MuyaType {
const host = document.createElement('div');
document.body.appendChild(host);
const muya = new Muya(host, { markdown } as ConstructorParameters<typeof Muya>[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');
});
});
8 changes: 4 additions & 4 deletions packages/muya/src/block/extra/math/mathPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,10 +71,9 @@ class MathPreview extends Parent {
});
this.domNode!.innerHTML = html;
}
catch {
this.domNode!.innerHTML = `<div class="${CLASS_NAMES.MU_MATH_ERROR}">&lt; ${i18n.t(
'Invalid Mathematical Formula',
)} &gt;</div>`;
catch (err) {
const message = err instanceof Error ? err.message : i18n.t('Invalid Mathematical Formula');
this.domNode!.innerHTML = `<div class="${CLASS_NAMES.MU_MATH_ERROR}">${escapeHTML(message)}</div>`;
}
}
else {
Expand Down
10 changes: 8 additions & 2 deletions packages/muya/src/inlineRenderer/renderer/inlineMath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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 : '';
}
}

Expand All @@ -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
Expand Down
Loading