From e416a99ddbbfbf67430f6338e2a34f5255f27e4e Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Fri, 26 Jun 2026 18:54:04 +0800 Subject: [PATCH 1/8] chore: audit Claude-authored comments against the commenting guidelines (#4740) Apply .github/COMMENTING-GUIDELINES.md to every Claude-authored comment in the tree; remove/rewrite 74 comments across 50 files that restate code, echo a symbol name, are pure noise, or have gone stale. Comment-only changes. - refactor(muya): drop name-echo labels + a duplicate JSDoc in the engine - refactor(desktop): drop comments that restate code, names, or are stale - test: remove stale comments that contradict now-passing tests - test: drop comments that restate the adjacent assertion Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/desktop/src/renderer/src/bootstrap.ts | 2 -- packages/desktop/src/renderer/src/i18n/index.ts | 2 -- packages/desktop/src/renderer/src/pages/app.vue | 1 - .../image/components/uploader/index.vue | 6 +----- packages/desktop/src/renderer/src/store/editor.ts | 1 - packages/desktop/src/renderer/src/store/index.ts | 1 - .../desktop/src/renderer/src/store/preferences.ts | 2 +- packages/desktop/src/renderer/src/util/exportHtml.ts | 1 - packages/desktop/src/shared/types/bufferedState.ts | 2 -- packages/desktop/src/types/muya-core.d.ts | 1 - packages/desktop/src/types/muya.d.ts | 3 --- packages/desktop/test/e2e/crash-range-offset.spec.ts | 2 -- .../test/e2e/parity-source-undo-saved.spec.ts | 3 +-- packages/desktop/test/e2e/view-modes.spec.ts | 1 - packages/desktop/test/unit/markdown.ts | 4 ---- .../test/unit/specs/format-menu-state.spec.ts | 2 -- packages/desktop/test/unit/specs/pdf.spec.ts | 2 +- .../e2e/tests/blocks/reference-link-image.spec.ts | 2 -- packages/muya/e2e/tests/diagrams/mermaid.spec.ts | 1 - .../tests/diagrams/sequence-overflow-3560.spec.ts | 4 ++-- packages/muya/e2e/tests/diagrams/sequence.spec.ts | 1 - .../muya/e2e/tests/inline/format-toolbar.spec.ts | 1 - packages/muya/e2e/tests/inline/shortcuts.spec.ts | 1 - packages/muya/e2e/tests/options/disable-html.spec.ts | 1 - .../muya/e2e/tests/typing/math-and-diagrams.spec.ts | 1 - .../e2e/tests/typing/paragraph-and-headings.spec.ts | 2 -- packages/muya/e2e/tests/typing/table.spec.ts | 2 -- .../src/__tests__/createTableImageCursor.spec.ts | 1 - .../muya/src/__tests__/crossBlockParagraph.spec.ts | 2 +- .../muya/src/__tests__/historySerialization.spec.ts | 2 -- .../muya/src/__tests__/parityHeadingCopyLink.spec.ts | 1 - .../__tests__/parityInsertParagraphNested.spec.ts | 5 ++--- packages/muya/src/__tests__/resetToParagraph.spec.ts | 1 - .../muya/src/__tests__/setCursorByOffset.spec.ts | 1 - packages/muya/src/__tests__/setOptions.spec.ts | 1 - .../muya/src/__tests__/updateParagraph.menu.spec.ts | 4 ++-- packages/muya/src/__tests__/updateParagraph.spec.ts | 1 - .../src/block/base/__tests__/formatToggle.spec.ts | 6 ------ .../__tests__/tableConversion.spec.ts | 1 - .../content/tableCell/__tests__/enterHandler.spec.ts | 1 - .../src/clipboard/__tests__/parityImagePaste.spec.ts | 8 +++----- .../src/editor/__tests__/parityHideLinkPopup.spec.ts | 2 -- packages/muya/src/inlineRenderer/lexer.ts | 12 ------------ packages/muya/src/search/__tests__/search.spec.ts | 1 - packages/muya/src/selection/TableRectSelection.ts | 5 ----- .../selection/__tests__/parityPreviewImage.spec.ts | 4 ++-- .../muya/src/selection/__tests__/selectAll.spec.ts | 1 - .../src/state/__tests__/nestedMixedLists.spec.ts | 1 - .../__tests__/search.spec.ts | 1 - .../muya/src/utils/__tests__/embedKatexFonts.spec.ts | 1 - 50 files changed, 16 insertions(+), 99 deletions(-) diff --git a/packages/desktop/src/renderer/src/bootstrap.ts b/packages/desktop/src/renderer/src/bootstrap.ts index d3d800bda8..d9a3a2e639 100644 --- a/packages/desktop/src/renderer/src/bootstrap.ts +++ b/packages/desktop/src/renderer/src/bootstrap.ts @@ -77,8 +77,6 @@ const isCodeMirrorRaceCondition = (error: Error | null | undefined): boolean => const handleRendererError = (event: ErrorEvent | PromiseRejectionEvent | Event): void => { const errorEvent = event as ErrorEvent if (errorEvent.error) { - // Suppress known non-fatal CodeMirror race conditions - // These occur during rapid clicking/editing and don't affect functionality if (isCodeMirrorRaceCondition(errorEvent.error)) { console.warn('Suppressed non-fatal CodeMirror race condition:', errorEvent.error.message) return diff --git a/packages/desktop/src/renderer/src/i18n/index.ts b/packages/desktop/src/renderer/src/i18n/index.ts index 39a1bd21d8..83af3d0f33 100644 --- a/packages/desktop/src/renderer/src/i18n/index.ts +++ b/packages/desktop/src/renderer/src/i18n/index.ts @@ -4,7 +4,6 @@ import bus from '../bus' // Directly import translation files import enTranslations from '../../../../static/locales/en.json' -// Create the Vue i18n instance. // vue-i18n's options type intersection between Composition + Legacy modes is // notoriously difficult to satisfy with mixed shapes; we cast the options once // at the call site rather than spreading `any` further. @@ -61,7 +60,6 @@ export const t = (key: string, ...args: unknown[]): string => { // don't fire duplicate IPCs for the same locale. const inflightLoads = new Map | undefined>>() -// Export language setter function export const setLanguage = async(locale: string): Promise => { if (!locale) return const globalI18n = i18n.global diff --git a/packages/desktop/src/renderer/src/pages/app.vue b/packages/desktop/src/renderer/src/pages/app.vue index 4e1289bea3..e7ac49ae95 100644 --- a/packages/desktop/src/renderer/src/pages/app.vue +++ b/packages/desktop/src/renderer/src/pages/app.vue @@ -74,7 +74,6 @@ const notificationStore = useNotificationStore() const timer = ref | null>(null) -// States from Pinia const { windowActive, platform, init } = storeToRefs(mainStore) const { showTabBar } = storeToRefs(layoutStore) const { sourceCode, theme, customCss, textDirection, zoom } = storeToRefs(preferencesStore) diff --git a/packages/desktop/src/renderer/src/prefComponents/image/components/uploader/index.vue b/packages/desktop/src/renderer/src/prefComponents/image/components/uploader/index.vue index aea8be4c62..b68ea7cbd5 100644 --- a/packages/desktop/src/renderer/src/prefComponents/image/components/uploader/index.vue +++ b/packages/desktop/src/renderer/src/prefComponents/image/components/uploader/index.vue @@ -24,7 +24,6 @@
{{ t('preferences.image.uploader.picgoDetection') }}
- -
-
{{ t('preferences.image.uploader.usageGuide.title') }} @@ -290,7 +287,7 @@ const picgoDetectionFailed = ref(false) // Whether detection failed const picgoDetectionStatus = ref('') // Detection status text const picgoDebugInfo = ref('') // Debug information const isDetecting = ref(false) // Whether detection is in progress -const lastDetectionTime = ref(null) // Last detection time +const lastDetectionTime = ref(null) const lastSuccessTime = ref(null) // Last successful detection time const detectionTimer = ref | null>(null) // Detection interval constant moved into scheduleNextDetection function const consecutiveFailures = ref(0) // Number of consecutive failures @@ -695,7 +692,6 @@ const stopAnimationAndButton = () => { } const testPicgo = async (): Promise => { - // Record detection start time lastDetectionTime.value = new Date().toISOString() const debugMessages: string[] = [] diff --git a/packages/desktop/src/renderer/src/store/editor.ts b/packages/desktop/src/renderer/src/store/editor.ts index d589d1e52e..4189a33618 100644 --- a/packages/desktop/src/renderer/src/store/editor.ts +++ b/packages/desktop/src/renderer/src/store/editor.ts @@ -1942,7 +1942,6 @@ const createApplicationMenuState = ({ } } - // Clean up if (Object.getOwnPropertyNames(state.affiliation).length >= 2 && state.affiliation.p) { delete state.affiliation.p } diff --git a/packages/desktop/src/renderer/src/store/index.ts b/packages/desktop/src/renderer/src/store/index.ts index 43189b435f..d154fdcb44 100644 --- a/packages/desktop/src/renderer/src/store/index.ts +++ b/packages/desktop/src/renderer/src/store/index.ts @@ -7,7 +7,6 @@ const pinia = createPinia() export const useMainStore = defineStore('main', () => { // Platform of system: 'darwin' | 'win32' | 'linux' const platform = ref(window.electron.process.platform) - // MarkText version string const appVersion = ref(window.electron.process.env.MARKTEXT_VERSION_STRING ?? '') // Whether current window is active or focused const windowActive = ref(true) diff --git a/packages/desktop/src/renderer/src/store/preferences.ts b/packages/desktop/src/renderer/src/store/preferences.ts index c5fdcb8fed..325dfe2307 100644 --- a/packages/desktop/src/renderer/src/store/preferences.ts +++ b/packages/desktop/src/renderer/src/store/preferences.ts @@ -226,7 +226,7 @@ export const usePreferencesStore = defineStore('preferences', { // Edit modes of the current window (not part of persistent settings) typewriter: false, // typewriter mode - focus: false, // focus mode + focus: false, sourceCode: false, // source code mode // user configration diff --git a/packages/desktop/src/renderer/src/util/exportHtml.ts b/packages/desktop/src/renderer/src/util/exportHtml.ts index 4baf87b35b..f74d59e50d 100644 --- a/packages/desktop/src/renderer/src/util/exportHtml.ts +++ b/packages/desktop/src/renderer/src/util/exportHtml.ts @@ -164,7 +164,6 @@ export const exportStyledHTML = async( dir }) - // Pull out the rendered
body. const articleMatch = /
([\s\S]*)<\/article>/.exec(fullDoc) let article = articleMatch ? articleMatch[1] : fullDoc diff --git a/packages/desktop/src/shared/types/bufferedState.ts b/packages/desktop/src/shared/types/bufferedState.ts index e3c3fbf2d7..9cb6941e3c 100644 --- a/packages/desktop/src/shared/types/bufferedState.ts +++ b/packages/desktop/src/shared/types/bufferedState.ts @@ -1,6 +1,4 @@ // Buffered editor state (persisted across window close/reopen). -// -// Refined when Commit 7 rewrites the editor + bufferedState stores. import type { IFileState } from './files' diff --git a/packages/desktop/src/types/muya-core.d.ts b/packages/desktop/src/types/muya-core.d.ts index 9bf2621547..a8b98758b0 100644 --- a/packages/desktop/src/types/muya-core.d.ts +++ b/packages/desktop/src/types/muya-core.d.ts @@ -84,7 +84,6 @@ declare module '@muyajs/core' { export function renderToStaticHTML(...args: any[]): any - // Utils. export function escapeHTML(str: string): string export function unescapeHTML(str: string): string export function sanitize(html: string, config?: any, isInline?: boolean): string diff --git a/packages/desktop/src/types/muya.d.ts b/packages/desktop/src/types/muya.d.ts index 0a40f3fcc9..c0916a7b78 100644 --- a/packages/desktop/src/types/muya.d.ts +++ b/packages/desktop/src/types/muya.d.ts @@ -5,9 +5,6 @@ * Delete this file the day upstream lands; see packages/website/content/docs/dev/TYPESCRIPT.md. */ -// Ambient bridge to the JavaScript muya/ tree. Delete this file the day -// upstream TS muya (https://github.com/marktext/muya) lands. -// // We declare every muya path that's imported from outside src/muya/. This // cuts the dependency graph at the boundary: TypeScript no longer follows // imports into the legacy JS source, so e.g. inferred types from dompurify diff --git a/packages/desktop/test/e2e/crash-range-offset.spec.ts b/packages/desktop/test/e2e/crash-range-offset.spec.ts index 4c92d0134a..590ba72b62 100644 --- a/packages/desktop/test/e2e/crash-range-offset.spec.ts +++ b/packages/desktop/test/e2e/crash-range-offset.spec.ts @@ -42,7 +42,6 @@ test.describe('Crash: setStart Range offset', () => { if (app) await app.close() }) - // Recipe from #2526. test('Issue #2526: typing escaped
...
then re-selecting does not crash', async() => { // Type literal `\some text\` as the user described. await typeIntoEditor(page, '\\some text\\') @@ -64,7 +63,6 @@ test.describe('Crash: setStart Range offset', () => { await expectNoRendererErrors(app) }) - // Recipe from #3737 — backspace at the start of a code block. test('Issue #3737: backspace at start of a code block does not crash', async() => { // Build a code block via the markdown source-mode round-trip. // (Code blocks are rendered as CodeMirror instances inside Muya; the diff --git a/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts b/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts index bea9ec25d6..6f9b7ee847 100644 --- a/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts +++ b/packages/desktop/test/e2e/parity-source-undo-saved.spec.ts @@ -13,8 +13,7 @@ import { } from './helpers' // PARITY SCOREBOARD — desktop gaps PG2 (file PG02), PG14 (file PG15), -// PG15 (file PG16). Each RUNS headless but currently fails, so each is marked -// `test.fail()`. When the corresponding fix lands, remove the `test.fail()`. +// PG15 (file PG16). // Trigger an editor undo through the same IPC channel the Edit › Undo menu item // uses (`mt::editor-edit-action` → bus `undo` → editor.undo()). More reliable diff --git a/packages/desktop/test/e2e/view-modes.spec.ts b/packages/desktop/test/e2e/view-modes.spec.ts index 3daa30f8ad..79d3d6b30c 100644 --- a/packages/desktop/test/e2e/view-modes.spec.ts +++ b/packages/desktop/test/e2e/view-modes.spec.ts @@ -39,7 +39,6 @@ const waitForChecked = async( return last ?? { checked: false, enabled: false } } -// Poll the menu item until its `enabled` flag matches `want`. const waitForEnabled = async( app: ElectronApplication, id: string, diff --git a/packages/desktop/test/unit/markdown.ts b/packages/desktop/test/unit/markdown.ts index f425b630a8..06e7fe2219 100644 --- a/packages/desktop/test/unit/markdown.ts +++ b/packages/desktop/test/unit/markdown.ts @@ -40,10 +40,6 @@ export const ListsTemplate = () => { return loadMarkdownContent('common/Lists.md') } -// -------------------------------------------------------- -// GFM templates -// - export const GfmBasicTextFormattingTemplate = () => { return loadMarkdownContent('gfm/BasicTextFormatting.md') } diff --git a/packages/desktop/test/unit/specs/format-menu-state.spec.ts b/packages/desktop/test/unit/specs/format-menu-state.spec.ts index 3f6d61304c..f32d4956bc 100644 --- a/packages/desktop/test/unit/specs/format-menu-state.spec.ts +++ b/packages/desktop/test/unit/specs/format-menu-state.spec.ts @@ -23,8 +23,6 @@ vi.mock('main_renderer/i18n', () => ({ t: (key: string) => key })) import { createSelectionFormatState } from '@/store/editor' import { updateFormatMenu } from 'main_renderer/menu/actions/format' -// The toolbar config is a deep subpath of @muyajs/core that vite resolves at -// runtime but whose types are not exposed via the package `exports` map. // @ts-expect-error deep @muyajs/core subpath resolves at runtime (vite) but exposes no types import inlineFormatIcons from '@muyajs/core/ui/inlineFormatToolbar/config' import keybindingsWindows from 'main_renderer/keyboard/keybindingsWindows' diff --git a/packages/desktop/test/unit/specs/pdf.spec.ts b/packages/desktop/test/unit/specs/pdf.spec.ts index 699c378fbd..12a870b2df 100644 --- a/packages/desktop/test/unit/specs/pdf.spec.ts +++ b/packages/desktop/test/unit/specs/pdf.spec.ts @@ -25,7 +25,7 @@ vi.hoisted(() => { // characterize the branch *dispatch* (academic/liber take the inline path and // never touch `window.fileUtils`/`window.marktext`, unlike a disk theme name) // rather than asserting a theme-specific selector token, which is unavailable -// here. See suspectedBugs / notes. +// here. const loadPdf = async() => { return import('@/util/pdf') diff --git a/packages/muya/e2e/tests/blocks/reference-link-image.spec.ts b/packages/muya/e2e/tests/blocks/reference-link-image.spec.ts index 9e67e9c788..26b87e9659 100644 --- a/packages/muya/e2e/tests/blocks/reference-link-image.spec.ts +++ b/packages/muya/e2e/tests/blocks/reference-link-image.spec.ts @@ -109,9 +109,7 @@ test.describe('reference link', () => { window.muya!.setContent('[a][r]\n'); }); - // No reference-link element of any kind survives an unresolved label. await expect(page.locator('.mu-reference-link')).toHaveCount(0); - // The raw text round-trips through getMarkdown unchanged. const md = await page.evaluate(() => window.muya!.getMarkdown()); expect(md).toContain('[a][r]'); }); diff --git a/packages/muya/e2e/tests/diagrams/mermaid.spec.ts b/packages/muya/e2e/tests/diagrams/mermaid.spec.ts index cc6741b8d4..b772563527 100644 --- a/packages/muya/e2e/tests/diagrams/mermaid.spec.ts +++ b/packages/muya/e2e/tests/diagrams/mermaid.spec.ts @@ -83,7 +83,6 @@ test.describe('mermaid diagram', () => { await expect(error).toBeVisible({ timeout: 15_000 }); await expect(error).toContainText('Invalid Diagram Code'); - // No SVG should have mounted for a failed parse. await expect(page.locator(`${editor.diagramPreview} svg`)).toHaveCount(0); // The editor stays alive (no thrown crash): the source still round-trips. diff --git a/packages/muya/e2e/tests/diagrams/sequence-overflow-3560.spec.ts b/packages/muya/e2e/tests/diagrams/sequence-overflow-3560.spec.ts index 263d401253..25aa89b96e 100644 --- a/packages/muya/e2e/tests/diagrams/sequence-overflow-3560.spec.ts +++ b/packages/muya/e2e/tests/diagrams/sequence-overflow-3560.spec.ts @@ -36,6 +36,6 @@ test('a wide sequence diagram scales to fit its block, not clipped (#3560)', asy return { wideEnough: intrinsic > blockWidth, fits: rendered <= blockWidth + 1 }; }, editor.diagramPreview); - expect(fits.wideEnough).toBe(true); // the diagram really is wider than the block - expect(fits.fits).toBe(true); // ...but is scaled down to fit it + expect(fits.wideEnough).toBe(true); + expect(fits.fits).toBe(true); }); diff --git a/packages/muya/e2e/tests/diagrams/sequence.spec.ts b/packages/muya/e2e/tests/diagrams/sequence.spec.ts index 0cfcc54649..047e90ff87 100644 --- a/packages/muya/e2e/tests/diagrams/sequence.spec.ts +++ b/packages/muya/e2e/tests/diagrams/sequence.spec.ts @@ -34,7 +34,6 @@ test.describe('sequence diagram', () => { const svg = page.locator(`${editor.diagramPreview} svg`).first(); await expect(svg).toBeVisible({ timeout: 15_000 }); - // The vendored renderer always tags the svg with the `sequence` class. await expect(svg).toHaveClass(/sequence/); }); diff --git a/packages/muya/e2e/tests/inline/format-toolbar.spec.ts b/packages/muya/e2e/tests/inline/format-toolbar.spec.ts index 2f1051a160..b6c045372a 100644 --- a/packages/muya/e2e/tests/inline/format-toolbar.spec.ts +++ b/packages/muya/e2e/tests/inline/format-toolbar.spec.ts @@ -53,7 +53,6 @@ test.describe('inline format toolbar', () => { // the strong token; the markers should collapse to `.mu-hide`. await page.locator(editor.paragraph).first().click({ position: { x: 2, y: 2 } }); - // The strong run itself is a live `` node. const strongRun = page.locator(`${editor.paragraph} strong.mu-inline-rule`); await expect(strongRun).toHaveCount(1); await expect(strongRun).toHaveText('bold'); diff --git a/packages/muya/e2e/tests/inline/shortcuts.spec.ts b/packages/muya/e2e/tests/inline/shortcuts.spec.ts index 93a19520f8..18710bf58a 100644 --- a/packages/muya/e2e/tests/inline/shortcuts.spec.ts +++ b/packages/muya/e2e/tests/inline/shortcuts.spec.ts @@ -33,7 +33,6 @@ test.describe('keyboard shortcuts', () => { await page.evaluate(() => window.muya!.setContent('struck text')); await tripleClickFirstParagraph(page); await page.keyboard.press(`${metaKey()}+d`); - // The `del` renderer mounts a live `` element inside the paragraph. const del = page.locator(`${editor.paragraph} del`).first(); await expect(del).toBeVisible(); await expect(del).toContainText('struck text'); diff --git a/packages/muya/e2e/tests/options/disable-html.spec.ts b/packages/muya/e2e/tests/options/disable-html.spec.ts index 1bd3440c4d..2f1be23d76 100644 --- a/packages/muya/e2e/tests/options/disable-html.spec.ts +++ b/packages/muya/e2e/tests/options/disable-html.spec.ts @@ -126,7 +126,6 @@ test.describe('options / disableHtml', () => { }); test('disableHtml: true — inline raw-html renders the same as with disableHtml: false', async ({ page }) => { - // disableHtml: true await page.evaluate(() => { window.__e2e!.rebuildMuya({ disableHtml: true }); window.muya!.setContent([{ diff --git a/packages/muya/e2e/tests/typing/math-and-diagrams.spec.ts b/packages/muya/e2e/tests/typing/math-and-diagrams.spec.ts index b211228e41..6021f10989 100644 --- a/packages/muya/e2e/tests/typing/math-and-diagrams.spec.ts +++ b/packages/muya/e2e/tests/typing/math-and-diagrams.spec.ts @@ -48,7 +48,6 @@ test.describe('math and diagrams', () => { await expect(page.locator(`${editor.paragraph} ${editor.katex}`).first()) .toBeVisible({ timeout: 10_000 }); - // Exactly one inline formula was rendered. await expect(page.locator(editor.katex)).toHaveCount(1); // The source markdown round-trips losslessly through the serializer. diff --git a/packages/muya/e2e/tests/typing/paragraph-and-headings.spec.ts b/packages/muya/e2e/tests/typing/paragraph-and-headings.spec.ts index 831e246be0..2bd71aca91 100644 --- a/packages/muya/e2e/tests/typing/paragraph-and-headings.spec.ts +++ b/packages/muya/e2e/tests/typing/paragraph-and-headings.spec.ts @@ -52,9 +52,7 @@ test.describe('paragraphs and headings', () => { const heading = page.locator(editor.atxHeading).first(); await expect(heading).toBeVisible(); - // The heading tag reflects the level (h4 / h5 / h6). await expect(heading).toHaveJSProperty('tagName', `H${level}`); - // No paragraph remains. await expect(page.locator(editor.paragraph)).toHaveCount(0); // Markdown round-trips with the right number of leading hashes. expect(await getMarkdown(page)).toContain(`${hashes} Title`); diff --git a/packages/muya/e2e/tests/typing/table.spec.ts b/packages/muya/e2e/tests/typing/table.spec.ts index e02346c546..7d1c73f4ab 100644 --- a/packages/muya/e2e/tests/typing/table.spec.ts +++ b/packages/muya/e2e/tests/typing/table.spec.ts @@ -130,7 +130,6 @@ test.describe('table', () => { // No atx-heading appears, and the table is still a table. await expect(page.locator(editor.atxHeading)).toHaveCount(0); await expect(table).toBeVisible(); - // The literal text lives in the cell. await expect(cellContent).toContainText('# x'); // The active selection is still inside a table cell content block — @@ -189,7 +188,6 @@ test.describe('table', () => { await expect(linkSpan).toHaveAttribute('href', 'http://y'); await expect(linkSpan).toContainText('x'); - // getMarkdown serialises the link back into the body cell. await expect.poll(() => getMarkdown(page)).toContain('[x](http://y)'); }); diff --git a/packages/muya/src/__tests__/createTableImageCursor.spec.ts b/packages/muya/src/__tests__/createTableImageCursor.spec.ts index 41674f930a..e50696d8e6 100644 --- a/packages/muya/src/__tests__/createTableImageCursor.spec.ts +++ b/packages/muya/src/__tests__/createTableImageCursor.spec.ts @@ -100,7 +100,6 @@ describe('muya.createTable()', () => { }); const sel = muya.editor.selection.getSelection(); expect(sel).not.toBeNull(); - // The caret lands on a table-cell content block. expect(sel!.anchor.block.blockName).toBe('table.cell.content'); }); diff --git a/packages/muya/src/__tests__/crossBlockParagraph.spec.ts b/packages/muya/src/__tests__/crossBlockParagraph.spec.ts index ccdb83a027..93efc2a43c 100644 --- a/packages/muya/src/__tests__/crossBlockParagraph.spec.ts +++ b/packages/muya/src/__tests__/crossBlockParagraph.spec.ts @@ -125,7 +125,7 @@ describe('cross-block paragraph wrapping', () => { }); it('preserves a multi-block selection across wrap then unwrap (paragraph + list)', async () => { - const muya = boot('alpha\n\n- bravo\n'); // a paragraph and an unordered list + const muya = boot('alpha\n\n- bravo\n'); selectFirstTwoBlocks(muya); muya.updateParagraph('ol-order'); // wrap both into an ordered list await vi.waitFor(() => expect(muya.getState()[0].name).toBe('order-list')); diff --git a/packages/muya/src/__tests__/historySerialization.spec.ts b/packages/muya/src/__tests__/historySerialization.spec.ts index 969f8af2b1..19f1158976 100644 --- a/packages/muya/src/__tests__/historySerialization.spec.ts +++ b/packages/muya/src/__tests__/historySerialization.spec.ts @@ -63,8 +63,6 @@ function placeCursorOnFirstBlock(muya: Muya): Content { return first; } -// Undo entries coalesce within options.delay; cutoff() forces the next edit -// into its own undo entry so depth assertions are deterministic. function undoDepth(muya: Muya): number { // @ts-expect-error — reach into the private stack for test assertions. return muya.editor.history._stack.undo.length; diff --git a/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts b/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts index d925e004fc..a3e99546f0 100644 --- a/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts +++ b/packages/muya/src/__tests__/parityHeadingCopyLink.spec.ts @@ -62,7 +62,6 @@ describe('parity PG11: heading hover-to-copy-anchor affordance', () => { const muya = bootMuya('# Getting Started\n'); const affordance = muya.domNode.querySelector(COPY_LINK_SELECTOR); - // Desired: the heading exposes a hover-copy-anchor affordance. expect(affordance).toBeTruthy(); }, ); diff --git a/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts b/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts index 4467783400..83ee9525cc 100644 --- a/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts +++ b/packages/muya/src/__tests__/parityInsertParagraphNested.spec.ts @@ -105,7 +105,7 @@ describe('parity PG13: insertParagraph anchors to the immediate block in nested // Desired: the new paragraph is an INNER sibling — the top-level // block count is unchanged (still just the one bullet-list) and the - // paragraph is NOT a root-level sibling. Today it lands at root. + // paragraph is NOT a root-level sibling. expect(muya.getState().length).toBe(1); expect(topLevelHasParagraph(muya, 'INNERSIBLING')).toBe(false); }, @@ -124,8 +124,7 @@ describe('parity PG13: insertParagraph anchors to the immediate block in nested }); // Desired: still a single top-level block (the blockquote) with the - // new paragraph nested inside it. Today it lands after the quote at - // document root. + // new paragraph nested inside it. expect(muya.getState().length).toBe(1); expect(muya.getState()[0].name).toBe('block-quote'); expect(topLevelHasParagraph(muya, 'QUOTESIBLING')).toBe(false); diff --git a/packages/muya/src/__tests__/resetToParagraph.spec.ts b/packages/muya/src/__tests__/resetToParagraph.spec.ts index 82c2387af6..d0b2bc5623 100644 --- a/packages/muya/src/__tests__/resetToParagraph.spec.ts +++ b/packages/muya/src/__tests__/resetToParagraph.spec.ts @@ -138,7 +138,6 @@ describe('paragraph front menu — a single menu open performs at most one actio // 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(); diff --git a/packages/muya/src/__tests__/setCursorByOffset.spec.ts b/packages/muya/src/__tests__/setCursorByOffset.spec.ts index d7dabd6f29..3aefbdebb8 100644 --- a/packages/muya/src/__tests__/setCursorByOffset.spec.ts +++ b/packages/muya/src/__tests__/setCursorByOffset.spec.ts @@ -52,7 +52,6 @@ describe('muya.setCursorByOffset() (PG2)', () => { await vi.waitFor(() => { const sel = muya.editor.selection.getSelection(); expect(sel).not.toBeNull(); - // The caret lands inside the "third para here" block. expect(sel!.anchor.block.text).toBe('third para here'); expect(sel!.anchor.offset).toBe(6); }); diff --git a/packages/muya/src/__tests__/setOptions.spec.ts b/packages/muya/src/__tests__/setOptions.spec.ts index c327c47d42..da3f6a968c 100644 --- a/packages/muya/src/__tests__/setOptions.spec.ts +++ b/packages/muya/src/__tests__/setOptions.spec.ts @@ -74,7 +74,6 @@ describe('muya runtime options', () => { muya.insertParagraph(); await vi.waitFor(() => { expect(muya.getState().length).toBe(2); - // the edit was recorded onto the undo stack expect(muya.editor.history.canUndo()).toBe(true); }); diff --git a/packages/muya/src/__tests__/updateParagraph.menu.spec.ts b/packages/muya/src/__tests__/updateParagraph.menu.spec.ts index 486762815c..e00402466a 100644 --- a/packages/muya/src/__tests__/updateParagraph.menu.spec.ts +++ b/packages/muya/src/__tests__/updateParagraph.menu.spec.ts @@ -148,9 +148,9 @@ describe('updateParagraph same-block menu model', () => { expect(s.length).toBe(3); expect(s[0].name).toBe('atx-heading'); expect((s[0] as { text: string }).text).toBe('# Title'); // heading kept - expect(s[1].name).toBe('thematic-break'); // rule below it + expect(s[1].name).toBe('thematic-break'); expect(s[2].name).toBe('paragraph'); - expect((s[2] as { text: string }).text).toBe(''); // trailing empty paragraph + expect((s[2] as { text: string }).text).toBe(''); }); }); diff --git a/packages/muya/src/__tests__/updateParagraph.spec.ts b/packages/muya/src/__tests__/updateParagraph.spec.ts index 714401e4f7..17d4042c65 100644 --- a/packages/muya/src/__tests__/updateParagraph.spec.ts +++ b/packages/muya/src/__tests__/updateParagraph.spec.ts @@ -286,7 +286,6 @@ describe('muya.updateParagraph()', () => { }); const state = muya.getState(); - // The trailing paragraph is empty and ready for input. expect((state[1] as { text: string }).text).toBe(''); // The cursor moved off the original paragraph into the trailing one: diff --git a/packages/muya/src/block/base/__tests__/formatToggle.spec.ts b/packages/muya/src/block/base/__tests__/formatToggle.spec.ts index bdc82e61b2..df755fb493 100644 --- a/packages/muya/src/block/base/__tests__/formatToggle.spec.ts +++ b/packages/muya/src/block/base/__tests__/formatToggle.spec.ts @@ -228,12 +228,6 @@ describe('format.format() apply-ON over a non-collapsed selection', () => { }); }); -// The markdown round-trip above proves the html_tag format committed to state. -// These pin the *live* DOM mount: the inline renderer turns ``/`` -// html_tag tokens into real elements (bare tag + `.mu-inline-rule.mu-raw-html` -// via `buildRawHtmlTag`) inside the booted content block, not just into a -// serialized markdown string. (The export path is covered by -// renderToStaticHTML.spec; this is the editing-surface mount.) describe('format.format(\'clear\') with the caret inside the run', () => { it('strips a strong run to plain text', () => { const content = caretInFirstBlock(bootMuya('**word**\n'), 2); diff --git a/packages/muya/src/block/content/paragraphContent/__tests__/tableConversion.spec.ts b/packages/muya/src/block/content/paragraphContent/__tests__/tableConversion.spec.ts index 0bebae3b89..80f5ce524b 100644 --- a/packages/muya/src/block/content/paragraphContent/__tests__/tableConversion.spec.ts +++ b/packages/muya/src/block/content/paragraphContent/__tests__/tableConversion.spec.ts @@ -115,7 +115,6 @@ describe('paragraphContent.enterHandler — pipe-table conversion', () => { await flush(); const anchorBlock = muya.editor.selection.anchorBlock; expect(anchorBlock).not.toBeNull(); - // Caret is in a table cell content leaf. expect(anchorBlock!.blockName).toBe('table.cell.content'); // The owning cell sits in the SECOND row (the empty body row, index 1) diff --git a/packages/muya/src/block/content/tableCell/__tests__/enterHandler.spec.ts b/packages/muya/src/block/content/tableCell/__tests__/enterHandler.spec.ts index 96d3386efc..26fa22e1f3 100644 --- a/packages/muya/src/block/content/tableCell/__tests__/enterHandler.spec.ts +++ b/packages/muya/src/block/content/tableCell/__tests__/enterHandler.spec.ts @@ -112,7 +112,6 @@ describe('tableCellContent.enterHandler', () => { await flush(); expect(cell.text).toContain('
'); - // `a` + `
` + `b` expect(cell.text).toBe('a
b'); const cursor = cell.getCursor(); diff --git a/packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts b/packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts index 41ea326cc2..7b8b6108da 100644 --- a/packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts +++ b/packages/muya/src/clipboard/__tests__/parityImagePaste.spec.ts @@ -103,7 +103,7 @@ describe('parity PG6: pasted image FILE routes through imageAction', () => { async () => { const clipboardFilePath = vi.fn().mockResolvedValue('/abs/photo.png'); // The user's insert preference moves the file into the assets dir and - // returns the rewritten src. With the gap, this is never called. + // returns the rewritten src. const imageAction = vi .fn() .mockResolvedValue('assets/photo.png'); @@ -140,8 +140,7 @@ describe('parity PG6: pasted image FILE routes through imageAction', () => { await clipboard.pasteHandler(event); // Desired: the assets-relative src returned by imageAction is what - // lands in the document (portable). Today the raw absolute path is - // written verbatim. + // lands in the document (portable). expect(anchorBlock.text).toBe('![](assets/photo.png)'); }, ); @@ -168,8 +167,7 @@ describe('parity PG5: binary/bitmap clipboard image paste', () => { await clipboard.pasteHandler(event); // Desired: the binary image is persisted through imageAction and an - // image is inserted. Today nothing is read from clipboardData.files - // and nothing is inserted. + // image is inserted. expect(imageAction).toHaveBeenCalledTimes(1); expect(anchorBlock.text).toBe('![](assets/pasted.png)'); }, diff --git a/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts b/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts index 173c0d275b..f8c1423af3 100644 --- a/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts +++ b/packages/muya/src/editor/__tests__/parityHideLinkPopup.spec.ts @@ -90,8 +90,6 @@ describe('parity PG12: hideLinkPopup gates the link hover popover', () => { muya.on('muya-link-tools', handler); hover(link); - // Desired: the popover stays suppressed when the preference is set. - // Today the emitter ignores the option and opens it anyway. expect(countOpenEmits(handler)).toBe(0); }, ); diff --git a/packages/muya/src/inlineRenderer/lexer.ts b/packages/muya/src/inlineRenderer/lexer.ts index 66bc84d829..1babf51b03 100644 --- a/packages/muya/src/inlineRenderer/lexer.ts +++ b/packages/muya/src/inlineRenderer/lexer.ts @@ -114,7 +114,6 @@ function consumeBeginRules(state: ILexState, beginRules: BeginRules) { } } -// backlash function tryBacklash(state: ILexState): boolean { const backTo = state.inlineRules.backlash.exec(state.src); if (!backTo) @@ -140,7 +139,6 @@ function tryBacklash(state: ILexState): boolean { return true; } -// strong | em function tryStrongEm(state: ILexState): boolean { const emRules = ['strong', 'em'] as const; @@ -254,7 +252,6 @@ function tryChunks(state: ILexState): boolean { return false; } -// superscript and subscript function trySuperSubScript(state: ILexState): boolean { if (!state.superSubScript) return false; @@ -282,7 +279,6 @@ function trySuperSubScript(state: ILexState): boolean { return true; } -// footnote identifier function tryFootnote(state: ILexState): boolean { if (state.pos === 0 || !state.footnote) return false; @@ -309,7 +305,6 @@ function tryFootnote(state: ILexState): boolean { return true; } -// image function tryImage(state: ILexState): boolean { const imageTo = state.inlineRules.image.exec(state.src); correctUrl(imageTo); @@ -348,7 +343,6 @@ function tryImage(state: ILexState): boolean { return true; } -// link function tryLink(state: ILexState): boolean { const linkTo = state.inlineRules.link.exec(state.src); correctUrl(linkTo); @@ -491,7 +485,6 @@ function tryReferenceImage(state: ILexState): boolean { return true; } -// html escape function tryHtmlEscape(state: ILexState): boolean { const htmlEscapeTo = state.inlineRules.html_escape.exec(state.src); if (!htmlEscapeTo) @@ -569,7 +562,6 @@ function trimAutoLinkExtent(raw: string): string { return raw.slice(0, end); } -// auto link extension function tryAutoLinkExtension(state: ILexState): boolean { const autoLinkExtTo = state.inlineRules.auto_link_extension.exec(state.src); if ( @@ -621,7 +613,6 @@ function tryAutoLinkExtension(state: ILexState): boolean { return true; } -// auto link function tryAutoLink(state: ILexState): boolean { const autoLTo = state.inlineRules.auto_link.exec(state.src); if (!autoLTo) @@ -718,7 +709,6 @@ function tryHtmlTag(state: ILexState): boolean { return false; } -// soft line break function trySoftLineBreak(state: ILexState): boolean { const softTo = state.inlineRules.soft_line_break.exec(state.src); if (!softTo) @@ -743,7 +733,6 @@ function trySoftLineBreak(state: ILexState): boolean { return true; } -// hard line break function tryHardLineBreak(state: ILexState): boolean { const hardTo = state.inlineRules.hard_line_break.exec(state.src); if (!hardTo) @@ -769,7 +758,6 @@ function tryHardLineBreak(state: ILexState): boolean { return true; } -// tail header function tryTailHeader(state: ILexState): boolean { const tailTo = state.inlineRules.tail_header.exec(state.src); if (!(tailTo && state.top)) diff --git a/packages/muya/src/search/__tests__/search.spec.ts b/packages/muya/src/search/__tests__/search.spec.ts index 1f231ef4ee..9ee96e36b2 100644 --- a/packages/muya/src/search/__tests__/search.spec.ts +++ b/packages/muya/src/search/__tests__/search.spec.ts @@ -127,7 +127,6 @@ describe('search.find() — cursor navigation across matches', () => { search.search('x'); expect(search.matches.length).toBe(3); expect(search.index).toBe(0); - // Exactly one active highlight, the other two are selections. expect(highlightCount(muya)).toBe(1); expect(selectionCount(muya)).toBe(2); diff --git a/packages/muya/src/selection/TableRectSelection.ts b/packages/muya/src/selection/TableRectSelection.ts index 5013e9966b..4e79b0f391 100644 --- a/packages/muya/src/selection/TableRectSelection.ts +++ b/packages/muya/src/selection/TableRectSelection.ts @@ -278,11 +278,6 @@ class TableRectSelection { ); } - /** - * Empty every selected cell's text in place (cut). Routed through the - * content block's `text` setter so each edit dispatches a json op and the - * document state stays in sync. The caret is placed in the anchor cell. - */ /** * Empty every selected cell's text and re-render it, keeping the frozen * selection. Returns whether any cell actually had content to clear — the diff --git a/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts b/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts index 83966d850e..0c0e84cbd2 100644 --- a/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts +++ b/packages/muya/src/selection/__tests__/parityPreviewImage.spec.ts @@ -94,8 +94,8 @@ describe('parity PG10: Space previews a selected image', () => { }), ); - // Desired: the engine emits preview-image so the host can open the - // full-screen viewer. Today nothing fires (Space inserts a space). + // The engine emits preview-image so the host can open the + // full-screen viewer. expect(handler).toHaveBeenCalledTimes(1); }, ); diff --git a/packages/muya/src/selection/__tests__/selectAll.spec.ts b/packages/muya/src/selection/__tests__/selectAll.spec.ts index a1c8904dd9..bcc4391bf0 100644 --- a/packages/muya/src/selection/__tests__/selectAll.spec.ts +++ b/packages/muya/src/selection/__tests__/selectAll.spec.ts @@ -105,7 +105,6 @@ describe('selection.selectAll table escalation', () => { const { selection } = muya.editor; const tableSelection = selection.table; - // Caret inside the (0,0) cell content. cellContent(table, 0, 0).setCursor(0, 0, false); selection.selectAll(); diff --git a/packages/muya/src/state/__tests__/nestedMixedLists.spec.ts b/packages/muya/src/state/__tests__/nestedMixedLists.spec.ts index 28326332ec..bf5b926451 100644 --- a/packages/muya/src/state/__tests__/nestedMixedLists.spec.ts +++ b/packages/muya/src/state/__tests__/nestedMixedLists.spec.ts @@ -115,7 +115,6 @@ describe('nested mixed lists (#4341)', () => { const nestedList = children(secondItem).find(c => c.name === 'order-list'); expect(nestedList, 'expected an order-list nested inside the second bullet-list item').toBeDefined(); - // The item is exactly [leading paragraph, nested order-list]. expect(children(secondItem).map(c => c.name)).toEqual(['paragraph', 'order-list']); expect(children(nestedList)).toHaveLength(3); expect(children(nestedList).map(firstText)).toEqual(['First step', 'Second step', 'Third step']); diff --git a/packages/muya/src/ui/paragraphQuickInsertMenu/__tests__/search.spec.ts b/packages/muya/src/ui/paragraphQuickInsertMenu/__tests__/search.spec.ts index 221317f630..7b5c25840c 100644 --- a/packages/muya/src/ui/paragraphQuickInsertMenu/__tests__/search.spec.ts +++ b/packages/muya/src/ui/paragraphQuickInsertMenu/__tests__/search.spec.ts @@ -169,7 +169,6 @@ describe('paragraphQuickInsertMenu render() — DOM output', () => { expect(noResult).not.toBeNull(); // '无结果' is the zh-CN translation of 'No result'. expect(noResult!.textContent).toBe('无结果'); - // No section is rendered when there is no result. expect(menu.scrollElement!.querySelectorAll('section').length).toBe(0); }); diff --git a/packages/muya/src/utils/__tests__/embedKatexFonts.spec.ts b/packages/muya/src/utils/__tests__/embedKatexFonts.spec.ts index 1a37a9407c..8aed32d2b6 100644 --- a/packages/muya/src/utils/__tests__/embedKatexFonts.spec.ts +++ b/packages/muya/src/utils/__tests__/embedKatexFonts.spec.ts @@ -18,7 +18,6 @@ describe('embedKatexFonts', () => { it('drops the now-broken relative font references and woff/ttf fallbacks', () => { const out = embedKatexFonts(katexCss); - // No `url(fonts/…)` left, and no woff/ttf alternatives in any src. expect(out).not.toMatch(/url\(\s*fonts\//); expect(out).not.toContain('format("woff")'); expect(out).not.toContain('format("truetype")'); From 6ce079c04e75102a6fa476f8da231d9dd7aa4bb8 Mon Sep 17 00:00:00 2001 From: Sec <129755144+Renakoni@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:58:23 +0800 Subject: [PATCH 2/8] fix: normalize HTML task-list paste (#4710) * fix: normalize HTML task-list paste * fix: close clipboard task-list e2e test --- .../muya/e2e/tests/editing/clipboard.spec.ts | 33 ++++++++ .../utils/__tests__/htmlTaskListPaste.spec.ts | 78 +++++++++++++++++++ .../muya/src/utils/turndownService/index.ts | 26 +++++-- 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 packages/muya/src/utils/__tests__/htmlTaskListPaste.spec.ts diff --git a/packages/muya/e2e/tests/editing/clipboard.spec.ts b/packages/muya/e2e/tests/editing/clipboard.spec.ts index d87125d111..9af8087c1a 100644 --- a/packages/muya/e2e/tests/editing/clipboard.spec.ts +++ b/packages/muya/e2e/tests/editing/clipboard.spec.ts @@ -71,6 +71,39 @@ test.describe('clipboard paste', () => { expect(md).toMatch(/\|\s*r1c1\s*\|\s*r1c2\s*\|/); }); + test('pasting HTML task-list items creates task-list Markdown', async ({ browserName, context, page }) => { + test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); + await grantClipboardPermissions(context); + + await pasteClipboard( + page, + '
  •  task
', + ' task', + ); + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toContain('- [ ] task'); + await expect.poll(async () => page.evaluate(() => window.muya!.getState()[0].name), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toBe('task-list'); + + await pasteClipboard( + page, + '
  •  done
', + ' done', + ); + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toContain('- [x] done'); + await expect.poll(async () => page.evaluate(() => window.muya!.getState()[0].name), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toBe('task-list'); + }); + 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); diff --git a/packages/muya/src/utils/__tests__/htmlTaskListPaste.spec.ts b/packages/muya/src/utils/__tests__/htmlTaskListPaste.spec.ts new file mode 100644 index 0000000000..8fd33792b9 --- /dev/null +++ b/packages/muya/src/utils/__tests__/htmlTaskListPaste.spec.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; +import HtmlToMarkdown from '../../state/htmlToMarkdown'; +import { MarkdownToState } from '../../state/markdownToState'; + +interface IStateLike { + name: string; + text?: string; + meta?: { checked?: boolean }; + children?: IStateLike[]; +} + +function htmlToState(html: string) { + const markdown = new HtmlToMarkdown({ bulletListMarker: '-' }).generate(html); + const states = new MarkdownToState({ + footnote: false, + math: false, + isGitlabCompatibilityEnabled: false, + trimUnnecessaryCodeBlockEmptyLines: false, + frontMatter: false, + }).generate(markdown) as unknown as IStateLike[]; + + return { markdown, states }; +} + +function firstTask(states: IStateLike[]) { + const item = states[0].children?.[0]; + return { + checked: item?.meta?.checked, + text: item?.children?.find(child => child.name === 'paragraph')?.text, + }; +} + +describe('htmlToMarkdown - task-list HTML paste', () => { + it('normalizes a direct unchecked checkbox inside a list item', () => { + const { markdown, states } = htmlToState( + '
  •  task
', + ); + + expect(markdown).toContain('- [ ] task'); + expect(markdown).not.toContain('\u00A0'); + expect(states[0].name).toBe('task-list'); + expect(firstTask(states)).toEqual({ checked: false, text: 'task' }); + }); + + it('normalizes a direct checked checkbox inside a list item', () => { + const { markdown, states } = htmlToState( + '
  •  done
', + ); + + expect(markdown).toContain('- [x] done'); + expect(markdown).not.toContain('\u00A0'); + expect(states[0].name).toBe('task-list'); + expect(firstTask(states)).toEqual({ checked: true, text: 'done' }); + }); + + it('keeps paragraph-wrapped task-list checkboxes normalized', () => { + const { markdown, states } = htmlToState( + '
  •  done

', + ); + + expect(markdown).toContain('- [x] done'); + expect(markdown).not.toContain('\u00A0'); + expect(states[0].name).toBe('task-list'); + expect(firstTask(states)).toEqual({ checked: true, text: 'done' }); + }); + + it('does not normalize a parent list item only because a nested child item has a checkbox', () => { + const { markdown, states } = htmlToState( + '
  • [x] parent
    •  child
', + ); + + expect(markdown).toContain('- [x]\u00A0parent'); + expect(markdown).toContain(' - [ ] child'); + expect(states[0].name).toBe('bullet-list'); + }); +}); diff --git a/packages/muya/src/utils/turndownService/index.ts b/packages/muya/src/utils/turndownService/index.ts index bb3fc511dd..4f1a0352ea 100644 --- a/packages/muya/src/utils/turndownService/index.ts +++ b/packages/muya/src/utils/turndownService/index.ts @@ -5,6 +5,24 @@ import { identity, isHTMLElement, isHTMLInputElement } from '../../utils'; const DEFAULT_KEEPS: Filter = ['u', 'mark', 'ruby', 'rt', 'sub', 'sup']; +function isTaskListCheckbox(node: unknown) { + return ( + isHTMLInputElement(node) + && node.type === 'checkbox' + && (node.parentNode?.nodeName === 'P' || node.parentNode?.nodeName === 'LI') + ); +} + +function normalizeTaskMarkerSpacing(content: string) { + return content.replace(/^(\[[ x]\])[ \t\u00A0]+/i, (_, marker: string) => `${marker.toLowerCase()} `); +} + +function containsOwnTaskListCheckbox(node: Node) { + return isHTMLElement(node) + && Array.from(node.querySelectorAll('input[type="checkbox"]')) + .some(input => isTaskListCheckbox(input) && input.closest('li') === node); +} + export function usePluginsAddRules(turndownService: TurndownService) { // Use the gfm plugin const { strikethrough, tables } = turndownPluginGfm; @@ -50,11 +68,7 @@ export function usePluginsAddRules(turndownService: TurndownService) { turndownService.addRule('taskListItems', { filter(node) { - return ( - isHTMLInputElement(node) - && node.type === 'checkbox' - && node.parentNode?.nodeName === 'P' - ); + return isTaskListCheckbox(node); }, replacement(_content, node) { return `${isHTMLInputElement(node) && node.checked ? '[x]' : '[ ]'} `; @@ -86,6 +100,8 @@ export function usePluginsAddRules(turndownService: TurndownService) { .replace(/^\n+/, '') // remove leading newlines .replace(/\n+$/, '\n') // replace trailing newlines with just a single one .replace(/\n/g, '\n '); // indent + if (containsOwnTaskListCheckbox(node)) + content = normalizeTaskMarkerSpacing(content); let prefix = `${options.bulletListMarker} `; const parent = node.parentNode; From 4921f5d684e3f52a13618ceb4c333c20675a6397 Mon Sep 17 00:00:00 2001 From: Sec <129755144+Renakoni@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:26:17 +0800 Subject: [PATCH 3/8] fix: preserve bare URL HTML links on paste (#4711) * fix: preserve bare URL HTML links on paste * fix: preserve bare URL links only without autolink boundaries * fix: align bare URL paste fallback with autolink tokenizer --- .../muya/e2e/tests/editing/clipboard.spec.ts | 61 ++++++++++++++++++- packages/muya/src/clipboard/paste.ts | 48 ++++++++++++++- .../__tests__/normalizePastedHtml.spec.ts | 23 +++++-- packages/muya/src/utils/paste.ts | 11 +++- 4 files changed, 133 insertions(+), 10 deletions(-) diff --git a/packages/muya/e2e/tests/editing/clipboard.spec.ts b/packages/muya/e2e/tests/editing/clipboard.spec.ts index 9af8087c1a..b1cfbf1fc1 100644 --- a/packages/muya/e2e/tests/editing/clipboard.spec.ts +++ b/packages/muya/e2e/tests/editing/clipboard.spec.ts @@ -55,6 +55,63 @@ test.describe('clipboard paste', () => { }).toMatch(/\[click here\]\(https:\/\/example\.test\/?\)/); }); + test('pasting bare URL HTML link inside text preserves link markup', async ({ browserName, context, page }) => { + test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); + await grantClipboardPermissions(context); + const url = 'http://10.255.255.1/page'; + + await page.evaluate(() => { + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }); + window.muya!.setContent('AB'); + const block = window.muya!.editor.scrollPage!.firstContentInDescendant()!; + block.setCursor(1, 1, true); + }); + + await pasteClipboard(page, `${url}`, url, { resetContent: false }); + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toBe(`A[${url}](${url})B\n`); + }); + + test('pasting bare URL HTML link with auto-link boundaries keeps plain URL fallback', async ({ browserName, context, page }) => { + test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); + await grantClipboardPermissions(context); + const url = 'http://10.255.255.1/page'; + + await page.evaluate(() => { + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }); + window.muya!.setContent('A B'); + const block = window.muya!.editor.scrollPage!.firstContentInDescendant()!; + block.setCursor(2, 2, true); + }); + + await pasteClipboard(page, `${url}`, url, { resetContent: false }); + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toBe(`A ${url} B\n`); + }); + + test('pasting bare URL HTML link before trailing punctuation keeps plain URL fallback', async ({ browserName, context, page }) => { + test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); + await grantClipboardPermissions(context); + const url = 'http://10.255.255.1/page'; + + await page.evaluate(() => { + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }); + window.muya!.setContent('A .'); + const block = window.muya!.editor.scrollPage!.firstContentInDescendant()!; + block.setCursor(2, 2, true); + }); + + await pasteClipboard(page, `${url}`, url, { resetContent: false }); + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toBe(`A ${url}.\n`); + }); + test('pasting a basic
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); @@ -160,8 +217,10 @@ async function pasteClipboard( page: Parameters[1]>[0]['page'], html: string, text: string, + options: { resetContent?: boolean } = {}, ): Promise { - await page.evaluate(() => window.muya!.setContent('')); + if (options.resetContent !== false) + await page.evaluate(() => window.muya!.setContent('')); await page.evaluate(async ({ html, text }) => { await navigator.clipboard.write([ diff --git a/packages/muya/src/clipboard/paste.ts b/packages/muya/src/clipboard/paste.ts index 8c92b3cfad..2fb901ebd1 100644 --- a/packages/muya/src/clipboard/paste.ts +++ b/packages/muya/src/clipboard/paste.ts @@ -9,6 +9,7 @@ import CodeBlockContent from '../block/content/codeBlockContent'; import LangInputContent from '../block/content/langInputContent'; import { ScrollPage } from '../block/scrollPage'; import { URL_REG } from '../config'; +import { tokenizer } from '../inlineRenderer/lexer'; import HtmlToMarkdown from '../state/htmlToMarkdown'; import { MarkdownToState } from '../state/markdownToState'; import { isAnyListState, isParagraphState } from '../state/types'; @@ -100,6 +101,38 @@ function removeEmptyOriginWrapper(originWrapperBlock: Nullable): void { originWrapperBlock!.remove(); } +function isSinglePlainUrl(text: string): boolean { + return URL_REG.test(text) && !/\s/.test(text); +} + +function canPlainUrlFallbackAutoLink( + text: string, + content: string, + start: { offset: number }, + end: { offset: number }, +): boolean { + const candidate + = content.substring(0, start.offset) + + text + + content.substring(end.offset); + + return tokenizer(candidate, { hasBeginRules: false }).some(token => + token.type === 'auto_link_extension' + && token.linkType === 'url' + && token.range.start === start.offset + && token.range.end === start.offset + text.length, + ); +} + +function shouldPreserveBareUrlLinkForPaste( + text: string, + content: string, + start: { offset: number }, + end: { offset: number }, +): boolean { + return isSinglePlainUrl(text) && !canPlainUrlFallbackAutoLink(text, content, start, end); +} + function seatCursorAtSeam(last: Nullable, offset: number): void { last?.lastContentInDescendant()?.setCursor(offset, offset, true); } @@ -540,6 +573,8 @@ async function applyPaste(clipboard: Clipboard, data: IPasteData): Promise const { imageFile, pasteType } = data; let { html } = data; + // Preserve source provenance before synthetic URL/table HTML promotion. + const hasClipboardHtml = html !== ''; // Normalize Windows CRLF / lone CR to LF so every downstream `split('\n')` // and offset calculation sees one newline convention (muyajs strips \r). const text = data.text.replace(/\r\n?/g, '\n'); @@ -567,8 +602,19 @@ async function applyPaste(clipboard: Clipboard, data: IPasteData): Promise if (!html && isStandaloneTableHtml(text)) html = text; + const cursorBeforeNormalize = anchorBlock.getCursor(); + // Remove crap from HTML such as meta data and styles. - html = await normalizePastedHTML(html); + html = await normalizePastedHTML(html, { + preserveBareUrlLinks: hasClipboardHtml + && cursorBeforeNormalize != null + && shouldPreserveBareUrlLinkForPaste( + text, + anchorBlock.text, + cursorBeforeNormalize.start, + cursorBeforeNormalize.end, + ), + }); const copyType = getCopyTextType(html, text, pasteType); const { start, end } = anchorBlock.getCursor()!; diff --git a/packages/muya/src/utils/__tests__/normalizePastedHtml.spec.ts b/packages/muya/src/utils/__tests__/normalizePastedHtml.spec.ts index 8c8817a9d1..169e7d3999 100644 --- a/packages/muya/src/utils/__tests__/normalizePastedHtml.spec.ts +++ b/packages/muya/src/utils/__tests__/normalizePastedHtml.spec.ts @@ -1,13 +1,12 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest'; +import HtmlToMarkdown from '../../state/htmlToMarkdown'; import { normalizePastedHTML } from '../paste'; -// muyajs `pasteCtrl.normalizePastedHTML` only "unlinks" an whose visible -// text equals its href AND whose href is an actual URL (`URL_REG.test(href) && -// href === text`). muya was missing the URL_REG guard, so any link whose text -// happened to equal its href — even a non-URL like `foo` — -// was stripped to a bare span. Restore the guard and sanitize the fallback span. +// Bare-URL links need two separate paths: callers can keep the old plain URL +// fallback when auto-link can still recognize it, but preserve the anchor when +// the paste context would otherwise lose link semantics. function setOnline(value: boolean) { Object.defineProperty(navigator, 'onLine', { value, configurable: true }); @@ -17,7 +16,7 @@ afterEach(() => { setOnline(true); }); -describe('normalizePastedHTML — link unlinking matches muyajs', () => { +describe('normalizePastedHTML — bare URL link normalization', () => { it('keeps a link whose href is not a URL even when text === href', async () => { const out = await normalizePastedHTML('foo'); // Non-URL href: the link must survive, not collapse into a bare span. @@ -39,4 +38,16 @@ describe('normalizePastedHTML — link unlinking matches muyajs', () => { expect(out).not.toContain(' { + setOnline(false); + const url = 'http://example.com/page'; + const out = await normalizePastedHTML(`${url}`, { + preserveBareUrlLinks: true, + }); + const markdown = new HtmlToMarkdown({ bulletListMarker: '-' }).generate(out); + + expect(out).toContain(`href="${url}"`); + expect(markdown).toContain(`[${url}](${url})`); + }); }); diff --git a/packages/muya/src/utils/paste.ts b/packages/muya/src/utils/paste.ts index 6faf512d27..c71febe89c 100644 --- a/packages/muya/src/utils/paste.ts +++ b/packages/muya/src/utils/paste.ts @@ -4,6 +4,10 @@ import { sanitize } from '../utils'; const TIMEOUT = 1500; +interface INormalizePastedHTMLOptions { + preserveBareUrlLinks?: boolean; +} + export const isOnline = () => navigator.onLine === true; function expandTableColspans(table: HTMLTableElement) { @@ -58,7 +62,10 @@ export async function getPageTitle(url: string) { } } -export async function normalizePastedHTML(html: string) { +export async function normalizePastedHTML( + html: string, + options: INormalizePastedHTMLOptions = {}, +) { // Only extract the `body.innerHTML` when the `html` is a full HTML Document. if (/[\s\S]*<\/body>/.test(html)) { const match = /([\s\S]*)<\/body>/.exec(html); @@ -130,7 +137,7 @@ export async function normalizePastedHTML(html: string) { if (title) { link.textContent = title as string; } - else { + else if (!options.preserveBareUrlLinks) { // Escape + sanitize the fallback text (muyajs uses // `sanitize(text, PREVIEW_DOMPURIFY_CONFIG, true)`) so a stray // angle bracket can't re-enter as live markup. From a8f6d03af6d3413408c2db1ce69fe2dd9b6afd6d Mon Sep 17 00:00:00 2001 From: Sec <129755144+Renakoni@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:46:19 +0800 Subject: [PATCH 4/8] fix: preserve whitespace-only plain text paste (#4706) * fix: preserve whitespace-only plain text paste * fix: preserve inline spaces from rich clipboard --- .../muya/e2e/tests/editing/clipboard.spec.ts | 37 +++++++++++++++++++ .../__tests__/pasteHandlerParity.spec.ts | 33 +++++++++++++++++ packages/muya/src/clipboard/paste.ts | 12 +++--- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/muya/e2e/tests/editing/clipboard.spec.ts b/packages/muya/e2e/tests/editing/clipboard.spec.ts index b1cfbf1fc1..5e7ebbde9c 100644 --- a/packages/muya/e2e/tests/editing/clipboard.spec.ts +++ b/packages/muya/e2e/tests/editing/clipboard.spec.ts @@ -188,6 +188,16 @@ test.describe('clipboard paste', () => { const md = await getMarkdown(page); expect(md).not.toMatch(/[*_`|[\]]/); }); + + test('pasting inline spaces with HTML still inserts the plain spaces', async ({ browserName, context, page }) => { + test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); + await grantClipboardPermissions(context); + await pasteClipboardAt(page, 'AB', 1, ' ', ' '); + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toBe('A B\n'); + }); }); /** @@ -240,6 +250,33 @@ async function pasteClipboard( await page.keyboard.press(`${metaKey()}+v`); } +async function pasteClipboardAt( + page: Parameters[1]>[0]['page'], + initial: string, + offset: number, + html: string, + text: string, +): Promise { + await page.evaluate((value) => window.muya!.setContent(value), initial); + + await page.evaluate(async ({ html, text, offset }) => { + const block = window.muya!.editor.scrollPage!.firstContentInDescendant()!; + block.setCursor(offset, offset, true); + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([html], { type: 'text/html' }), + 'text/plain': new Blob([text], { type: 'text/plain' }), + }), + ]); + }, { html, text, offset }); + + await page.evaluate(() => { + window.muya!.focus(); + window.muya!.domNode.focus(); + }); + await page.keyboard.press(`${metaKey()}+v`); +} + /** * Same as pasteClipboard but writes only `text/plain`. */ diff --git a/packages/muya/src/clipboard/__tests__/pasteHandlerParity.spec.ts b/packages/muya/src/clipboard/__tests__/pasteHandlerParity.spec.ts index 4c6695e1c3..071617aaff 100644 --- a/packages/muya/src/clipboard/__tests__/pasteHandlerParity.spec.ts +++ b/packages/muya/src/clipboard/__tests__/pasteHandlerParity.spec.ts @@ -144,6 +144,39 @@ function makePasteEvent(data: Record = {}) { } as unknown as ClipboardEvent; } +describe('pasteHandler - whitespace-only plain text paste', () => { + it('preserves inline whitespace-only text inside a paragraph', async () => { + const created: IRecordedBlock[] = []; + installLoadBlockSpy(created); + const wrapper = makeWrapper('paragraph'); + const anchor = makeAnchorBlock('paragraph.content', 'AB', wrapper, 1); + const clipboard = makeClipboard(anchor); + + await clipboard.pasteHandler(makePasteEvent({ 'text/plain': ' ' })); + + expect(created).toHaveLength(0); + expect(anchor.text).toBe('A B'); + expect(anchor.setCursor).toHaveBeenCalledWith(3, 3, true); + }); + + it('preserves inline spaces when the clipboard also contains HTML', async () => { + const created: IRecordedBlock[] = []; + installLoadBlockSpy(created); + const wrapper = makeWrapper('paragraph'); + const anchor = makeAnchorBlock('paragraph.content', 'AB', wrapper, 1); + const clipboard = makeClipboard(anchor); + + await clipboard.pasteHandler(makePasteEvent({ + 'text/html': ' ', + 'text/plain': ' ', + })); + + expect(created).toHaveLength(0); + expect(anchor.text).toBe('A B'); + expect(anchor.setCursor).toHaveBeenCalledWith(3, 3, true); + }); +}); + describe('pasteHandler — single-line markdown parses into real blocks (sub-item 1)', () => { it('pastes `# Title` into an empty paragraph as an atx-heading block', async () => { const created: IRecordedBlock[] = []; diff --git a/packages/muya/src/clipboard/paste.ts b/packages/muya/src/clipboard/paste.ts index 2fb901ebd1..7d00b85ed2 100644 --- a/packages/muya/src/clipboard/paste.ts +++ b/packages/muya/src/clipboard/paste.ts @@ -352,8 +352,8 @@ function applyParsedPaste( const { muya } = clipboard; const { anchorBlock, start, end, content } = ctx; - // An empty / whitespace-only paste is a no-op; the parser would otherwise - // emit a lone empty paragraph and churn blocks. + // An empty / whitespace-only paste is a no-op while parsing; non-empty + // inline whitespace from text/plain is routed through literal insertion. if (markdown.trim().length === 0) return; @@ -643,10 +643,12 @@ async function applyPaste(clipboard: Clipboard, data: IPasteData): Promise || anchorBlock.blockName === 'table.cell.content' || anchorBlock.blockName === 'codeblock.content'; - if (!isLiteralAnchor) - applyParsedPaste(clipboard, ctx, markdown); + const isPlainInlineSpaces = /^ +$/.test(text); + + if (isLiteralAnchor || isPlainInlineSpaces) + applyLiteralPaste(clipboard, ctx, isPlainInlineSpaces ? text : markdown); else - applyLiteralPaste(clipboard, ctx, markdown); + applyParsedPaste(clipboard, ctx, markdown); } else if (pasteType === PasteType.PASTE_AS_PLAIN_TEXT) { // Paste as Plain Text inserts block-level HTML as literal text, not a From 786929e514505be683217f65165e8abf5b01476d Mon Sep 17 00:00:00 2001 From: Sec <129755144+Renakoni@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:32:44 +0800 Subject: [PATCH 5/8] fix: preserve nested HTML lists under ordered parents (#4709) * fix: preserve nested HTML lists under ordered parents * fix: close clipboard nested-list e2e test --- .../muya/e2e/tests/editing/clipboard.spec.ts | 17 ++++++ .../__tests__/htmlNestedListPaste.spec.ts | 60 +++++++++++++++++++ .../muya/src/utils/turndownService/index.ts | 15 ++--- 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 packages/muya/src/utils/__tests__/htmlNestedListPaste.spec.ts diff --git a/packages/muya/e2e/tests/editing/clipboard.spec.ts b/packages/muya/e2e/tests/editing/clipboard.spec.ts index 5e7ebbde9c..b9eb2ac7eb 100644 --- a/packages/muya/e2e/tests/editing/clipboard.spec.ts +++ b/packages/muya/e2e/tests/editing/clipboard.spec.ts @@ -128,6 +128,23 @@ test.describe('clipboard paste', () => { expect(md).toMatch(/\|\s*r1c1\s*\|\s*r1c2\s*\|/); }); + test('pasting nested HTML lists under ordered parents preserves nesting', async ({ browserName, context, page }) => { + test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); + await grantClipboardPermissions(context); + + await pasteClipboard(page, '
  1. one
    • two
  2. three
', 'one\ntwo\nthree'); + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toContain('1. one\n - two\n2. three'); + + await pasteClipboard(page, '
  1. one
    1. two
  2. three
', 'one\ntwo\nthree'); + await expect.poll(async () => getMarkdown(page), { + timeout: 5_000, + intervals: [50, 100, 250, 500], + }).toContain('1. one\n 1. two\n2. three'); + }); + test('pasting HTML task-list items creates task-list Markdown', async ({ browserName, context, page }) => { test.skip(browserName !== 'chromium', 'ClipboardItem text/html unreliable on Firefox/WebKit headless — BACKLOG Phase 3.'); await grantClipboardPermissions(context); diff --git a/packages/muya/src/utils/__tests__/htmlNestedListPaste.spec.ts b/packages/muya/src/utils/__tests__/htmlNestedListPaste.spec.ts new file mode 100644 index 0000000000..a3f47e37fd --- /dev/null +++ b/packages/muya/src/utils/__tests__/htmlNestedListPaste.spec.ts @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; +import HtmlToMarkdown from '../../state/htmlToMarkdown'; +import { MarkdownToState } from '../../state/markdownToState'; + +interface IStateLike { + name: string; + text?: string; + children?: IStateLike[]; +} + +function htmlToState(html: string) { + const markdown = new HtmlToMarkdown({ bulletListMarker: '-' }).generate(html); + const states = new MarkdownToState({ + footnote: false, + math: false, + isGitlabCompatibilityEnabled: false, + trimUnnecessaryCodeBlockEmptyLines: false, + frontMatter: false, + }).generate(markdown) as unknown as IStateLike[]; + + return { markdown, states }; +} + +function firstItemChildren(states: IStateLike[]) { + return states[0].children?.[0].children?.map(child => child.name) ?? []; +} + +describe('htmlToMarkdown - nested HTML lists under ordered parents', () => { + it('keeps a bullet child list nested under an ordered parent item', () => { + const { markdown, states } = htmlToState( + '
  1. one
    • two
  2. three
', + ); + + expect(markdown).toContain('1. one\n - two'); + expect(states.map(state => state.name)).toEqual(['order-list']); + expect(firstItemChildren(states)).toEqual(['paragraph', 'bullet-list']); + }); + + it('keeps an ordered child list nested under an ordered parent item', () => { + const { markdown, states } = htmlToState( + '
  1. one
    1. two
  2. three
', + ); + + expect(markdown).toContain('1. one\n 1. two'); + expect(states.map(state => state.name)).toEqual(['order-list']); + expect(firstItemChildren(states)).toEqual(['paragraph', 'order-list']); + }); + + it('uses the ordered marker width when the parent starts at a two-digit number', () => { + const { markdown, states } = htmlToState( + '
  1. ten
    • child
  2. eleven
', + ); + + expect(markdown).toContain('10. ten\n - child'); + expect(states.map(state => state.name)).toEqual(['order-list']); + expect(firstItemChildren(states)).toEqual(['paragraph', 'bullet-list']); + }); +}); diff --git a/packages/muya/src/utils/turndownService/index.ts b/packages/muya/src/utils/turndownService/index.ts index 4f1a0352ea..19590f9f49 100644 --- a/packages/muya/src/utils/turndownService/index.ts +++ b/packages/muya/src/utils/turndownService/index.ts @@ -96,13 +96,6 @@ export function usePluginsAddRules(turndownService: TurndownService) { node: Node, options: { bulletListMarker?: string }, ) { - content = content - .replace(/^\n+/, '') // remove leading newlines - .replace(/\n+$/, '\n') // replace trailing newlines with just a single one - .replace(/\n/g, '\n '); // indent - if (containsOwnTaskListCheckbox(node)) - content = normalizeTaskMarkerSpacing(content); - let prefix = `${options.bulletListMarker} `; const parent = node.parentNode; if (isHTMLElement(parent) && parent.nodeName === 'OL') { @@ -111,6 +104,14 @@ export function usePluginsAddRules(turndownService: TurndownService) { prefix = `${start ? Number(start) + index : index + 1}. `; } + const continuationIndent = ' '.repeat(prefix.length); + content = content + .replace(/^\n+/, '') // remove leading newlines + .replace(/\n+$/, '\n') // replace trailing newlines with just a single one + .replace(/\n/g, `\n${continuationIndent}`); // indent + if (containsOwnTaskListCheckbox(node)) + content = normalizeTaskMarkerSpacing(content); + return ( prefix + content From 796185512e5c71447bdd6a128599b00097d11631 Mon Sep 17 00:00:00 2001 From: Sec <129755144+Renakoni@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:42:18 +0800 Subject: [PATCH 6/8] fix: prevent nested empty list items from becoming setext headings (#4697) * fix: prevent nested empty list items from becoming setext headings * fix: avoid setext ambiguity for nested empty list items --- .../state/__tests__/listSerialization.spec.ts | 153 +++++++++++++++++- packages/muya/src/state/stateToMarkdown.ts | 38 ++++- 2 files changed, 188 insertions(+), 3 deletions(-) diff --git a/packages/muya/src/state/__tests__/listSerialization.spec.ts b/packages/muya/src/state/__tests__/listSerialization.spec.ts index 7cc7f8daed..7b654c7e95 100644 --- a/packages/muya/src/state/__tests__/listSerialization.spec.ts +++ b/packages/muya/src/state/__tests__/listSerialization.spec.ts @@ -1,4 +1,11 @@ -import type { IBulletListState, IOrderListState, TState } from '../types'; +import type { + IBulletListState, + IListItemState, + IOrderListState, + ITaskListItemState, + ITaskListState, + TState, +} from '../types'; import { describe, expect, it } from 'vitest'; import { MarkdownToState } from '../markdownToState'; import ExportMarkdown from '../stateToMarkdown'; @@ -17,6 +24,150 @@ function roundTrip(md: string, listIndentation: number | string = 1): string { return new ExportMarkdown({ listIndentation }).generate(states); } +function parseMarkdown(md: string): TState[] { + return new MarkdownToState({ + footnote: false, + math: false, + isGitlabCompatibilityEnabled: false, + trimUnnecessaryCodeBlockEmptyLines: false, + frontMatter: false, + }).generate(md); +} + +function serializeState(states: TState[]): string { + return new ExportMarkdown({ listIndentation: 1 }).generate(states); +} + +function firstNestedBulletList( + state: IBulletListState | IOrderListState | ITaskListState, +): IBulletListState { + const parent = state.children[0]; + expect(parent.children.some(child => child.name === 'setext-heading')).toBe(false); + const nested = parent.children.find( + (child): child is IBulletListState => child.name === 'bullet-list', + ); + expect(nested).toBeDefined(); + return nested!; +} + +function paragraph(text: string): TState { + return { name: 'paragraph', text }; +} + +function listItem(children: TState[]): IListItemState { + return { name: 'list-item', children }; +} + +function taskListItem(children: TState[]): ITaskListItemState { + return { name: 'task-list-item', meta: { checked: false }, children }; +} + +function emptyListItem(): IListItemState { + return listItem([paragraph('')]); +} + +function bulletList(marker: string, children: IListItemState[]): IBulletListState { + return { name: 'bullet-list', meta: { marker, loose: false }, children }; +} + +function bulletParent(nested: IBulletListState): TState[] { + return [{ + name: 'bullet-list', + meta: { marker: '-', loose: false }, + children: [listItem([paragraph('a'), nested])], + }]; +} + +describe('stateToMarkdown — nested empty list items', () => { + it('uses an alternate nested marker instead of making a tight list loose', () => { + const nested = bulletList('-', [emptyListItem(), emptyListItem()]); + const out = serializeState(bulletParent(nested)); + expect(out).toBe('- a\n * \n * \n'); + expect(nested.meta.marker).toBe('-'); + + const reparsed = parseMarkdown(out); + const outer = reparsed[0] as IBulletListState; + expect(outer.name).toBe('bullet-list'); + expect(outer.meta.loose).toBe(false); + expect(outer.children[0].children[0]).toEqual({ name: 'paragraph', text: 'a' }); + const reparsedNested = firstNestedBulletList(outer); + expect(reparsedNested.meta.marker).toBe('*'); + expect(reparsedNested.children).toHaveLength(2); + expect(serializeState(reparsed)).toBe(out); + }); + + it('uses the same safe marker under ordered and task-list parents', () => { + const orderedStates: TState[] = [{ + name: 'order-list', + meta: { start: 1, loose: false, delimiter: '.' }, + children: [listItem([paragraph('a'), bulletList('-', [emptyListItem(), emptyListItem()])])], + }]; + const orderedOut = serializeState(orderedStates); + expect(orderedOut).toBe('1. a\n * \n * \n'); + const ordered = parseMarkdown(orderedOut)[0] as IOrderListState; + expect(ordered.name).toBe('order-list'); + expect(ordered.meta.loose).toBe(false); + expect(firstNestedBulletList(ordered).children).toHaveLength(2); + + const taskStates: TState[] = [{ + name: 'task-list', + meta: { marker: '-', loose: false }, + children: [taskListItem([paragraph('a'), bulletList('-', [emptyListItem(), emptyListItem()])])], + }]; + const taskOut = serializeState(taskStates); + expect(taskOut).toBe('- [ ] a\n * \n * \n'); + const task = parseMarkdown(taskOut)[0] as ITaskListState; + expect(task.name).toBe('task-list'); + expect(task.meta.loose).toBe(false); + expect(firstNestedBulletList(task).children).toHaveLength(2); + }); + + it('handles a first empty nested item followed by a non-empty item', () => { + const out = serializeState(bulletParent(bulletList('-', [ + emptyListItem(), + listItem([paragraph('b')]), + ]))); + expect(out).toBe('- a\n * \n * b\n'); + const outer = parseMarkdown(out)[0] as IBulletListState; + const nested = firstNestedBulletList(outer); + expect(nested.children).toHaveLength(2); + expect(nested.children[1].children[0]).toEqual({ name: 'paragraph', text: 'b' }); + }); + + it('does not rewrite nested dash lists whose first item is not empty', () => { + const out = serializeState(bulletParent(bulletList('-', [ + listItem([paragraph('b')]), + emptyListItem(), + ]))); + expect(out).toBe('- a\n - b\n - \n'); + const outer = parseMarkdown(out)[0] as IBulletListState; + const nested = firstNestedBulletList(outer); + expect(nested.meta.marker).toBe('-'); + expect(nested.children).toHaveLength(2); + expect(serializeState(parseMarkdown(out))).toBe(out); + }); + + it('keeps already-loose parent lists on their original dash marker', () => { + const states: TState[] = [{ + name: 'bullet-list', + meta: { marker: '-', loose: true }, + children: [listItem([paragraph('a'), bulletList('-', [emptyListItem(), emptyListItem()])])], + }]; + const out = serializeState(states); + expect(out).toBe('- a\n\n - \n - \n'); + const outer = parseMarkdown(out)[0] as IBulletListState; + expect(outer.meta.loose).toBe(true); + const nested = firstNestedBulletList(outer); + expect(nested.meta.marker).toBe('-'); + expect(nested.children).toHaveLength(2); + }); + + it('serializes parser-created empty list items as separate lines', () => { + const out = serializeState(parseMarkdown('- \n- \n')); + expect(out).toBe('- \n- \n'); + }); +}); + // Regression baseline ported from marktext's // test/unit/specs/markdown-list-indentation.spec.js, the suite touched by // commit 02841ffd (fix: subsequent list paragraphs, PR #916). marktext used diff --git a/packages/muya/src/state/stateToMarkdown.ts b/packages/muya/src/state/stateToMarkdown.ts index b8faedf6e5..97bd88cec5 100644 --- a/packages/muya/src/state/stateToMarkdown.ts +++ b/packages/muya/src/state/stateToMarkdown.ts @@ -36,6 +36,8 @@ import stringWidth from '../utils/stringWidth'; import { isAnyListState } from './types'; const debug = logger('export markdown: '); +const SETEXT_SAFE_BULLET_MARKER = '*'; + function escapeText(str: string) { return str.replace(/(? Date: Fri, 26 Jun 2026 21:38:41 +0800 Subject: [PATCH 7/8] fix: preserve empty task list items when parsing markdown (#4696) * fix: preserve empty task list items when parsing markdown * fix: satisfy lint for empty task checkbox regex * fix: handle empty task list item edge cases * fix: resolve empty task item merge regressions * fix: satisfy import order lint for empty task tests --- .../base/__tests__/arrowNavigation.spec.ts | 29 ++++++- .../__tests__/emptyTaskItemRender.spec.ts | 72 ++++++++++++++++ .../state/__tests__/listSerialization.spec.ts | 34 ++++++++ .../state/__tests__/markdownToState.spec.ts | 84 +++++++++++++++++++ packages/muya/src/state/markdownToState.ts | 5 +- .../src/utils/marked/compatibleTaskList.ts | 58 +++++++++++++ 6 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 packages/muya/src/block/gfm/taskListItem/__tests__/emptyTaskItemRender.spec.ts diff --git a/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts b/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts index 6065e8dada..bdeb090531 100644 --- a/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts +++ b/packages/muya/src/block/base/__tests__/arrowNavigation.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import type { TState } from '../../../state/types'; import type Content from '../content'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Muya } from '../../../muya'; @@ -47,6 +48,15 @@ function bootMuya(markdown: string): Muya { return muya; } +function bootMuyaState(json: TState[]): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { json } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + function contentByText(muya: Muya, text: string): Content { let target: Content | null = null; const visit = (block: { @@ -313,8 +323,19 @@ describe('content arrowHandler — trailing-paragraph creation at document end', // 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. +// and reach the content beyond it. +function listWithChildlessMiddleItem(): TState[] { + return [{ + name: 'bullet-list', + meta: { marker: '*', loose: false }, + children: [ + { name: 'list-item', children: [{ name: 'paragraph', text: 'A' }] }, + { name: 'list-item', children: [] }, + { name: 'list-item', children: [{ name: 'paragraph', text: 'B' }] }, + ], + }]; +} + function allContentTexts(muya: Muya): string[] { const texts: string[] = []; const visit = (block: { @@ -332,7 +353,7 @@ function allContentTexts(muya: Muya): string[] { 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'); + const muya = bootMuyaState(listWithChildlessMiddleItem()); // 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']); @@ -351,7 +372,7 @@ describe('content arrowHandler — skips empty sibling containers (#4644)', () = }); 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'); + const muya = bootMuyaState(listWithChildlessMiddleItem()); expect(allContentTexts(muya)).toEqual(['A', 'B']); const a = contentByText(muya, 'A'); diff --git a/packages/muya/src/block/gfm/taskListItem/__tests__/emptyTaskItemRender.spec.ts b/packages/muya/src/block/gfm/taskListItem/__tests__/emptyTaskItemRender.spec.ts new file mode 100644 index 0000000000..2bb4a1ea23 --- /dev/null +++ b/packages/muya/src/block/gfm/taskListItem/__tests__/emptyTaskItemRender.spec.ts @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom + +import type Content from '../../../base/content'; +import type Parent from '../../../base/parent'; +import { afterEach, beforeEach, describe, expect, it } 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) + bootedHosts.pop()!.remove(); + + document.getSelection()?.removeAllRanges(); + 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, {} as ConstructorParameters[1]); + muya.init(); + muya.setContent(markdown); + bootedHosts.push(muya.domNode); + + return muya; +} + +function taskListItems(muya: Muya): Parent[] { + const result: Parent[] = []; + + const visit = (block: Parent) => { + if (block.blockName === 'task-list-item') + result.push(block); + + block.children?.forEach((child) => { + if (child.isParent()) + visit(child as Parent); + }); + }; + + visit(muya.editor.scrollPage as unknown as Parent); + + return result; +} + +describe('task-list-item rendering', () => { + it('renders empty task items with editable content blocks', () => { + const muya = bootMuya('- [ ] a\n\n- [ ] \n- [ ] \n- [ ] \ntext\n'); + + const items = taskListItems(muya); + expect(items).toHaveLength(4); + expect(muya.domNode.querySelectorAll('li.mu-task-list-item')).toHaveLength(4); + expect(muya.domNode.querySelectorAll('.mu-task-list-checkbox')).toHaveLength(4); + + const contentTexts = items.map(item => + (item.firstContentInDescendant() as Content | null)?.text, + ); + expect(contentTexts).toEqual(['a', '', '', 'text']); + }); +}); diff --git a/packages/muya/src/state/__tests__/listSerialization.spec.ts b/packages/muya/src/state/__tests__/listSerialization.spec.ts index 7b654c7e95..3a0f452cde 100644 --- a/packages/muya/src/state/__tests__/listSerialization.spec.ts +++ b/packages/muya/src/state/__tests__/listSerialization.spec.ts @@ -24,6 +24,40 @@ function roundTrip(md: string, listIndentation: number | string = 1): string { return new ExportMarkdown({ listIndentation }).generate(states); } +describe('stateToMarkdown — empty list item serialization', () => { + it('keeps consecutive empty task items on separate lines', () => { + expect(roundTrip('- [ ] \n- [ ] \n')).toBe('- [ ] \n- [ ] \n'); + expect(roundTrip('- [ ] \n- [x] \n')).toBe('- [ ] \n- [x] \n'); + }); + + it('keeps consecutive empty bullet items on separate lines', () => { + expect(roundTrip('- \n- \n')).toBe('- \n- \n'); + }); + + it('keeps adjacent empty bullet items before a following paragraph', () => { + const md = '- \n- \n- \n\nz\n'; + const out = roundTrip(md, 1); + expect(out).toBe(md); + + const reparsed = parseMarkdown(out); + expect(reparsed[0].name).toBe('bullet-list'); + expect((reparsed[0] as IBulletListState).children).toHaveLength(3); + expect(reparsed[1].name).toBe('paragraph'); + }); + + it('keeps an empty bullet item between populated sibling items', () => { + const md = '- a\n- \n- c\n'; + const out = roundTrip(md, 1); + expect(out).toBe(md); + + const reparsed = parseMarkdown(out); + const list = reparsed[0] as IBulletListState; + expect(list.name).toBe('bullet-list'); + expect(list.children).toHaveLength(3); + expect(list.children[1].children).toEqual([{ name: 'paragraph', text: '' }]); + }); +}); + function parseMarkdown(md: string): TState[] { return new MarkdownToState({ footnote: false, diff --git a/packages/muya/src/state/__tests__/markdownToState.spec.ts b/packages/muya/src/state/__tests__/markdownToState.spec.ts index d1e645d1fc..96c30d15b4 100644 --- a/packages/muya/src/state/__tests__/markdownToState.spec.ts +++ b/packages/muya/src/state/__tests__/markdownToState.spec.ts @@ -37,6 +37,90 @@ function generate( // the correct nesting so a future list refactor can't quietly re-introduce // the flattening. describe('markdownToState — task list nesting (marktext 23435ce6)', () => { + it('keeps an empty unchecked task item after a populated task item', () => { + const states = generate('- [ ] a\n- [ ] \n'); + + expect(states.length).toBe(1); + const list = states[0]; + expect(list.name).toBe('task-list'); + expect(list.children).toHaveLength(2); + expect(list.children!.map(c => c.name)).toEqual(['task-list-item', 'task-list-item']); + expect(list.children!.map(c => c.meta?.checked)).toEqual([false, false]); + expect(list.children![0].children!.find(c => c.name === 'paragraph')?.text).toBe('a'); + expect(list.children![1].children).toEqual([{ name: 'paragraph', text: '' }]); + }); + + it('parses a single empty unchecked task item as a task list item', () => { + const states = generate('- [ ] \n'); + + expect(states.length).toBe(1); + expect(states[0].name).toBe('task-list'); + expect(states[0].children).toHaveLength(1); + expect(states[0].children![0].name).toBe('task-list-item'); + expect(states[0].children![0].meta?.checked).toBe(false); + expect(states[0].children![0].children).toEqual([{ name: 'paragraph', text: '' }]); + }); + + it('parses a single empty checked task item as a checked task list item', () => { + const states = generate('- [x] \n'); + + expect(states.length).toBe(1); + expect(states[0].name).toBe('task-list'); + expect(states[0].children).toHaveLength(1); + expect(states[0].children![0].name).toBe('task-list-item'); + expect(states[0].children![0].meta?.checked).toBe(true); + expect(states[0].children![0].children).toEqual([{ name: 'paragraph', text: '' }]); + }); + + it('parses an empty task marker with lazy continuation text as a task item', () => { + const states = generate('- [ ] \ntext\n'); + + expect(states.length).toBe(1); + expect(states[0].name).toBe('task-list'); + expect(states[0].children).toHaveLength(1); + expect(states[0].children![0].name).toBe('task-list-item'); + expect(states[0].children![0].meta?.checked).toBe(false); + expect(states[0].children![0].children).toHaveLength(1); + expect(states[0].children![0].children![0]).toEqual({ name: 'paragraph', text: 'text' }); + }); + + it('keeps lazy continuation text on the final empty task marker in a task list', () => { + const states = generate('- [ ] a\n\n- [ ] \n- [ ] \n- [ ] \ntext\n'); + + expect(states.length).toBe(1); + expect(states[0].name).toBe('task-list'); + expect(states[0].children).toHaveLength(4); + expect(states[0].children!.map(c => c.name)).toEqual([ + 'task-list-item', + 'task-list-item', + 'task-list-item', + 'task-list-item', + ]); + expect(states[0].children!.map(c => c.meta?.checked)).toEqual([false, false, false, false]); + expect(states[0].children![0].children![0]).toEqual({ name: 'paragraph', text: 'a' }); + expect(states[0].children![1].children).toEqual([{ name: 'paragraph', text: '' }]); + expect(states[0].children![2].children).toEqual([{ name: 'paragraph', text: '' }]); + expect(states[0].children![3].children![0]).toEqual({ name: 'paragraph', text: 'text' }); + }); + + it('does not treat `- []` as an empty task item', () => { + const states = generate('- []\n'); + + expect(states.length).toBe(1); + expect(states[0].name).toBe('bullet-list'); + expect(states[0].children![0].name).toBe('list-item'); + expect(states[0].children![0].children![0]).toEqual({ name: 'paragraph', text: '[]' }); + }); + + it('does not treat `- [ ]text` as a task item without a marker separator', () => { + const states = generate('- [ ]text\n'); + + expect(states.length).toBe(1); + expect(states[0].name).toBe('bullet-list'); + expect(states[0].children![0].name).toBe('list-item'); + expect(states[0].children![0].children![0]).toEqual({ name: 'paragraph', text: '[ ]text' }); + }); + it('keeps three levels of task-list nesting', () => { const md = `- [ ] task1 diff --git a/packages/muya/src/state/markdownToState.ts b/packages/muya/src/state/markdownToState.ts index 8d57f4e293..9e367005ba 100644 --- a/packages/muya/src/state/markdownToState.ts +++ b/packages/muya/src/state/markdownToState.ts @@ -100,7 +100,10 @@ export class MarkdownToState { // Fix #1735 the blockquote maybe empty. like bellow: // > // bar - if (parentList[0].length === 0 && token.tokenType === 'blockquote') { + if ( + parentList[0].length === 0 + && (token.tokenType === 'blockquote' || token.tokenType === 'list-item') + ) { state = { name: 'paragraph' as const, text: '', diff --git a/packages/muya/src/utils/marked/compatibleTaskList.ts b/packages/muya/src/utils/marked/compatibleTaskList.ts index 9493cc23e6..6e8adadd51 100644 --- a/packages/muya/src/utils/marked/compatibleTaskList.ts +++ b/packages/muya/src/utils/marked/compatibleTaskList.ts @@ -6,6 +6,41 @@ function isListToken(token: Token | ListToken): token is ListToken { } const BULL_REG = /^ {0,3}([*+-]|\d{1,9}(?:\.|\)))/; +const EMPTY_TASK_REG = /^ {0,3}[*+-][ \t]+\[([ x])\][ \t]*$/i; +const TASK_MARKER_PREFIX_REG = /^ {0,3}[*+-][ \t]+\[([ x])\][ \t]+/i; + +function stripTaskTextPrefix(value: string, marker: string) { + if (!value.startsWith(marker)) + return value; + + const rest = value.slice(marker.length); + const newlinePrefix = /^[ \t]*\r?\n/.exec(rest); + if (newlinePrefix) + return rest.slice(newlinePrefix[0].length); + + return rest.replace(/^[ \t]+/, ''); +} + +function stripSyntheticTaskMarker(item: ListItemToken, marker: string) { + const first = item.tokens?.[0] as Token & { raw?: string; text?: string; tokens?: Token[] } | undefined; + if (!first) + return; + + if (typeof first.text === 'string') + first.text = stripTaskTextPrefix(first.text, marker); + if (typeof first.raw === 'string') + first.raw = stripTaskTextPrefix(first.raw, marker); + + const inner = first.type === 'paragraph' + ? first.tokens?.[0] as Token & { raw?: string; text?: string } | undefined + : undefined; + if (inner) { + if (typeof inner.text === 'string') + inner.text = stripTaskTextPrefix(inner.text, marker); + if (typeof inner.raw === 'string') + inner.raw = stripTaskTextPrefix(inner.raw, marker); + } +} // marked >=17 keeps the GFM task marker inside the item content: a leading // `checkbox` token, plus the literal "[ ] " / "[x] " prefix in the first @@ -32,6 +67,28 @@ function stripTaskMarker(item: ListItemToken) { } } +function normalizeEmptyTaskItem(item: ListItemToken) { + if (item.task) + return; + + const matches = EMPTY_TASK_REG.exec(item.raw) || TASK_MARKER_PREFIX_REG.exec(item.raw); + if (!matches) + return; + + const marker = `[${matches[1]}]`; + const text = typeof item.text === 'string' ? item.text : ''; + if (text.trimEnd() !== marker && !text.startsWith(marker)) + return; + + item.task = true; + item.checked = matches[1] !== ' '; + item.text = stripTaskTextPrefix(text, marker); + if (item.text === '') + item.tokens = []; + else + stripSyntheticTaskMarker(item, marker); +} + // If bullet list contains task list items, split the bullet list into bullet lists and task lists. // Add `listType` to token, it's type: "order" | "bullet" | "task". // Add `listItemType` to list_item token. it's type: "order" | "bullet" | "task". @@ -65,6 +122,7 @@ function compatibleTaskList(tokens: (Token | ListToken | ListItemToken)[] = []) for (const item of token.items) { item.tokens = compatibleTaskList(item.tokens); + normalizeEmptyTaskItem(item); const listItemType = item.task ? 'task' : 'bullet'; item.listItemType = listItemType; if (item.task) From 750f9200a2975ac90f663cbb89ea2c2993e3a2c3 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Fri, 26 Jun 2026 22:21:48 +0800 Subject: [PATCH 8/8] fix: remove ~1s sidebar delay for newly created files (#3955) (#4744) The directory-tree watcher inherited chokidar's `awaitWriteFinish` (stabilityThreshold 1000ms), which defers `add`/`change` events until the file size settles. That protection exists for #1043, where an external editor saving an *open* file could make MarkText reload partial/empty content. But only the file watcher reloads file CONTENT on change; the directory watcher just lists nodes and re-sorts by mtime, and the content it loads on `add` is consumed only for MarkText's own freshly-created empty file. So the deferral gave the directory watcher no real protection while delaying every new file's appearance in the sidebar by ~1s. Apply `awaitWriteFinish` only to the file watcher. New files (and the "New File" command) now appear in the sidebar immediately, and the #1043 protection for open files is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) --- .../desktop/src/main/filesystem/watcher.ts | 18 +++-- .../specs/watcher-await-write-finish.spec.ts | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 packages/desktop/test/unit/specs/watcher-await-write-finish.spec.ts diff --git a/packages/desktop/src/main/filesystem/watcher.ts b/packages/desktop/src/main/filesystem/watcher.ts index dd8b2fcdd0..43f12fd223 100644 --- a/packages/desktop/src/main/filesystem/watcher.ts +++ b/packages/desktop/src/main/filesystem/watcher.ts @@ -228,11 +228,19 @@ class Watcher { depth: type === 'file' ? (isOsx ? 1 : 0) : undefined, - // Please see GH#1043 - awaitWriteFinish: { - stabilityThreshold: WATCHER_STABILITY_THRESHOLD, - pollInterval: WATCHER_STABILITY_POLL_INTERVAL - }, + // Defer events until writes settle only for the file watcher, which + // reloads file CONTENT on change and would otherwise read a partial file + // (GH#1043). The directory watcher just lists nodes and re-sorts by mtime, + // so deferring its `add` events only made new files appear in the sidebar + // ~1s late (GH#3955). + ...(type === 'file' + ? { + awaitWriteFinish: { + stabilityThreshold: WATCHER_STABILITY_THRESHOLD, + pollInterval: WATCHER_STABILITY_POLL_INTERVAL + } + } + : {}), usePolling // chokidar's `ignored` callback signature varies between versions; this options diff --git a/packages/desktop/test/unit/specs/watcher-await-write-finish.spec.ts b/packages/desktop/test/unit/specs/watcher-await-write-finish.spec.ts new file mode 100644 index 0000000000..0510f2cc76 --- /dev/null +++ b/packages/desktop/test/unit/specs/watcher-await-write-finish.spec.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// #3955: newly created files appeared in the sidebar ~1s late because the +// directory watcher inherited chokidar's `awaitWriteFinish` (stabilityThreshold +// 1000ms), which defers `add`/`change` events until the file size settles. That +// protection (GH#1043) only matters for the file watcher, which reloads file +// CONTENT on change; the directory watcher just lists nodes and re-sorts by +// mtime. These tests pin that only the file watcher defers events. + +const watchMock = vi.fn() + +function fakeWatcher(): Record> { + const w: Record> = {} + w.on = vi.fn(() => w) + w.close = vi.fn() + w.add = vi.fn() + w.unwatch = vi.fn() + return w +} + +vi.mock('chokidar', () => ({ + default: { + watch: (...args: unknown[]) => { + watchMock(...args) + return fakeWatcher() + } + } +})) + +// Importing the watcher pulls in the markdown loader, whose encoding detection +// uses the native `ced` addon. Its bindings are built for Electron's ABI, not +// the plain-Node test runner, so stub it to keep this spec import-only. +vi.mock('ced', () => ({ default: () => 'UTF-8' })) + +import Watcher, { + WATCHER_STABILITY_THRESHOLD, + WATCHER_STABILITY_POLL_INTERVAL +} from 'main_renderer/filesystem/watcher' + +function optionsForLastWatch(): Record { + const calls = watchMock.mock.calls + return calls[calls.length - 1][1] as Record +} + +describe('watcher awaitWriteFinish (#3955)', () => { + let watcher: Watcher + const win = { id: 1, webContents: { send: vi.fn() } } + + beforeEach(() => { + watchMock.mockClear() + const preferences = { getItem: vi.fn(() => false) } + watcher = new Watcher(preferences as never) + }) + + it('does not defer directory-tree events with awaitWriteFinish', () => { + watcher.watch(win as never, '/project', 'dir') + expect(optionsForLastWatch().awaitWriteFinish).toBeFalsy() + }) + + it('keeps awaitWriteFinish for the file watcher (GH#1043)', () => { + watcher.watch(win as never, '/project/note.md', 'file') + expect(optionsForLastWatch().awaitWriteFinish).toEqual({ + stabilityThreshold: WATCHER_STABILITY_THRESHOLD, + pollInterval: WATCHER_STABILITY_POLL_INTERVAL + }) + }) +})