mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
fix: Deserialization of markdown checkboxes in table cells (#11217)
* Checkboxes in table cells * test
This commit is contained in:
@@ -662,5 +662,111 @@ describe("ProsemirrorHelper", () => {
|
||||
expect(secondText.type.name).toBe("text");
|
||||
expect(secondText.text).toBe("Next line");
|
||||
});
|
||||
|
||||
it("should convert markdown with unchecked checklist items", () => {
|
||||
const markdown = "- [ ] Task one\n- [ ] Task two";
|
||||
|
||||
const doc = ProsemirrorHelper.toProsemirror(markdown);
|
||||
|
||||
expect(doc.type.name).toBe("doc");
|
||||
expect(doc.content.childCount).toBe(1);
|
||||
|
||||
const checkboxList = doc.content.child(0);
|
||||
expect(checkboxList.type.name).toBe("checkbox_list");
|
||||
expect(checkboxList.content.childCount).toBe(2);
|
||||
|
||||
// Check first item
|
||||
const firstItem = checkboxList.content.child(0);
|
||||
expect(firstItem.type.name).toBe("checkbox_item");
|
||||
expect(firstItem.attrs.checked).toBe(false);
|
||||
expect(firstItem.textContent).toBe("Task one");
|
||||
|
||||
// Check second item
|
||||
const secondItem = checkboxList.content.child(1);
|
||||
expect(secondItem.type.name).toBe("checkbox_item");
|
||||
expect(secondItem.attrs.checked).toBe(false);
|
||||
expect(secondItem.textContent).toBe("Task two");
|
||||
});
|
||||
|
||||
it("should convert markdown with checked checklist items", () => {
|
||||
const markdown = "- [x] Completed task\n- [X] Another completed";
|
||||
|
||||
const doc = ProsemirrorHelper.toProsemirror(markdown);
|
||||
|
||||
expect(doc.type.name).toBe("doc");
|
||||
expect(doc.content.childCount).toBe(1);
|
||||
|
||||
const checkboxList = doc.content.child(0);
|
||||
expect(checkboxList.type.name).toBe("checkbox_list");
|
||||
expect(checkboxList.content.childCount).toBe(2);
|
||||
|
||||
// Check first item is checked
|
||||
const firstItem = checkboxList.content.child(0);
|
||||
expect(firstItem.type.name).toBe("checkbox_item");
|
||||
expect(firstItem.attrs.checked).toBe(true);
|
||||
expect(firstItem.textContent).toBe("Completed task");
|
||||
|
||||
// Check second item is checked (uppercase X)
|
||||
const secondItem = checkboxList.content.child(1);
|
||||
expect(secondItem.type.name).toBe("checkbox_item");
|
||||
expect(secondItem.attrs.checked).toBe(true);
|
||||
expect(secondItem.textContent).toBe("Another completed");
|
||||
});
|
||||
|
||||
it("should convert markdown with mixed checked and unchecked items", () => {
|
||||
const markdown = "- [x] Done\n- [ ] Not done\n- [x] Also done";
|
||||
|
||||
const doc = ProsemirrorHelper.toProsemirror(markdown);
|
||||
|
||||
expect(doc.type.name).toBe("doc");
|
||||
expect(doc.content.childCount).toBe(1);
|
||||
|
||||
const checkboxList = doc.content.child(0);
|
||||
expect(checkboxList.type.name).toBe("checkbox_list");
|
||||
expect(checkboxList.content.childCount).toBe(3);
|
||||
|
||||
expect(checkboxList.content.child(0).attrs.checked).toBe(true);
|
||||
expect(checkboxList.content.child(1).attrs.checked).toBe(false);
|
||||
expect(checkboxList.content.child(2).attrs.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("should convert markdown table with multiple checklist items in cell separated by br", () => {
|
||||
const markdown = `| Tasks |
|
||||
| --- |
|
||||
| [ ] First<br>[ ] Second<br>[x] Third |`;
|
||||
|
||||
const doc = ProsemirrorHelper.toProsemirror(markdown);
|
||||
|
||||
expect(doc.type.name).toBe("doc");
|
||||
|
||||
const table = doc.content.child(0);
|
||||
expect(table.type.name).toBe("table");
|
||||
|
||||
const dataRow = table.content.child(1);
|
||||
const cell = dataRow.content.child(0);
|
||||
|
||||
// Cell should contain a single checkbox_list with 3 items
|
||||
const checkboxList = cell.content.child(0);
|
||||
expect(checkboxList.type.name).toBe("checkbox_list");
|
||||
expect(checkboxList.content.childCount).toBe(3);
|
||||
|
||||
// First item - unchecked
|
||||
const firstItem = checkboxList.content.child(0);
|
||||
expect(firstItem.type.name).toBe("checkbox_item");
|
||||
expect(firstItem.attrs.checked).toBe(false);
|
||||
expect(firstItem.textContent).toBe("First");
|
||||
|
||||
// Second item - unchecked
|
||||
const secondItem = checkboxList.content.child(1);
|
||||
expect(secondItem.type.name).toBe("checkbox_item");
|
||||
expect(secondItem.attrs.checked).toBe(false);
|
||||
expect(secondItem.textContent).toBe("Second");
|
||||
|
||||
// Third item - checked
|
||||
const thirdItem = checkboxList.content.child(2);
|
||||
expect(thirdItem.type.name).toBe("checkbox_item");
|
||||
expect(thirdItem.attrs.checked).toBe(true);
|
||||
expect(thirdItem.textContent).toBe("Third");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export class ProsemirrorHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a plain object into a Prosemirror Node.
|
||||
* Converts a plain object or Markdown string into a Prosemirror Node.
|
||||
*
|
||||
* @param data The ProsemirrorData object or string to parse.
|
||||
* @returns The content as a Prosemirror Node
|
||||
|
||||
@@ -16,10 +16,10 @@ function isParagraph(token: Token | void): boolean {
|
||||
}
|
||||
|
||||
function isListItem(token: Token | void): boolean {
|
||||
return (
|
||||
!!token &&
|
||||
(token.type === "list_item_open" || token.type === "checkbox_item_open")
|
||||
);
|
||||
// Only match list_item_open, not checkbox_item_open - items that are already
|
||||
// checkbox_item_open have been processed (e.g., by the tables rule for
|
||||
// checkboxes in table cells) and should not be processed again.
|
||||
return !!token && token.type === "list_item_open";
|
||||
}
|
||||
|
||||
function looksLikeChecklist(tokens: Token[], index: number) {
|
||||
@@ -93,10 +93,16 @@ export default function markdownItCheckbox(md: MarkdownIt): void {
|
||||
|
||||
// close the list item
|
||||
let j = i;
|
||||
while (tokens[j].type !== "list_item_close") {
|
||||
while (
|
||||
tokens[j] &&
|
||||
tokens[j].type !== "list_item_close" &&
|
||||
tokens[j].type !== "checkbox_item_close"
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
tokens[j].type = "checkbox_item_close";
|
||||
if (tokens[j]) {
|
||||
tokens[j].type = "checkbox_item_close";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import type MarkdownIt from "markdown-it";
|
||||
|
||||
const BREAK_REGEX = /(?<=^|[^\\])\\n/;
|
||||
const BR_TAG_REGEX = /<br\s*\/?>/gi;
|
||||
// Matches checkbox syntax with optional list prefix: "- [x] Task" or "[x] Task"
|
||||
// Stops at <br> or newline to handle multiple checkboxes in a cell
|
||||
const CHECKBOX_REGEX = /^(?:-\s*)?\[(X|\s|_|-)\]\s([^<\n]*)?/i;
|
||||
|
||||
export default function markdownTables(md: MarkdownIt): void {
|
||||
// insert a new rule after the "inline" rules are parsed
|
||||
@@ -70,10 +73,6 @@ export default function markdownTables(md: MarkdownIt): void {
|
||||
}
|
||||
|
||||
if (["th_open", "td_open"].includes(tokens[i].type)) {
|
||||
// markdown-it table parser does not return paragraphs inside the cells
|
||||
// but prosemirror requires them, so we add 'em in here.
|
||||
tokens.splice(i + 1, 0, new state.Token("paragraph_open", "p", 1));
|
||||
|
||||
// markdown-it table parser stores alignment as html styles, convert
|
||||
// to a simple string here
|
||||
const tokenAttrs = tokens[i].attrs;
|
||||
@@ -81,10 +80,90 @@ export default function markdownTables(md: MarkdownIt): void {
|
||||
const style = tokenAttrs[0][1];
|
||||
tokens[i].info = style.split(":")[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (["th_close", "td_close"].includes(tokens[i].type)) {
|
||||
tokens.splice(i, 0, new state.Token("paragraph_close", "p", -1));
|
||||
// Find the corresponding close token
|
||||
const closeType =
|
||||
tokens[i].type === "th_open" ? "th_close" : "td_close";
|
||||
let closeIndex = i + 2; // Start after inline token
|
||||
while (
|
||||
closeIndex < tokens.length &&
|
||||
tokens[closeIndex].type !== closeType
|
||||
) {
|
||||
closeIndex++;
|
||||
}
|
||||
|
||||
// Check if the cell content looks like a checkbox (or multiple checkboxes)
|
||||
const inlineToken = tokens[i + 1];
|
||||
if (inlineToken?.type !== "inline") {
|
||||
// No inline content, just add paragraph wrapper
|
||||
tokens.splice(
|
||||
closeIndex,
|
||||
0,
|
||||
new state.Token("paragraph_close", "p", -1)
|
||||
);
|
||||
tokens.splice(i + 1, 0, new state.Token("paragraph_open", "p", 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split content by <br> to find all checkboxes
|
||||
const parts = inlineToken.content.split(BR_TAG_REGEX);
|
||||
const checkboxItems: Array<{ checked: boolean; label: string }> = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const match = part.trim().match(CHECKBOX_REGEX);
|
||||
if (match) {
|
||||
checkboxItems.push({
|
||||
checked: match[1].toLowerCase() === "x",
|
||||
label: match[2] || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (checkboxItems.length > 0) {
|
||||
// Build tokens for all checkbox items
|
||||
const newTokens: InstanceType<typeof state.Token>[] = [];
|
||||
|
||||
// Opening: checkbox_list_open
|
||||
newTokens.push(new state.Token("checkbox_list_open", "ul", 1));
|
||||
|
||||
// Add each checkbox item
|
||||
for (const item of checkboxItems) {
|
||||
const itemOpen = new state.Token("checkbox_item_open", "li", 1);
|
||||
if (item.checked) {
|
||||
itemOpen.attrs = [["checked", "true"]];
|
||||
}
|
||||
newTokens.push(itemOpen);
|
||||
newTokens.push(new state.Token("paragraph_open", "p", 1));
|
||||
|
||||
// Create inline token for the label
|
||||
const labelInline = new state.Token("inline", "", 0);
|
||||
labelInline.content = item.label;
|
||||
const textToken = new state.Token("text", "", 0);
|
||||
textToken.content = item.label;
|
||||
labelInline.children = [textToken];
|
||||
newTokens.push(labelInline);
|
||||
|
||||
newTokens.push(new state.Token("paragraph_close", "p", -1));
|
||||
newTokens.push(new state.Token("checkbox_item_close", "li", -1));
|
||||
}
|
||||
|
||||
// Closing: checkbox_list_close
|
||||
newTokens.push(new state.Token("checkbox_list_close", "ul", -1));
|
||||
|
||||
// Replace the inline token with our new structure
|
||||
tokens.splice(i + 1, closeIndex - i - 1, ...newTokens);
|
||||
} else {
|
||||
// markdown-it table parser does not return paragraphs inside the cells
|
||||
// but prosemirror requires them, so we add 'em in here.
|
||||
// Insert closing token first (before closeIndex shifts)
|
||||
tokens.splice(
|
||||
closeIndex,
|
||||
0,
|
||||
new state.Token("paragraph_close", "p", -1)
|
||||
);
|
||||
// Then insert opening token
|
||||
tokens.splice(i + 1, 0, new state.Token("paragraph_open", "p", 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user