Skip to content
Merged
56 changes: 56 additions & 0 deletions packages/desktop/src/common/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,59 @@ export const isDarkThemeId = (theme: unknown): theme is string => {
typeof theme === 'string' && (railscastsThemes.includes(theme) || oneDarkThemes.includes(theme))
)
}

// Each built-in theme's editor background colour, kept in sync with the
// `--editorBgColor` of the matching renderer theme (renderer/src/assets/themes/
// *.theme.css; the default light theme lives in styles/index.css and is handled
// by the white fallback below). The main process paints a freshly-created window
// with this colour before the renderer loads, so a dark theme no longer flashes
// white on launch (#3957).
const themeBackgroundColors: ReadonlyMap<string, string> = new Map([
['ayu-dark', '#0a0e14'],
['ayu-light', '#fafafa'],
['ayu-mirage', '#1f2430'],
['catppuccin-latte', '#eff1f5'],
['catppuccin-mocha', '#1e1e2e'],
['cyberdream', '#16181a'],
['dark', '#282828'],
['dracula', '#282a36'],
['everforest-dark', '#2d353b'],
['everforest-light', '#fdf6e3'],
['graphite', '#f7f7f7'],
['gruvbox-dark', '#282828'],
['gruvbox-light', '#fbf1c7'],
['horizon-dark', '#1c1e26'],
['kanagawa', '#1f1f28'],
['material-dark', '#34393f'],
['monokai-pro', '#2d2a2e'],
['nightfox', '#192330'],
['nord', '#2e3440'],
['one-dark', '#282c34'],
['oxocarbon-dark', '#161616'],
['palenight', '#292d3e'],
['rose-pine', '#191724'],
['rose-pine-dawn', '#faf4ed'],
['rose-pine-moon', '#232136'],
['solarized-dark', '#002b36'],
['solarized-light', '#fdf6e3'],
['synthwave-84', '#262335'],
['tokyo-night', '#1a1b26'],
['tokyo-night-light', '#d5d6db'],
['tokyo-night-storm', '#24283b'],
['ulysses', '#f3f3f3']
])

const DARK_FALLBACK_BACKGROUND = '#282828'
const LIGHT_FALLBACK_BACKGROUND = '#ffffff'

/**
* Background colour to paint a freshly-created window before the renderer
* loads, so the window matches the active theme instead of flashing white
* (#3957). Falls back by dark/light classification for any theme without an
* explicit colour (e.g. the default light theme or a future/custom theme).
*/
export const getThemeBackgroundColor = (theme: string | undefined): string => {
const exact = typeof theme === 'string' ? themeBackgroundColors.get(theme) : undefined
if (exact) return exact
return isDarkThemeId(theme) ? DARK_FALLBACK_BACKGROUND : LIGHT_FALLBACK_BACKGROUND
}
24 changes: 6 additions & 18 deletions packages/desktop/src/main/windows/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path'
import type { BrowserWindow } from 'electron'
import { TypedEmitter } from '@shared/types/typedEmitter'
import type Accessor from '../app/accessor'
import { getThemeBackgroundColor } from '../../common/theme'

