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);
+});
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/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__/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/__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/__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 115c9a12e0..9c92a95bbe 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(diagramBlock);
- this.parent!.replaceWith(codeBlock);
+ 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,
+ );
- codeBlock.lastContentInDescendant().setCursor(0, 0);
+ this.parent!.replaceWith(codeBlock);
+
+ codeBlock.lastContentInDescendant().setCursor(0, 0);
+ }
}
else if (
tableMatch
@@ -649,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;
@@ -678,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;
@@ -741,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,
+ );
}
}
@@ -792,7 +823,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) {
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/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;
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('ab')).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;
+}