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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/muya/e2e/tests/blocks/codeblock-scroll-3191.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
51 changes: 51 additions & 0 deletions packages/muya/e2e/tests/blocks/diagram-fence-2177.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
2 changes: 1 addition & 1 deletion packages/muya/src/block/base/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@
event.preventDefault();
}

arrowHandler(event: Event) {

Check warning on line 423 in packages/muya/src/block/base/content.ts

View workflow job for this annotation

GitHub Actions / lint

Method 'arrowHandler' has a complexity of 22. Maximum allowed is 20
if (!isKeyboardEvent(event))
return;

Expand Down Expand Up @@ -609,7 +609,7 @@
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div>` — converts to an html-block', () => {
it('replaces the paragraph with a single html-block', async () => {
const muya = bootMuya('seed\n');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -97,7 +100,7 @@ function blockText(state: ReturnType<Muya['getState']>, 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');
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<Window>).MUYA_VERSION;
});

function bootMuya(markdown: string): Muya {
const host = document.createElement('div');
document.body.appendChild(host);
const muya = new Muya(host, { markdown } as ConstructorParameters<typeof Muya>[1]);
muya.init();
bootedHosts.push(muya.domNode);
return muya;
}

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<typeof visit>[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<void> {
return new Promise<void>(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);
});
});
Loading
Loading