/**
* A MarkText window.
Expand Down Expand Up @@ -149,24 +150,11 @@ class BaseWindow extends TypedEmitter<BaseWindowEvents> {
}

protected _getPreferredBackgroundColor(theme: string | undefined): string {
// Hardcode the theme background color and show the window direct for the fastet window ready time.
// Later with custom themes we need the background color (e.g. from meta information) and wait
// that the window is loaded and then pass theme data to the renderer.
switch (theme) {
case 'dark':
return '#282828'
case 'material-dark':
return '#34393f'
case 'ulysses':
return '#f3f3f3'
case 'graphite':
return '#f7f7f7'
case 'one-dark':
return '#282c34'
case 'light':
default:
return '#ffffff'
}
// Paint the window with the active theme's background and show it directly,
// for the fastest window-ready time. Previously only a handful of themes
// were mapped and every other (dark) theme fell back to white, flashing
// white on launch (#3957); the full per-theme map lives in common/theme.
return getThemeBackgroundColor(theme)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,15 @@ const dumpKeyboardInformation = (): void => {
.pref-keybindings .el-table tr {
background: var(--editorBgColor) !important;
}
/* Element Plus colours table text with its own --el-text-color-regular grey,
which the app never themes — so the list rendered as low-contrast grey on
every theme (≈2.3:1 on dark themes, well below WCAG AA). Use the theme's own
editor text colour so the bindings stay readable everywhere (#3937). */
.pref-keybindings .el-table,
.pref-keybindings .el-table th.el-table__cell,
.pref-keybindings .el-table td.el-table__cell {
color: var(--editorColor);
}
.pref-keybindings .el-table th.el-table__cell.is-leaf,
.pref-keybindings .el-table th,
.pref-keybindings .el-table td {
Expand Down
33 changes: 33 additions & 0 deletions packages/desktop/test/unit/specs/theme-background-color.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { getThemeBackgroundColor, railscastsThemes, oneDarkThemes } from 'common/theme'

// #3957: a dark theme used to flash white on launch because the main process
// only mapped a handful of themes to a background colour and every other theme
// fell back to white. `getThemeBackgroundColor` now covers every built-in theme
// and classifies unknown ones, so no dark theme is painted white.
describe('theme launch background colour (#3957)', () => {
it('never returns white for a dark theme (no white flash on launch)', () => {
for (const theme of [...railscastsThemes, ...oneDarkThemes]) {
expect(getThemeBackgroundColor(theme).toLowerCase()).not.toBe('#ffffff')
}
})

it('maps representative dark themes to their own editor background', () => {
expect(getThemeBackgroundColor('dracula')).toBe('#282a36')
expect(getThemeBackgroundColor('nord')).toBe('#2e3440')
expect(getThemeBackgroundColor('tokyo-night')).toBe('#1a1b26')
expect(getThemeBackgroundColor('dark')).toBe('#282828')
expect(getThemeBackgroundColor('one-dark')).toBe('#282c34')
})

it('uses the explicit background for built-in light themes', () => {
expect(getThemeBackgroundColor('ulysses')).toBe('#f3f3f3')
expect(getThemeBackgroundColor('tokyo-night-light')).toBe('#d5d6db')
})

it('falls back to white for the default light theme and unknown themes', () => {
for (const theme of ['light', undefined, 'no-such-theme']) {
expect(getThemeBackgroundColor(theme as string | undefined)).toBe('#ffffff')
}
})
})
15 changes: 15 additions & 0 deletions packages/muya/e2e/tests/editing/clipboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ test.describe('clipboard paste', () => {
expect(md).toMatch(/\|\s*r1c1\s*\|\s*r1c2\s*\|/);
});

test('pasting a <table> with first-row colspan converts to a GFM table', async ({ browserName, context, page }) => {
test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.');
await grantClipboardPermissions(context);
const html = '<table><tr><td colspan="2">A</td></tr><tr><td>B</td><td>C</td></tr></table>';
await pasteClipboard(page, html, 'A\nB\tC');
await expect.poll(async () => getMarkdown(page), {
timeout: 5_000,
intervals: [50, 100, 250, 500],
}).toMatch(/\|\s*A\s*\|\s*\|/);

const md = await getMarkdown(page);
expect(md).toMatch(/\|\s*-+\s*\|\s*-+\s*\|/);
expect(md).toMatch(/\|\s*B\s*\|\s*C\s*\|/);
});

test('pasting plain text without HTML falls back to text insertion', async ({ browserName, context, page }) => {
test.skip(browserName !== 'chromium', 'ClipboardItem unreliable on Firefox/WebKit headless — BACKLOG Phase 3.');
await grantClipboardPermissions(context);
Expand Down
84 changes: 84 additions & 0 deletions packages/muya/e2e/tests/options/autopair.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,57 @@ async function getFirstBlockText(page: Page): Promise<string> {
});
}

async function setContentAndSelect(
page: Page,
initial: string,
start: number,
end: number,
): Promise<void> {
await page.evaluate(({ initial, start, end }) => {
window.muya!.setContent(initial);
window.muya!.focus();
window.muya!.domNode.focus();
const block = window.muya!.editor.scrollPage!.firstContentInDescendant()!;
block.setCursor(start, end, true);
}, { initial, start, end });

const selectedText = initial.slice(start, end);
await expect.poll(() => page.evaluate(() => {
const live = window.muya!.editor.selection.getSelection();
const native = window.getSelection();
return {
anchorOffset: live?.anchor.offset ?? null,
focusOffset: live?.focus.offset ?? null,
selectedText: native?.toString() ?? '',
};
})).toEqual({
anchorOffset: start,
focusOffset: end,
selectedText,
});
}

async function expectSelectedText(
page: Page,
selectedText: string,
start: number,
end: number,
): Promise<void> {
await expect.poll(() => page.evaluate(() => {
const live = window.muya!.editor.selection.getSelection();
const native = window.getSelection();
return {
anchorOffset: live?.anchor.offset ?? null,
focusOffset: live?.focus.offset ?? null,
selectedText: native?.toString() ?? '',
};
})).toEqual({
anchorOffset: start,
focusOffset: end,
selectedText,
});
}

test.describe('options / auto-pair matrix', () => {
test('autoPairBracket: on → `(` produces `()`', async ({ page }) => {
await rebuildAndFocus(page, {
Expand Down Expand Up @@ -149,4 +200,37 @@ test.describe('options / auto-pair matrix', () => {
await page.keyboard.type('"');
await expect.poll(() => getFirstBlockText(page)).toBe('"');
});

test('typing an auto-pair character over a selection wraps the selected text', async ({ page }) => {
await rebuildAndFocus(page, {
autoPairBracket: true,
autoPairMarkdownSyntax: true,
autoPairQuote: true,
});

await setContentAndSelect(page, 'hello world', 0, 5);
await page.keyboard.type('(');
await expect.poll(() => getFirstBlockText(page)).toBe('(hello) world');
await expectSelectedText(page, 'hello', 1, 6);

await setContentAndSelect(page, 'hello world', 0, 11);
await page.keyboard.type('"');
await expect.poll(() => getFirstBlockText(page)).toBe('"hello world"');
await expectSelectedText(page, 'hello world', 1, 12);

await setContentAndSelect(page, 'hello world', 0, 5);
await page.keyboard.type('*');
await expect.poll(() => getFirstBlockText(page)).toBe('*hello* world');
await expectSelectedText(page, 'hello', 1, 6);

await setContentAndSelect(page, 'hello world', 0, 5);
await page.keyboard.type('`');
await expect.poll(() => getFirstBlockText(page)).toBe('`hello` world');
await expectSelectedText(page, 'hello', 1, 6);

await setContentAndSelect(page, '中文文本', 0, 4);
await page.keyboard.type('"');
await expect.poll(() => getFirstBlockText(page)).toBe('"中文文本"');
await expectSelectedText(page, '中文文本', 1, 5);
});
});
79 changes: 79 additions & 0 deletions packages/muya/e2e/tests/stability/unwrap-undo-empty-4716.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../fixtures/muya';
import { editor } from '../helpers/selectors';

// #4716: undoing a list unwrap (or any op) must never leave ScrollPage empty.
// `updateContents` dispatches to the json state first, then rebuilds the live
// tree incrementally via pick/drop. If a block throws while being rebuilt
// (KaTeX/diagram/etc.), the tree was left half-applied — `pick` removed blocks
// `drop` never re-inserted — so the document looked correct (json state is
// right) but the live ScrollPage was empty, and the next blank-area click
// crashed the renderer in `ScrollPage._clickHandler`.

function liveTree(page: Page) {
return page.evaluate(() => {
const sp = (window.muya as any).editor.scrollPage;
return {
len: sp.children.length,
tail: sp.children.tail?.blockName ?? null,
dom: sp.domNode.childElementCount,
md: window.muya!.getMarkdown(),
};
});
}

test('undo that fails to rebuild a block re-syncs from state and never empties the editor', async ({ page }) => {
const pageErrors: string[] = [];
page.on('pageerror', err => pageErrors.push(err.message));

await page.evaluate(() => window.muya!.setContent([
{ name: 'bullet-list', meta: { loose: true, marker: '-' }, children: [
{ name: 'list-item', children: [{ name: 'paragraph', text: 'foo' }] },
{ name: 'list-item', children: [{ name: 'paragraph', text: 'bar' }] },
] },
] as never));

// Unwrap the list (what the front menu's highlighted list item does).
await page.evaluate(() => {
const sp = (window.muya as any).editor.scrollPage;
sp.firstChild.firstContentInDescendant().setCursor(0, 0, true);
window.muya!.resetToParagraph(sp.firstChild);
(window.muya as any).editor.jsonState.flush();
});
expect((await liveTree(page)).md).not.toContain('- ');

// Make the bullet-list throw ONCE while the undo's drop phase rebuilds it,
// then undo. The incremental apply fails after pick emptied the tree.
await page.evaluate(() => {
const SP = (window.muya as any).editor.scrollPage.constructor;
const real = SP.loadBlock.bind(SP);
let thrown = false;
SP.loadBlock = (name: string) => {
if (name === 'bullet-list' && !thrown) {
thrown = true;
throw new Error('simulated block build failure');
}
return real(name);
};
try {
window.muya!.undo();
(window.muya as any).editor.jsonState.flush();
}
finally {
SP.loadBlock = real;
}
});

const afterUndo = await liveTree(page);
expect(afterUndo.md).toContain('- foo');
expect(afterUndo.tail, 'ScrollPage must not be left empty').not.toBeNull();
expect(afterUndo.len).toBe(afterUndo.dom);

// The reported crash trigger: click the editor's blank area.
const edBox = await page.locator(editor.root).boundingBox();
if (edBox)
await page.mouse.click(edBox.x + edBox.width / 2, edBox.y + edBox.height - 4);
await page.waitForTimeout(50);

expect(pageErrors, `renderer errors: ${pageErrors.join(' | ')}`).toEqual([]);
});
Loading
Loading