From e37f52b5b68dd528feb889a387c9dfa423510ff1 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 12:46:34 +0800 Subject: [PATCH 1/6] fix(muya): recognize `c++`/`h++` as aliases for the cpp code grammar (#4794) * fix(muya): recognize `c++`/`h++` as aliases for the cpp code grammar prismjs ships C++ without a `c++` alias, so a fenced block tagged ```c++ never resolved to the cpp grammar via transformAliasToOrigin and stayed unhighlighted. Register `c++` and `h++` as cpp aliases before the language tables are built so alias resolution, the dependency loader, and the language selector all pick them up. Fixes #2910 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(muya): tokenize code blocks with the resolved language grammar The c++/h++ aliases resolve to the cpp grammar, but backspaceHandler passed the raw alias (`prism.languages[lang]`) to prism.tokenize while its guard checked the resolved id. prism only registers the runtime grammar under the resolved id, so `prism.languages['c++']` is undefined and tokenize crashed with "Cannot read properties of undefined (reading 'rest')" on backspace in a ```c++ block. Tokenize with the resolved `fullLengthLang` instead. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../block/content/codeBlockContent/index.ts | 2 +- .../prism/__tests__/languageAlias.spec.ts | 30 +++++++++++++++++++ packages/muya/src/utils/prism/index.ts | 11 +++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 packages/muya/src/utils/prism/__tests__/languageAlias.spec.ts diff --git a/packages/muya/src/block/content/codeBlockContent/index.ts b/packages/muya/src/block/content/codeBlockContent/index.ts index c4c05a2452..6c57fc4200 100644 --- a/packages/muya/src/block/content/codeBlockContent/index.ts +++ b/packages/muya/src/block/content/codeBlockContent/index.ts @@ -417,7 +417,7 @@ class CodeBlockContent extends Content { // transform alias to original language const fullLengthLang = transformAliasToOrigin([lang])[0]; if (fullLengthLang && /\S/.test(text) && loadedLanguages.has(fullLengthLang)) { - const tokens = prism.tokenize(text, prism.languages[lang]); + const tokens = prism.tokenize(text, prism.languages[fullLengthLang]); let offset = start.offset; let code = ''; let needRender = false; diff --git a/packages/muya/src/utils/prism/__tests__/languageAlias.spec.ts b/packages/muya/src/utils/prism/__tests__/languageAlias.spec.ts new file mode 100644 index 0000000000..d9edd5ea7e --- /dev/null +++ b/packages/muya/src/utils/prism/__tests__/languageAlias.spec.ts @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest'; +import prism, { loadLanguage, transformAliasToOrigin } from '../index'; + +describe('c++ language alias (#2910)', () => { + it('resolves `c++` to the cpp grammar', () => { + expect(transformAliasToOrigin(['c++'])[0]).toBe('cpp'); + }); + + it('resolves `h++` to the cpp grammar', () => { + expect(transformAliasToOrigin(['h++'])[0]).toBe('cpp'); + }); + + it('leaves the canonical `cpp` id untouched', () => { + expect(transformAliasToOrigin(['cpp'])[0]).toBe('cpp'); + }); + + // The runtime grammar is registered under the resolved id (`cpp`), never the + // alias, so code paths must tokenize with `transformAliasToOrigin(lang)` — + // tokenizing with the raw `c++` grammar is `undefined` and crashes (the + // backspaceHandler regression this alias exposed). + it('exposes a runtime grammar for the resolved id but not the raw alias', async () => { + await loadLanguage('c++'); + const resolved = transformAliasToOrigin(['c++'])[0]; + expect(resolved).toBe('cpp'); + expect(prism.languages[resolved]).toBeTruthy(); + expect(prism.languages['c++']).toBeUndefined(); + expect(() => prism.tokenize('int main(){}', prism.languages[resolved])).not.toThrow(); + }); +}); diff --git a/packages/muya/src/utils/prism/index.ts b/packages/muya/src/utils/prism/index.ts index 665c4770c1..b8e75b0e76 100644 --- a/packages/muya/src/utils/prism/index.ts +++ b/packages/muya/src/utils/prism/index.ts @@ -7,6 +7,17 @@ const prism = Prism; window.Prism = Prism; import('prismjs/plugins/keep-markup/prism-keep-markup'); +// prismjs ships C++ without a `c++`/`h++` alias, so fenced blocks tagged +// ```c++ never resolve to the cpp grammar and stay unhighlighted (#2910). +if (languages.cpp) { + const existing = languages.cpp.alias; + languages.cpp.alias = Array.isArray(existing) + ? [...existing, 'c++', 'h++'] + : existing + ? [existing, 'c++', 'h++'] + : ['c++', 'h++']; +} + const langs: { name: string; [key: string]: string; From 6a7e9df521df4a8ae55a9b95f32951bd8f1f5264 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 14:44:18 +0800 Subject: [PATCH 2/6] fix(muya): don't highlight LaTeX `\%` as a comment (#4795) prismjs's latex `comment` token is `/%.*/`, which swallows everything after an escaped percent. In LaTeX `\%` is a literal percent, not a line comment. Require the `%` to not follow a backslash so `\%` highlights as a control sequence; bare and line-leading `%` still start comments. The tex/context aliases share the grammar object, so the single override covers all three. Fixes #3037 Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/latexEscapedPercent.spec.ts | 29 +++++++++++++++++++ packages/muya/src/utils/prism/index.ts | 13 ++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 packages/muya/src/utils/prism/__tests__/latexEscapedPercent.spec.ts diff --git a/packages/muya/src/utils/prism/__tests__/latexEscapedPercent.spec.ts b/packages/muya/src/utils/prism/__tests__/latexEscapedPercent.spec.ts new file mode 100644 index 0000000000..e0683245f8 --- /dev/null +++ b/packages/muya/src/utils/prism/__tests__/latexEscapedPercent.spec.ts @@ -0,0 +1,29 @@ +// @vitest-environment happy-dom +import { beforeAll, describe, expect, it } from 'vitest'; +import prism, { loadLanguage, patchLatexEscapedPercent } from '../index'; + +function hasCommentToken(code: string): boolean { + const tokens = prism.tokenize(code, prism.languages.latex); + return tokens.some( + t => typeof t === 'object' && (t as { type?: string }).type === 'comment', + ); +} + +describe('latex escaped percent highlighting (#3037)', () => { + beforeAll(async () => { + await loadLanguage('latex'); + patchLatexEscapedPercent(prism); + }); + + it('does not treat `\\%` as the start of a comment', () => { + expect(hasCommentToken('1\\%+2\\%=3\\%')).toBe(false); + }); + + it('still treats a bare `%` as a comment', () => { + expect(hasCommentToken('100% done')).toBe(true); + }); + + it('still treats a line-leading `%` as a comment', () => { + expect(hasCommentToken('%a real comment')).toBe(true); + }); +}); diff --git a/packages/muya/src/utils/prism/index.ts b/packages/muya/src/utils/prism/index.ts index b8e75b0e76..7cd04666c1 100644 --- a/packages/muya/src/utils/prism/index.ts +++ b/packages/muya/src/utils/prism/index.ts @@ -61,8 +61,19 @@ function search(text: string) { return fuse.search(text).map(i => i.item).slice(0, 5); } +// In LaTeX `\%` is an escaped literal percent, not a line comment, but +// prismjs's default latex `comment` token (`/%.*/`) swallows everything after +// it. Require the `%` to not follow a backslash so `\%` highlights as a normal +// control sequence (#3037). tex/context alias the same grammar object, so this +// one override covers all three. +export function patchLatexEscapedPercent(prismInstance: typeof Prism) { + const latex = prismInstance.languages.latex as { comment?: unknown } | undefined; + if (latex?.comment) + latex.comment = { pattern: /(^|[^\\])%.*/, lookbehind: true }; +} + // pre load latex and yaml and html for `math block` \ `front matter` and `html block` -loadLanguage('latex'); +loadLanguage('latex').then(() => patchLatexEscapedPercent(prism)); loadLanguage('yaml'); export { walkTokens } from './walkToken'; From 9142cd37c78e3d189d909e1896a01bfe1a4cdc7e Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 15:10:29 +0800 Subject: [PATCH 3/6] fix(muya): recolor sequence-diagram text/boxes for dark themes (#4800) The dark-theme diagram recolor rules key on explicit fill attributes (`text[fill='#000000']`, `rect[fill='#ffffff']`). js-sequence-diagrams creates its `` with no fill attribute and its note/actor boxes with the 3-char `#fff`, so those rules miss them and sequence labels/boxes stayed black-on-white on dark themes. Add `svg.sequence`-scoped rules that recolor the text to `--editor-color` and the `#fff` boxes to `--editor-bg-color`, leaving mermaid/vega/flowchart (which set fills via attributes) untouched. Fixes #3047 Co-authored-by: Claude Opus 4.8 (1M context) --- packages/muya/src/assets/styles/blockSyntax.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/muya/src/assets/styles/blockSyntax.css b/packages/muya/src/assets/styles/blockSyntax.css index 7291952a57..2eb1eed6c1 100644 --- a/packages/muya/src/assets/styles/blockSyntax.css +++ b/packages/muya/src/assets/styles/blockSyntax.css @@ -815,6 +815,20 @@ figure.mu-diagram-block .mu-diagram-preview svg path[stroke='#000000'] { stroke: var(--editor-color); } +/* js-sequence-diagrams creates its with no fill attribute and its + note/actor boxes with the 3-char `#fff`, so the attribute-keyed rules above + miss them and the labels/boxes stay black-on-white on dark themes (#3047). + Recolor the sequence SVG by element, scoped to `svg.sequence` so mermaid / + vega / flowchart (which set fills via attributes) are untouched. */ +figure.mu-diagram-block .mu-diagram-preview svg.sequence text { + fill: var(--editor-color); +} + +figure.mu-diagram-block .mu-diagram-preview svg.sequence rect[fill='#fff'], +figure.mu-diagram-block .mu-diagram-preview svg.sequence path[fill='#fff'] { + fill: var(--editor-bg-color); +} + figure.mu-active.mu-math-block > div.mu-math-preview, figure.mu-active.mu-diagram-block > div.mu-diagram-preview { position: absolute; From 34e2f42f7af4c51632b58ed2e36fab3766fc150c Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 15:13:44 +0800 Subject: [PATCH 4/6] fix(muya): keep image resize handles attached on layout reflow (#4802) ImageResizeBar positioned its left/right handles once on image click and never tracked layout reflow, unlike imageToolbar which rides baseFloat's autoUpdate. Toggling the sidebar or resizing the window left the handles at stale coordinates, detached from the image. Drive repositioning with `@floating-ui/dom` autoUpdate (already used by baseFloat), tearing it down in hide()/destroy(). Fixes #2939 Co-authored-by: Claude Opus 4.8 (1M context) --- .../drag/image-resize-bar-reflow-2939.spec.ts | 56 +++++++++++++++++++ packages/muya/src/ui/imageResizeBar/index.ts | 10 ++++ 2 files changed, 66 insertions(+) create mode 100644 packages/muya/e2e/tests/drag/image-resize-bar-reflow-2939.spec.ts diff --git a/packages/muya/e2e/tests/drag/image-resize-bar-reflow-2939.spec.ts b/packages/muya/e2e/tests/drag/image-resize-bar-reflow-2939.spec.ts new file mode 100644 index 0000000000..73870402e8 --- /dev/null +++ b/packages/muya/e2e/tests/drag/image-resize-bar-reflow-2939.spec.ts @@ -0,0 +1,56 @@ +import type { Locator } from '@playwright/test'; +import { expect, test } from '../fixtures/muya'; +import { editor, floats } from '../helpers/selectors'; + +/** + * #2939 — the ImageResizeBar positioned its left/right handles once on image + * click and never tracked layout reflow (unlike imageToolbar, which rides + * baseFloat's `autoUpdate`). When the surrounding layout shifted — the desktop + * sidebar toggling, or any window/ancestor resize — the handles stayed at their + * stale coordinates, detached from the image. + * + * Reproduce the reflow with a viewport resize (the muya container is centered, + * so narrowing the viewport moves the image's left edge) and assert the left + * handle stays aligned with the image. + */ + +const DATA_URI = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='; + +test.describe('ImageResizeBar reflow (#2939)', () => { + test('left handle tracks the image when the viewport resizes', async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.evaluate(uri => window.muya!.setContent(`![alt](${uri})`), DATA_URI); + + const image = page.locator(editor.image).first(); + await expect(image).toBeVisible(); + await expect.poll(async () => image.evaluate(el => + el.classList.contains('mu-image-success')), { timeout: 5_000 }).toBe(true); + + const innerImg = image.locator('img').first(); + await innerImg.click(); + + const leftHandle = page.locator(`${floats.imageTransformer} .bar.left`); + await expect(leftHandle).toBeVisible(); + + const offsetBefore = await getHandleOffset(innerImg, leftHandle); + expect(Math.abs(offsetBefore)).toBeLessThan(4); + + // Narrow the viewport: the centered container — and the image — shift right. + await page.setViewportSize({ width: 760, height: 900 }); + + await expect.poll(async () => Math.abs(await getHandleOffset(innerImg, leftHandle)), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toBeLessThan(4); + }); +}); + +async function getHandleOffset(img: Locator, handle: Locator): Promise { + const imgBox = await img.boundingBox(); + const handleBox = await handle.boundingBox(); + if (!imgBox || !handleBox) + throw new Error('missing bounding box'); + // The left handle sits at `image.left - 5` (CIRCLE_RADIO); compare centers. + return (handleBox.x + handleBox.width / 2) - imgBox.x; +} diff --git a/packages/muya/src/ui/imageResizeBar/index.ts b/packages/muya/src/ui/imageResizeBar/index.ts index 1354a966f6..887b1ba6b4 100644 --- a/packages/muya/src/ui/imageResizeBar/index.ts +++ b/packages/muya/src/ui/imageResizeBar/index.ts @@ -1,6 +1,7 @@ import type Format from '../../block/base/format'; import type { Muya } from '../../index'; import type { ImageToken } from '../../inlineRenderer/types'; +import { autoUpdate } from '@floating-ui/dom'; import { isHTMLElement, isMouseEvent } from '../../utils'; import { findScrollContainer } from '../../utils/dom'; @@ -26,6 +27,8 @@ export class ImageResizeBar { private _eventId: string[] = []; private _lastScrollTop: number | null = null; private _resizing: boolean = false; + // Stops the autoUpdate reposition loop set up in `_render`. + private _cleanup: (() => void) | null = null; // A container for storing drag strips private _container: HTMLDivElement; @@ -89,6 +92,9 @@ export class ImageResizeBar { this._createElements(); this._update(); + // Reposition the handles whenever the image moves (window/ancestor + // resize, sidebar toggle, scroll), so they stay attached to it (#2939). + this._cleanup = autoUpdate(this._reference!, this._container, () => this._update()); eventCenter.emit('muya-float', this, true); } @@ -202,6 +208,8 @@ export class ImageResizeBar { hide() { const { eventCenter } = this.muya; + this._cleanup?.(); + this._cleanup = null; const circles = this._container.querySelectorAll('.bar'); Array.from(circles).forEach(c => c.remove()); this._status = false; @@ -211,6 +219,8 @@ export class ImageResizeBar { // Remove the `.mu-transformer` container appended to document.body in the // constructor; invoked by `Muya.destroy()` so it is not leaked (#3315). destroy() { + this._cleanup?.(); + this._cleanup = null; this._container.remove(); } } From 14a651e1d356068f451c4e3e6fcf8b76682b9159 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 15:47:42 +0800 Subject: [PATCH 5/6] fix(muya): make cpp c++/h++ alias registration idempotent (#4816) The #2910 alias registration rebuilt cpp.alias as [...existing, 'c++', 'h++'] each time prism/index.ts was evaluated. components.languages is a shared singleton, so re-evaluation (multiple test files, HMR) accumulated duplicate aliases and prism's dependency loader threw 'c++ cannot be alias for both cpp and cpp', failing the desktop unit-test job. Add each alias only if absent. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/muya/src/utils/prism/index.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/muya/src/utils/prism/index.ts b/packages/muya/src/utils/prism/index.ts index 7cd04666c1..ce9c4f1daf 100644 --- a/packages/muya/src/utils/prism/index.ts +++ b/packages/muya/src/utils/prism/index.ts @@ -9,13 +9,18 @@ import('prismjs/plugins/keep-markup/prism-keep-markup'); // prismjs ships C++ without a `c++`/`h++` alias, so fenced blocks tagged // ```c++ never resolve to the cpp grammar and stay unhighlighted (#2910). +// Add each alias only once — `components.languages` is a shared singleton and +// this module may be evaluated more than once (tests, HMR); pushing duplicates +// makes prism's dependency loader throw "c++ cannot be alias for both cpp and +// cpp". if (languages.cpp) { const existing = languages.cpp.alias; - languages.cpp.alias = Array.isArray(existing) - ? [...existing, 'c++', 'h++'] - : existing - ? [existing, 'c++', 'h++'] - : ['c++', 'h++']; + const alias = Array.isArray(existing) ? [...existing] : existing ? [existing] : []; + for (const name of ['c++', 'h++']) { + if (!alias.includes(name)) + alias.push(name); + } + languages.cpp.alias = alias; } const langs: { From 835c47e3b7218b69f69571fc4e4109b4a7d5995b Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 2 Jul 2026 15:53:23 +0800 Subject: [PATCH 6/6] fix(desktop): preserve TOC collapse state across content edits (#4803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): preserve TOC collapse state across content edits The TOC el-tree was bound with default-expand-all and reseeded from a fresh listToTree on every json-change, so any edit re-expanded the whole tree and discarded the heading a user had collapsed. Give the tree a stable node-key (the heading slug, deduplicated in document order), expand all only on first render, track the keys the user collapses, and re-apply those collapses after each data update so the choice survives edits. Fixes #3028 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(desktop): apply TOC collapse state without an expand-all flicker Binding default-expanded-keys to all keys made el-tree expand everything on each content edit, and a watcher then re-collapsed the remembered nodes — a visible expand-then-collapse flash. Instead compute the exact expanded set (every node not collapsed and not under a collapsed ancestor) and bind that, so el-tree paints the remembered state directly on rebuild. Drops the tree ref + post-render watcher. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../renderer/src/components/sideBar/toc.vue | 71 ++++++++++- .../test/e2e/toc-collapse-state-3028.spec.ts | 113 ++++++++++++++++++ 2 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 packages/desktop/test/e2e/toc-collapse-state-3028.spec.ts diff --git a/packages/desktop/src/renderer/src/components/sideBar/toc.vue b/packages/desktop/src/renderer/src/components/sideBar/toc.vue index 20c4b136a6..bb6726155d 100644 --- a/packages/desktop/src/renderer/src/components/sideBar/toc.vue +++ b/packages/desktop/src/renderer/src/components/sideBar/toc.vue @@ -7,19 +7,23 @@ {{ t('sideBar.toc.title') }}