From d6b617ab9a2964734973ab6ce7257f813a21651b Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Mon, 22 Jun 2026 18:19:51 +0800 Subject: [PATCH 1/6] test(muya): e2e regression guard for code-block scroll on typing (#3191) (#4638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3191 (filed against 0.17.1 / the legacy muyajs engine) reported that typing at the end of a long, horizontally-scrolled line in a code block reset the scroll back to the start. This adds an end-to-end test that reproduces the issue's exact steps — long line, caret to end, keep typing — and asserts the scroll stays at the caret after every keystroke. The test passes on the current @muyajs/core engine: the bug does not reproduce (the caret stays in view across re-renders). The spec is kept as a regression guard for the behaviour. Co-authored-by: Claude Opus 4.8 (1M context) --- .../blocks/codeblock-scroll-3191.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/muya/e2e/tests/blocks/codeblock-scroll-3191.spec.ts diff --git a/packages/muya/e2e/tests/blocks/codeblock-scroll-3191.spec.ts b/packages/muya/e2e/tests/blocks/codeblock-scroll-3191.spec.ts new file mode 100644 index 0000000000..d87f3dae4a --- /dev/null +++ b/packages/muya/e2e/tests/blocks/codeblock-scroll-3191.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { loadMarkdown, slowType } from '../helpers/keyboard'; +import { editor } from '../helpers/selectors'; + +// #3191: typing near the end of a long, horizontally-scrolled line in a code +// block reset the scroll back to the start of the line, because every +// keystroke rewrites the content `innerHTML` (which resets the scroll +// container's scrollLeft). The fix preserves and restores the scroll position +// across the re-render. This is a real-browser layout behaviour, so it can +// only be verified end-to-end. + +const LONG_LINE = `const x = "${'a'.repeat(400)}";`; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await loadMarkdown(page, `\`\`\`js\n${LONG_LINE}\n\`\`\`\n`); +}); + +test('continuing to type at the end of a long line keeps the caret in view', async ({ page }) => { + const codeContent = page.locator(editor.codeContent).first(); + await codeContent.click(); + + // The `.mu-code` element is the horizontal overflow container. + const scroller = page.locator(editor.codeBlock).locator('.mu-code').first(); + + // Go to the end of the line (the issue's step 2). + await page.keyboard.press('End'); + + const maxScroll = await scroller.evaluate((el) => { + el.scrollLeft = el.scrollWidth; + return el.scrollLeft; + }); + expect(maxScroll, 'the line should overflow horizontally').toBeGreaterThan(100); + + // Step 3: keep typing characters. After each keystroke the scroll must + // stay near the caret (the end), not jump back to the start. + for (const ch of ['1', '2', '3', '4', '5']) { + await slowType(page, ch); + const scrollLeft = await scroller.evaluate(el => el.scrollLeft); + expect(scrollLeft, `scroll reset to start after typing "${ch}"`).toBeGreaterThan(maxScroll - 60); + } + + // The typed characters landed at the end of the line. + const text = await codeContent.evaluate(el => el.textContent ?? ''); + expect(text.endsWith('12345')).toBe(true); +}); From 9c8640acfea79b08c1f42d9914b2ee6712ffc0f3 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Mon, 22 Jun 2026 18:19:56 +0800 Subject: [PATCH 2/6] fix(muya): align serialized table columns by visual width (#1983) (#4636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table serializer measured and padded column widths with String.prototype.length (UTF-16 code units). Cells containing combining marks (e.g. IPA `aʊ̯x`, whose code units exceed its visual width) were over-measured, and East-Asian wide characters were under-measured, so prettified table source came out misaligned. Add a dependency-free `stringWidth` util (zero-width combining marks and formatting characters count 0; East-Asian wide / fullwidth code points count 2; iterates by code point so astral characters are measured once) and use it for both the column-width computation and the cell padding in `_serializeTable`. Padding now fills to the visual width instead of truncating by code-unit index. Co-authored-by: Claude Opus 4.8 (1M context) --- .../state/__tests__/stateToMarkdown.spec.ts | 36 +++++++++++ packages/muya/src/state/stateToMarkdown.ts | 10 +++- .../src/utils/__tests__/stringWidth.spec.ts | 46 ++++++++++++++ packages/muya/src/utils/stringWidth.ts | 60 +++++++++++++++++++ 4 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 packages/muya/src/utils/__tests__/stringWidth.spec.ts create mode 100644 packages/muya/src/utils/stringWidth.ts diff --git a/packages/muya/src/state/__tests__/stateToMarkdown.spec.ts b/packages/muya/src/state/__tests__/stateToMarkdown.spec.ts index 03d1096645..59d485bd0d 100644 --- a/packages/muya/src/state/__tests__/stateToMarkdown.spec.ts +++ b/packages/muya/src/state/__tests__/stateToMarkdown.spec.ts @@ -74,6 +74,42 @@ describe('serializeTable — row width mismatch', () => { }); }); +// Regression for #1983. Column padding used String.prototype.length, so a +// cell containing a combining mark (its code units exceed its visual width) +// was over-measured and broke alignment. Padding must use visual column width. +describe('serializeTable — visual column width (#1983)', () => { + it('aligns a column whose cells contain combining marks', () => { + const state = table([ + row([cell('A')]), + row([cell('nɔx')]), + row([cell('aʊ̯x')]), // a, ʊ, U+032F combining mark, x — 4 code units, 3 columns + ]); + + const md = new ExportMarkdown().generate([state]); + const lines = md.split('\n'); + + expect(lines[0]).toBe('| A |'); + expect(lines[1]).toBe('| --- |'); + expect(lines[2]).toBe('| nɔx |'); + expect(lines[3]).toBe('| aʊ̯x |'); + }); + + it('widens a column to fit East-Asian wide characters', () => { + const state = table([ + row([cell('id')]), + row([cell('中文')]), // two wide characters → 4 columns + ]); + + const md = new ExportMarkdown().generate([state]); + const lines = md.split('\n'); + + // Column width is max(visual): 'id' = 2, '中文' = 4 → inner width 4. + expect(lines[0]).toBe('| id |'); + expect(lines[1]).toBe('| ---- |'); + expect(lines[2]).toBe('| 中文 |'); + }); +}); + // `align` lives on every cell's `meta.align` ('none' | 'left' | 'center' | // 'right'). The serializer renders the delimiter row from the *header* row's // cell aligns: left → ':---', center → ':---:', right → '---:', none → '---'. diff --git a/packages/muya/src/state/stateToMarkdown.ts b/packages/muya/src/state/stateToMarkdown.ts index 6fc1c0f0de..b8faedf6e5 100644 --- a/packages/muya/src/state/stateToMarkdown.ts +++ b/packages/muya/src/state/stateToMarkdown.ts @@ -32,6 +32,7 @@ import type { import { deepClone } from '../utils'; import logger from '../utils/logger'; +import stringWidth from '../utils/stringWidth'; import { isAnyListState } from './types'; const debug = logger('export markdown: '); @@ -433,7 +434,7 @@ export default class ExportMarkdown { for (j = 0; j < cells; j++) { columnWidth[j].width = Math.max( columnWidth[j].width, - tableData[i][j].length + 2, + stringWidth(tableData[i][j]) + 2, ); // add 2, because have two space around text } } @@ -445,9 +446,12 @@ export default class ExportMarkdown { r .slice(0, columnWidth.length) .map((cell, j) => { - const raw = ` ${cell + ' '.repeat(columnWidth[j].width)}`; + // Pad by visual column width, not code-unit length, + // so combining marks and wide characters stay + // aligned (#1983). One leading space + cell + fill. + const fill = columnWidth[j].width - 1 - stringWidth(cell); - return raw.substring(0, columnWidth[j].width); + return ` ${cell}${' '.repeat(Math.max(fill, 0))}`; }) .join('|') }|`; diff --git a/packages/muya/src/utils/__tests__/stringWidth.spec.ts b/packages/muya/src/utils/__tests__/stringWidth.spec.ts new file mode 100644 index 0000000000..d752532db8 --- /dev/null +++ b/packages/muya/src/utils/__tests__/stringWidth.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import stringWidth from '../stringWidth'; + +// `stringWidth` returns the number of monospace columns a string occupies, used +// by the markdown table serializer to align columns (#1983). It must count +// combining marks as zero width and East-Asian wide / fullwidth code points as +// two, rather than counting UTF-16 code units like String.prototype.length. + +describe('stringWidth', () => { + it('counts plain ASCII as one column per character', () => { + expect(stringWidth('abc')).toBe(3); + expect(stringWidth('')).toBe(0); + }); + + it('counts a combining diacritic as zero width (#1983 IPA case)', () => { + // `aʊ̯x` = a, ʊ, COMBINING INVERTED BREVE BELOW (U+032F), x. + // length is 4 code units but it occupies 3 columns. + const ipa = 'aʊ̯x'; + expect(ipa.length).toBe(4); + expect(stringWidth(ipa)).toBe(3); + // and it must align with the other plain 3-column cell. + expect(stringWidth('nɔx')).toBe(3); + }); + + it('counts East-Asian wide characters as two columns', () => { + expect(stringWidth('中')).toBe(2); + expect(stringWidth('中文')).toBe(4); + expect(stringWidth('a中')).toBe(3); + }); + + it('counts fullwidth forms as two columns', () => { + // U+FF21 FULLWIDTH LATIN CAPITAL LETTER A + expect(stringWidth('A')).toBe(2); + }); + + it('counts zero-width formatting characters as zero', () => { + expect(stringWidth('a​b')).toBe(2); + }); + + it('counts astral wide code points (CJK Ext B) by code point, not surrogate pair', () => { + // U+20000 (𠀀) is a single wide ideograph encoded as a surrogate pair. + const ext = '\u{20000}'; + expect(ext.length).toBe(2); + expect(stringWidth(ext)).toBe(2); + }); +}); diff --git a/packages/muya/src/utils/stringWidth.ts b/packages/muya/src/utils/stringWidth.ts new file mode 100644 index 0000000000..943eadb2f1 --- /dev/null +++ b/packages/muya/src/utils/stringWidth.ts @@ -0,0 +1,60 @@ +// East-Asian Wide (W) and Fullwidth (F) code-point ranges. JavaScript's Unicode +// property escapes do not expose East_Asian_Width, so the ranges are inlined +// from the Unicode East Asian Width table. Each pair is an inclusive [start, +// end] range whose code points occupy two monospace columns. +const WIDE_RANGES: readonly [number, number][] = [ + [0x1100, 0x115F], // Hangul Jamo + [0x2E80, 0x303E], // CJK Radicals .. Kangxi Radicals .. CJK symbols + [0x3041, 0x33FF], // Hiragana, Katakana, CJK symbols and punctuation + [0x3400, 0x4DBF], // CJK Unified Ideographs Extension A + [0x4E00, 0x9FFF], // CJK Unified Ideographs + [0xA000, 0xA4CF], // Yi Syllables / Radicals + [0xAC00, 0xD7A3], // Hangul Syllables + [0xF900, 0xFAFF], // CJK Compatibility Ideographs + [0xFE10, 0xFE19], // Vertical forms + [0xFE30, 0xFE6F], // CJK Compatibility Forms / Small Form Variants + [0xFF00, 0xFF60], // Fullwidth Forms + [0xFFE0, 0xFFE6], // Fullwidth signs + [0x1F300, 0x1F64F], // Emoticons / Misc symbols and pictographs + [0x1F900, 0x1F9FF], // Supplemental symbols and pictographs + [0x20000, 0x3FFFD], // CJK Unified Ideographs Extension B and beyond +]; + +// Nonspacing (Mn) and enclosing (Me) combining marks render with zero advance. +const COMBINING_MARK = /\p{Mn}|\p{Me}/u; + +// Format characters that occupy no columns (zero-width space family and BOM). +function isZeroWidth(codePoint: number): boolean { + return ( + codePoint === 0x200B // zero width space + || (codePoint >= 0x200C && codePoint <= 0x200F) // ZWNJ/ZWJ/marks + || codePoint === 0xFEFF // zero width no-break space (BOM) + ); +} + +function isWide(codePoint: number): boolean { + return WIDE_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end); +} + +/** + * The number of monospace columns `str` occupies. Combining marks and + * zero-width formatting characters contribute 0; East-Asian wide / fullwidth + * code points contribute 2; everything else contributes 1. + * + * Iterating with `for...of` walks the string by code point, so astral + * characters (surrogate pairs) are measured once rather than per code unit. + */ +export default function stringWidth(str: string): number { + let width = 0; + + for (const char of str) { + const codePoint = char.codePointAt(0)!; + + if (isZeroWidth(codePoint) || COMBINING_MARK.test(char)) + continue; + + width += isWide(codePoint) ? 2 : 1; + } + + return width; +} From 72886d115e3ad4343afa44fb459284793bf2cee5 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Mon, 22 Jun 2026 18:20:16 +0800 Subject: [PATCH 3/6] fix(muya): insert ordinary spaces (U+0020) for Tab, not non-breaking spaces (#3273) (#4634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Content.insertTab` and the `ParagraphContent` override wrote `String.fromCharCode(160)` (U+00A0, NBSP) `tabSize` times into the block text. Since the block text is the markdown source, Tab indentation leaked non-breaking spaces into saved files, where many tools do not treat U+00A0 as whitespace. `.mu-content` is `white-space: pre-wrap`, so ordinary spaces are already preserved visually — NBSP was unnecessary. The legacy muyajs engine used charCode 32; this restores that behavior. Updates the characterization spec that previously locked in the NBSP behavior to assert ordinary spaces, and adds a regression test ensuring no U+00A0 reaches the serialized markdown. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/muya/src/block/base/content.ts | 2 +- .../__tests__/insertTabAndIndent.spec.ts | 42 +++++++++++++------ .../block/content/paragraphContent/index.ts | 2 +- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/muya/src/block/base/content.ts b/packages/muya/src/block/base/content.ts index 78a43ee4cf..b6a3c4d489 100644 --- a/packages/muya/src/block/base/content.ts +++ b/packages/muya/src/block/base/content.ts @@ -609,7 +609,7 @@ class Content extends TreeNode { protected insertTab() { const { muya, text } = this; const { tabSize } = muya.options; - const tabCharacter = String.fromCharCode(160).repeat(tabSize); + const tabCharacter = String.fromCharCode(32).repeat(tabSize); const { start, end } = this.getCursor()!; if (this.isCollapsed) { diff --git a/packages/muya/src/block/content/paragraphContent/__tests__/insertTabAndIndent.spec.ts b/packages/muya/src/block/content/paragraphContent/__tests__/insertTabAndIndent.spec.ts index 98c431cdbf..8a25cf64d1 100644 --- a/packages/muya/src/block/content/paragraphContent/__tests__/insertTabAndIndent.spec.ts +++ b/packages/muya/src/block/content/paragraphContent/__tests__/insertTabAndIndent.spec.ts @@ -6,9 +6,12 @@ import { Muya } from '../../../../muya'; // Characterization of ParagraphContent.tabHandler's three plain-Tab branches // (src/block/content/paragraphContent/index.ts): -// 1. insertTab() — a normal paragraph gains `tabSize` non-breaking -// spaces (U+00A0) at the caret and the caret -// advances by that width. +// 1. insertTab() — a normal paragraph gains `tabSize` ordinary +// spaces (U+0020) at the caret and the caret +// advances by that width. `.mu-content` is +// `white-space: pre-wrap`, so ordinary spaces are +// preserved visually without polluting the saved +// markdown with non-breaking spaces (#3273). // 2. _indentListItem() / _unindentListItem() — Tab nests a list item under // its predecessor; Shift+Tab lifts it back out. // 3. _checkCursorAtEndFormat() — with the caret just inside a closing inline @@ -24,8 +27,8 @@ const bootedHosts: HTMLElement[] = []; let originalVersion: string | undefined; let hadVersion = false; -// A non-breaking space — the character insertTab repeats `tabSize` times. -const NBSP = String.fromCharCode(160); +// An ordinary space (U+0020) — the character insertTab repeats `tabSize` times. +const SPACE = String.fromCharCode(32); beforeEach(() => { hadVersion = 'MUYA_VERSION' in window; @@ -97,7 +100,7 @@ function blockText(state: ReturnType, index: number): string { } describe('paragraphContent.tabHandler — insertTab in a plain paragraph', () => { - it('inserts `tabSize` non-breaking spaces and advances the caret by that width (default 4)', async () => { + it('inserts `tabSize` ordinary spaces and advances the caret by that width (default 4)', async () => { const muya = bootMuya('hello\n'); expect(muya.options.tabSize).toBe(4); const content = contentByText(muya, 'hello'); @@ -106,17 +109,32 @@ describe('paragraphContent.tabHandler — insertTab in a plain paragraph', () => await flush(); const text = blockText(muya.getState(), 0); - // `hello` + four U+00A0 spaces. - expect(text).toBe(`hello${NBSP.repeat(4)}`); + // `hello` + four U+0020 spaces. + expect(text).toBe(`hello${SPACE.repeat(4)}`); expect(text.length).toBe(9); - // The inserted run is non-breaking spaces, not ordinary spaces. - expect([...text.slice(5)].every(ch => ch.charCodeAt(0) === 160)).toBe(true); + // The inserted run is ordinary spaces (U+0020), not non-breaking spaces. + expect([...text.slice(5)].every(ch => ch.charCodeAt(0) === 32)).toBe(true); const cursor = content.getCursor(); expect(cursor).not.toBeNull(); expect(cursor!.start.offset).toBe(9); }); + // #3273: Tab used to insert U+00A0 (charCode 160), which leaked into the + // saved markdown and was not treated as whitespace by other tools. The + // serialized document must contain only ordinary spaces. + it('does not write any non-breaking space into the serialized markdown', async () => { + const muya = bootMuya('hello\n'); + const content = contentByText(muya, 'hello'); + + tabAt(muya, content, 5); + + await flush(); + const markdown = muya.getMarkdown(); + expect(markdown).toContain(`hello${SPACE.repeat(4)}`); + expect([...markdown].some(ch => ch.charCodeAt(0) === 160)).toBe(false); + }); + it('honors a custom tabSize of 2 (narrower insert, caret advances by 2)', async () => { const muya = bootMuya('hello\n', { tabSize: 2 }); expect(muya.options.tabSize).toBe(2); @@ -126,7 +144,7 @@ describe('paragraphContent.tabHandler — insertTab in a plain paragraph', () => await flush(); const text = blockText(muya.getState(), 0); - expect(text).toBe(`hello${NBSP.repeat(2)}`); + expect(text).toBe(`hello${SPACE.repeat(2)}`); expect(text.length).toBe(7); const cursor = content.getCursor(); @@ -222,7 +240,7 @@ describe('paragraphContent.tabHandler — jump past a closing inline marker', () await flush(); const text = blockText(muya.getState(), 0); // Caret was past the closing marker, so insertTab runs. - expect(text).toBe(`**bold**${NBSP.repeat(4)}`); + expect(text).toBe(`**bold**${SPACE.repeat(4)}`); expect(content.getCursor()!.start.offset).toBe(12); }); }); diff --git a/packages/muya/src/block/content/paragraphContent/index.ts b/packages/muya/src/block/content/paragraphContent/index.ts index 115c9a12e0..0d97a717de 100644 --- a/packages/muya/src/block/content/paragraphContent/index.ts +++ b/packages/muya/src/block/content/paragraphContent/index.ts @@ -792,7 +792,7 @@ class ParagraphContent extends Format { protected override insertTab() { const { muya, text } = this; const { tabSize } = muya.options; - const tabCharacter = String.fromCharCode(160).repeat(tabSize); + const tabCharacter = String.fromCharCode(32).repeat(tabSize); const { start, end } = this.getCursor()!; if (this.isCollapsed) { From df15ad3006e72e1266d0ce3d4bd4dc57e01e4e3b Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Mon, 22 Jun 2026 18:21:12 +0800 Subject: [PATCH 4/6] fix(muya): convert typed diagram fences to diagram blocks on Enter (#2177) (#4635) Typing a diagram fence (```` ```mermaid ````, ```` ```vega-lite ````, plantuml/flowchart/sequence) and pressing Enter produced a fenced code block, even though loading the same fence from a file correctly yields a diagram block. The live-typing path in `ParagraphContent._enterConvert` always built a `code-block`, while the file-load path in `markdownToState` routes the five diagram languages to a `diagram` state. Mirror the parser: when the fence info string is a diagram language, build a diagram block (meta.type = the language, meta.lang = `json` for vega-lite else `yaml`) and land the caret inside it. Non-diagram fences are unchanged. Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/enterConvert.spec.ts | 56 +++++++++++++++++++ .../block/content/paragraphContent/index.ts | 55 +++++++++++++----- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/packages/muya/src/block/content/paragraphContent/__tests__/enterConvert.spec.ts b/packages/muya/src/block/content/paragraphContent/__tests__/enterConvert.spec.ts index 5fce51ca2f..39b5f2ab28 100644 --- a/packages/muya/src/block/content/paragraphContent/__tests__/enterConvert.spec.ts +++ b/packages/muya/src/block/content/paragraphContent/__tests__/enterConvert.spec.ts @@ -164,6 +164,62 @@ describe('enter on ```` ```js ```` — converts to a fenced code-block', () => { }); }); +// #2177: typing ```` ```mermaid ```` (or any diagram language) and pressing +// Enter must produce a diagram block, the same as loading that fence from a +// file. The file-load path (markdownToState) already routes the five diagram +// languages to a `diagram` state; the live-typing path must match. +describe('enter on ```` ```mermaid ```` — converts to a diagram block', () => { + it('replaces the paragraph with a single diagram block (not a code-block)', async () => { + const muya = bootMuya('seed\n'); + const content = contentByText(muya, 'seed'); + + enterWithText(muya, content, '```mermaid'); + + await flush(); + const state = muya.getState(); + expect(state.length).toBe(1); + expect(state[0].name).toBe('diagram'); + }); + + it('records meta.type === "mermaid" and meta.lang === "yaml"', async () => { + const muya = bootMuya('seed\n'); + const content = contentByText(muya, 'seed'); + + enterWithText(muya, content, '```mermaid'); + + await flush(); + const state = muya.getState(); + const meta = (state[0] as { meta: { lang: string; type: string } }).meta; + expect(meta.type).toBe('mermaid'); + expect(meta.lang).toBe('yaml'); + }); + + it('uses meta.lang === "json" for a vega-lite fence', async () => { + const muya = bootMuya('seed\n'); + const content = contentByText(muya, 'seed'); + + enterWithText(muya, content, '```vega-lite'); + + await flush(); + const state = muya.getState(); + expect(state[0].name).toBe('diagram'); + const meta = (state[0] as { meta: { lang: string; type: string } }).meta; + expect(meta.type).toBe('vega-lite'); + expect(meta.lang).toBe('json'); + }); + + it('still converts a non-diagram fence (```js) to a code-block', async () => { + const muya = bootMuya('seed\n'); + const content = contentByText(muya, 'seed'); + + enterWithText(muya, content, '```js'); + + await flush(); + const state = muya.getState(); + expect(state[0].name).toBe('code-block'); + }); +}); + describe('enter on `
` — converts to an html-block', () => { it('replaces the paragraph with a single html-block', async () => { const muya = bootMuya('seed\n'); diff --git a/packages/muya/src/block/content/paragraphContent/index.ts b/packages/muya/src/block/content/paragraphContent/index.ts index 0d97a717de..bc6f8ce069 100644 --- a/packages/muya/src/block/content/paragraphContent/index.ts +++ b/packages/muya/src/block/content/paragraphContent/index.ts @@ -4,6 +4,7 @@ import type { IRenderCursor } from '../../../selection/types'; import type { IBlockQuoteState, IBulletListState, + IDiagramMeta, IListItemState, IOrderListState, IParagraphState, @@ -249,24 +250,48 @@ class ParagraphContent extends Format { mathBlock.firstContentInDescendant().setCursor(0, 0); } else if (codeBlockToken) { - // Convert to code block const lang = codeBlockToken[2]; - const state = { - name: 'code-block', - meta: { - lang, - type: 'fenced', - }, - text: '', - }; - const codeBlock = ScrollPage.loadBlock(state.name).create( - this.muya, - state, - ); + // Diagram fences (```mermaid etc.) become diagram blocks, mirroring + // the file-load path in markdownToState; everything else is a fenced + // code block. + const diagramMatch = /^(?:mermaid|vega-lite|plantuml|flowchart|sequence)$/.exec(lang); + if (diagramMatch) { + const type = lang as IDiagramMeta['type']; + const state = { + name: 'diagram', + text: '', + meta: { + type, + lang: type === 'vega-lite' ? 'json' : 'yaml', + }, + }; + const diagramBlock = ScrollPage.loadBlock(state.name).create( + this.muya, + state, + ); - this.parent!.replaceWith(codeBlock); + this.parent!.replaceWith(diagramBlock); - codeBlock.lastContentInDescendant().setCursor(0, 0); + diagramBlock.firstContentInDescendant().setCursor(0, 0, true); + } + else { + const state = { + name: 'code-block', + meta: { + lang, + type: 'fenced', + }, + text: '', + }; + const codeBlock = ScrollPage.loadBlock(state.name).create( + this.muya, + state, + ); + + this.parent!.replaceWith(codeBlock); + + codeBlock.lastContentInDescendant().setCursor(0, 0); + } } else if ( tableMatch From a5bdc5e53c6c0c37689d88c3e5ce0991298abf1f Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Mon, 22 Jun 2026 18:22:07 +0800 Subject: [PATCH 5/6] fix(muya): keep the caret on Shift+Tab list unindent REPLACEMENT (#3223) (#4637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the list containing the focused item is the first child of an outer list item, `_unindentListItem` takes the REPLACEMENT path: it promotes the paragraph out of the list but — unlike the INDENT path — never called setCursor. The caret was left on the now-detached original block, so it was lost, and the stale selection pointing at a removed node could crash history recording (`Cannot read properties of null` while resolving the block path). Restore the caret onto the promoted paragraph. Extracted the shared "place the caret in a block" step used by both branches into a small `_placeCursorIn` helper. Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/unindentReplacement.spec.ts | 96 +++++++++++++++++++ .../block/content/paragraphContent/index.ts | 16 +++- 2 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 packages/muya/src/block/content/paragraphContent/__tests__/unindentReplacement.spec.ts diff --git a/packages/muya/src/block/content/paragraphContent/__tests__/unindentReplacement.spec.ts b/packages/muya/src/block/content/paragraphContent/__tests__/unindentReplacement.spec.ts new file mode 100644 index 0000000000..f1a6a6081c --- /dev/null +++ b/packages/muya/src/block/content/paragraphContent/__tests__/unindentReplacement.spec.ts @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom + +import type Content from '../../../base/content'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../../../muya'; + +// #3223: Shift+Tab on a list item whose containing list is the FIRST child of +// an outer list item takes the REPLACEMENT path in `_unindentListItem`. That +// branch promoted the paragraph but — unlike the INDENT branch — never called +// setCursor, so the caret was left on the detached original block and lost. +// These tests boot real Muya, drive Shift+Tab through the handler, and assert +// the caret lands on the promoted paragraph. + +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 tabAt(muya: Muya, content: Content, offset: number, shiftKey = false): void { + muya.editor.activeContentBlock = content; + content.setCursor(offset, offset, true); + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + key: 'Tab', + shiftKey, + } as unknown as KeyboardEvent; + content.tabHandler(event); +} + +function flush(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +describe('paragraphContent.tabHandler — Shift+Tab REPLACEMENT keeps the caret (#3223)', () => { + // `- - A\n - B\n`: the outer list item's first (and only) child is the + // inner list [A, B], so `list.prev` is null -> REPLACEMENT path. + it('promotes B and lands the caret on the promoted paragraph', async () => { + const muya = bootMuya('- - A\n - B\n'); + const b = contentByText(muya, 'B'); + + tabAt(muya, b, 1, true); + + await flush(); + + const promoted = contentByText(muya, 'B'); + const cursor = promoted.getCursor(); + expect(cursor).not.toBeNull(); + expect(cursor!.start.offset).toBe(1); + // The promoted block is the active content block. + expect(muya.editor.activeContentBlock).toBe(promoted); + }); +}); diff --git a/packages/muya/src/block/content/paragraphContent/index.ts b/packages/muya/src/block/content/paragraphContent/index.ts index bc6f8ce069..9c92a95bbe 100644 --- a/packages/muya/src/block/content/paragraphContent/index.ts +++ b/packages/muya/src/block/content/paragraphContent/index.ts @@ -674,6 +674,10 @@ class ParagraphContent extends Format { return list && /ol|ul/.test(list.tagName) && listItem.prev; } + private _placeCursorIn(block: Nullable, startOffset: number, endOffset: number) { + block?.firstContentInDescendant()?.setCursor(startOffset, endOffset, true); + } + private _unindentListItem(type: UnindentType) { const { parent } = this; const listItem = parent?.parent; @@ -703,6 +707,8 @@ class ParagraphContent extends Format { list.remove(); else listItem.remove(); + + this._placeCursorIn(paragraph, start.offset, end.offset); } else if (type === UnindentType.INDENT) { const newListItem = listItem.clone() as Parent; @@ -766,11 +772,11 @@ class ParagraphContent extends Format { return; } - const cursorBlock = ( - newListItem.find(cursorParagraphOffset) as Parent - ).firstContentInDescendant(); - - cursorBlock?.setCursor(start.offset, end.offset, true); + this._placeCursorIn( + newListItem.find(cursorParagraphOffset) as Parent, + start.offset, + end.offset, + ); } } From db3f79e107e16fed91cb445e36a4ac8e1ec01dbc Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Mon, 22 Jun 2026 19:17:37 +0800 Subject: [PATCH 6/6] fix(muya): create diagram block from typed ```diagram fence via the language selector (#2177) (#4640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a diagram fence (```mermaid, ```vega-lite, ```plantuml, ```flowchart, ```sequence) and pressing Enter opens the CodeBlockLanguageSelector float, which consumes the Enter (handleContentKeydown) before ParagraphContent's enter handler runs. The selector always built a `code-block`, so the earlier fix in `_enterConvert` (#4635) never ran for the real typing path. Route diagram languages to a diagram block in `selectItem`. The diagram type is read from the text the user typed after the fence, not the selector's fuzzy-matched Prism language — the fuzzy search resolves non-code languages to unrelated entries (e.g. `vega-lite` -> `keepalived`), which would otherwise misdetect the type. Non-diagram fences keep the matched language for syntax highlighting. Verified end-to-end (real Chrome, the desktop typing path) for all five diagram languages plus a ```js code-block control. Co-authored-by: Claude Opus 4.8 (1M context) --- .../tests/blocks/diagram-fence-2177.spec.ts | 51 +++++++++++++++++++ .../src/ui/codeBlockLanguageSelector/index.ts | 50 +++++++++++------- 2 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 packages/muya/e2e/tests/blocks/diagram-fence-2177.spec.ts diff --git a/packages/muya/e2e/tests/blocks/diagram-fence-2177.spec.ts b/packages/muya/e2e/tests/blocks/diagram-fence-2177.spec.ts new file mode 100644 index 0000000000..1254d2de29 --- /dev/null +++ b/packages/muya/e2e/tests/blocks/diagram-fence-2177.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { loadMarkdown, slowType } from '../helpers/keyboard'; +import { editor } from '../helpers/selectors'; + +// #2177: typing a diagram fence (```mermaid etc.) and pressing Enter must +// create a diagram block. The real typing flow goes through the +// CodeBlockLanguageSelector float (it opens on ```lang and consumes Enter), +// so the conversion must happen there — not only in +// ParagraphContent._enterConvert. This end-to-end test drives the real +// keystroke path that the desktop app uses. + +async function typeFenceAndEnter(page: import('@playwright/test').Page, lang: string) { + await page.goto('/'); + await loadMarkdown(page, 'seed\n'); + const seed = page.locator(editor.paragraph).filter({ hasText: 'seed' }).first(); + await seed.click(); + await page.keyboard.press('End'); + for (let i = 0; i < 4; i++) + await page.keyboard.press('Backspace'); + await slowType(page, `\`\`\`${lang}`); + await page.keyboard.press('Enter'); + await page.waitForTimeout(200); + return page.evaluate(() => window.muya!.getState()[0] as { name: string; meta?: { type?: string; lang?: string } }); +} + +test('typing ```mermaid + Enter creates a mermaid diagram block', async ({ page }) => { + const block = await typeFenceAndEnter(page, 'mermaid'); + expect(block.name).toBe('diagram'); + expect(block.meta?.type).toBe('mermaid'); + expect(block.meta?.lang).toBe('yaml'); +}); + +test('typing ```vega-lite + Enter creates a vega-lite diagram block (lang json)', async ({ page }) => { + const block = await typeFenceAndEnter(page, 'vega-lite'); + expect(block.name).toBe('diagram'); + expect(block.meta?.type).toBe('vega-lite'); + expect(block.meta?.lang).toBe('json'); +}); + +for (const lang of ['flowchart', 'sequence', 'plantuml']) { + test(`typing \`\`\`${lang} + Enter creates a ${lang} diagram block`, async ({ page }) => { + const block = await typeFenceAndEnter(page, lang); + expect(block.name).toBe('diagram'); + expect(block.meta?.type).toBe(lang); + }); +} + +test('typing ```js + Enter still creates a fenced code block', async ({ page }) => { + const block = await typeFenceAndEnter(page, 'js'); + expect(block.name).toBe('code-block'); +}); diff --git a/packages/muya/src/ui/codeBlockLanguageSelector/index.ts b/packages/muya/src/ui/codeBlockLanguageSelector/index.ts index 7469b221a9..0a19176654 100644 --- a/packages/muya/src/ui/codeBlockLanguageSelector/index.ts +++ b/packages/muya/src/ui/codeBlockLanguageSelector/index.ts @@ -21,6 +21,34 @@ const defaultOptions = { showArrow: false, }; +const DIAGRAM_LANGS = new Set(['mermaid', 'vega-lite', 'plantuml', 'flowchart', 'sequence']); + +// The language the user actually typed after the ``` fence. The selector's +// fuzzy search may resolve a non-code language (e.g. `vega-lite`) to an +// unrelated Prism language, so the diagram check keys off this raw text. +function typedFenceLang(text: string): string { + return text.match(/`{3,}\s*([\w-]+)/)?.[1] ?? ''; +} + +// Build the state for the block a ```lang fence becomes. Diagram languages +// (from the typed text) become a diagram block, mirroring markdownToState's +// file-load path; GitLab math becomes a math-block; everything else a fenced +// code block highlighted with the selector's matched language. +function newBlockStateForLang(typedLang: string, matchedLang: string, isGitlabMath: boolean) { + if (isGitlabMath) + return { name: 'math-block', meta: { mathStyle: 'gitlab' }, text: '' }; + + if (DIAGRAM_LANGS.has(typedLang)) { + return { + name: 'diagram', + meta: { type: typedLang, lang: typedLang === 'vega-lite' ? 'json' : 'yaml' }, + text: '', + }; + } + + return { name: 'code-block', meta: { lang: matchedLang, type: 'fenced' }, text: '' }; +} + export class CodeBlockLanguageSelector extends BaseScrollFloat { static pluginName = 'codePicker'; public override capturesContentKeydown = true; @@ -149,23 +177,9 @@ export class CodeBlockLanguageSelector extends BaseScrollFloat { } if (isParagraphContent(block)) { - const state - = muya.options.isGitlabCompatibilityEnabled && name === 'math' - ? { - name: 'math-block', - meta: { - mathStyle: 'gitlab', - }, - text: '', - } - : { - name: 'code-block', - meta: { - lang: name, - type: 'fenced', - }, - text: '', - }; + const isGitlabMath + = muya.options.isGitlabCompatibilityEnabled && name === 'math'; + const state = newBlockStateForLang(typedFenceLang(block.text), name, isGitlabMath); const newBlock = ScrollPage.loadBlock(state.name).create( this.muya, @@ -173,7 +187,7 @@ export class CodeBlockLanguageSelector extends BaseScrollFloat { ); block.parent?.replaceWith(newBlock); const codeContent = newBlock.lastContentInDescendant(); - codeContent.setCursor(0, 0); + codeContent?.setCursor(0, 0); } else { block.text = name;