mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
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:
@@ -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",
|
||||
Generated
+19
-18
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user