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/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 { 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') + } + }) +}) 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/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/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/__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/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/__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, 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(); } /** 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) { 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) 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) => {