From 3716a7bb49dda294ea987c44eaa0151e566e0507 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 27 Jul 2026 16:17:58 -0700 Subject: [PATCH] fix(markdown): render task continuations as text (#11008) --- .../src/assets/rich-markdown-editor.css | 16 ++++---- .../rich-markdown-task-list-style.test.ts | 15 ++++++++ .../editor/markdown-round-trip.test.ts | 25 +++++++++++++ .../editor/rich-markdown-extensions.ts | 4 +- .../editor/rich-markdown-task-list.ts | 37 +++++++++++++++++++ 5 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/assets/rich-markdown-task-list-style.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-task-list.ts diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 4b9184552..66dddbabe 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -628,13 +628,13 @@ padding-left: 0.75em; } -.rich-markdown-editor ul[data-type='taskList'] li { +.rich-markdown-editor ul[data-type='taskList'] > li { display: flex; align-items: flex-start; gap: 6px; } -.rich-markdown-editor ul[data-type='taskList'] li > label { +.rich-markdown-editor ul[data-type='taskList'] > li > label { flex-shrink: 0; display: flex; align-items: center; @@ -643,7 +643,7 @@ user-select: none; } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox'] { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox'] { appearance: none; width: 16px; height: 16px; @@ -657,12 +657,12 @@ flex-shrink: 0; } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox']:checked { background: var(--primary); border-color: var(--primary); } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked::after { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox']:checked::after { content: ''; position: absolute; left: 4px; @@ -677,16 +677,16 @@ transform: rotate(45deg); } -.rich-markdown-editor ul[data-type='taskList'] li > div { +.rich-markdown-editor ul[data-type='taskList'] > li > div { flex: 1; min-width: 0; } -.rich-markdown-editor ul[data-type='taskList'] li > div > p { +.rich-markdown-editor ul[data-type='taskList'] > li > div > p { margin: 0; } -.rich-markdown-editor ul[data-type='taskList'] li[data-checked='true'] > div { +.rich-markdown-editor ul[data-type='taskList'] > li[data-checked='true'] > div { text-decoration: line-through; color: var(--muted-foreground); } diff --git a/src/renderer/src/assets/rich-markdown-task-list-style.test.ts b/src/renderer/src/assets/rich-markdown-task-list-style.test.ts new file mode 100644 index 000000000..5f9a1cac4 --- /dev/null +++ b/src/renderer/src/assets/rich-markdown-task-list-style.test.ts @@ -0,0 +1,15 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const editorCss = fs.readFileSync(new URL('./rich-markdown-editor.css', import.meta.url), 'utf8') + +describe('rich markdown task-list styling', () => { + it('keeps flex layout scoped to direct task items', () => { + expect(editorCss).toMatch( + /\.rich-markdown-editor ul\[data-type='taskList'\] > li\s*{[^}]*display:\s*flex/s + ) + expect(editorCss).not.toMatch( + /\.rich-markdown-editor ul\[data-type='taskList'\] li\s*{[^}]*display:\s*flex/s + ) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 029692d83..c1ab84354 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -281,6 +281,31 @@ describe('rich markdown round trip', () => { ) }) + it('preserves aligned task-item continuations before nested bullets', () => { + const input = [ + '- [ ] Complete the provider action map used by the', + ' unchanged UI:', + ' - review creation and eligibility;', + ' - merge and auto-merge.', + '- [ ] Keep provider behavior explicit.' + ].join('\n') + + expect(roundTripMarkdown(input)).toBe( + [ + '- [ ] Complete the provider action map used by the', + '', + ' unchanged UI:', + ' - review creation and eligibility;', + ' - merge and auto-merge.', + '- [ ] Keep provider behavior explicit.' + ].join('\n') + ) + }) + + it('preserves blank-separated indented code inside task items', () => { + expect(roundTripMarkdown('- [ ] Run this:\n\n echo ok\n')).toContain('```') + }) + it('preserves doc links', () => { expect(roundTripMarkdown('See [[setup-guide]] for details\n')).toBe( 'See [[setup-guide]] for details' diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index a19e307b5..abe3e0a11 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -5,7 +5,6 @@ import { Code } from '@tiptap/extension-code' import Image from '@tiptap/extension-image' import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' import Placeholder from '@tiptap/extension-placeholder' -import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' @@ -33,6 +32,7 @@ import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown- import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-superscript-link' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' +import { RichMarkdownTaskList } from './rich-markdown-task-list' const lowlight = createLowlight(common) @@ -190,7 +190,7 @@ export function createRichMarkdownExtensions({ }).configure({ allowBase64: true }), - TaskList, + RichMarkdownTaskList, TaskItem.configure({ nested: true }), diff --git a/src/renderer/src/components/editor/rich-markdown-task-list.ts b/src/renderer/src/components/editor/rich-markdown-task-list.ts new file mode 100644 index 000000000..9b59e4af5 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-task-list.ts @@ -0,0 +1,37 @@ +import type { MarkdownLexerConfiguration, MarkdownToken, MarkdownTokenizer } from '@tiptap/core' +import TaskList from '@tiptap/extension-task-list' + +const baseTokenizer = TaskList.config.markdownTokenizer as MarkdownTokenizer + +function normalizeTaskListToken(token: MarkdownToken, lexer: MarkdownLexerConfiguration): void { + const firstNested = token.nestedTokens?.[0] + if ( + token.type === 'taskItem' && + firstNested?.type === 'code' && + firstNested.codeBlockStyle === 'indented' && + typeof firstNested.text === 'string' + ) { + // Tiptap re-lexes aligned no-blank task continuations as indented code. + firstNested.type = 'paragraph' + firstNested.raw = firstNested.text + firstNested.tokens = lexer.inlineTokens(firstNested.text) + delete firstNested.codeBlockStyle + } + + for (const child of [...(token.items ?? []), ...(token.nestedTokens ?? [])]) { + normalizeTaskListToken(child, lexer) + } +} + +export const RichMarkdownTaskList = TaskList.extend({ + markdownTokenizer: { + ...baseTokenizer, + tokenize(src, tokens, lexer) { + const token = baseTokenizer.tokenize(src, tokens, lexer) + if (token) { + normalizeTaskListToken(token, lexer) + } + return token + } + } +})