From 90dba58ccfe0a94360a4637c3d71c0cd34adff1e Mon Sep 17 00:00:00 2001 From: Sec <129755144+Renakoni@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:25:23 +0800 Subject: [PATCH 1/7] fix: normalize first-row colspan table paste (#4707) --- .../muya/e2e/tests/editing/clipboard.spec.ts | 15 +++++++ .../__tests__/pastedHtmlTableColspan.spec.ts | 45 +++++++++++++++++++ packages/muya/src/utils/paste.ts | 25 +++++++++++ 3 files changed, 85 insertions(+) create mode 100644 packages/muya/src/utils/__tests__/pastedHtmlTableColspan.spec.ts diff --git a/packages/muya/e2e/tests/editing/clipboard.spec.ts b/packages/muya/e2e/tests/editing/clipboard.spec.ts index 3667472b3d..d87125d111 100644 --- a/packages/muya/e2e/tests/editing/clipboard.spec.ts +++ b/packages/muya/e2e/tests/editing/clipboard.spec.ts @@ -71,6 +71,21 @@ test.describe('clipboard paste', () => { expect(md).toMatch(/\|\s*r1c1\s*\|\s*r1c2\s*\|/); }); + test('pasting a 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 = '
A
BC
'; + 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); diff --git a/packages/muya/src/utils/__tests__/pastedHtmlTableColspan.spec.ts b/packages/muya/src/utils/__tests__/pastedHtmlTableColspan.spec.ts new file mode 100644 index 0000000000..8c930f3f56 --- /dev/null +++ b/packages/muya/src/utils/__tests__/pastedHtmlTableColspan.spec.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; +import HtmlToMarkdown from '../../state/htmlToMarkdown'; +import { MarkdownToState } from '../../state/markdownToState'; +import { normalizePastedHTML } from '../paste'; + +interface IStateLike { + name: string; + text?: string; + children?: IStateLike[]; +} + +async function pasteHtmlToState(html: string) { + const normalized = await normalizePastedHTML(html); + const markdown = new HtmlToMarkdown({ bulletListMarker: '-' }).generate(normalized); + const states = new MarkdownToState({ + footnote: false, + math: false, + isGitlabCompatibilityEnabled: false, + trimUnnecessaryCodeBlockEmptyLines: false, + frontMatter: false, + }).generate(markdown) as unknown as IStateLike[]; + + return { markdown, states }; +} + +function rowTexts(row: IStateLike) { + return row.children?.map(cell => cell.text ?? '') ?? []; +} + +describe('normalizePastedHTML - table colspan paste', () => { + it('keeps a first-row colspan table parseable as a Markdown table', async () => { + const { markdown, states } = await pasteHtmlToState( + '
A
BC
', + ); + + expect(markdown).toMatch(/\|\s*A\s*\|\s*\|/); + expect(markdown).toMatch(/\|\s*B\s*\|\s*C\s*\|/); + + expect(states[0].name).toBe('table'); + expect(rowTexts(states[0].children![0])).toEqual(['A', '']); + expect(rowTexts(states[0].children![1])).toEqual(['B', 'C']); + }); +}); diff --git a/packages/muya/src/utils/paste.ts b/packages/muya/src/utils/paste.ts index e65aa7c3d8..116d392720 100644 --- a/packages/muya/src/utils/paste.ts +++ b/packages/muya/src/utils/paste.ts @@ -6,6 +6,29 @@ const TIMEOUT = 1500; export const isOnline = () => navigator.onLine === true; +function expandTableColspans(table: HTMLTableElement) { + for (const row of Array.from(table.rows)) { + const cells = Array.from(row.cells); + + for (const cell of cells) { + const colSpan = Math.max(1, Math.trunc(cell.colSpan || 1)); + if (colSpan <= 1) + continue; + + cell.removeAttribute('colspan'); + + const placeholders: HTMLTableCellElement[] = []; + for (let i = 1; i < colSpan; i++) { + placeholders.push( + document.createElement(cell.tagName.toLowerCase()) as HTMLTableCellElement, + ); + } + + cell.after(...placeholders); + } + } +} + export async function getPageTitle(url: string) { // No need to request the title when it's not url. if (!url.startsWith('http')) @@ -56,6 +79,8 @@ export async function normalizePastedHTML(html: string) { const tables = Array.from(tempWrapper.querySelectorAll('table')); for (const table of tables) { + expandTableColspans(table); + const row = table.querySelector('tr'); if (row && row.firstElementChild?.tagName !== 'TH') { [...row.children].forEach((cell) => { From 866c2077bf75f3315cdadbd832740bffe723046e Mon Sep 17 00:00:00 2001 From: Furkan Bora Murat <64507603+FurkaanBoraa@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:03:46 +0300 Subject: [PATCH 2/7] fix(renderer): use the theme text colour for the key bindings list (#3937) (#4713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key bindings table is an Element Plus el-table, which colours its cells with --el-text-color-regular. The app never themes that variable, so the list rendered in Element Plus's default grey (#606266) on every theme, ignoring the active theme's text colour. On dark themes the contrast dropped to ~2.3:1 — well below the WCAG AA 4.5:1 minimum — making the bindings hard to read or practically invisible, as reported. Point the table cells at the theme's own --editorColor so the bindings inherit the editor text colour and stay readable on every theme (light unchanged at 8.45:1, dark themes now 5.6-13.4:1). --- .../renderer/src/prefComponents/keybindings/index.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/desktop/src/renderer/src/prefComponents/keybindings/index.vue b/packages/desktop/src/renderer/src/prefComponents/keybindings/index.vue index 75fdbd44a6..675db1ea3b 100644 --- a/packages/desktop/src/renderer/src/prefComponents/keybindings/index.vue +++ b/packages/desktop/src/renderer/src/prefComponents/keybindings/index.vue @@ -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 { From 3905bcda19577d27272f6284980e148c566f1c9f Mon Sep 17 00:00:00 2001 From: Sec <129755144+Renakoni@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:57:24 +0800 Subject: [PATCH 3/7] fix: wrap selected text when typing auto-pair characters (#4698) * fix: wrap selected text when typing auto-pair characters * fix: mark history boundary for selection auto-pair wrapping * test: wait for selection before auto-pair e2e input * fix: preserve selection after auto-pair wrapping --- .../muya/e2e/tests/options/autopair.spec.ts | 84 +++++++++++ .../base/__tests__/selectionAutoPair.spec.ts | 135 ++++++++++++++++++ packages/muya/src/block/base/content.ts | 72 ++++++++++ packages/muya/src/block/base/format.ts | 4 + 4 files changed, 295 insertions(+) create mode 100644 packages/muya/src/block/base/__tests__/selectionAutoPair.spec.ts diff --git a/packages/muya/e2e/tests/options/autopair.spec.ts b/packages/muya/e2e/tests/options/autopair.spec.ts index 27ef8a1cf8..f437e84361 100644 --- a/packages/muya/e2e/tests/options/autopair.spec.ts +++ b/packages/muya/e2e/tests/options/autopair.spec.ts @@ -55,6 +55,57 @@ async function getFirstBlockText(page: Page): Promise { }); } +async function setContentAndSelect( + page: Page, + initial: string, + start: number, + end: number, +): Promise { + 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 { + 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, { @@ -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); + }); }); diff --git a/packages/muya/src/block/base/__tests__/selectionAutoPair.spec.ts b/packages/muya/src/block/base/__tests__/selectionAutoPair.spec.ts new file mode 100644 index 0000000000..e11563d46f --- /dev/null +++ b/packages/muya/src/block/base/__tests__/selectionAutoPair.spec.ts @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom + +import type Format from '../format'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../../muya'; + +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, + options: Partial[1]> = {}, +): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown, ...options } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +function firstBlock(muya: Muya): Format { + const content = muya.editor.scrollPage!.firstContentInDescendant() as unknown as Format; + muya.editor.activeContentBlock = content as never; + return content; +} + +function pressKey(content: Format, key: string): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + content.keydownHandler(event); + return event; +} + +function expectSelectedRange(content: Format, start: number, end: number) { + const cursor = content.getCursor(); + expect(cursor?.start.offset).toBe(start); + expect(cursor?.end.offset).toBe(end); +} + +describe('autoPair — wraps selected text on keydown before native replacement', () => { + it('wraps a selected word with brackets', () => { + const muya = bootMuya('hello world\n'); + const content = firstBlock(muya); + content.setCursor(0, 5); + const markInputBoundary = vi.spyOn(muya.editor.history, 'markInputBoundary'); + + const event = pressKey(content, '('); + + expect(event.defaultPrevented).toBe(true); + expect(markInputBoundary).toHaveBeenCalledWith('insertText', '('); + expect(content.text).toBe('(hello) world'); + expectSelectedRange(content, 1, 6); + }); + + it('wraps a full-line selection with quotes', () => { + const muya = bootMuya('hello world\n'); + const content = firstBlock(muya); + content.setCursor(0, 11); + + const event = pressKey(content, '"'); + + expect(event.defaultPrevented).toBe(true); + expect(content.text).toBe('"hello world"'); + expectSelectedRange(content, 1, 12); + }); + + it('wraps a selected word with markdown syntax markers', () => { + const muya = bootMuya('hello world\n'); + const content = firstBlock(muya); + content.setCursor(0, 5); + + const event = pressKey(content, '*'); + + expect(event.defaultPrevented).toBe(true); + expect(content.text).toBe('*hello* world'); + expectSelectedRange(content, 1, 6); + }); + + it('wraps a selected word with backticks', () => { + const muya = bootMuya('hello world\n'); + const content = firstBlock(muya); + content.setCursor(0, 5); + + const event = pressKey(content, '`'); + + expect(event.defaultPrevented).toBe(true); + expect(content.text).toBe('`hello` world'); + expectSelectedRange(content, 1, 6); + }); + + it('leaves ordinary selected-text replacement to the browser', () => { + const muya = bootMuya('hello world\n'); + const content = firstBlock(muya); + content.setCursor(0, 5); + + const event = pressKey(content, 'X'); + + expect(event.defaultPrevented).toBe(false); + expect(content.text).toBe('hello world'); + }); + + it('does not wrap when the relevant auto-pair option is disabled', () => { + const muya = bootMuya('hello world\n', { autoPairBracket: false }); + const content = firstBlock(muya); + content.setCursor(0, 5); + + const event = pressKey(content, '('); + + expect(event.defaultPrevented).toBe(false); + expect(content.text).toBe('hello world'); + }); +}); diff --git a/packages/muya/src/block/base/content.ts b/packages/muya/src/block/base/content.ts index fb0ae41746..2892eaa01d 100644 --- a/packages/muya/src/block/base/content.ts +++ b/packages/muya/src/block/base/content.ts @@ -149,6 +149,33 @@ function shouldInsertClosingPair( ); } +function selectionPairForKey( + key: string, + options: { + autoPairBracket: boolean; + autoPairMarkdownSyntax: boolean; + autoPairQuote: boolean; + }, + type: string, +) { + if (key.length !== 1) + return null; + + const close = key === '`' ? '`' : BRACKET_HASH[key]; + if (!close) + return null; + + const { autoPairBracket, autoPairMarkdownSyntax, autoPairQuote } = options; + if (autoPairQuote && /['"]/.test(key)) + return { open: key, close }; + if (autoPairBracket && /[{[(]/.test(key)) + return { open: key, close }; + if (type === 'format' && autoPairMarkdownSyntax && /[*$~_`]/.test(key)) + return { open: key, close }; + + return null; +} + interface IAutoPairCollapsedContext { blockText: string; options: { @@ -320,6 +347,10 @@ class Content extends TreeNode { return this.muya.editor.inlineRenderer; } + protected get autoPairType() { + return this.blockName; + } + get path(): TBlockPath { if (this.parent == null) return ['text']; @@ -684,6 +715,9 @@ class Content extends TreeNode { if (this.muya.ui.handleContentKeydown(event)) return; + if (this._wrapSelectionWithAutoPair(event)) + return; + switch (event.key) { case EVENT_KEYS.Backspace: this.backspaceHandler(event); @@ -719,6 +753,44 @@ class Content extends TreeNode { } }; + private _wrapSelectionWithAutoPair(event: KeyboardEvent) { + if ( + this.isComposed + || event.defaultPrevented + || event.ctrlKey + || event.metaKey + || event.altKey + ) { + return false; + } + + const cursor = this.getCursor(); + if (!cursor || cursor.start.offset === cursor.end.offset) + return false; + + const pair = selectionPairForKey(event.key, this.muya.options, this.autoPairType); + if (!pair) + return false; + + event.preventDefault(); + event.stopPropagation(); + this.muya.editor.history.markInputBoundary('insertText', event.key); + + const { start, end } = cursor; + const selectedText = this.text.substring(start.offset, end.offset); + const wrappedText = `${pair.open}${selectedText}${pair.close}`; + this.text + = this.text.substring(0, start.offset) + + wrappedText + + this.text.substring(end.offset); + + const selectionStart = start.offset + pair.open.length; + const selectionEnd = selectionStart + selectedText.length; + this.setCursor(selectionStart, selectionEnd, true); + + return true; + } + blurHandler() { this.scrollPage?.handleBlurFromContent(this); } diff --git a/packages/muya/src/block/base/format.ts b/packages/muya/src/block/base/format.ts index 5272586082..7bc63bd835 100644 --- a/packages/muya/src/block/base/format.ts +++ b/packages/muya/src/block/base/format.ts @@ -215,6 +215,10 @@ function checkTokenIsInlineFormat(token: Token) { class Format extends Content { static override blockName = 'format'; + protected override get autoPairType() { + return 'format'; + } + private _checkCursorInTokenType( text: string, offset: number, From 479f16421b3954acbf69512bfcfd3bca2b7725dd Mon Sep 17 00:00:00 2001 From: Furkan Bora Murat <64507603+FurkaanBoraa@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:10:10 +0300 Subject: [PATCH 4/7] fix(muya): skip empty sibling containers when navigating across blocks (#4644) (#4715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit previousContentInContext / nextContentInContext returned null when the adjacent sibling was an empty container with no content descendant — e.g. a list item whose only paragraph was removed during editing, or an empty `* ` item parsed from markdown. That left ArrowUp/ArrowDown unable to cross the empty item, so the caret got stuck and could only be moved by clicking. Walk the previous/next siblings and skip empty containers, falling through to the parent only when no sibling holds content. Add regression tests covering ArrowUp and ArrowDown across an empty list item (they fail without the fix). --- .../base/__tests__/arrowNavigation.spec.ts | 59 +++++++++++++++++++ packages/muya/src/block/base/treeNode.ts | 45 ++++++++++---- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts b/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts index 6fafebe49c..6065e8dada 100644 --- a/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts +++ b/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts @@ -309,6 +309,65 @@ describe('content arrowHandler — trailing-paragraph creation at document end', }); }); +// #4644: an empty list item with no content descendant (e.g. left behind after +// its only paragraph is removed during editing) sitting between two items used +// to make previous/nextContentInContext return null, so ArrowUp/ArrowDown could +// not cross it and the caret got stuck. Navigation must skip the empty container +// and reach the content beyond it. `* ` (a bullet marker with no text) parses to +// exactly such a childless list item. +function allContentTexts(muya: Muya): string[] { + const texts: string[] = []; + const visit = (block: { + text?: string; + constructor: { blockName?: string }; + children?: { forEach: (cb: (b: unknown) => void) => void }; + }) => { + if (block.constructor.blockName?.endsWith('.content')) + texts.push(block.text ?? ''); + block.children?.forEach(b => visit(b as typeof block)); + }; + visit(muya.editor.scrollPage as unknown as Parameters[0]); + return texts; +} + +describe('content arrowHandler — skips empty sibling containers (#4644)', () => { + it('arrowUp at offset 0 skips an empty list item and lands at the END of the item above', async () => { + const muya = bootMuya('* A\n* \n* B\n'); + // Precondition: the middle item holds NO content block, so a passing + // caret assertion below can only mean the empty item was skipped. + expect(allContentTexts(muya)).toEqual(['A', 'B']); + + const b = contentByText(muya, 'B'); + const event = arrowAt(muya, b, 'ArrowUp', 0); + await flush(); + + const a = contentByText(muya, 'A'); + const cursor = a.getCursor(); + expect(cursor).not.toBeNull(); + expect(cursor!.start.offset).toBe('A'.length); + expect(cursor!.end.offset).toBe('A'.length); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('arrowDown at end of an item skips an empty list item and lands at offset 0 of the item below', async () => { + const muya = bootMuya('* A\n* \n* B\n'); + expect(allContentTexts(muya)).toEqual(['A', 'B']); + + const a = contentByText(muya, 'A'); + const event = arrowAt(muya, a, 'ArrowDown', 'A'.length); + await flush(); + + const b = contentByText(muya, 'B'); + const cursor = b.getCursor(); + expect(cursor).not.toBeNull(); + expect(cursor!.start.offset).toBe(0); + expect(cursor!.end.offset).toBe(0); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); +}); + // marktext #3568: in RTL mode the physical Left/Right arrows are visually // mirrored, so the cross-block boundary keys must swap. Offset 0 is the visual // RIGHT end of an RTL line (ArrowRight should go to the previous block); offset diff --git a/packages/muya/src/block/base/treeNode.ts b/packages/muya/src/block/base/treeNode.ts index c57f69dc0c..1f0829c0b5 100644 --- a/packages/muya/src/block/base/treeNode.ts +++ b/packages/muya/src/block/base/treeNode.ts @@ -119,14 +119,25 @@ class TreeNode implements ILinkedNode { return null; const { parent } = this; - if (parent.prev) { - return parent.prev.isParent() - ? parent.prev.lastContentInDescendant() - : parent.prev; // language input - } - else { - return parent.previousContentInContext(); + + // Walk previous siblings, skipping empty containers (e.g. a list item + // whose only paragraph was removed) that hold no content descendant. + // Otherwise such a sibling yields null and the caret gets stuck when + // navigating up/left across it (#4644). + let sibling = parent.prev; + while (sibling) { + if (sibling.isParent()) { + const content = sibling.lastContentInDescendant(); + if (content) + return content; + } + else { + return sibling; // language input + } + sibling = sibling.prev; } + + return parent.previousContentInContext(); } // Get next content block in block tree. @@ -139,10 +150,22 @@ class TreeNode implements ILinkedNode { if (this.blockName === 'language-input') return parent.lastContentInDescendant(); - if (parent.next) - return parent.next.firstContentInDescendant(); - else - return parent.nextContentInContext(); + // Walk next siblings, skipping empty containers with no content + // descendant so the caret can cross them instead of getting stuck (#4644). + let sibling = parent.next; + while (sibling) { + if (sibling.isParent()) { + const content = sibling.firstContentInDescendant(); + if (content) + return content; + } + else { + return sibling; // language input + } + sibling = sibling.next; + } + + return parent.nextContentInContext(); } /** From 9c749272c1c62b862ddb98e8172fb037b87ab65a Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 25 Jun 2026 22:50:11 +0800 Subject: [PATCH 5/7] fix(muya): stop the paragraph front-menu from acting on a stale block (#4686) (#4714) * fix(muya): make the paragraph front-menu single-shot so it can't act on a stale block (#4686) ParagraphFrontMenu stored the block it opened on in `_block`, hid itself on a deferred `setTimeout`, and never checked the block was still in the document before acting. Two real interactions broke it: opening the menu on a list and then using the app menu bar to unwrap it (the native menu never closes the float, so `_block` detaches), and a fast double-click running a second action before the deferred hide(). Either way the next action ran on a detached block, dereferencing a null `parent` (e.g. `_unwrapToParagraphs`, or Duplicate/New's `block.parent!.insertAfter`) and crashing with "Cannot read properties of null (reading 'insertAfter')". `selectItem` now consumes `_block` synchronously and bails unless it is still attached, so a single menu open performs at most one action on a live block (covers turn-into, duplicate, new, delete). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(muya): make resetToParagraph a no-op on a detached block (#4686) Defense in depth for the front-menu fix: `_unwrapToParagraphs` asserted `block.parent!`, so the public `resetToParagraph` crashed if ever handed a block already removed from the document. Replace the assertion with a real null guard so it is a safe no-op instead. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/__tests__/resetToParagraph.spec.ts | 101 ++++++++++++++++++ packages/muya/src/muya.ts | 6 +- .../muya/src/ui/paragraphFrontMenu/index.ts | 11 +- 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/packages/muya/src/__tests__/resetToParagraph.spec.ts b/packages/muya/src/__tests__/resetToParagraph.spec.ts index 95a26e0d7a..82c2387af6 100644 --- a/packages/muya/src/__tests__/resetToParagraph.spec.ts +++ b/packages/muya/src/__tests__/resetToParagraph.spec.ts @@ -97,3 +97,104 @@ describe('paragraph front menu — clicking the active list type unwraps the lis }); }); }); + +// Regression for #4686: the front menu kept a reference to the block it was +// opened on (`_block`) and hid itself on a deferred `setTimeout`, so a rapid +// second click (a real double-click) ran a second action on the same target. +// When the first action removed/replaced the block, the second dereferenced a +// null `parent` deep in `_unwrapToParagraphs` and crashed the renderer with +// "Cannot read properties of null (reading 'insertAfter')". The fix makes a +// single menu open perform at most one action. +describe('paragraph front menu — a single menu open performs at most one action (#4686)', () => { + it('a rapid second click runs no further action (double-click is single-shot)', async () => { + const muya = bootMuya('hello\n'); + const para = firstOutmostBlock(muya); + expect(para.blockName).toBe('paragraph'); + + const menu = new ParagraphFrontMenu(muya, {}); + (menu as unknown as { _block: Parent })._block = para; + + // Double "duplicate": the first inserts one copy; the second click + // (before the menu's deferred hide) must be ignored, not insert a + // second copy. + menu.selectItem(new Event('click'), { label: 'duplicate' }); + menu.selectItem(new Event('click'), { label: 'duplicate' }); + + await vi.waitFor(() => { + const state = muya.getState(); + expect(state.length).toBe(2); // original + exactly one duplicate + }); + }); + + it('ignores a second turn-into after the first action detached the block', async () => { + const muya = bootMuya('- one\n- two\n- three\n'); + const list = firstOutmostBlock(muya); + expect(list.blockName).toBe('bullet-list'); + + const menu = new ParagraphFrontMenu(muya, {}); + (menu as unknown as { _block: Parent })._block = list; + + // Convert bullet -> order: `replaceWith` detaches the original bullet + // list, but the menu still holds it in `_block`. + menu.selectItem(new Event('click'), { label: 'order-list' }); + + // Selecting again on the now-detached block must not throw. + expect(() => + menu.selectItem(new Event('click'), { label: 'bullet-list' }), + ).not.toThrow(); + + await vi.waitFor(() => { + const state = muya.getState(); + expect(state.length).toBe(1); + expect(state[0].name).toBe('order-list'); + }); + }); + + it('toggling the active list type twice does not crash', () => { + const muya = bootMuya('- one\n- two\n'); + const list = firstOutmostBlock(muya); + + const menu = new ParagraphFrontMenu(muya, {}); + (menu as unknown as { _block: Parent })._block = list; + + // First toggle unwraps the list back to paragraphs (removes the list). + menu.selectItem(new Event('click'), { label: 'bullet-list' }); + + // The stale `_block` now points at the removed list; toggling again + // must be a no-op, not a crash. + expect(() => + menu.selectItem(new Event('click'), { label: 'bullet-list' }), + ).not.toThrow(); + }); + + // The deterministic real-world repro: an external command (the app menu bar) + // unwraps the list while the front menu stays open, detaching `_block`. The + // next front-menu click then targets the detached block. This must hold for + // EVERY item — Duplicate/New reparent via `block.parent!.insertAfter`, which + // the engine-level `_unwrapToParagraphs` guard does not cover. + it('ignores a Duplicate click after an external command unwrapped the open block', () => { + const muya = bootMuya('- one\n- two\n'); + const list = firstOutmostBlock(muya); + + const menu = new ParagraphFrontMenu(muya, {}); + (menu as unknown as { _block: Parent })._block = list; + + // Simulate the menu bar's "reset to paragraph": unwraps + removes the + // list, but leaves the front menu open with its now-detached `_block`. + muya.resetToParagraph(list); + + expect(() => + menu.selectItem(new Event('click'), { label: 'duplicate' }), + ).not.toThrow(); + }); +}); + +describe('muya.resetToParagraph(block) — detached block (#4686)', () => { + it('is a no-op on a list already removed from the document', () => { + const muya = bootMuya('- one\n- two\n'); + const list = firstOutmostBlock(muya); + list.remove(); // detach: parent -> null, children left intact + + expect(() => muya.resetToParagraph(list)).not.toThrow(); + }); +}); diff --git a/packages/muya/src/muya.ts b/packages/muya/src/muya.ts index e59bc2887d..ddc7243089 100644 --- a/packages/muya/src/muya.ts +++ b/packages/muya/src/muya.ts @@ -1455,6 +1455,11 @@ export class Muya { * blocks it contains, preserving every item. */ private _unwrapToParagraphs(block: Parent) { + // A detached block has no parent to reparent its children into (#4686). + const parent = block.parent; + if (!parent) + return; + const state = block.getState(); let inner: TState[] = []; if (isAnyListState(state)) @@ -1466,7 +1471,6 @@ export class Muya { return; const cursorText = (this.editor.activeContentBlock ?? this.editor.selection.anchorBlock)?.text; - const parent = block.parent!; let ref: Parent = block; let firstNew: Parent | null = null; for (const childState of inner) { diff --git a/packages/muya/src/ui/paragraphFrontMenu/index.ts b/packages/muya/src/ui/paragraphFrontMenu/index.ts index 3f1cb1ab29..e871dc3121 100644 --- a/packages/muya/src/ui/paragraphFrontMenu/index.ts +++ b/packages/muya/src/ui/paragraphFrontMenu/index.ts @@ -187,10 +187,17 @@ export class ParagraphFrontMenu extends BaseFloat { event.preventDefault(); event.stopPropagation(); - if (!this._block) + // A single menu open performs at most one action: consume the target + // synchronously, then bail unless it is still in the document. This + // covers both a rapid second click (a real double-click before the + // deferred hide()) and an external command — e.g. the app menu bar — + // that unwrapped the block while this menu stayed open. Every action + // below assumes `block.parent` (#4686). + const block = this._block; + this._block = null; + if (!block?.parent) return; - const { _block: block } = this; const oldState = block.getState(); const cursorBlock = /duplicate|new|delete/.test(label) From b22b3effae042be8b2378191f478bb858023469a Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Thu, 25 Jun 2026 22:54:34 +0800 Subject: [PATCH 6/7] fix(muya): re-sync the live tree from json state when an undo/redo apply throws (#4716) (#4717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Editor.updateContents` dispatches an op to the authoritative json state first, then rebuilds the live block tree incrementally via the pick/drop walker. The walker is not atomic: `pick` removes the blocks an op deletes, then `drop` re-creates the blocks it inserts. If any block throws while being rebuilt (e.g. a KaTeX / diagram / code block whose `create` or render fails), `drop` aborts after `pick` already emptied the tree — leaving the live ScrollPage half-applied, often with NO children at all. Because the json state was updated first, `getMarkdown()` still returned the correct document, masking the desync. But the live tree was empty, so the next click on the editor's blank area dereferenced `this.lastChild` (null) in `ScrollPage._clickHandler` and crashed the renderer: TypeError: Cannot read properties of null (reading 'lastContentInDescendant') An empty ScrollPage is never a valid state. Wrap the incremental apply in try/catch and, on failure, rebuild the live tree wholesale from the authoritative json state (the same safe path `rebuildContents`/`setContent` use) so the document can never be left half-applied or empty. Co-authored-by: Claude Opus 4.8 (1M context) --- .../stability/unwrap-undo-empty-4716.spec.ts | 79 +++++++++++++++++++ .../__tests__/updateContentsResync.spec.ts | 79 +++++++++++++++++++ packages/muya/src/editor/index.ts | 16 +++- 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 packages/muya/e2e/tests/stability/unwrap-undo-empty-4716.spec.ts create mode 100644 packages/muya/src/editor/__tests__/updateContentsResync.spec.ts diff --git a/packages/muya/e2e/tests/stability/unwrap-undo-empty-4716.spec.ts b/packages/muya/e2e/tests/stability/unwrap-undo-empty-4716.spec.ts new file mode 100644 index 0000000000..731fb4d78a --- /dev/null +++ b/packages/muya/e2e/tests/stability/unwrap-undo-empty-4716.spec.ts @@ -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([]); +}); diff --git a/packages/muya/src/editor/__tests__/updateContentsResync.spec.ts b/packages/muya/src/editor/__tests__/updateContentsResync.spec.ts new file mode 100644 index 0000000000..3efa2217c3 --- /dev/null +++ b/packages/muya/src/editor/__tests__/updateContentsResync.spec.ts @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ScrollPage } from '../../block/scrollPage'; +import { Muya } from '../../muya'; + +const bootedHosts: HTMLElement[] = []; + +beforeEach(() => { + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + 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 spState(muya: Muya) { + const sp = muya.editor.scrollPage as unknown as { + children: { length: number; tail: unknown }; + domNode: HTMLElement; + }; + return { len: sp.children.length, tail: sp.children.tail, dom: sp.domNode.childElementCount }; +} + +describe('updateContents keeps the live tree in sync with jsonState when block construction throws', () => { + it('does not leave the ScrollPage empty if a block fails to build during an undo apply', () => { + const muya = bootMuya('- foo\n\n- bar\n'); + const list = (muya.editor.scrollPage as unknown as { firstChild: { firstContentInDescendant: () => { setCursor: (a: number, b: number, c: boolean) => void } } }).firstChild; + list.firstContentInDescendant().setCursor(0, 0, true); + + // Unwrap the list -> two paragraphs, recorded as one undo entry. + muya.resetToParagraph(list as never); + muya.editor.jsonState.flush(); + expect(muya.getMarkdown()).not.toContain('- '); + + // Simulate a block whose construction throws while the undo's `drop` + // phase rebuilds the live tree (KaTeX/diagram/etc. can throw on + // create). The undo re-inserts the bullet-list, so make THAT throw. + const realLoadBlock = ScrollPage.loadBlock.bind(ScrollPage); + let thrown = false; + const spy = (name: string) => { + if (name === 'bullet-list' && !thrown) { + thrown = true; + throw new Error('simulated block build failure'); + } + return realLoadBlock(name); + } + ;(ScrollPage as unknown as { loadBlock: (n: string) => unknown }).loadBlock = spy as never; + + try { + muya.undo(); + } + catch { + // The apply may throw; the engine must still leave a consistent tree. + } + finally { + ;(ScrollPage as unknown as { loadBlock: unknown }).loadBlock = realLoadBlock; + } + + // jsonState was updated first, so the markdown is the restored list. + expect(muya.getMarkdown()).toContain('- foo'); + // The live tree MUST match it — never left empty/half-applied. + const s = spState(muya); + expect(s.tail, 'ScrollPage must not be left empty after a failed undo apply').not.toBeNull(); + expect(s.len).toBe(s.dom); + expect(s.len).toBeGreaterThan(0); + }); +}); diff --git a/packages/muya/src/editor/index.ts b/packages/muya/src/editor/index.ts index 8917825b8f..a040dd4637 100644 --- a/packages/muya/src/editor/index.ts +++ b/packages/muya/src/editor/index.ts @@ -407,11 +407,21 @@ export class Editor { if (operations === null) return; - const snapshot = pick(this.scrollPage as BlockNode, operations); + try { + const snapshot = pick(this.scrollPage as BlockNode, operations); - drop(snapshot, operations, muya); + drop(snapshot, operations, muya); - this._restoreSelection(selection); + this._restoreSelection(selection); + } + catch (error) { + // The incremental walk left the live tree half-applied (pick removed + // blocks drop never re-inserted). The json state is authoritative and + // already up to date — rebuild from it instead of leaving an empty doc. + debug.error(`updateContents incremental apply failed; rebuilding from state: ${String(error)}`); + this.scrollPage!.updateState(this.jsonState.getState()); + this._restoreSelection(selection, true); + } } private _restoreSelection(selection: Nullable, treeRebuilt = false) { From b9722dca8102c5c39faa9ce9e71b716f9e7ad401 Mon Sep 17 00:00:00 2001 From: Furkan Bora Murat <64507603+FurkaanBoraa@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:55:33 +0300 Subject: [PATCH 7/7] fix(desktop): paint the launch window in the active theme's background (#3957) (#4718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main process set a freshly-created window's backgroundColor from a switch that only covered six themes; every other theme — including dark ones like dracula, nord, and tokyo-night — fell back to #ffffff. Because the window is shown immediately, those themes flashed white on launch before the renderer painted the real theme. Move the lookup into common/theme as getThemeBackgroundColor, backed by a per-theme map of every built-in theme's editor background (kept in sync with each theme's --editorBgColor) plus a dark/light fallback for unknown themes. BaseWindow._getPreferredBackgroundColor now delegates to it. Adds a unit test asserting no dark theme resolves to white. --- packages/desktop/src/common/theme.ts | 56 +++++++++++++++++++ packages/desktop/src/main/windows/base.ts | 24 ++------ .../unit/specs/theme-background-color.spec.ts | 33 +++++++++++ 3 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 packages/desktop/test/unit/specs/theme-background-color.spec.ts diff --git a/packages/desktop/src/common/theme.ts b/packages/desktop/src/common/theme.ts index 816b5a5f9c..d7a8474d02 100644 --- a/packages/desktop/src/common/theme.ts +++ b/packages/desktop/src/common/theme.ts @@ -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 = 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 +} diff --git a/packages/desktop/src/main/windows/base.ts b/packages/desktop/src/main/windows/base.ts index 73e776948c..3b72afaac0 100644 --- a/packages/desktop/src/main/windows/base.ts +++ b/packages/desktop/src/main/windows/base.ts @@ -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. @@ -149,24 +150,11 @@ class BaseWindow extends TypedEmitter { } 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) } } diff --git a/packages/desktop/test/unit/specs/theme-background-color.spec.ts b/packages/desktop/test/unit/specs/theme-background-color.spec.ts new file mode 100644 index 0000000000..fea16bfcac --- /dev/null +++ b/packages/desktop/test/unit/specs/theme-background-color.spec.ts @@ -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') + } + }) +})