From 38e7212579d1d12244d76f51c554f427a91c59e9 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Wed, 24 Jun 2026 23:36:07 +0800 Subject: [PATCH 1/5] test(e2e): wait for selection commit before opening find bar (#4682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `search-prefill-from-selection` double-click test sent the find IPC immediately after the DOM selection settled, but the engine commits its selection to the model on the next animation frame (content block `clickHandler` defers `setCursor` via requestAnimationFrame). A real user always opens the find bar well after that commit; only the test's instantaneous select->find raced it, and under headless xvfb — where rAF scheduling is erratic — the race intermittently left the prefill reading a stale value (`expected 'fox' got 'T'`). Wait on the frame, not wall-clock time: `clickHandler` runs synchronously on the click, so its commit rAF is already scheduled; a double rAF is then guaranteed to run after it, robust even under erratic xvfb frame timing (a fixed timeout could still lose if a frame exceeds it). No production change: the #4646 store-based prefill is correct once the selection has committed. Co-authored-by: Claude Opus 4.8 (1M context) --- .../e2e/search-prefill-from-selection.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/desktop/test/e2e/search-prefill-from-selection.spec.ts b/packages/desktop/test/e2e/search-prefill-from-selection.spec.ts index 4b4f23e2c8..88531f55c9 100644 --- a/packages/desktop/test/e2e/search-prefill-from-selection.spec.ts +++ b/packages/desktop/test/e2e/search-prefill-from-selection.spec.ts @@ -48,6 +48,24 @@ test.describe('Find bar prefill from selection', () => { await page.mouse.dblclick(point.x, point.y) await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe('fox') + // The DOM selection is set synchronously by the double-click, but the engine + // commits it to its model on the next animation frame (content block + // `clickHandler` defers `setCursor` via requestAnimationFrame → `selection-change` + // → store). A real user always opens the find bar well after that commit; only + // an instantaneous select→find (as here) can race it. + // + // Wait on the *frame*, not wall-clock time: `clickHandler` runs synchronously + // on the click, so its commit rAF is already scheduled (the DOM-selection poll + // above proves the click fired). A double rAF is therefore guaranteed to run + // after that deferred commit — robust even under xvfb's erratic frame timing, + // where a fixed `waitForTimeout` could still lose if a frame exceeds it. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ) + await sendIpcToRenderer(app, 'mt::editor-edit-action', 'find') const searchBar = page.locator('.search-bar') await expect(searchBar).toBeVisible({ timeout: 5000 }) From 99082539546c0c07a7107ef71bdf969a06747b06 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Wed, 24 Jun 2026 23:36:39 +0800 Subject: [PATCH 2/5] fix: export PDF/HTML/print in the editor's text direction (RTL) (#4678) * feat(muya): support text direction in MarkdownToHtml.generate Add an optional `dir` to `generate(options)` that sets the attribute on the exported document's root ``. `rtl` / `auto` are emitted; `ltr` is the HTML default and stays implicit so existing LTR exports are byte-identical. Enables RTL-correct standalone HTML / PDF / print export (#4553). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(desktop): export PDF/HTML/print in the editor's text direction The styled-export wrapper never carried the editor's text direction, so RTL documents always exported left-to-right (#4553). Thread `textDirection` from the editor through `exportStyledHTML` into the engine's `generate({ dir })` for the pdf, styledHtml, and print paths, and widen the hand-written `@muyajs/core` `generate` declaration to match. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/editorWithTabs/editor.vue | 9 ++++-- .../src/renderer/src/util/exportHtml.ts | 10 ++++-- packages/desktop/src/types/muya-core.d.ts | 7 +++- .../test/unit/specs/exportHtml.spec.ts | 23 +++++++++++++ .../src/state/__tests__/exportHtmlDir.spec.ts | 32 +++++++++++++++++++ packages/muya/src/state/markdownToHtml.ts | 18 +++++++++-- 6 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 packages/muya/src/state/__tests__/exportHtmlDir.spec.ts diff --git a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue index 423299563e..33ba5ec73c 100644 --- a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue +++ b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue @@ -1258,7 +1258,8 @@ const handleExport = async (options: unknown) => { title: htmlTitle || '', printOptimization: false, extraCss, - toc: htmlToc + toc: htmlToc, + dir: props.textDirection }) editorStore.EXPORT({ type, content }) } catch (err) { @@ -1290,7 +1291,8 @@ const handleExport = async (options: unknown) => { toc: htmlToc, header, footer, - headerFooterStyled: headerFooterStyled as boolean | undefined + headerFooterStyled: headerFooterStyled as boolean | undefined, + dir: props.textDirection }) printer!.renderMarkdown(html, true) editorStore.EXPORT({ type, pageOptions }) @@ -1315,7 +1317,8 @@ const handleExport = async (options: unknown) => { toc: htmlToc, header, footer, - headerFooterStyled: headerFooterStyled as boolean | undefined + headerFooterStyled: headerFooterStyled as boolean | undefined, + dir: props.textDirection }) printer!.renderMarkdown(html, true) editorStore.PRINT_RESPONSE() diff --git a/packages/desktop/src/renderer/src/util/exportHtml.ts b/packages/desktop/src/renderer/src/util/exportHtml.ts index c5261f4a59..4baf87b35b 100644 --- a/packages/desktop/src/renderer/src/util/exportHtml.ts +++ b/packages/desktop/src/renderer/src/util/exportHtml.ts @@ -30,6 +30,8 @@ export interface ExportStyledHtmlOptions { header?: HeaderFooterPart | null footer?: HeaderFooterPart | null headerFooterStyled?: boolean + /** Editor text direction ('ltr' | 'rtl' | 'auto'); set on the exported . */ + dir?: string } // Ported verbatim from legacy muyajs `headerFooterStyle.css` so the page @@ -143,7 +145,7 @@ export const exportStyledHTML = async( markdown: string, options: ExportStyledHtmlOptions = {} ): Promise => { - const { title = '', toc = '', header, footer, headerFooterStyled } = options + const { title = '', toc = '', header, footer, headerFooterStyled, dir } = options let { extraCss = '' } = options // The header/footer page table needs its own stylesheet — fold it into @@ -156,7 +158,11 @@ export const exportStyledHTML = async( // Render the engine's full HTML document. We re-extract its
body so // we can inject the TOC / header-footer, then re-emit the document shell. - const fullDoc = await new MarkdownToHtml(markdown, muya).generate({ title, extraCSS: extraCss }) + const fullDoc = await new MarkdownToHtml(markdown, muya).generate({ + title, + extraCSS: extraCss, + dir + }) // Pull out the rendered
body. const articleMatch = /
([\s\S]*)<\/article>/.exec(fullDoc) diff --git a/packages/desktop/src/types/muya-core.d.ts b/packages/desktop/src/types/muya-core.d.ts index 5c7ccb0d3a..9bf2621547 100644 --- a/packages/desktop/src/types/muya-core.d.ts +++ b/packages/desktop/src/types/muya-core.d.ts @@ -74,7 +74,12 @@ declare module '@muyajs/core' { markdown: string constructor(markdown: string, muya?: unknown) renderHtml(): Promise - generate(options?: { title?: string; extraCSS?: string }): Promise + generate(options?: { + title?: string + extraCSS?: string + inlineStyles?: boolean + dir?: string + }): Promise } export function renderToStaticHTML(...args: any[]): any diff --git a/packages/desktop/test/unit/specs/exportHtml.spec.ts b/packages/desktop/test/unit/specs/exportHtml.spec.ts index 43b284cf90..67b2800c30 100644 --- a/packages/desktop/test/unit/specs/exportHtml.spec.ts +++ b/packages/desktop/test/unit/specs/exportHtml.spec.ts @@ -215,6 +215,29 @@ describe('exportStyledHTML — header/footer assembly', () => { }) }) +describe('exportStyledHTML — text direction (issue #4553)', () => { + it('sets dir="rtl" on the exported when dir is "rtl"', async() => { + const out = await exportStyledHTML(NO_MUYA, '# سلام\n\nمتن', { dir: 'rtl' }) + + expect(out).toMatch(//) + }) + + it('forwards dir="auto" to the exported ', async() => { + const out = await exportStyledHTML(NO_MUYA, '# Hi', { dir: 'auto' }) + + expect(out).toMatch(//) + }) + + it('leaves the default LTR export without a dir attribute', async() => { + const ltr = await exportStyledHTML(NO_MUYA, '# Hi', { dir: 'ltr' }) + const none = await exportStyledHTML(NO_MUYA, '# Hi', {}) + + expect(ltr).toContain('') + expect(ltr).not.toMatch(/]+dir=/) + expect(none).not.toMatch(/]+dir=/) + }) +}) + describe('exportStyledHTML — relative image paths', () => { it('rewrites a relative img src to an absolute file:// URL (issue 230)', async() => { // window.DIRNAME is stubbed to '/docs', so `./a.png` resolves against it. diff --git a/packages/muya/src/state/__tests__/exportHtmlDir.spec.ts b/packages/muya/src/state/__tests__/exportHtmlDir.spec.ts new file mode 100644 index 0000000000..279851d38d --- /dev/null +++ b/packages/muya/src/state/__tests__/exportHtmlDir.spec.ts @@ -0,0 +1,32 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; +import { MarkdownToHtml } from '../markdownToHtml'; + +// `generate({ dir })` controls the text direction of the exported document. +// RTL documents must export with `dir="rtl"` on the root so the PDF / +// HTML / print output flows right-to-left (issue #4553). LTR is the HTML +// default, so it is left implicit to keep existing exports byte-identical. + +const SAMPLE = '# سلام\n\nمتن نمونه.\n'; + +describe('markdownToHtml.generate — text direction', () => { + it('emits dir="rtl" on when dir is "rtl"', async () => { + const out = await new MarkdownToHtml(SAMPLE).generate({ dir: 'rtl' }); + expect(out).toMatch(//); + }); + + it('emits dir="auto" on when dir is "auto"', async () => { + const out = await new MarkdownToHtml(SAMPLE).generate({ dir: 'auto' }); + expect(out).toMatch(//); + }); + + it('omits the dir attribute for the default LTR direction', async () => { + const ltr = await new MarkdownToHtml(SAMPLE).generate({ dir: 'ltr' }); + const none = await new MarkdownToHtml(SAMPLE).generate({}); + expect(ltr).toContain(''); + expect(ltr).not.toMatch(/]+dir=/); + expect(none).toContain(''); + expect(none).not.toMatch(/]+dir=/); + }); +}); diff --git a/packages/muya/src/state/markdownToHtml.ts b/packages/muya/src/state/markdownToHtml.ts index 3e041b9ae2..58de94bc0c 100644 --- a/packages/muya/src/state/markdownToHtml.ts +++ b/packages/muya/src/state/markdownToHtml.ts @@ -231,14 +231,26 @@ export class MarkdownToHtml { * @param options.inlineStyles Inline the core stylesheets so the output is * self-contained and renders offline (default `true`); pass `false` to fall * back to CDN `` tags. + * @param options.dir Text direction set on the root `` (`rtl` / `auto`); + * `ltr` is the HTML default and stays implicit. */ async generate( - options: { title?: string; extraCSS?: string; inlineStyles?: boolean } = {}, + options: { + title?: string; + extraCSS?: string; + inlineStyles?: boolean; + dir?: string; + } = {}, ) { const html = await this.renderHtml(); // `extraCSS` may changed in the mean time. - const { title = '', extraCSS = '', inlineStyles = true } = options; + const { title = '', extraCSS = '', inlineStyles = true, dir } = options; + + // Mirror the editor's text direction onto the exported document so RTL + // documents export right-to-left (#4553). LTR is the HTML default, so it + // stays implicit to keep existing exports byte-identical. + const dirAttr = dir === 'rtl' || dir === 'auto' ? ` dir="${dir}"` : ''; let baseStyles: string; if (inlineStyles) { @@ -255,7 +267,7 @@ export class MarkdownToHtml { } return ` - + From 8efe80a9995de4ab92a8e289c1d34e00cc96ad32 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Wed, 24 Jun 2026 23:49:50 +0800 Subject: [PATCH 3/5] fix(muya): convert ``` fence to a code-block inside block-quotes and lists (#4689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Enter on a paragraph whose text is a code fence (```lang) only converted to a fenced code-block at the top level. Inside a block-quote or list item, enterHandler routed to the container handlers, which merely split or unwrapped the paragraph, so ``` + Enter produced a plain paragraph instead of a nested code-block. Check the code fence in enterHandler before routing by container type and delegate to _enterConvert, whose replaceWith swaps the paragraph in place and keeps the resulting code-block (or diagram) nested in its container — matching the legacy muyajs behaviour. The fence regex is hoisted to a shared CODE_BLOCK_REG constant so the conversion and the gate stay in sync. Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/enterConvertNested.spec.ts | 143 ++++++++++++++++++ .../block/content/paragraphContent/index.ts | 9 +- 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 packages/muya/src/block/content/paragraphContent/__tests__/enterConvertNested.spec.ts diff --git a/packages/muya/src/block/content/paragraphContent/__tests__/enterConvertNested.spec.ts b/packages/muya/src/block/content/paragraphContent/__tests__/enterConvertNested.spec.ts new file mode 100644 index 0000000000..ee22e8a9f2 --- /dev/null +++ b/packages/muya/src/block/content/paragraphContent/__tests__/enterConvertNested.spec.ts @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom + +import type Content from '../../../base/content'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../../../muya'; + +// ENTER-CONVERT INSIDE CONTAINERS — pressing Enter on a paragraph whose text is +// a code-fence trigger (```` ```lang ````) must convert it to a fenced +// code-block IN PLACE, even when the paragraph lives inside a block-quote or a +// list item. The conversion replaces the paragraph node within its container, +// so the code-block stays nested (matching the legacy muyajs behaviour). Before +// this, the block-quote/list Enter handlers only split or unwrapped the +// paragraph and never reached `_enterConvert`, so ```` ```js ```` + Enter +// produced a plain paragraph instead of a code-block. + +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) { + const host = bootedHosts.pop()!; + host.remove(); + } + document.getSelection()?.removeAllRanges(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + 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; +} + +function contentByText(muya: Muya, text: string): Content { + let target: Content | null = null; + const visit = (block: { + text?: string; + constructor: { blockName?: string }; + children?: { forEach: (cb: (b: unknown) => void) => void }; + }) => { + if (block.constructor.blockName?.endsWith('.content') && block.text === text) + target = block as unknown as Content; + block.children?.forEach(b => visit(b as typeof block)); + }; + visit(muya.editor.scrollPage as unknown as Parameters[0]); + if (!target) + throw new Error(`content block with text "${text}" not found`); + return target; +} + +function enterWithText(muya: Muya, content: Content, text: string): { preventDefault: ReturnType } { + muya.editor.activeContentBlock = content; + content.text = text; + content.update(); + const offset = content.text.length; + content.setCursor(offset, offset, true); + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + shiftKey: false, + key: 'Enter', + } as unknown as KeyboardEvent & { preventDefault: ReturnType }; + content.enterHandler(event); + return event; +} + +function flush(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +interface IStateNode { + name: string; + meta?: { lang?: string; type?: string }; + children?: IStateNode[]; +} + +describe('enter on ```` ```js ```` inside a block-quote — converts to a nested code-block', () => { + it('replaces the paragraph with a code-block kept inside the block-quote', async () => { + const muya = bootMuya('> seed\n'); + const content = contentByText(muya, 'seed'); + + enterWithText(muya, content, '```js'); + + await flush(); + const state = muya.getState() as IStateNode[]; + expect(state.length).toBe(1); + expect(state[0].name).toBe('block-quote'); + const children = state[0].children!; + expect(children.length).toBe(1); + expect(children[0].name).toBe('code-block'); + expect(children[0].meta!.lang).toBe('js'); + expect(children[0].meta!.type).toBe('fenced'); + }); +}); + +describe('enter on ```` ```js ```` inside a list item — converts to a nested code-block', () => { + it('replaces the paragraph with a code-block kept inside the list item', async () => { + const muya = bootMuya('- seed\n'); + const content = contentByText(muya, 'seed'); + + enterWithText(muya, content, '```js'); + + await flush(); + const state = muya.getState() as IStateNode[]; + expect(state.length).toBe(1); + expect(state[0].name).toBe('bullet-list'); + const listItem = state[0].children![0]; + expect(listItem.name).toBe('list-item'); + expect(listItem.children!.length).toBe(1); + expect(listItem.children![0].name).toBe('code-block'); + expect(listItem.children![0].meta!.lang).toBe('js'); + }); +}); + +describe('enter on ```` ```mermaid ```` inside a block-quote — converts to a nested diagram', () => { + it('replaces the paragraph with a diagram kept inside the block-quote', async () => { + const muya = bootMuya('> seed\n'); + const content = contentByText(muya, 'seed'); + + enterWithText(muya, content, '```mermaid'); + + await flush(); + const state = muya.getState() as IStateNode[]; + expect(state[0].name).toBe('block-quote'); + const children = state[0].children!; + expect(children.length).toBe(1); + expect(children[0].name).toBe('diagram'); + expect(children[0].meta!.type).toBe('mermaid'); + }); +}); diff --git a/packages/muya/src/block/content/paragraphContent/index.ts b/packages/muya/src/block/content/paragraphContent/index.ts index 28ba820013..8aa0b162d1 100644 --- a/packages/muya/src/block/content/paragraphContent/index.ts +++ b/packages/muya/src/block/content/paragraphContent/index.ts @@ -40,6 +40,7 @@ enum UnindentType { const debug = logger('paragraph:content'); const HTML_BLOCK_REG = /^<([a-z\d-]+)(?=\s|>)[^<>]*>$/i; +const CODE_BLOCK_REG = /(^ {0,3}`{3,})([^` ]*)/; const BOTH_SIDES_FORMATS = [ 'strong', @@ -227,7 +228,7 @@ class ParagraphContent extends Format { const TABLE_BLOCK_REG = /^\|.*?(\\*)\|.*?(\\*)\|/; const MATH_BLOCK_REG = /^\$\$/; const { text } = this; - const codeBlockToken = text.match(/(^ {0,3}`{3,})([^` ]*)/); + const codeBlockToken = text.match(CODE_BLOCK_REG); const tableMatch = TABLE_BLOCK_REG.exec(text); const htmlMatch = HTML_BLOCK_REG.exec(text); const mathMath = MATH_BLOCK_REG.exec(text); @@ -523,6 +524,12 @@ class ParagraphContent extends Format { if (event.shiftKey) return this.shiftEnterHandler(event); + // A code fence (```` ```lang ````) always converts the paragraph in + // place, even inside a block-quote or list item — the resulting + // code-block stays nested in its container (matches muyajs). + if (CODE_BLOCK_REG.test(this.text)) + return this._enterConvert(event); + const type = this._paragraphParentType(); if (type === 'block-quote') From 613effc1abfa639b2adbf9e90b060c73e69376d6 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 25 Jun 2026 00:07:01 +0800 Subject: [PATCH 4/5] fix(muya): prevent undo/redo exceptions from permanently disabling history (#4685) (#4692) * fix(muya): release the undo/redo guard on exception so history can't wedge (#4685) History._change set `_ignoreChange = true` before applying an undo/redo op and only cleared it on the line after the apply. If the apply threw, the flag stayed true forever: the json-change listener then ignored all later user edits, so nothing was pushed to the undo stack and Undo was permanently dead for the session. `clear()` did not reset the flag either, so reloading the document could not recover the recorder. Wrap the apply in try/finally so the guard is always released, and reset it in clear() as a recovery hatch. Co-Authored-By: Claude Opus 4.8 (1M context) * test(muya): e2e regression for the #4685 redo-after-cross-block repro Drives the reported sequence (paragraph -> ATX heading -> bullet list, undo back to start, redo many times) through real keystrokes and asserts no renderer error, lossless content restore, and that a later edit is still undoable. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../editing/undo-redo-wedge-4685.spec.ts | 57 ++++++++++++ .../__tests__/historyWedgeRecovery.spec.ts | 88 +++++++++++++++++++ packages/muya/src/history/index.ts | 15 ++-- 3 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 packages/muya/e2e/tests/editing/undo-redo-wedge-4685.spec.ts create mode 100644 packages/muya/src/history/__tests__/historyWedgeRecovery.spec.ts diff --git a/packages/muya/e2e/tests/editing/undo-redo-wedge-4685.spec.ts b/packages/muya/e2e/tests/editing/undo-redo-wedge-4685.spec.ts new file mode 100644 index 0000000000..54a41da170 --- /dev/null +++ b/packages/muya/e2e/tests/editing/undo-redo-wedge-4685.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from '../fixtures/muya'; +import { getMarkdown } from '../helpers/api'; +import { slowType } from '../helpers/keyboard'; +import { editor } from '../helpers/selectors'; + +// #4685: redo after a cross-block paragraph -> ATX heading -> bullet list +// sequence threw an unhandled renderer error, corrupted the document, and +// could leave History._ignoreChange stuck true so later edits were never +// recorded (Undo permanently dead). +test.describe('#4685 redo after cross-block edits', () => { + test('redo restores content without crashing and Undo keeps working', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', err => pageErrors.push(err.message)); + + await page.evaluate(() => window.muya!.setContent('start')); + const root = page.locator(editor.root); + await page.locator(editor.paragraph).first().click(); + await page.keyboard.press('End'); + + await page.keyboard.press('Enter'); + await slowType(page, '# heading'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await slowType(page, '- list item'); + + await expect(root.locator(editor.atxHeading)).toContainText('heading'); + await expect(root.locator(editor.bulletList)).toContainText('list item'); + + // Undo all the way back to "start". + for (let i = 0; i < 20; i++) { + const md = await getMarkdown(page); + if (md.trim() === 'start') + break; + await page.evaluate(() => window.muya!.undo()); + } + expect((await getMarkdown(page)).trim()).toBe('start'); + + // Redo many times — the reported crash path. + for (let i = 0; i < 15; i++) + await page.evaluate(() => window.muya!.redo()); + + const redone = await getMarkdown(page); + expect(pageErrors, `renderer errors: ${pageErrors.join(' | ')}`).toEqual([]); + expect(redone).toContain('# heading'); + expect(redone).toContain('- list item'); + + // History must not be wedged: a fresh edit has to be undoable. + await page.locator(editor.paragraph).first().click(); + await page.keyboard.press('End'); + await slowType(page, ' MORE'); + await expect(page.locator(editor.paragraph).first()).toContainText('start MORE'); + + await page.evaluate(() => window.muya!.undo()); + await expect(page.locator(editor.paragraph).first()).not.toContainText('MORE'); + expect(pageErrors, `renderer errors: ${pageErrors.join(' | ')}`).toEqual([]); + }); +}); diff --git a/packages/muya/src/history/__tests__/historyWedgeRecovery.spec.ts b/packages/muya/src/history/__tests__/historyWedgeRecovery.spec.ts new file mode 100644 index 0000000000..1daddfb5e2 --- /dev/null +++ b/packages/muya/src/history/__tests__/historyWedgeRecovery.spec.ts @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom + +import type Format from '../../block/base/format'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../muya'; + +const bootedHosts: HTMLElement[] = []; + +beforeEach(() => { + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): Muya { + 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; +} + +function firstContent(muya: Muya): Format { + return muya.editor.scrollPage!.firstContentInDescendant() as unknown as Format; +} + +function undoDepth(muya: Muya): number { + // @ts-expect-error — reach into the private stack for test assertions. + return muya.editor.history._stack.undo.length; +} + +function ignoreChange(muya: Muya): boolean { + // @ts-expect-error — reach into the private flag for test assertions. + return muya.editor.history._ignoreChange; +} + +async function editText(muya: Muya, text: string): Promise { + const content = firstContent(muya) as unknown as { text: string; checkInlineUpdate: () => void }; + content.text = text; + content.checkInlineUpdate(); + await vi.waitFor(() => { + expect(muya.getMarkdown()).toContain(text); + }); +} + +describe('history recorder recovery after an undo/redo exception', () => { + it('resets _ignoreChange and keeps recording when updateContents throws', async () => { + const muya = bootMuya('seed\n'); + + await editText(muya, 'seedX'); + await vi.waitFor(() => expect(undoDepth(muya)).toBe(1)); + + const spy = vi + .spyOn(muya.editor, 'updateContents') + .mockImplementationOnce(() => { + throw new Error('simulated replay failure'); + }); + + expect(() => muya.undo()).toThrow('simulated replay failure'); + + // The recorder must not be wedged: the in-flight guard has to be + // released even though the apply threw. + expect(ignoreChange(muya)).toBe(false); + + spy.mockRestore(); + + // A subsequent edit must still reach the undo stack. + await editText(muya, 'seedXY'); + await vi.waitFor(() => expect(undoDepth(muya)).toBeGreaterThan(0)); + }); + + it('clear() resets a stuck _ignoreChange guard', () => { + const muya = bootMuya('seed\n'); + + // Simulate a guard left stuck on by a prior exception. + // @ts-expect-error — force the private flag for the test. + muya.editor.history._ignoreChange = true; + + muya.editor.history.clear(); + + expect(ignoreChange(muya)).toBe(false); + }); +}); diff --git a/packages/muya/src/history/index.ts b/packages/muya/src/history/index.ts index a29fc43a01..e7033fc523 100644 --- a/packages/muya/src/history/index.ts +++ b/packages/muya/src/history/index.ts @@ -164,11 +164,15 @@ class History { this._lastRecorded = 0; this._ignoreChange = true; - if (rebuild) - this._muya.editor.rebuildContents(operation, selection, 'user'); - else - this._muya.editor.updateContents(operation, selection, 'user'); - this._ignoreChange = false; + try { + if (rebuild) + this._muya.editor.rebuildContents(operation, selection, 'user'); + else + this._muya.editor.updateContents(operation, selection, 'user'); + } + finally { + this._ignoreChange = false; + } this._getLastSelection(); } @@ -177,6 +181,7 @@ class History { this._stack = { undo: [], redo: [] }; this._selectionStack = []; this._lastRecorded = 0; + this._ignoreChange = false; } getHistory(): ISerializedHistory { From 4ebf0ab8816e31c52a96f46ac193c3813027f75d Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 25 Jun 2026 00:23:20 +0800 Subject: [PATCH 5/5] fix(muya): auto-hide inline markers when holding an arrow key out of a token (#4693) (#4694) Inline emphasis/strong markers are revealed/hidden in `Format.keyupHandler` based on the new caret position. Holding an arrow key fires many keydown events but only a single keyup on release, so the caret can leap clear of a token in one step and `checkNeedRender(newPosition)` no longer detects the token left behind, leaving its markers stuck revealed. Tapping the key step by step worked because each intermediate keyup re-rendered while still adjacent to the token. Also check the previously committed selection (the no-arg `checkNeedRender` default), mirroring the existing guard in `clickHandler`, so the stale markers collapse. Adds a real-browser e2e covering both tap and held-key (autorepeat) navigation. Co-authored-by: Claude Opus 4.8 (1M context) --- .../tests/inline/marker-arrow-hold.spec.ts | 62 +++++++++++++++++++ packages/muya/src/block/base/format.ts | 6 +- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 packages/muya/e2e/tests/inline/marker-arrow-hold.spec.ts diff --git a/packages/muya/e2e/tests/inline/marker-arrow-hold.spec.ts b/packages/muya/e2e/tests/inline/marker-arrow-hold.spec.ts new file mode 100644 index 0000000000..9759bef7cd --- /dev/null +++ b/packages/muya/e2e/tests/inline/marker-arrow-hold.spec.ts @@ -0,0 +1,62 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures/muya'; +import { editor } from '../helpers/selectors'; + +// `**hello**bar`: source offsets — `**`(0-2) `hello`(2-7) `**`(7-9) `bar`(9-12). +// The strong token spans [0, 9]. With the caret at offset 6 (`**hell|o**`) the +// caret is inside the token, so the `**` markers render revealed (`.mu-gray`). +// Moving the caret all the way to the end (offset 12, after `bar`) takes it +// clear of the token, so the markers must collapse back to `.mu-hide`. +const strongMarkerHidden = `${editor.paragraph} span.mu-remove.mu-hide`; +const strongMarkerGray = `${editor.paragraph} span.mu-remove.mu-gray`; + +async function setupCaretInsideStrong(page: Page) { + await page.evaluate(() => window.muya!.setContent('**hello**bar')); + // Focus the contenteditable so subsequent keyboard events drive the caret. + await page.locator(editor.paragraph).first().click(); + // Park the caret at offset 6 (`**hell|o**bar`), inside the strong token, so + // the markers render revealed. + await page.evaluate(() => { + const block = window.muya!.editor.scrollPage!.firstContentInDescendant(); + block.setCursor(6, 6, true); + }); + // Sanity: markers are revealed while the caret sits inside the token. + await expect(page.locator(strongMarkerGray)).toHaveCount(2); + await expect(page.locator(strongMarkerHidden)).toHaveCount(0); +} + +async function caretOffset(page: Page): Promise { + return page.evaluate(() => { + const sel = window.muya!.editor.selection.getSelection(); + return sel ? sel.anchor.offset : null; + }); +} + +test.describe('emphasis markers auto-hide when arrowing out of the token', () => { + test('tapping ArrowRight to the line end hides the markers', async ({ page }) => { + await setupCaretInsideStrong(page); + + // Tap (down + up per press) six times: offset 6 -> 12 (after `bar`). + for (let i = 0; i < 6; i++) + await page.keyboard.press('ArrowRight'); + + expect(await caretOffset(page)).toBe(12); + await expect(page.locator(strongMarkerHidden)).toHaveCount(2); + await expect(page.locator(strongMarkerGray)).toHaveCount(0); + }); + + test('holding ArrowRight to the line end hides the markers', async ({ page }) => { + await setupCaretInsideStrong(page); + + // Hold: six trusted keydown events (autoRepeat) and a single keyup on + // release — the caret travels offset 6 -> 12 (after `bar`) but only one + // keyup fires, exactly like physically holding the arrow key. + for (let i = 0; i < 6; i++) + await page.keyboard.down('ArrowRight'); + await page.keyboard.up('ArrowRight'); + + expect(await caretOffset(page)).toBe(12); + await expect(page.locator(strongMarkerHidden)).toHaveCount(2); + await expect(page.locator(strongMarkerGray)).toHaveCount(0); + }); +}); diff --git a/packages/muya/src/block/base/format.ts b/packages/muya/src/block/base/format.ts index 66e64ae7db..5272586082 100644 --- a/packages/muya/src/block/base/format.ts +++ b/packages/muya/src/block/base/format.ts @@ -554,7 +554,11 @@ class Format extends Content { anchor.offset !== oldAnchor?.offset || focus.offset !== oldFocus?.offset ) { - const needUpdate = this.checkNeedRender({ anchor, focus }); + // Also check the previously committed selection (no-arg default): + // a held arrow fires one keyup on release, so the caret can leap + // clear of a token in a single step and leave its markers stuck + // revealed. Mirrors the guard in `clickHandler`. + const needUpdate = this.checkNeedRender({ anchor, focus }) || this.checkNeedRender(); const cursor = { anchor, focus, block: this }; if (needUpdate)