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>
This commit is contained in:
OrcaWin
2026-10-29 17:38:19 -07:00
committed by GitHub
co-authored by OrcaWin
parent e988b5dd77
commit 5951dbbf92
11 changed files with 908 additions and 32 deletions
@@ -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);
@@ -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",
+19 -18
View File
@@ -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))
+1
View File
@@ -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
@@ -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(),
@@ -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()
}
})
})
@@ -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()
}
})
})
@@ -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()
}
})
})
@@ -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
)
}
})
@@ -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)
}
@@ -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()
}
})
})