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
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 })
Expand All @@ -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()
Expand Down
10 changes: 8 additions & 2 deletions packages/desktop/src/renderer/src/util/exportHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html>. */
dir?: string
}

// Ported verbatim from legacy muyajs `headerFooterStyle.css` so the page
Expand Down Expand Up @@ -143,7 +145,7 @@ export const exportStyledHTML = async(
markdown: string,
options: ExportStyledHtmlOptions = {}
): Promise<string> => {
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
Expand All @@ -156,7 +158,11 @@ export const exportStyledHTML = async(

// Render the engine's full HTML document. We re-extract its <article> 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 <article class="markdown-body">…</article> body.
const articleMatch = /<article class="markdown-body">([\s\S]*)<\/article>/.exec(fullDoc)
Expand Down
7 changes: 6 additions & 1 deletion packages/desktop/src/types/muya-core.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ declare module '@muyajs/core' {
markdown: string
constructor(markdown: string, muya?: unknown)
renderHtml(): Promise<string>
generate(options?: { title?: string; extraCSS?: string }): Promise<string>
generate(options?: {
title?: string
extraCSS?: string
inlineStyles?: boolean
dir?: string
}): Promise<string>
}

export function renderToStaticHTML(...args: any[]): any
Expand Down
18 changes: 18 additions & 0 deletions packages/desktop/test/e2e/search-prefill-from-selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
)

await sendIpcToRenderer(app, 'mt::editor-edit-action', 'find')
const searchBar = page.locator('.search-bar')
await expect(searchBar).toBeVisible({ timeout: 5000 })
Expand Down
23 changes: 23 additions & 0 deletions packages/desktop/test/unit/specs/exportHtml.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,29 @@ describe('exportStyledHTML — header/footer assembly', () => {
})
})

describe('exportStyledHTML — text direction (issue #4553)', () => {
it('sets dir="rtl" on the exported <html> when dir is "rtl"', async() => {
const out = await exportStyledHTML(NO_MUYA, '# سلام\n\nمتن', { dir: 'rtl' })

expect(out).toMatch(/<html lang="en" dir="rtl">/)
})

it('forwards dir="auto" to the exported <html>', async() => {
const out = await exportStyledHTML(NO_MUYA, '# Hi', { dir: 'auto' })

expect(out).toMatch(/<html lang="en" dir="auto">/)
})

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('<html lang="en">')
expect(ltr).not.toMatch(/<html[^>]+dir=/)
expect(none).not.toMatch(/<html[^>]+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.
Expand Down
57 changes: 57 additions & 0 deletions packages/muya/e2e/tests/editing/undo-redo-wedge-4685.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
62 changes: 62 additions & 0 deletions packages/muya/e2e/tests/inline/marker-arrow-hold.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number | null> {
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);
});
});
6 changes: 5 additions & 1 deletion packages/muya/src/block/base/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
'(?:^|\n) {0,3}((?:\\* *\\* *\\*|- *- *-|_ *_ *_)[ *_-]*)(?=\n|$)', // Thematic break
];

const INLINE_UPDATE_REG = new RegExp(INLINE_UPDATE_FRAGMENTS.join('|'), 'i');

Check warning on line 59 in packages/muya/src/block/base/format.ts

View workflow job for this annotation

GitHub Actions / lint

The quantified expression ' +' at the end of the expression tree should only be matched a constant number of times. The expression can be replaced with ' ' (no quantifier) without affecting the lookaround

Check warning on line 59 in packages/muya/src/block/base/format.ts

View workflow job for this annotation

GitHub Actions / lint

The quantified expression '\s+' at the end of the expression tree should only be matched a constant number of times. The expression can be replaced with '\s' (no quantifier) without affecting the lookaround

// Offset of the cursor relative to a symmetric/asymmetric marker pair
// (strong/em/code/math/html_tag). `open`/`close` are the opening/closing
Expand Down Expand Up @@ -554,7 +554,11 @@
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)
Expand Down Expand Up @@ -1008,7 +1012,7 @@
let atxLineHasPushed = false;

for (const l of lines) {
if (/^ {0,3}#{1,6}(?=\s+|$)/.test(l) && !atxLineHasPushed) {

Check warning on line 1015 in packages/muya/src/block/base/format.ts

View workflow job for this annotation

GitHub Actions / lint

The quantified expression '\s+' at the end of the expression tree should only be matched a constant number of times. The expression can be replaced with '\s' (no quantifier) without affecting the lookaround
atxLine = l;
atxLineHasPushed = true;
}
Expand Down Expand Up @@ -1087,7 +1091,7 @@
let setextLineHasPushed = false;

for (const l of lines) {
if (/^ {0,3}(?:={3,}|-{3,})(?= +|$)/.test(l) && !setextLineHasPushed)

Check warning on line 1094 in packages/muya/src/block/base/format.ts

View workflow job for this annotation

GitHub Actions / lint

The quantified expression ' +' at the end of the expression tree should only be matched a constant number of times. The expression can be replaced with ' ' (no quantifier) without affecting the lookaround
setextLineHasPushed = true;
else if (!setextLineHasPushed)
setextLines.push(l);
Expand Down
Loading
Loading