From 5951dbbf92f6df13051814c382939d2abddfb672 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 17:38:19 -0700 Subject: [PATCH] fix(editor): task-list parsing fixes (ordered parents, wrapped markup, tight continuations, bare markers) (#35187) * fix(editor): preserve sibling tasks beneath ordered list items * fix(editor): preserve wrapped inline markup in task items * fix(editor): keep nested task continuations valid and editable * fix(editor): keep literal task markers out of mixed checklists * fix(editor): apply review fixes to task-list parsing stack - Trim synthetic test matrices to the behavioral cases - Regenerate pnpm-lock (drops a stale github-slugger importer entry) - Assert the plain bullet's node type under an ordered parent * fix(editor): keep one-column-deeper ordered tasks as sibling checkboxes Dedent an ordered item's task block by the first task's column instead of keeping its leading whitespace. A task one column deeper stays a sibling checkbox, and text at or within the first task's column stays nested as before. Strip leftover paragraph indent from task children. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../@tiptap__extension-list@3.31.3.patch | 234 +++++++++++++++++- config/patches/@tiptap__markdown@3.31.3.patch | 24 ++ pnpm-lock.yaml | 37 +-- pnpm-workspace.yaml | 1 + .../editor/rich-markdown-extensions.ts | 4 +- .../rich-markdown-mixed-task-lists.test.ts | 116 +++++++++ ...ich-markdown-ordered-task-children.test.ts | 173 +++++++++++++ ...-markdown-task-continuation-markup.test.ts | 146 +++++++++++ .../editor/rich-markdown-task-item.ts | 22 ++ .../editor/rich-markdown-task-list.ts | 52 ++++ ...h-markdown-tight-task-continuation.test.ts | 131 ++++++++++ 11 files changed, 908 insertions(+), 32 deletions(-) create mode 100644 config/patches/@tiptap__markdown@3.31.3.patch create mode 100644 src/renderer/src/components/editor/rich-markdown-mixed-task-lists.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-ordered-task-children.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-task-continuation-markup.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-task-item.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-tight-task-continuation.test.ts diff --git a/config/patches/@tiptap__extension-list@3.31.3.patch b/config/patches/@tiptap__extension-list@3.31.3.patch index 4f1640a6920..736bd982217 100644 --- a/config/patches/@tiptap__extension-list@3.31.3.patch +++ b/config/patches/@tiptap__extension-list@3.31.3.patch @@ -1,8 +1,7 @@ diff --git a/dist/index.cjs b/dist/index.cjs -index 283575c0a81fa24ea2d73cb8096de05bce7ab26b..c7b19bda70eececb4688250f8bd11cbe6db6a9aa 100644 --- a/dist/index.cjs +++ b/dist/index.cjs -@@ -245,10 +245,9 @@ const ROMAN_NUMERALS = [ +@@ -245,10 +245,9 @@ ]; const ALPHA_NUMERALS = "abcdefghijklmnopqrstuvwxyz"; /** @@ -15,11 +14,46 @@ index 283575c0a81fa24ea2d73cb8096de05bce7ab26b..c7b19bda70eececb4688250f8bd11cbe /** * Convert a number to lowercase roman numerals. * @example toRoman(1) // 'i' +@@ -897,6 +896,26 @@ + }; + } + /** ++* Orca patch: marker columns are counted without the delimiter, so trim() left later sibling tasks indented and ++* parseIndentedBlocks cut into them. Dedent by the first task; a task one column deeper is a sibling, and other ++* text at or within the first task's column stays nested under the task above. ++*/ ++function dedentOrderedItemTaskBlock(blockLines) { ++ const isTaskLine = (line) => /^\s*[-+*]\s+\[[ xX]\][^\S\r\n]/.test(line); ++ const leadingWidth = (line) => line.length - line.trimStart().length; ++ const firstIndex = blockLines.findIndex((line) => line.trim() !== ""); ++ if (firstIndex === -1 || !isTaskLine(blockLines[firstIndex])) return null; ++ const firstIndent = leadingWidth(blockLines[firstIndex]); ++ return blockLines.slice(firstIndex).map((line) => { ++ if (line.trim() === "") return ""; ++ const indent = leadingWidth(line); ++ const depth = indent - firstIndent; ++ if (isTaskLine(line)) return depth === 1 ? line.trimStart() : line.slice(Math.min(indent, firstIndent)); ++ if (depth > 0) return depth === 1 ? ` ${line.slice(firstIndent)}` : line.slice(firstIndent); ++ return indent > 0 ? ` ${line.trimStart()}` : line; ++ }).join("\n").trimEnd(); ++} ++/** + * Recursively builds a nested structure from a flat array of list items + * based on their indentation levels. Creates proper markdown tokens with + * nested lists where appropriate. +@@ -920,7 +939,7 @@ + raw: mainText, + tokens: lexer.inlineTokens(mainText) + }); +- const additionalContent = blockLines.join("\n").trim(); ++ const additionalContent = dedentOrderedItemTaskBlock(blockLines) ?? blockLines.join("\n").trim(); + if (additionalContent) { + const blockTokens = lexer.blockTokens(additionalContent); + tokens.push(...blockTokens); diff --git a/dist/index.js b/dist/index.js -index 1fa29e72a755ce527b1840765bbe44e80df38c3d..9a857225b17a51fa6afd14674502e556566bef8c 100644 --- a/dist/index.js +++ b/dist/index.js -@@ -233,10 +233,9 @@ const ROMAN_NUMERALS = [ +@@ -233,10 +233,9 @@ ]; const ALPHA_NUMERALS = "abcdefghijklmnopqrstuvwxyz"; /** @@ -32,11 +66,46 @@ index 1fa29e72a755ce527b1840765bbe44e80df38c3d..9a857225b17a51fa6afd14674502e556 /** * Convert a number to lowercase roman numerals. * @example toRoman(1) // 'i' +@@ -885,6 +884,26 @@ + }; + } + /** ++* Orca patch: marker columns are counted without the delimiter, so trim() left later sibling tasks indented and ++* parseIndentedBlocks cut into them. Dedent by the first task; a task one column deeper is a sibling, and other ++* text at or within the first task's column stays nested under the task above. ++*/ ++function dedentOrderedItemTaskBlock(blockLines) { ++ const isTaskLine = (line) => /^\s*[-+*]\s+\[[ xX]\][^\S\r\n]/.test(line); ++ const leadingWidth = (line) => line.length - line.trimStart().length; ++ const firstIndex = blockLines.findIndex((line) => line.trim() !== ""); ++ if (firstIndex === -1 || !isTaskLine(blockLines[firstIndex])) return null; ++ const firstIndent = leadingWidth(blockLines[firstIndex]); ++ return blockLines.slice(firstIndex).map((line) => { ++ if (line.trim() === "") return ""; ++ const indent = leadingWidth(line); ++ const depth = indent - firstIndent; ++ if (isTaskLine(line)) return depth === 1 ? line.trimStart() : line.slice(Math.min(indent, firstIndent)); ++ if (depth > 0) return depth === 1 ? ` ${line.slice(firstIndent)}` : line.slice(firstIndent); ++ return indent > 0 ? ` ${line.trimStart()}` : line; ++ }).join("\n").trimEnd(); ++} ++/** + * Recursively builds a nested structure from a flat array of list items + * based on their indentation levels. Creates proper markdown tokens with + * nested lists where appropriate. +@@ -908,7 +927,7 @@ + raw: mainText, + tokens: lexer.inlineTokens(mainText) + }); +- const additionalContent = blockLines.join("\n").trim(); ++ const additionalContent = dedentOrderedItemTaskBlock(blockLines) ?? blockLines.join("\n").trim(); + if (additionalContent) { + const blockTokens = lexer.blockTokens(additionalContent); + tokens.push(...blockTokens); diff --git a/dist/kit/index.cjs b/dist/kit/index.cjs -index a1f217bd0eb67c84404a4e4b447379eaf3a7f367..1f26c89d35a87bcd98113ad425245a9d86cb7ef0 100644 --- a/dist/kit/index.cjs +++ b/dist/kit/index.cjs -@@ -233,10 +233,9 @@ const ROMAN_NUMERALS = [ +@@ -233,10 +233,9 @@ ]; const ALPHA_NUMERALS = "abcdefghijklmnopqrstuvwxyz"; /** @@ -49,11 +118,46 @@ index a1f217bd0eb67c84404a4e4b447379eaf3a7f367..1f26c89d35a87bcd98113ad425245a9d /** * Convert a number to lowercase roman numerals. * @example toRoman(1) // 'i' +@@ -839,6 +838,26 @@ + }; + } + /** ++* Orca patch: marker columns are counted without the delimiter, so trim() left later sibling tasks indented and ++* parseIndentedBlocks cut into them. Dedent by the first task; a task one column deeper is a sibling, and other ++* text at or within the first task's column stays nested under the task above. ++*/ ++function dedentOrderedItemTaskBlock(blockLines) { ++ const isTaskLine = (line) => /^\s*[-+*]\s+\[[ xX]\][^\S\r\n]/.test(line); ++ const leadingWidth = (line) => line.length - line.trimStart().length; ++ const firstIndex = blockLines.findIndex((line) => line.trim() !== ""); ++ if (firstIndex === -1 || !isTaskLine(blockLines[firstIndex])) return null; ++ const firstIndent = leadingWidth(blockLines[firstIndex]); ++ return blockLines.slice(firstIndex).map((line) => { ++ if (line.trim() === "") return ""; ++ const indent = leadingWidth(line); ++ const depth = indent - firstIndent; ++ if (isTaskLine(line)) return depth === 1 ? line.trimStart() : line.slice(Math.min(indent, firstIndent)); ++ if (depth > 0) return depth === 1 ? ` ${line.slice(firstIndent)}` : line.slice(firstIndent); ++ return indent > 0 ? ` ${line.trimStart()}` : line; ++ }).join("\n").trimEnd(); ++} ++/** + * Recursively builds a nested structure from a flat array of list items + * based on their indentation levels. Creates proper markdown tokens with + * nested lists where appropriate. +@@ -862,7 +881,7 @@ + raw: mainText, + tokens: lexer.inlineTokens(mainText) + }); +- const additionalContent = blockLines.join("\n").trim(); ++ const additionalContent = dedentOrderedItemTaskBlock(blockLines) ?? blockLines.join("\n").trim(); + if (additionalContent) { + const blockTokens = lexer.blockTokens(additionalContent); + tokens.push(...blockTokens); diff --git a/dist/kit/index.js b/dist/kit/index.js -index ee2b2a9114cb40daa6061340ccd80d128c2981ba..8152d964a20dfac20fc3234f93182325593d027d 100644 --- a/dist/kit/index.js +++ b/dist/kit/index.js -@@ -232,10 +232,9 @@ const ROMAN_NUMERALS = [ +@@ -232,10 +232,9 @@ ]; const ALPHA_NUMERALS = "abcdefghijklmnopqrstuvwxyz"; /** @@ -66,11 +170,46 @@ index ee2b2a9114cb40daa6061340ccd80d128c2981ba..8152d964a20dfac20fc3234f93182325 /** * Convert a number to lowercase roman numerals. * @example toRoman(1) // 'i' +@@ -838,6 +837,26 @@ + }; + } + /** ++* Orca patch: marker columns are counted without the delimiter, so trim() left later sibling tasks indented and ++* parseIndentedBlocks cut into them. Dedent by the first task; a task one column deeper is a sibling, and other ++* text at or within the first task's column stays nested under the task above. ++*/ ++function dedentOrderedItemTaskBlock(blockLines) { ++ const isTaskLine = (line) => /^\s*[-+*]\s+\[[ xX]\][^\S\r\n]/.test(line); ++ const leadingWidth = (line) => line.length - line.trimStart().length; ++ const firstIndex = blockLines.findIndex((line) => line.trim() !== ""); ++ if (firstIndex === -1 || !isTaskLine(blockLines[firstIndex])) return null; ++ const firstIndent = leadingWidth(blockLines[firstIndex]); ++ return blockLines.slice(firstIndex).map((line) => { ++ if (line.trim() === "") return ""; ++ const indent = leadingWidth(line); ++ const depth = indent - firstIndent; ++ if (isTaskLine(line)) return depth === 1 ? line.trimStart() : line.slice(Math.min(indent, firstIndent)); ++ if (depth > 0) return depth === 1 ? ` ${line.slice(firstIndent)}` : line.slice(firstIndent); ++ return indent > 0 ? ` ${line.trimStart()}` : line; ++ }).join("\n").trimEnd(); ++} ++/** + * Recursively builds a nested structure from a flat array of list items + * based on their indentation levels. Creates proper markdown tokens with + * nested lists where appropriate. +@@ -861,7 +880,7 @@ + raw: mainText, + tokens: lexer.inlineTokens(mainText) + }); +- const additionalContent = blockLines.join("\n").trim(); ++ const additionalContent = dedentOrderedItemTaskBlock(blockLines) ?? blockLines.join("\n").trim(); + if (additionalContent) { + const blockTokens = lexer.blockTokens(additionalContent); + tokens.push(...blockTokens); diff --git a/dist/ordered-list/index.cjs b/dist/ordered-list/index.cjs -index 9373d830293b67e463c9764cec5bacd8c189b473..4292a2b0f1b45fdc0f06d6fe7f351f7484e45974 100644 --- a/dist/ordered-list/index.cjs +++ b/dist/ordered-list/index.cjs -@@ -19,10 +19,9 @@ const ROMAN_NUMERALS = [ +@@ -19,10 +19,9 @@ ]; const ALPHA_NUMERALS = "abcdefghijklmnopqrstuvwxyz"; /** @@ -83,11 +222,46 @@ index 9373d830293b67e463c9764cec5bacd8c189b473..4292a2b0f1b45fdc0f06d6fe7f351f74 /** * Convert a number to lowercase roman numerals. * @example toRoman(1) // 'i' +@@ -333,6 +332,26 @@ + }; + } + /** ++* Orca patch: marker columns are counted without the delimiter, so trim() left later sibling tasks indented and ++* parseIndentedBlocks cut into them. Dedent by the first task; a task one column deeper is a sibling, and other ++* text at or within the first task's column stays nested under the task above. ++*/ ++function dedentOrderedItemTaskBlock(blockLines) { ++ const isTaskLine = (line) => /^\s*[-+*]\s+\[[ xX]\][^\S\r\n]/.test(line); ++ const leadingWidth = (line) => line.length - line.trimStart().length; ++ const firstIndex = blockLines.findIndex((line) => line.trim() !== ""); ++ if (firstIndex === -1 || !isTaskLine(blockLines[firstIndex])) return null; ++ const firstIndent = leadingWidth(blockLines[firstIndex]); ++ return blockLines.slice(firstIndex).map((line) => { ++ if (line.trim() === "") return ""; ++ const indent = leadingWidth(line); ++ const depth = indent - firstIndent; ++ if (isTaskLine(line)) return depth === 1 ? line.trimStart() : line.slice(Math.min(indent, firstIndent)); ++ if (depth > 0) return depth === 1 ? ` ${line.slice(firstIndent)}` : line.slice(firstIndent); ++ return indent > 0 ? ` ${line.trimStart()}` : line; ++ }).join("\n").trimEnd(); ++} ++/** + * Recursively builds a nested structure from a flat array of list items + * based on their indentation levels. Creates proper markdown tokens with + * nested lists where appropriate. +@@ -356,7 +375,7 @@ + raw: mainText, + tokens: lexer.inlineTokens(mainText) + }); +- const additionalContent = blockLines.join("\n").trim(); ++ const additionalContent = dedentOrderedItemTaskBlock(blockLines) ?? blockLines.join("\n").trim(); + if (additionalContent) { + const blockTokens = lexer.blockTokens(additionalContent); + tokens.push(...blockTokens); diff --git a/dist/ordered-list/index.js b/dist/ordered-list/index.js -index ace73267067e8c785e080cbf5bc6655a0527aa36..de1bb3061ec1629b20616f10a42eeba0a4e2222d 100644 --- a/dist/ordered-list/index.js +++ b/dist/ordered-list/index.js -@@ -18,10 +18,9 @@ const ROMAN_NUMERALS = [ +@@ -18,10 +18,9 @@ ]; const ALPHA_NUMERALS = "abcdefghijklmnopqrstuvwxyz"; /** @@ -100,3 +274,39 @@ index ace73267067e8c785e080cbf5bc6655a0527aa36..de1bb3061ec1629b20616f10a42eeba0 /** * Convert a number to lowercase roman numerals. * @example toRoman(1) // 'i' +@@ -332,6 +331,26 @@ + }; + } + /** ++* Orca patch: marker columns are counted without the delimiter, so trim() left later sibling tasks indented and ++* parseIndentedBlocks cut into them. Dedent by the first task; a task one column deeper is a sibling, and other ++* text at or within the first task's column stays nested under the task above. ++*/ ++function dedentOrderedItemTaskBlock(blockLines) { ++ const isTaskLine = (line) => /^\s*[-+*]\s+\[[ xX]\][^\S\r\n]/.test(line); ++ const leadingWidth = (line) => line.length - line.trimStart().length; ++ const firstIndex = blockLines.findIndex((line) => line.trim() !== ""); ++ if (firstIndex === -1 || !isTaskLine(blockLines[firstIndex])) return null; ++ const firstIndent = leadingWidth(blockLines[firstIndex]); ++ return blockLines.slice(firstIndex).map((line) => { ++ if (line.trim() === "") return ""; ++ const indent = leadingWidth(line); ++ const depth = indent - firstIndent; ++ if (isTaskLine(line)) return depth === 1 ? line.trimStart() : line.slice(Math.min(indent, firstIndent)); ++ if (depth > 0) return depth === 1 ? ` ${line.slice(firstIndent)}` : line.slice(firstIndent); ++ return indent > 0 ? ` ${line.trimStart()}` : line; ++ }).join("\n").trimEnd(); ++} ++/** + * Recursively builds a nested structure from a flat array of list items + * based on their indentation levels. Creates proper markdown tokens with + * nested lists where appropriate. +@@ -355,7 +374,7 @@ + raw: mainText, + tokens: lexer.inlineTokens(mainText) + }); +- const additionalContent = blockLines.join("\n").trim(); ++ const additionalContent = dedentOrderedItemTaskBlock(blockLines) ?? blockLines.join("\n").trim(); + if (additionalContent) { + const blockTokens = lexer.blockTokens(additionalContent); + tokens.push(...blockTokens); diff --git a/config/patches/@tiptap__markdown@3.31.3.patch b/config/patches/@tiptap__markdown@3.31.3.patch new file mode 100644 index 00000000000..abbd1eb6489 --- /dev/null +++ b/config/patches/@tiptap__markdown@3.31.3.patch @@ -0,0 +1,24 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -129,7 +129,7 @@ + * ``` + */ + function isTaskItem(item) { +- const match = (item.raw || item.text || "").match(/^(\s*)[-+*]\s+\[([ xX])\]\s+/); ++ const match = (item.raw || item.text || "").match(/^(\s*)[-+*]\s+\[([ xX])\][^\S\r\n]+/); + if (match) return { + isTask: true, + checked: match[2].toLowerCase() === "x", +diff --git a/dist/index.js b/dist/index.js +--- a/dist/index.js ++++ b/dist/index.js +@@ -128,7 +128,7 @@ + * ``` + */ + function isTaskItem(item) { +- const match = (item.raw || item.text || "").match(/^(\s*)[-+*]\s+\[([ xX])\]\s+/); ++ const match = (item.raw || item.text || "").match(/^(\s*)[-+*]\s+\[([ xX])\][^\S\r\n]+/); + if (match) return { + isTask: true, + checked: match[2].toLowerCase() === "x", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0e1d380e16..18d80db07a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,7 +173,8 @@ packageExtensionsChecksum: sha256-TrarseUCmMKP0lVjkfyLP2TAvkGZMZzvzPxjKY48nrY= pnpmfileChecksum: sha256-6l6qdgUHKyTLYIQLx/UGfGRnEiFXxIaSlGMgK4mttEw= patchedDependencies: - '@tiptap/extension-list@3.31.3': 294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd + '@tiptap/extension-list@3.31.3': afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299 + '@tiptap/markdown@3.31.3': 4fe6aebb0d868ea9e8977f371bd088e96d5018ea8cf83a7ae3f3f924b87ee7cf '@vscode/windows-process-tree@0.8.0': a09f2e8b2b4e73ef20c317af91f9e06c09abf62fa2c891d99b16f53047f596fd '@xterm/addon-image@0.10.0-beta.301': f088c9207dac42844a542b9da8078be52351cee60585725ef189b9fd30f33f34 '@xterm/addon-ligatures@0.11.0-beta.301': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 @@ -350,7 +351,7 @@ importers: version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-list': specifier: 3.31.3 - version: 3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + version: 3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-mathematics': specifier: 3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/katex@0.16.8)(katex@0.18.10) @@ -362,7 +363,7 @@ importers: version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/markdown': specifier: 3.31.3 - version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + version: 3.31.3(patch_hash=4fe6aebb0d868ea9e8977f371bd088e96d5018ea8cf83a7ae3f3f924b87ee7cf)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/pm': specifier: 3.31.3 version: 3.31.3 @@ -10517,9 +10518,9 @@ snapshots: '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-code-block-lowlight@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.1)(lowlight@3.3.0)': dependencies: @@ -10590,15 +10591,15 @@ snapshots: '@tiptap/pm': 3.31.3 linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + '@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/pm': 3.31.3 @@ -10612,9 +10613,9 @@ snapshots: optionalDependencies: '@types/katex': 0.16.8 - '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: @@ -10650,7 +10651,7 @@ snapshots: '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/pm': 3.31.3 - '@tiptap/markdown@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + '@tiptap/markdown@3.31.3(patch_hash=4fe6aebb0d868ea9e8977f371bd088e96d5018ea8cf83a7ae3f3f924b87ee7cf)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/pm': 3.31.3 @@ -10695,7 +10696,7 @@ snapshots: '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) @@ -10706,10 +10707,10 @@ snapshots: '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list': 3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) - '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(patch_hash=294a9c187f6733c78b52c5ac182a8adf90c24c07093a2b40b68725cdb320d7bd)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list': 3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(patch_hash=afc553bf4f7cb888dfcdc30d52b7fed589176fd1987736fd72fd1f4f7173f299)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9e6c81ddabf..dffa74b8b09 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -131,4 +131,5 @@ patchedDependencies: monaco-editor@0.55.1: config/patches/monaco-editor@0.55.1.patch sprintf-js@1.1.3: config/patches/sprintf-js@1.1.3.patch '@tiptap/extension-list@3.31.3': config/patches/@tiptap__extension-list@3.31.3.patch + '@tiptap/markdown@3.31.3': config/patches/@tiptap__markdown@3.31.3.patch vitest@5.0.5: config/patches/vitest@5.0.5.patch diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 21914bd04b2..a0482abba8f 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -11,7 +11,7 @@ import { RichMarkdownLink } from './rich-markdown-link-mark' import { Code } from '@tiptap/extension-code' import Image from '@tiptap/extension-image' import Placeholder from '@tiptap/extension-placeholder' -import { TaskItem } from '@tiptap/extension-list' +import { RichMarkdownTaskItem } from './rich-markdown-task-item' import { RichMarkdownTable } from './rich-markdown-table' import { TableCell, TableHeader, TableRow } from '@tiptap/extension-table' import { BlockMath, InlineMath } from '@tiptap/extension-mathematics' @@ -247,7 +247,7 @@ export function createRichMarkdownExtensions({ }), RichMarkdownOrderedList, RichMarkdownTaskList, - TaskItem.configure({ + RichMarkdownTaskItem.configure({ nested: true }), ...createOrcaDetailsExtensions(), diff --git a/src/renderer/src/components/editor/rich-markdown-mixed-task-lists.test.ts b/src/renderer/src/components/editor/rich-markdown-mixed-task-lists.test.ts new file mode 100644 index 00000000000..5ca4c1f179f --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-mixed-task-lists.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { normalizeEmptyListItems } from './rich-markdown-normalize' +import { registerRichMarkdownBlockSource } from './rich-markdown-block-source' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' + +function openMarkdown(source: string): Editor { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec), + contentType: 'markdown' + }) + normalizeEmptyListItems(editor) + registerRichMarkdownBlockSource(editor, codec, false) + return editor +} + +function roundTrip(source: string): string { + const editor = openMarkdown(source) + try { + return editor.getMarkdown() + } finally { + editor.destroy() + } +} + +function taskItems(editor: Editor): { text: string; checked: boolean }[] { + const tasks: { text: string; checked: boolean }[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'taskItem') { + tasks.push({ text: node.firstChild?.textContent ?? '', checked: node.attrs.checked }) + } + }) + return tasks +} + +const contexts = [ + (body: string) => body, + (body: string) => `- Parent\n ${body.replaceAll('\n', '\n ')}`, + (body: string) => `4. Parent\n ${body.replaceAll('\n', '\n ')}`, + (body: string) => `> ${body.replaceAll('\n', '\n> ')}` +] +const mixedCases = [...contexts.map((wrap) => wrap('- [ ]\n- [x] Later')), '- [ ]\r\n- [x] Later'] + +describe('mixed literal checkbox markers and task lists', () => { + it.each(mixedCases)('keeps the real task editable in %j', (source) => { + const literal = '[ ]' + const checked = true + const editor = openMarkdown(source) + try { + editor.state.doc.check() + expect(taskItems(editor)).toEqual([{ text: 'Later', checked }]) + let literalItems = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'listItem' && node.firstChild?.textContent === literal) { + literalItems++ + } + }) + expect(literalItems).toBe(1) + const refs = { + originalSourceRef: { current: source }, + baseCanonicalRef: { current: editor.getMarkdown() }, + lastCommittedMarkdownRef: { current: source } + } + expect(commitRichMarkdownSerialization(editor, refs, roundTrip).markdown).toBe(source) + let end = -1 + editor.state.doc.descendants((node, position) => { + if (node.isText && node.text === 'Later') { + end = position + node.nodeSize + } + }) + expect(end).toBeGreaterThan(0) + editor.view.dispatch(editor.state.tr.insertText(' updated', end)) + const saved = commitRichMarkdownSerialization(editor, refs, roundTrip) + expect(saved.markdown).toBe(source.replace('Later', 'Later updated')) + const reopened = openMarkdown(saved.markdown) + try { + reopened.state.doc.check() + expect(taskItems(reopened)).toEqual([{ text: 'Later updated', checked }]) + } finally { + reopened.destroy() + } + } finally { + editor.destroy() + } + }) + + it.each([ + ['- [ ]\n- [x] Later\n- plain', [{ text: 'Later', checked: true }]], + ['- [ ]\n- plain', []] + ])('does not invent tasks from bare markers in %j', (source, expected) => { + const editor = openMarkdown(source) + try { + editor.state.doc.check() + expect(taskItems(editor)).toEqual(expected) + } finally { + editor.destroy() + } + }) + + it('keeps a checkbox followed by a non-breaking space as a task', () => { + const editor = openMarkdown('- literal\n- [x]\u00a0Later') + try { + editor.state.doc.check() + expect(taskItems(editor)).toEqual([{ text: 'Later', checked: true }]) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-ordered-task-children.test.ts b/src/renderer/src/components/editor/rich-markdown-ordered-task-children.test.ts new file mode 100644 index 00000000000..e2ed24f4f0c --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-ordered-task-children.test.ts @@ -0,0 +1,173 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { normalizeEmptyListItems } from './rich-markdown-normalize' +import { registerRichMarkdownBlockSource } from './rich-markdown-block-source' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' + +function openMarkdown(source: string): Editor { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec), + contentType: 'markdown' + }) + normalizeEmptyListItems(editor) + registerRichMarkdownBlockSource(editor, codec, false) + return editor +} + +function taskItems(editor: Editor) { + const tasks: { text: string; checked: boolean; depth: number }[] = [] + editor.state.doc.descendants((node, position) => { + if (node.type.name === 'taskItem') { + tasks.push({ + text: node.firstChild?.textContent ?? '', + checked: node.attrs.checked === true, + depth: editor.state.doc.resolve(position).depth + }) + } + }) + return tasks +} + +function roundTrip(source: string): string { + const editor = openMarkdown(source) + try { + return editor.getMarkdown() + } finally { + editor.destroy() + } +} + +const expectedTasks = [ + { text: 'First', checked: false, depth: 3 }, + { text: 'Middle', checked: true, depth: 3 }, + { text: 'Last', checked: true, depth: 3 } +] + +describe('task children of ordered list items', () => { + it.each([ + ['4. Parent', 3, '-'], + ['10) Parent', 4, '-'] + ] as const)('keeps sibling task states under %s at indent %s', (parent, indent, marker) => { + const source = [ + parent, + `${' '.repeat(indent)}${marker} [ ] First`, + `${' '.repeat(indent)}${marker} [x] Middle`, + `${' '.repeat(indent)}${marker} [X] Last` + ].join('\n') + const editor = openMarkdown(source) + try { + editor.state.doc.check() + expect(taskItems(editor)).toEqual(expectedTasks) + } finally { + editor.destroy() + } + }) + + it('preserves checked siblings when Enter splits a task before save and reopen', () => { + const source = '4. Parent\n - [ ] First\n - [x] Middle\n - [x] Last' + const editor = openMarkdown(source) + try { + const refs = { + originalSourceRef: { current: source }, + baseCanonicalRef: { current: editor.getMarkdown() }, + lastCommittedMarkdownRef: { current: source } + } + let end = -1 + editor.state.doc.descendants((node, position) => { + if (node.type.name === 'taskItem' && node.firstChild?.textContent === 'First') { + end = position + 2 + node.firstChild.content.size + } + }) + expect(end).toBeGreaterThan(0) + editor.commands.setTextSelection(end) + expect(editor.commands.splitListItem('taskItem')).toBe(true) + const saved = commitRichMarkdownSerialization(editor, refs, roundTrip) + expect(saved.didSerialize).toBe(true) + const reopened = openMarkdown(saved.markdown) + try { + reopened.state.doc.check() + expect(taskItems(reopened).filter((task) => task.text !== '')).toEqual(expectedTasks) + } finally { + reopened.destroy() + } + } finally { + editor.destroy() + } + }) + + it.each([ + ['1. Parent\n - [ ] a\n - [x] b', 3], + ['10. Parent\n - [ ] a\n - [x] b', 3], + ['> 1. Parent\n> - [ ] a\n> - [x] b', 4] + ] as const)('keeps a task one column deeper as a sibling checkbox in %j', (source, depth) => { + const editor = openMarkdown(source) + try { + editor.state.doc.check() + expect(taskItems(editor)).toEqual([ + { text: 'a', checked: false, depth }, + { text: 'b', checked: true, depth } + ]) + } finally { + editor.destroy() + } + }) + + it('keeps a four-space continuation paragraph free of leftover indent', () => { + const editor = openMarkdown('1. Parent\n - [ ] a\n more text') + try { + const paragraphs: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'paragraph') { + paragraphs.push(node.textContent) + } + }) + expect(paragraphs).toEqual(['Parent', 'a', 'more text']) + } finally { + editor.destroy() + } + }) + + it('keeps an intentionally nested task under its owning sibling', () => { + const editor = openMarkdown( + '4. Parent\n - [ ] First\n - [x] Child\n - [x] Middle\n - [x] Last' + ) + try { + expect(taskItems(editor)).toEqual([ + expectedTasks[0], + { text: 'Child', checked: true, depth: 5 }, + expectedTasks[1], + expectedTasks[2] + ]) + } finally { + editor.destroy() + } + }) + + it('keeps following plain bullets and the next ordered item in their own lists', () => { + const editor = openMarkdown( + '4. Parent\n - [ ] First\n - Plain\n - [x] Last\n5. Following' + ) + try { + editor.state.doc.check() + expect(taskItems(editor)).toEqual([expectedTasks[0], expectedTasks[2]]) + expect(editor.state.doc.firstChild?.childCount).toBe(2) + expect(editor.state.doc.firstChild?.lastChild?.textContent).toBe('Following') + const plainItems: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'listItem' && node.firstChild?.textContent === 'Plain') { + plainItems.push(node.type.name) + } + }) + expect(plainItems).toEqual(['listItem']) + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-task-continuation-markup.test.ts b/src/renderer/src/components/editor/rich-markdown-task-continuation-markup.test.ts new file mode 100644 index 00000000000..478bf0ea208 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-task-continuation-markup.test.ts @@ -0,0 +1,146 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { normalizeEmptyListItems } from './rich-markdown-normalize' +import { registerRichMarkdownBlockSource } from './rich-markdown-block-source' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' + +function openMarkdown(source: string): Editor { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec), + contentType: 'markdown' + }) + normalizeEmptyListItems(editor) + registerRichMarkdownBlockSource(editor, codec, false) + return editor +} + +function roundTrip(source: string): string { + const editor = openMarkdown(source) + try { + return editor.getMarkdown() + } finally { + editor.destroy() + } +} + +function markedText(editor: Editor, mark: string): string[] { + const text: string[] = [] + editor.state.doc.descendants((node) => { + if (node.isText && node.marks.some((item) => item.type.name === mark)) { + text.push(node.text ?? '') + } + }) + return text +} + +const spans = [ + ['Open [API\n docs](https://example.com/docs) now', 'link', 'API\ndocs'], + ['Use `git\n status` now', 'code', 'git status'], + ['Read **important\n guidance** now', 'bold', 'important\nguidance'] +] as const + +describe('task continuation inline markup', () => { + it.each(spans)( + 'preserves a wrapped %s after Enter on another task and save/reopen', + (body, mark, text) => { + const source = `4. Parent\n${`- [ ] ${body}\n- [x] Done` + .split('\n') + .map((line) => ` ${line}`) + .join('\n')}\n5. Following` + const editor = openMarkdown(source) + try { + expect(markedText(editor, mark)).toEqual([text]) + const refs = { + originalSourceRef: { current: source }, + baseCanonicalRef: { current: editor.getMarkdown() }, + lastCommittedMarkdownRef: { current: source } + } + let end = -1 + editor.state.doc.descendants((node, position) => { + if (node.type.name === 'taskItem' && node.firstChild?.textContent === 'Done') { + end = position + 2 + node.firstChild.content.size + } + }) + expect(end).toBeGreaterThan(0) + editor.commands.setTextSelection(end) + expect(editor.commands.splitListItem('taskItem')).toBe(true) + const saved = commitRichMarkdownSerialization(editor, refs, roundTrip) + expect(saved.didSerialize).toBe(true) + const reopened = openMarkdown(saved.markdown) + try { + reopened.state.doc.check() + expect(markedText(reopened, mark)).toEqual([text]) + expect(reopened.state.doc.firstChild?.lastChild?.textContent).toBe('Following') + } finally { + reopened.destroy() + } + } finally { + editor.destroy() + } + } + ) + + it.each([ + '- [ ] Open [API\n docs](https://example.com/docs) now\n- [x] Done', + '- Parent\n - [ ] Open [API\n docs](https://example.com/docs) now\n - [x] Done', + '> - [ ] Open [API\n> docs](https://example.com/docs) now\n> - [x] Done', + '- [ ] Parent\n - [ ] Open [API\n docs](https://example.com/docs) now\n - [x] Done' + ])('keeps the wrapped link in its container across canonical serialization: %s', (source) => { + const editor = openMarkdown(source) + try { + expect(markedText(editor, 'link')).toEqual(['API\ndocs']) + const reopened = openMarkdown(editor.getMarkdown()) + try { + reopened.state.doc.check() + expect(markedText(reopened, 'link')).toEqual(['API\ndocs']) + } finally { + reopened.destroy() + } + } finally { + editor.destroy() + } + }) + + it('keeps blank-separated paragraphs from completing a link', () => { + const editor = openMarkdown('- [ ] Open [API\n\n docs](https://example.com/docs) now') + try { + expect(editor.state.doc.firstChild?.firstChild?.childCount).toBe(2) + expect(markedText(editor, 'link')).toEqual(['https://example.com/docs']) + } finally { + editor.destroy() + } + }) + + it('keeps a link wholly in the continuation paragraph separate', () => { + const editor = openMarkdown('- [ ] Open\n [API docs](https://example.com/docs) now') + try { + expect(editor.state.doc.firstChild?.firstChild?.childCount).toBe(2) + expect(markedText(editor, 'link')).toEqual(['API docs']) + } finally { + editor.destroy() + } + }) + + it('preserves an explicit hard break through task serialization', () => { + const editor = openMarkdown('- [ ] First \n continuation\n- [x] Done') + try { + const reopened = openMarkdown(editor.getMarkdown()) + try { + const first = reopened.state.doc.firstChild?.firstChild?.firstChild + expect(first?.childCount).toBe(3) + expect(first?.child(1).type.name).toBe('hardBreak') + } finally { + reopened.destroy() + } + } finally { + editor.destroy() + } + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-task-item.ts b/src/renderer/src/components/editor/rich-markdown-task-item.ts new file mode 100644 index 00000000000..e35a8d58269 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-task-item.ts @@ -0,0 +1,22 @@ +import { renderNestedMarkdownContent } from '@tiptap/core' +import { TaskItem } from '@tiptap/extension-list' + +export const RichMarkdownTaskItem = TaskItem.extend({ + renderMarkdown(node, helpers) { + const mainContent = node.content?.[0] + const prefix = `- [${node.attrs?.checked ? 'x' : ' '}] ` + return renderNestedMarkdownContent( + node, + { + ...helpers, + renderChildren(children) { + const markdown = helpers.renderChildren(children) + return Array.isArray(children) && children.length === 1 && children[0] === mainContent + ? markdown.replaceAll('\n', `\n${helpers.indent('')}`) + : markdown + } + }, + prefix + ) + } +}) diff --git a/src/renderer/src/components/editor/rich-markdown-task-list.ts b/src/renderer/src/components/editor/rich-markdown-task-list.ts index b5b6c0b1d91..8faa30f9969 100644 --- a/src/renderer/src/components/editor/rich-markdown-task-list.ts +++ b/src/renderer/src/components/editor/rich-markdown-task-list.ts @@ -3,7 +3,36 @@ import { TaskList } from '@tiptap/extension-list' const baseTokenizer = TaskList.config.markdownTokenizer as MarkdownTokenizer +function inlineTokenSpansLineBreak(tokens: MarkdownToken[], lineBreak: number): boolean { + let offset = 0 + for (const token of tokens) { + if (typeof token.raw !== 'string') { + return false + } + const end = offset + token.raw.length + if (offset <= lineBreak && end > lineBreak) { + return token.type !== 'text' + } + offset = end + } + return false +} + function normalizeTaskListToken(token: MarkdownToken, lexer: MarkdownLexerConfiguration): void { + if (token.type === 'taskItem') { + for (const nested of token.nestedTokens ?? []) { + if ( + nested.type === 'paragraph' && + typeof nested.text === 'string' && + /^[ \t]/m.test(nested.text) + ) { + // Why: Tiptap dedents task children by two columns, leaving deeper paragraph indent as text. + nested.text = nested.text.replace(/^[ \t]+/gm, '') + nested.raw = nested.text + nested.tokens = lexer.inlineTokens(nested.text) + } + } + } const firstNested = token.nestedTokens?.[0] if ( token.type === 'taskItem' && @@ -18,6 +47,29 @@ function normalizeTaskListToken(token: MarkdownToken, lexer: MarkdownLexerConfig delete firstNested.codeBlockStyle } + if ( + token.type === 'taskItem' && + (firstNested?.type === 'paragraph' || firstNested?.type === 'text') && + typeof token.text === 'string' && + typeof firstNested.text === 'string' + ) { + const text = `${token.text}\n${firstNested.text}` + const inlineTokens = lexer.inlineTokens(text) + if (inlineTokenSpansLineBreak(inlineTokens, token.text.length)) { + token.text = text + token.tokens = inlineTokens + token.nestedTokens.shift() + } + } + + if (token.type === 'taskItem') { + for (const nested of token.nestedTokens ?? []) { + if (nested.type === 'text') { + nested.type = 'paragraph' + } + } + } + for (const child of [...(token.items ?? []), ...(token.nestedTokens ?? [])]) { normalizeTaskListToken(child, lexer) } diff --git a/src/renderer/src/components/editor/rich-markdown-tight-task-continuation.test.ts b/src/renderer/src/components/editor/rich-markdown-tight-task-continuation.test.ts new file mode 100644 index 00000000000..92179ea0f0d --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-tight-task-continuation.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom +import { Editor } from '@tiptap/core' +import { describe, expect, it } from 'vitest' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { normalizeEmptyListItems } from './rich-markdown-normalize' +import { registerRichMarkdownBlockSource } from './rich-markdown-block-source' +import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' + +function openMarkdown(source: string): Editor { + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: null, + extensions: createRichMarkdownExtensions({ codec }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec), + contentType: 'markdown' + }) + normalizeEmptyListItems(editor) + registerRichMarkdownBlockSource(editor, codec, false) + return editor +} + +function roundTrip(source: string): string { + const editor = openMarkdown(source) + try { + return editor.getMarkdown() + } finally { + editor.destroy() + } +} + +function markedText(editor: Editor, mark: string): string[] { + const text: string[] = [] + editor.state.doc.descendants((node) => { + if (node.isText && node.marks.some((item) => item.type.name === mark)) { + text.push(node.text ?? '') + } + }) + return text +} + +describe('tight bullet task continuations', () => { + it('keeps a formatted continuation editable through save and reopen', () => { + const source = '- Parent\n - [ ] First\n **bold** continuation\n - [x] Done' + const mark = 'bold' + const text = 'bold' + const editor = openMarkdown(source) + try { + editor.state.doc.check() + expect(markedText(editor, mark)).toEqual([text]) + const refs = { + originalSourceRef: { current: source }, + baseCanonicalRef: { current: editor.getMarkdown() }, + lastCommittedMarkdownRef: { current: source } + } + let end = -1 + editor.state.doc.descendants((node, position) => { + if (node.isText && node.text?.includes('continuation')) { + end = position + node.nodeSize + } + }) + expect(end).toBeGreaterThan(0) + editor.view.dispatch(editor.state.tr.insertText('!', end)) + const saved = commitRichMarkdownSerialization(editor, refs, roundTrip) + const reopened = openMarkdown(saved.markdown) + try { + reopened.state.doc.check() + expect(markedText(reopened, mark)).toEqual([text]) + expect(reopened.state.doc.textContent).toBe(`ParentFirst${text} continuation!Done`) + let found = false + reopened.state.doc.descendants((node) => { + if (node.type.name === 'taskItem' && node.firstChild?.textContent === 'First') { + found = true + expect(node.childCount).toBe(2) + expect(node.child(1).type.name).toBe('paragraph') + } + if (node.type.name === 'taskItem' && node.firstChild?.textContent === 'Done') { + expect(node.attrs.checked).toBe(true) + } + }) + expect(found).toBe(true) + } finally { + reopened.destroy() + } + } finally { + editor.destroy() + } + }) + + it.each([ + '> - Parent\n> - [ ] First\n> continuation', + '- Parent\n - Nested\n - [ ] First\n continuation', + '- Parent\n - [ ] First\n\n continuation' + ])('wraps the continuation as a block in %s', (source) => { + const editor = openMarkdown(source) + try { + editor.state.doc.check() + let found = false + editor.state.doc.descendants((node) => { + if (node.type.name === 'taskItem') { + found = true + expect(node.childCount).toBe(2) + expect(node.child(1).type.name).toBe('paragraph') + expect(node.child(1).textContent).toBe('continuation') + } + }) + expect(found).toBe(true) + } finally { + editor.destroy() + } + }) + + it('keeps a fenced continuation as code', () => { + const editor = openMarkdown('- Parent\n - [ ] First\n ```ts\n const value = 1\n ```') + try { + editor.state.doc.check() + let found = false + editor.state.doc.descendants((node) => { + if (node.type.name === 'taskItem') { + found = true + expect(node.child(1).type.name).toBe('codeBlock') + expect(node.child(1).textContent).toBe('const value = 1') + } + }) + expect(found).toBe(true) + } finally { + editor.destroy() + } + }) +})