diff --git a/packages/desktop/src/main/filesystem/index.ts b/packages/desktop/src/main/filesystem/index.ts index 978c4bfa2d..ccf848bf51 100644 --- a/packages/desktop/src/main/filesystem/index.ts +++ b/packages/desktop/src/main/filesystem/index.ts @@ -32,5 +32,8 @@ export const writeFile = ( } pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}` + // `outputFile` creates any missing parent directories before writing, so a + // save whose folder was moved/deleted recreates it and still succeeds — + // matching VS Code, and keeping (auto)save from ever silently failing (#3509). return outputFile(pathname, content, options) } diff --git a/packages/desktop/src/main/menu/templates/theme.ts b/packages/desktop/src/main/menu/templates/theme.ts index e3183b6470..2d17976eee 100644 --- a/packages/desktop/src/main/menu/templates/theme.ts +++ b/packages/desktop/src/main/menu/templates/theme.ts @@ -3,11 +3,65 @@ import * as actions from '../actions/theme' import { t } from '../../i18n' import type Preference from '../../preferences' +// [i18nLabelKey, themeId] for each selectable theme. The menu label is +// `menu.theme.`; `themeId` is both the menu item id and the value +// passed to `selectTheme` / compared against the saved theme. (A few light +// themes keep historical ids, e.g. cadmiumLight -> 'light'.) +const LIGHT_THEMES: ReadonlyArray = [ + ['ayuLight', 'ayu-light'], + ['cadmiumLight', 'light'], + ['catppuccinLatte', 'catppuccin-latte'], + ['everforestLight', 'everforest-light'], + ['graphiteLight', 'graphite'], + ['gruvboxLight', 'gruvbox-light'], + ['rosePineDawn', 'rose-pine-dawn'], + ['solarizedLight', 'solarized-light'], + ['tokyoNightLight', 'tokyo-night-light'], + ['ulyssesLight', 'ulysses'] +] + +const DARK_THEMES: ReadonlyArray = [ + ['ayuDark', 'ayu-dark'], + ['ayuMirage', 'ayu-mirage'], + ['cadmiumDark', 'dark'], + ['catppuccinMocha', 'catppuccin-mocha'], + ['cyberdream', 'cyberdream'], + ['dracula', 'dracula'], + ['everforestDark', 'everforest-dark'], + ['gruvboxDark', 'gruvbox-dark'], + ['horizonDark', 'horizon-dark'], + ['kanagawa', 'kanagawa'], + ['materialDark', 'material-dark'], + ['monokaiPro', 'monokai-pro'], + ['nightfox', 'nightfox'], + ['nord', 'nord'], + ['oneDark', 'one-dark'], + ['oxocarbonDark', 'oxocarbon-dark'], + ['palenight', 'palenight'], + ['rosePine', 'rose-pine'], + ['rosePineMoon', 'rose-pine-moon'], + ['solarizedDark', 'solarized-dark'], + ['synthwave84', 'synthwave-84'], + ['tokyoNight', 'tokyo-night'], + ['tokyoNightStorm', 'tokyo-night-storm'] +] + export default function(userPreference: Preference): MenuItemConstructorOptions { const preferences = userPreference.getAll() as { theme?: string; followSystemTheme?: boolean } const { theme, followSystemTheme } = preferences const isThemeSelectionEnabled = !followSystemTheme + const themeRadio = ([labelKey, id]: readonly [string, string]): MenuItemConstructorOptions => ({ + label: t(`menu.theme.${labelKey}`), + type: 'radio', + id, + enabled: isThemeSelectionEnabled, + checked: theme === id, + click() { + actions.selectTheme(id) + } + }) + const submenu: MenuItemConstructorOptions[] = [ // Follow System Theme { @@ -28,349 +82,20 @@ export default function(userPreference: Preference): MenuItemConstructorOptions }) } + // Group themes into nested submenus so the top-level Theme menu stays short + // instead of expanding to the full window height with 30+ flat items (#4534). submenu.push( - // Light Themes (alphabetical) + { type: 'separator' }, { label: t('menu.theme.lightThemes'), - enabled: false - }, - { - label: t('menu.theme.ayuLight'), - type: 'radio', - id: 'ayu-light', - enabled: isThemeSelectionEnabled, - checked: theme === 'ayu-light', - click() { - actions.selectTheme('ayu-light') - } - }, - { - label: t('menu.theme.cadmiumLight'), - type: 'radio', - id: 'light', - enabled: isThemeSelectionEnabled, - checked: theme === 'light', - click() { - actions.selectTheme('light') - } - }, - { - label: t('menu.theme.catppuccinLatte'), - type: 'radio', - id: 'catppuccin-latte', - enabled: isThemeSelectionEnabled, - checked: theme === 'catppuccin-latte', - click() { - actions.selectTheme('catppuccin-latte') - } - }, - { - label: t('menu.theme.everforestLight'), - type: 'radio', - id: 'everforest-light', - enabled: isThemeSelectionEnabled, - checked: theme === 'everforest-light', - click() { - actions.selectTheme('everforest-light') - } - }, - { - label: t('menu.theme.graphiteLight'), - type: 'radio', - id: 'graphite', - enabled: isThemeSelectionEnabled, - checked: theme === 'graphite', - click() { - actions.selectTheme('graphite') - } - }, - { - label: t('menu.theme.gruvboxLight'), - type: 'radio', - id: 'gruvbox-light', - enabled: isThemeSelectionEnabled, - checked: theme === 'gruvbox-light', - click() { - actions.selectTheme('gruvbox-light') - } - }, - { - label: t('menu.theme.rosePineDawn'), - type: 'radio', - id: 'rose-pine-dawn', - enabled: isThemeSelectionEnabled, - checked: theme === 'rose-pine-dawn', - click() { - actions.selectTheme('rose-pine-dawn') - } + submenu: LIGHT_THEMES.map(themeRadio) }, - { - label: t('menu.theme.solarizedLight'), - type: 'radio', - id: 'solarized-light', - enabled: isThemeSelectionEnabled, - checked: theme === 'solarized-light', - click() { - actions.selectTheme('solarized-light') - } - }, - { - label: t('menu.theme.tokyoNightLight'), - type: 'radio', - id: 'tokyo-night-light', - enabled: isThemeSelectionEnabled, - checked: theme === 'tokyo-night-light', - click() { - actions.selectTheme('tokyo-night-light') - } - }, - { - label: t('menu.theme.ulyssesLight'), - type: 'radio', - id: 'ulysses', - enabled: isThemeSelectionEnabled, - checked: theme === 'ulysses', - click() { - actions.selectTheme('ulysses') - } - }, - { type: 'separator' }, - // Dark Themes (alphabetical) { label: t('menu.theme.darkThemes'), - enabled: false - }, - { - label: t('menu.theme.ayuDark'), - type: 'radio', - id: 'ayu-dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'ayu-dark', - click() { - actions.selectTheme('ayu-dark') - } - }, - { - label: t('menu.theme.ayuMirage'), - type: 'radio', - id: 'ayu-mirage', - enabled: isThemeSelectionEnabled, - checked: theme === 'ayu-mirage', - click() { - actions.selectTheme('ayu-mirage') - } - }, - { - label: t('menu.theme.cadmiumDark'), - type: 'radio', - id: 'dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'dark', - click() { - actions.selectTheme('dark') - } - }, - { - label: t('menu.theme.catppuccinMocha'), - type: 'radio', - id: 'catppuccin-mocha', - enabled: isThemeSelectionEnabled, - checked: theme === 'catppuccin-mocha', - click() { - actions.selectTheme('catppuccin-mocha') - } - }, - { - label: t('menu.theme.cyberdream'), - type: 'radio', - id: 'cyberdream', - enabled: isThemeSelectionEnabled, - checked: theme === 'cyberdream', - click() { - actions.selectTheme('cyberdream') - } - }, - { - label: t('menu.theme.dracula'), - type: 'radio', - id: 'dracula', - enabled: isThemeSelectionEnabled, - checked: theme === 'dracula', - click() { - actions.selectTheme('dracula') - } - }, - { - label: t('menu.theme.everforestDark'), - type: 'radio', - id: 'everforest-dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'everforest-dark', - click() { - actions.selectTheme('everforest-dark') - } - }, - { - label: t('menu.theme.gruvboxDark'), - type: 'radio', - id: 'gruvbox-dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'gruvbox-dark', - click() { - actions.selectTheme('gruvbox-dark') - } - }, - { - label: t('menu.theme.horizonDark'), - type: 'radio', - id: 'horizon-dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'horizon-dark', - click() { - actions.selectTheme('horizon-dark') - } - }, - { - label: t('menu.theme.kanagawa'), - type: 'radio', - id: 'kanagawa', - enabled: isThemeSelectionEnabled, - checked: theme === 'kanagawa', - click() { - actions.selectTheme('kanagawa') - } - }, - { - label: t('menu.theme.materialDark'), - type: 'radio', - id: 'material-dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'material-dark', - click() { - actions.selectTheme('material-dark') - } - }, - { - label: t('menu.theme.monokaiPro'), - type: 'radio', - id: 'monokai-pro', - enabled: isThemeSelectionEnabled, - checked: theme === 'monokai-pro', - click() { - actions.selectTheme('monokai-pro') - } - }, - { - label: t('menu.theme.nightfox'), - type: 'radio', - id: 'nightfox', - enabled: isThemeSelectionEnabled, - checked: theme === 'nightfox', - click() { - actions.selectTheme('nightfox') - } - }, - { - label: t('menu.theme.nord'), - type: 'radio', - id: 'nord', - enabled: isThemeSelectionEnabled, - checked: theme === 'nord', - click() { - actions.selectTheme('nord') - } - }, - { - label: t('menu.theme.oneDark'), - type: 'radio', - id: 'one-dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'one-dark', - click() { - actions.selectTheme('one-dark') - } - }, - { - label: t('menu.theme.oxocarbonDark'), - type: 'radio', - id: 'oxocarbon-dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'oxocarbon-dark', - click() { - actions.selectTheme('oxocarbon-dark') - } - }, - { - label: t('menu.theme.palenight'), - type: 'radio', - id: 'palenight', - enabled: isThemeSelectionEnabled, - checked: theme === 'palenight', - click() { - actions.selectTheme('palenight') - } - }, - { - label: t('menu.theme.rosePine'), - type: 'radio', - id: 'rose-pine', - enabled: isThemeSelectionEnabled, - checked: theme === 'rose-pine', - click() { - actions.selectTheme('rose-pine') - } - }, - { - label: t('menu.theme.rosePineMoon'), - type: 'radio', - id: 'rose-pine-moon', - enabled: isThemeSelectionEnabled, - checked: theme === 'rose-pine-moon', - click() { - actions.selectTheme('rose-pine-moon') - } - }, - { - label: t('menu.theme.solarizedDark'), - type: 'radio', - id: 'solarized-dark', - enabled: isThemeSelectionEnabled, - checked: theme === 'solarized-dark', - click() { - actions.selectTheme('solarized-dark') - } - }, - { - label: t('menu.theme.synthwave84'), - type: 'radio', - id: 'synthwave-84', - enabled: isThemeSelectionEnabled, - checked: theme === 'synthwave-84', - click() { - actions.selectTheme('synthwave-84') - } - }, - { - label: t('menu.theme.tokyoNight'), - type: 'radio', - id: 'tokyo-night', - enabled: isThemeSelectionEnabled, - checked: theme === 'tokyo-night', - click() { - actions.selectTheme('tokyo-night') - } - }, - { - label: t('menu.theme.tokyoNightStorm'), - type: 'radio', - id: 'tokyo-night-storm', - enabled: isThemeSelectionEnabled, - checked: theme === 'tokyo-night-storm', - click() { - actions.selectTheme('tokyo-night-storm') - } + submenu: DARK_THEMES.map(themeRadio) } ) + return { label: t('menu.theme.theme'), id: 'themeMenu', diff --git a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue index 84712fd397..423299563e 100644 --- a/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue +++ b/packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue @@ -1574,6 +1574,10 @@ const blurEditor = () => { editor.value?.blur(false, true) } +const flushActiveEditor = () => { + editor.value?.flush() +} + const focusEditor = () => { editor.value?.focus() } @@ -1780,6 +1784,7 @@ onMounted(() => { bus.on('insert-image', insertImage) bus.on('image-uploaded', handleUploadedImage) bus.on('file-changed', handleFileChange) + bus.on('flush-active-editor', flushActiveEditor) bus.on('editor-blur', blurEditor) bus.on('editor-focus', focusEditor) bus.on('copyAsRich', handleCopyPaste) @@ -1929,6 +1934,7 @@ onBeforeUnmount(() => { bus.off('insert-image', insertImage) bus.off('image-uploaded', handleUploadedImage) bus.off('file-changed', handleFileChange) + bus.off('flush-active-editor', flushActiveEditor) bus.off('editor-blur', blurEditor) bus.off('editor-focus', focusEditor) bus.off('copyAsRich', handleCopyPaste) diff --git a/packages/desktop/src/renderer/src/store/editor.ts b/packages/desktop/src/renderer/src/store/editor.ts index ea48f63375..d589d1e52e 100644 --- a/packages/desktop/src/renderer/src/store/editor.ts +++ b/packages/desktop/src/renderer/src/store/editor.ts @@ -809,6 +809,11 @@ export const useEditorStore = defineStore('editor', { if (oldCurrentFile == null || oldCurrentFile.id !== currentFile.id) { const { id, markdown, cursor, history, pathname, scrollTop, blocks, muyaIndexCursor } = currentFile + // Must run while `currentFile` still points at the outgoing tab, so its + // flushed edit is attributed to that tab and not lost on switch (#2938). + if (oldCurrentFile) { + bus.emit('flush-active-editor') + } window.DIRNAME = pathname ? window.path.dirname(pathname) : '' this.currentFile = currentFile didUpdateCurrentFile = true diff --git a/packages/desktop/test/unit/specs/write-file-missing-dir.spec.ts b/packages/desktop/test/unit/specs/write-file-missing-dir.spec.ts new file mode 100644 index 0000000000..49acd7e751 --- /dev/null +++ b/packages/desktop/test/unit/specs/write-file-missing-dir.spec.ts @@ -0,0 +1,44 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import path from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { writeFile } from 'main_renderer/filesystem' + +// #3509: with autosave on, moving/deleting a file's folder while it is open +// makes the save target a now-missing directory. MarkText intentionally +// recreates the directory tree and writes the file (via fs-extra `outputFile`), +// matching VS Code, so an (auto)save never silently fails or loses edits. These +// tests pin that behavior. + +const dirs: string[] = [] +function tempDir(): string { + const d = mkdtempSync(path.join(tmpdir(), 'mt-3509-')) + dirs.push(d) + return d +} + +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +describe('writeFile — missing parent directory (#3509)', () => { + it('recreates a directory that no longer exists and writes the file', async() => { + const base = tempDir() + const missingDir = path.join(base, 'moved-away') + const target = path.join(missingDir, 'note.md') + + await writeFile(target, 'hello', undefined) + + expect(existsSync(missingDir)).toBe(true) + expect(readFileSync(target, 'utf-8')).toBe('hello') + }) + + it('writes normally when the parent directory exists', async() => { + const base = tempDir() + const target = path.join(base, 'note.md') + + await writeFile(target, 'hello', undefined) + + expect(readFileSync(target, 'utf-8')).toBe('hello') + }) +}) diff --git a/packages/muya/e2e/tests/blocks/list-nav-4644.spec.ts b/packages/muya/e2e/tests/blocks/list-nav-4644.spec.ts new file mode 100644 index 0000000000..4d97030474 --- /dev/null +++ b/packages/muya/e2e/tests/blocks/list-nav-4644.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import { loadMarkdown, slowType } from '../helpers/keyboard'; +import { editor } from '../helpers/selectors'; + +// #4644 — a sequence of list edits used to leave a `list-item` with zero +// content children behind. An empty list item has no content descendant, so +// cross-block arrow navigation (`previousContentInContext`) could not step +// over it and Up arrow stopped moving the caret up a line. +// +// Repro sequence: * [space] A [return] B [up] [return] [backspace] [up] +// [return] [return] [return] + +// Index of the active content block among all `.content` leaves, plus the +// total content-block count — enough to tell whether the caret actually moved. +async function caretProbe(page: import('@playwright/test').Page) { + return page.evaluate(() => { + const muya = window.muya!; + const leaves: unknown[] = []; + const visit = (block: { + constructor: { blockName?: string }; + children?: { forEach: (cb: (b: unknown) => void) => void }; + }) => { + if (block.constructor?.blockName?.endsWith('.content')) + leaves.push(block); + block.children?.forEach(b => visit(b as typeof block)); + }; + visit(muya.editor.scrollPage as unknown as Parameters[0]); + return { + activeIndex: leaves.indexOf(muya.editor.activeContentBlock as unknown), + leafCount: leaves.length, + }; + }); +} + +async function runSequence(page: import('@playwright/test').Page) { + await page.goto('/'); + await loadMarkdown(page, 'seed\n'); + const seed = page.locator(editor.paragraph).filter({ hasText: 'seed' }).first(); + await seed.click(); + await page.keyboard.press('End'); + for (let i = 0; i < 4; i++) + await page.keyboard.press('Backspace'); + + await slowType(page, '* '); + await slowType(page, 'A'); + await page.keyboard.press('Enter'); + await slowType(page, 'B'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(100); +} + +test('list edit sequence never leaves an empty list item', async ({ page }) => { + await runSequence(page); + + const hasEmptyListItem = await page.evaluate(() => { + const state = window.muya!.getState(); + const found: boolean[] = []; + const walk = (node: { name?: string; children?: unknown[] }) => { + if (node.name === 'list-item' || node.name === 'task-list-item') + found.push(!node.children || node.children.length === 0); + node.children?.forEach(c => walk(c as typeof node)); + }; + state.forEach(n => walk(n as { name?: string; children?: unknown[] })); + return found.some(Boolean); + }); + + expect(hasEmptyListItem).toBe(false); +}); + +test('Up arrow still moves the caret up after the list edit sequence', async ({ page }) => { + await runSequence(page); + + const before = await caretProbe(page); + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(100); + const after = await caretProbe(page); + + // The caret must land on an earlier content block, not stay put. + expect(after.activeIndex).toBeLessThan(before.activeIndex); +}); diff --git a/packages/muya/src/__tests__/muyaDestroyPlugins.spec.ts b/packages/muya/src/__tests__/muyaDestroyPlugins.spec.ts new file mode 100644 index 0000000000..b3b20c7234 --- /dev/null +++ b/packages/muya/src/__tests__/muyaDestroyPlugins.spec.ts @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../muya'; + +// #3315: Muya.destroy() hid visible float tools but never invoked each UI +// plugin's destroy(), so nodes appended to document.body in plugin +// constructors/init (float boxes, the image resize bar) leaked permanently. +// destroy() must iterate the registered plugins and call destroy() on each. + +const bootedHosts: HTMLElement[] = []; +let hadVersion = false; +let originalVersion: string | undefined; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) bootedHosts.pop()!.remove(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else 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; +} + +describe('muya.destroy — UI plugin cleanup (#3315)', () => { + it('calls destroy() on every registered UI plugin', () => { + const muya = bootMuya('hello\n'); + const destroyA = vi.fn(); + const destroyB = vi.fn() + ;(muya as unknown as { _uiPlugins: Record })._uiPlugins = { + a: { destroy: destroyA }, + b: { destroy: destroyB }, + }; + + muya.destroy(); + + expect(destroyA).toHaveBeenCalledTimes(1); + expect(destroyB).toHaveBeenCalledTimes(1); + }); + + it('does not throw for a plugin without a destroy() method', () => { + const muya = bootMuya('hello\n') + ;(muya as unknown as { _uiPlugins: Record })._uiPlugins = { + legacy: {}, + }; + + expect(() => muya.destroy()).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 d41f3d7f67..6fafebe49c 100644 --- a/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts +++ b/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts @@ -138,6 +138,55 @@ describe('content arrowHandler — cross-block navigation up', () => { expect(event.preventDefault).not.toHaveBeenCalled(); expect(event.stopPropagation).not.toHaveBeenCalled(); }); + + // #3193: ArrowUp on the first visual line of the FIRST block (no previous + // block) used to preventDefault and return without moving the caret, so the + // caret stayed put. It should move to the start of the line (offset 0). + it('arrowUp in the first block (no previous) moves the caret to offset 0', async () => { + const muya = bootMuya('alpha\n\nbeta\n'); + const alpha = contentByText(muya, 'alpha'); + + const event = arrowAt(muya, alpha, 'ArrowUp', 3); + await flush(); + + const cursor = alpha.getCursor(); + expect(cursor).not.toBeNull(); + expect(cursor!.start.offset).toBe(0); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + // #3193 follow-up: when the caret is ALREADY at offset 0 of the first block, + // ArrowUp has nowhere to go — it must not re-set the selection (which would + // emit a spurious selection-change and needlessly re-render the block). + it('arrowUp already at offset 0 of the first block does not emit selection-change', async () => { + const muya = bootMuya('alpha\n\nbeta\n'); + const alpha = contentByText(muya, 'alpha'); + + // Land at offset 0 first; this setup emits its own change. + muya.editor.activeContentBlock = alpha; + alpha.setCursor(0, 0, true); + await flush(); + + // Only now start counting: the no-op ArrowUp must stay silent. + let emitted = 0; + muya.eventCenter.on('selection-change', () => { + emitted += 1; + }); + + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + key: 'ArrowUp', + shiftKey: false, + } as unknown as FakeArrowEvent; + alpha.arrowHandler(event); + await flush(); + + expect(emitted).toBe(0); + // The caret is untouched, and the native no-op scroll is still suppressed. + expect(alpha.getCursor()!.start.offset).toBe(0); + expect(event.preventDefault).toHaveBeenCalled(); + }); }); describe('content arrowHandler — cross-block navigation down', () => { diff --git a/packages/muya/src/block/base/__tests__/crossBlockFormat.spec.ts b/packages/muya/src/block/base/__tests__/crossBlockFormat.spec.ts index be87e8a402..17f56fd225 100644 --- a/packages/muya/src/block/base/__tests__/crossBlockFormat.spec.ts +++ b/packages/muya/src/block/base/__tests__/crossBlockFormat.spec.ts @@ -82,6 +82,30 @@ describe('cross-block format', () => { expect(sel.focusBlock!.text).toContain('bravo'); }); + it('bolds two paragraphs nested in the same blockquote (#3462)', async () => { + const muya = boot('> alpha\n>\n> bravo\n'); + const sp = muya.editor.scrollPage!; + const first = sp.firstContentInDescendant()!; + const second = sp.lastContentInDescendant()!; + // Both leaves live inside the SAME outmost block (the blockquote), so an + // outmost-block-granular same-block check wrongly reports "same block". + expect(first).not.toBe(second); + expect(first.outMostBlock).toBe(second.outMostBlock); + stubFullRange(first); + stubFullRange(second); + muya.editor.activeContentBlock = second; + muya.editor.selection.setSelection( + { offset: 0, block: first, path: first.path }, + { offset: second.text.length, block: second, path: second.path }, + ); + muya.format('strong'); + await vi.waitFor(() => { + const md = muya.getMarkdown(); + expect(md).toContain('**alpha**'); + expect(md).toContain('**bravo**'); + }); + }); + it('skips a code block inside the range', async () => { const muya = boot('alpha\n\n```\ncode\n```\n\nbravo\n'); const sp = muya.editor.scrollPage!; diff --git a/packages/muya/src/block/base/content.ts b/packages/muya/src/block/base/content.ts index fae1de790d..fb0ae41746 100644 --- a/packages/muya/src/block/base/content.ts +++ b/packages/muya/src/block/base/content.ts @@ -458,8 +458,17 @@ class Content extends TreeNode { event.preventDefault(); event.stopPropagation(); - if (!previousContentBlock) + if (!previousContentBlock) { + // First block, no previous: ArrowUp moves the caret to the + // start of the line (offset 0) instead of staying put (#3193). + // A boundary ArrowLeft has nowhere to go, so leave it. Skip the + // re-set when the caret is already at offset 0, so a no-op + // ArrowUp doesn't emit a spurious selection-change or re-render. + if (event.key === EVENT_KEYS.ArrowUp && start.offset !== 0) + this.setCursor(0, 0, true); + return; + } cursorBlock = previousContentBlock; offset = previousContentBlock.text.length; diff --git a/packages/muya/src/block/content/paragraphContent/__tests__/enterListItemEmptyFirst.spec.ts b/packages/muya/src/block/content/paragraphContent/__tests__/enterListItemEmptyFirst.spec.ts new file mode 100644 index 0000000000..45ec3077e7 --- /dev/null +++ b/packages/muya/src/block/content/paragraphContent/__tests__/enterListItemEmptyFirst.spec.ts @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +import type { TState } from '../../../../state/types'; +import type Content from '../../../base/content'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../../../muya'; + +// #4644 — pressing Enter on the EMPTY FIRST paragraph of a loose list item +// (one that holds more than one paragraph) must not strip every paragraph out +// of the list item. `_enterInListItem`'s "empty paragraph, not only child" +// branch moved every paragraph from the caret's index to the end into a new +// sibling list item; when the caret sat on the first paragraph (index 0) that +// emptied the original list item, leaving a `list-item` with zero children. +// +// An empty list item has no content descendant, so +// `previousContentInContext()`/`nextContentInContext()` return null when arrow +// navigation tries to cross it — the symptom reported in #4644 where Up arrow +// can no longer move the caret up a line. + +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; +}); + +// A loose bullet list whose single list item holds two paragraphs: an empty +// first paragraph followed by `tail`. +const LOOSE_ITEM_STATE: TState[] = [ + { + name: 'bullet-list', + meta: { loose: true, marker: '*' }, + children: [ + { + name: 'list-item', + children: [ + { name: 'paragraph', text: '' }, + { name: 'paragraph', text: 'tail' }, + ], + }, + ], + }, +] as unknown as TState[]; + +function bootMuya(content: TState[]): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, {} as ConstructorParameters[1]); + muya.init(); + muya.setContent(content); + bootedHosts.push(muya.domNode); + return muya; +} + +function contentByText(muya: Muya, text: string): Content { + let target: Content | null = null; + const visit = (block: { + text?: string; + constructor: { blockName?: string }; + children?: { forEach: (cb: (b: unknown) => void) => void }; + }) => { + if (block.constructor.blockName?.endsWith('.content') && block.text === text) + target = block as unknown as Content; + block.children?.forEach(b => visit(b as typeof block)); + }; + visit(muya.editor.scrollPage as unknown as Parameters[0]); + if (!target) + throw new Error(`content block with text "${text}" not found`); + return target; +} + +function enterAt(muya: Muya, content: Content, offset: number) { + muya.editor.activeContentBlock = content; + content.setCursor(offset, offset, true); + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + shiftKey: false, + key: 'Enter', + } as unknown as KeyboardEvent; + content.enterHandler(event); +} + +function flush(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +interface IListItemLike { name: string; children?: unknown[] } + +describe('#4644 enter on empty first paragraph of a multi-paragraph list item', () => { + it('never produces a list-item with zero content children', async () => { + const muya = bootMuya(LOOSE_ITEM_STATE); + const emptyFirst = contentByText(muya, ''); + + enterAt(muya, emptyFirst, 0); + await flush(); + + const state = muya.getState(); + const list = state[0] as { children: IListItemLike[] }; + for (const item of list.children) { + expect(item.name).toBe('list-item'); + expect(item.children && item.children.length).toBeGreaterThan(0); + } + }); + + it('splits into two non-empty list items (empty stays, tail moves down)', async () => { + const muya = bootMuya(LOOSE_ITEM_STATE); + const emptyFirst = contentByText(muya, ''); + + enterAt(muya, emptyFirst, 0); + await flush(); + + const state = muya.getState(); + const list = state[0] as { children: { children: { name: string; text: string }[] }[] }; + expect(list.children.length).toBe(2); + expect(list.children[0].children.map(p => p.text)).toEqual(['']); + expect(list.children[1].children.map(p => p.text)).toEqual(['tail']); + }); +}); diff --git a/packages/muya/src/block/content/paragraphContent/index.ts b/packages/muya/src/block/content/paragraphContent/index.ts index 9c92a95bbe..28ba820013 100644 --- a/packages/muya/src/block/content/paragraphContent/index.ts +++ b/packages/muya/src/block/content/paragraphContent/index.ts @@ -469,7 +469,11 @@ class ParagraphContent extends Format { : { name: 'list-item', children: [] }; const offset = listItem.offset(parent!); - listItem.forEachAt(offset, undefined, (node) => { + // Splitting from index 0 would empty the original list item, + // leaving a childless list-item that breaks arrow navigation + // (#4644). Keep the empty first paragraph and split below it. + const from = offset === 0 ? 1 : offset; + listItem.forEachAt(from, undefined, (node) => { if (node.isParent()) newListItemState.children.push(node.getState()); node.remove(); diff --git a/packages/muya/src/clipboard/__tests__/crossBlockCutGuard.spec.ts b/packages/muya/src/clipboard/__tests__/crossBlockCutGuard.spec.ts new file mode 100644 index 0000000000..cbfebe3f6a --- /dev/null +++ b/packages/muya/src/clipboard/__tests__/crossBlockCutGuard.spec.ts @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from 'vitest'; +import { shouldCrossBlockCut } from '../index'; + +// The clipboard module pulls in CodeBlockContent → utils/prism which touches +// `window` at import time. Stub the prism shim (same stub as sibling specs). +vi.mock('../../utils/prism/index', () => ({ + default: {}, + walkTokens: () => null, + loadedLanguages: new Set(), + transformAliasToOrigin: (s: string) => s, + loadLanguage: () => null, + search: () => [], +})); + +// #3491: on a cross-block selection, the keydown handler cuts (replaces) the +// selection for an editing keystroke. It must NOT cut on a modifier combo — +// in particular Ctrl+C (copy) on Windows/Linux, which previously deleted the +// selected text because only metaKey (macOS Cmd) was excluded. + +describe('shouldCrossBlockCut (#3491)', () => { + it('does NOT cut on Ctrl+C (Windows/Linux copy)', () => { + expect(shouldCrossBlockCut('c', false, true)).toBe(false); + }); + + it('does NOT cut on Cmd+C (macOS copy)', () => { + expect(shouldCrossBlockCut('c', true, false)).toBe(false); + }); + + it('does NOT cut on Ctrl+V / Ctrl+X / any Ctrl combo', () => { + expect(shouldCrossBlockCut('v', false, true)).toBe(false); + expect(shouldCrossBlockCut('x', false, true)).toBe(false); + }); + + it('cuts on a plain printable key (type-to-replace the selection)', () => { + expect(shouldCrossBlockCut('c', false, false)).toBe(true); + expect(shouldCrossBlockCut('a', false, false)).toBe(true); + }); + + it('cuts on Backspace / Delete', () => { + expect(shouldCrossBlockCut('Backspace', false, false)).toBe(true); + expect(shouldCrossBlockCut('Delete', false, false)).toBe(true); + }); + + it('does NOT cut on navigation / modifier-only keys', () => { + expect(shouldCrossBlockCut('ArrowDown', false, false)).toBe(false); + expect(shouldCrossBlockCut('Shift', false, false)).toBe(false); + }); +}); diff --git a/packages/muya/src/clipboard/index.ts b/packages/muya/src/clipboard/index.ts index f0acc3f080..d9c6032637 100644 --- a/packages/muya/src/clipboard/index.ts +++ b/packages/muya/src/clipboard/index.ts @@ -7,6 +7,21 @@ import { pastePlainText, pasteSelection } from './paste'; import { pasteImageSrc } from './pasteImage'; import { CopyType, PasteType } from './types'; +// After the table/same-block guards, decide whether a keydown over a +// cross-block selection should cut (replace) the selected text. Non-editing +// keys and any modifier combo must NOT cut — in particular Ctrl+ (e.g. +// Ctrl+C copy on Windows/Linux), which was previously not excluded and +// silently deleted the selection (#3491). Mirrors the macOS metaKey guard. +export function shouldCrossBlockCut(key: string, metaKey: boolean, ctrlKey: boolean): boolean { + if (/Alt|Option|Meta|Shift|CapsLock|ArrowUp|ArrowDown|ArrowLeft|ArrowRight/.test(key)) + return false; + + if (metaKey || ctrlKey) + return false; + + return true; +} + class Clipboard { public copyType: CopyType = CopyType.NORMAL; public pasteType: PasteType = PasteType.NORMAL; @@ -63,16 +78,7 @@ class Clipboard { if (isSelectionInSameBlock) return; - // TODO: Is there any way to identify these key bellow? - if ( - /Alt|Option|Meta|Shift|CapsLock|ArrowUp|ArrowDown|ArrowLeft|ArrowRight/.test( - key, - ) - ) { - return; - } - - if (metaKey) + if (!shouldCrossBlockCut(key, metaKey, event.ctrlKey)) return; if (key === 'Backspace' || key === 'Delete') diff --git a/packages/muya/src/muya.ts b/packages/muya/src/muya.ts index c299b62ecf..e59bc2887d 100644 --- a/packages/muya/src/muya.ts +++ b/packages/muya/src/muya.ts @@ -210,6 +210,12 @@ export class Muya { return this.editor.jsonState.getMarkdown(); } + // Flush queued edits synchronously; call before swapping the document out + // (e.g. a tab switch) so a same-frame keystroke isn't lost (#2938). + flush() { + this.editor.jsonState.flush(); + } + getTOC(): ITocItem[] { return this.editor.jsonState.getTOC(); } @@ -389,10 +395,12 @@ export class Muya { format(type: string) { const { selection } = this.editor; - // Cross-block selection: apply to each formattable leaf in range. The + // Cross-leaf selection: apply to each formattable leaf in range. The // live DOM selection collapses across blocks, so detect via the cached - // endpoints (the same ones the menu/IPC round-trip relies on). - if (!this._selectionInSameBlock()) { + // endpoints (the same ones the menu/IPC round-trip relies on). Compare + // at the LEAF level, not the outmost block: two paragraphs nested in the + // same blockquote share an outmost block but are distinct leaves (#3462). + if (!this._selectionInSameLeaf()) { this._formatAcrossBlocks(type); return; } @@ -721,6 +729,28 @@ export class Muya { return endpoints.anchor === endpoints.focus; } + /** + * Whether the current selection stays within a single content leaf. Unlike + * `_selectionInSameBlock` (outmost-block granularity, for paragraph-menu + * dispatch), this compares the actual leaves so a selection spanning two + * paragraphs nested in one blockquote is correctly treated as cross-leaf + * for inline formatting (#3462). + */ + private _selectionInSameLeaf(): boolean { + const sel = this.editor.selection; + const liveSel = sel.getSelection(); + const liveAnchor = liveSel?.anchor.block; + const liveFocus = liveSel?.focus.block; + if (liveAnchor && liveFocus && liveAnchor !== liveFocus) + return false; + const cachedAnchor = sel.anchorBlock; + const cachedFocus = sel.focusBlock; + if (cachedAnchor && cachedFocus && cachedAnchor !== cachedFocus) + return false; + + return true; + } + /** * The contiguous run of OUTMOST (scrollPage-child) blocks the current * selection spans, in document order. Mirrors clipboard's outmost walk. @@ -1622,6 +1652,15 @@ export class Muya { // Hide all float tools. if (this.ui) this.ui.hideAllFloatTools(); + + // Destroy every registered UI plugin so the nodes they appended to + // `document.body` (float boxes, the image resize bar, tooltips) are + // removed rather than leaked (#3315). + for (const plugin of Object.values(this._uiPlugins)) { + const destroy = (plugin as { destroy?: unknown })?.destroy; + if (typeof destroy === 'function') + (destroy as () => void).call(plugin); + } } } diff --git a/packages/muya/src/state/__tests__/flushPendingOps.spec.ts b/packages/muya/src/state/__tests__/flushPendingOps.spec.ts new file mode 100644 index 0000000000..5d5c718c01 --- /dev/null +++ b/packages/muya/src/state/__tests__/flushPendingOps.spec.ts @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import type Content from '../../block/base/content'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Muya } from '../../muya'; + +// #2938 part 2: `muya.flush()` makes a same-frame edit durable before the +// document is swapped out (a tab switch calls setContent within the same frame +// as the last keystroke). Drives the real typing path: `content.text = ...` +// queues an op + schedules a requestAnimationFrame; the op lands only when that +// frame fires — unless flushed first. + +const hosts: HTMLElement[] = []; +beforeEach(() => { + window.MUYA_VERSION = 'test'; +}); +afterEach(() => { + while (hosts.length) + hosts.pop()!.remove(); + document.getSelection()?.removeAllRanges(); +}); + +function boot(md: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown: md } as ConstructorParameters[1]); + muya.init(); + hosts.push(muya.domNode); + return muya; +} + +function nextFrame(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +describe('muya.flush() — make pending edits durable synchronously (#2938)', () => { + it('applies a queued edit and emits json-change synchronously', () => { + const muya = boot('hello\n'); + const leaf = muya.editor.scrollPage!.firstContentInDescendant() as Content; + + let changes = 0; + muya.eventCenter.on('json-change', () => { + changes += 1; + }); + + leaf.text = 'hello world'; // queued, not yet applied + expect(muya.getMarkdown().trim()).toBe('hello'); + expect(changes).toBe(0); + + muya.flush(); + + // The edit is now in the document, and a json-change fired — all without + // waiting for the animation frame. + expect(muya.getMarkdown().trim()).toBe('hello world'); + expect(changes).toBe(1); + }); + + it('flushing before setContent persists the outgoing edit (no loss, no double-flush)', async () => { + const muya = boot('hello\n'); + const leaf = muya.editor.scrollPage!.firstContentInDescendant() as Content; + + const captured: string[] = []; + muya.eventCenter.on('json-change', () => { + captured.push(muya.getMarkdown().trim()); + }); + + leaf.text = 'hello world'; // pending + + // Tab-switch sequence: flush the outgoing doc FIRST, then swap. + muya.flush(); + expect(captured).toEqual(['hello world']); // outgoing edit captured + + muya.setContent('B\n'); + await nextFrame(); + await nextFrame(); + + // No leftover op fired against B, and B is intact. + expect(captured).toEqual(['hello world']); + expect(muya.getMarkdown().trim()).toBe('B'); + }); + + it('is a no-op when nothing is pending', () => { + const muya = boot('hello\n'); + let changes = 0; + muya.eventCenter.on('json-change', () => { + changes += 1; + }); + + muya.flush(); + muya.flush(); + + expect(changes).toBe(0); + expect(muya.getMarkdown().trim()).toBe('hello'); + }); + + it('edits keep flushing normally after a flush', async () => { + const muya = boot('hello\n'); + const leaf = muya.editor.scrollPage!.firstContentInDescendant() as Content; + + leaf.text = 'one'; + muya.flush(); + expect(muya.getMarkdown().trim()).toBe('one'); + + // A subsequent edit still batches + flushes on its own frame. + const leaf2 = muya.editor.scrollPage!.firstContentInDescendant() as Content; + leaf2.text = 'two'; + expect(muya.getMarkdown().trim()).toBe('one'); // still deferred + await nextFrame(); + expect(muya.getMarkdown().trim()).toBe('two'); + }); +}); diff --git a/packages/muya/src/state/__tests__/setContentClearsOpCache.spec.ts b/packages/muya/src/state/__tests__/setContentClearsOpCache.spec.ts new file mode 100644 index 0000000000..540c2ef68c --- /dev/null +++ b/packages/muya/src/state/__tests__/setContentClearsOpCache.spec.ts @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom + +import type { Muya } from '../../muya'; +import type { TState } from '../types'; +import { describe, expect, it } from 'vitest'; +import JSONState from '../index'; + +// #2938: switching files (setContent) within the same frame as a pending edit +// left the previous document's deferred op batch in the cache. The scheduled +// requestAnimationFrame then applied that op to the NEW document's state, +// corrupting it (or throwing and freezing `_isGoing`), which broke saving the +// switched-to file. setContent must drop the pending batch and cancel its +// scheduled flush. + +function makeState(blocks: TState[]): JSONState { + const muya = { + options: { + footnote: false, + isGitlabCompatibilityEnabled: false, + trimUnnecessaryCodeBlockEmptyLines: false, + frontMatter: false, + math: false, + listIndentation: 1, + }, + eventCenter: { emit: () => {} }, + } as unknown as Muya; + return new JSONState(muya, blocks); +} + +function nextFrame(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} + +describe('setContent drops the previous document pending op batch (#2938)', () => { + it('a deferred op from the old doc does not corrupt the new content after a tab switch', async () => { + const state = makeState([{ name: 'paragraph', text: 'A' }]); + + // Pending edit against doc A (insert a block at index 1), not yet flushed. + state.insertOperation([1], { name: 'paragraph', text: 'STALE' }); + + // Switch to doc B within the same frame. + state.setContent([ + { name: 'paragraph', text: 'B1' }, + { name: 'paragraph', text: 'B2' }, + ]); + + // Let the (cancelled) rAF window elapse. + await nextFrame(); + await nextFrame(); + + const texts = (state.getState() as Array<{ text: string }>).map(b => b.text); + // The stale insert must NOT have been applied to doc B. + expect(texts).toEqual(['B1', 'B2']); + }); + + it('edits after a setContent still flush normally', async () => { + const state = makeState([{ name: 'paragraph', text: 'A' }]); + state.insertOperation([1], { name: 'paragraph', text: 'STALE' }); + state.setContent([{ name: 'paragraph', text: 'B' }]); + await nextFrame(); + + // A fresh op against doc B applies cleanly (not frozen by a stuck _isGoing). + state.insertOperation([1], { name: 'paragraph', text: 'C' }); + await nextFrame(); + await nextFrame(); + + const texts = (state.getState() as Array<{ text: string }>).map(b => b.text); + expect(texts).toEqual(['B', 'C']); + }); +}); diff --git a/packages/muya/src/state/index.ts b/packages/muya/src/state/index.ts index fcfd360bd4..d7515f1b4f 100644 --- a/packages/muya/src/state/index.ts +++ b/packages/muya/src/state/index.ts @@ -44,7 +44,11 @@ class JSONState { private _operationCache: JSONOpList[] = []; - private _isGoing = false; + // Handle of the scheduled deferred-op flush. Doubles as the "a flush is + // already scheduled" guard (non-null ⇒ batching in progress), and lets + // `setContent` cancel a pending batch that belongs to the outgoing + // document (#2938). + private _rafId: number | null = null; private _state: TState[] = []; @@ -62,6 +66,16 @@ class JSONState { } setContent(content: TState[] | string) { + // A pending deferred-op batch belongs to the OUTGOING document. Applying + // it to the new content would corrupt it (or throw and leave the flush + // guard stuck, freezing all future edits). Drop the batch and cancel its + // scheduled flush before swapping the state (#2938). + if (this._rafId !== null) { + cancelAnimationFrame(this._rafId); + this._rafId = null; + } + this._operationCache = []; + if (typeof content === 'object') this._setState(content); else @@ -229,37 +243,52 @@ class JSONState { } private _emitStateChange() { - if (this._isGoing) + if (this._rafId !== null) + return; + + this._rafId = requestAnimationFrame(() => { + this._rafId = null; + this._flushOperationCache(); + }); + } + + // Apply queued edits to the current document now instead of on the next + // frame. Lets a tab switch persist the outgoing tab's last keystroke before + // `setContent` replaces the document, otherwise that edit is lost (#2938). + flush() { + if (this._rafId === null) return; - this._isGoing = true; - - requestAnimationFrame(() => { - // Wrap compose in a lambda — `Array.prototype.reduce` passes - // (acc, current, index, array) to the callback, but - // `json1.type.compose` only accepts (op1, op2). Without the - // wrapper TS rejects the signature mismatch. - // `compose` returns JSONOp (= null | JSONOpList); when the cache - // contains at least one op the result is the composed list, - // never null. The reduce above runs only when _operationCache is - // non-empty (guarded by the requestAnimationFrame in - // `_emitStateChange`), and a non-empty cache always composes to - // a non-null op. - const op = this._operationCache.reduce( - (acc, curr) => json1.type.compose(acc, curr) as JSONOpList, - ); - const prevDoc = this.getState(); - this._apply(op); - // TODO: remove doc in future - const doc = this.getState(); - this._muya.eventCenter.emit('json-change', { - op, - source: 'user', - prevDoc, - doc, - }); - this._operationCache = []; - this._isGoing = false; + cancelAnimationFrame(this._rafId); + this._rafId = null; + this._flushOperationCache(); + } + + private _flushOperationCache() { + if (!this._operationCache.length) + return; + + // Wrap compose in a lambda — `Array.prototype.reduce` passes + // (acc, current, index, array) to the callback, but + // `json1.type.compose` only accepts (op1, op2). Without the + // wrapper TS rejects the signature mismatch. + // `compose` returns JSONOp (= null | JSONOpList); a non-empty cache + // (guarded above) always composes to a non-null op. + const op = this._operationCache.reduce( + (acc, curr) => json1.type.compose(acc, curr) as JSONOpList, + ); + const prevDoc = this.getState(); + this._apply(op); + // TODO: remove doc in future + const doc = this.getState(); + // Clear before emitting: a listener that edits synchronously then starts + // a fresh batch instead of mutating the one being flushed. + this._operationCache = []; + this._muya.eventCenter.emit('json-change', { + op, + source: 'user', + prevDoc, + doc, }); } } diff --git a/packages/muya/src/ui/imageResizeBar/index.ts b/packages/muya/src/ui/imageResizeBar/index.ts index de5529d946..1354a966f6 100644 --- a/packages/muya/src/ui/imageResizeBar/index.ts +++ b/packages/muya/src/ui/imageResizeBar/index.ts @@ -207,4 +207,10 @@ export class ImageResizeBar { this._status = false; eventCenter.emit('muya-float', this, false); } + + // Remove the `.mu-transformer` container appended to document.body in the + // constructor; invoked by `Muya.destroy()` so it is not leaked (#3315). + destroy() { + this._container.remove(); + } }