mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
feat: convert plain list to checklist when typing checkbox marker
Typing "[ ] " at the start of an existing bullet or ordered list item now converts the whole list to a checklist, preserving nested list structure, reusing the in-place list conversion utility. https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import type { InputRule } from "prosemirror-inputrules";
|
||||
import type { Node } from "prosemirror-model";
|
||||
import {
|
||||
createEditorStateWithSelection,
|
||||
doc,
|
||||
p,
|
||||
schema,
|
||||
} from "@shared/test/editor";
|
||||
import { checkboxListInputRule } from "./listInputRule";
|
||||
|
||||
const { bullet_list, ordered_list, list_item, checkbox_list, checkbox_item } =
|
||||
schema.nodes;
|
||||
|
||||
/**
|
||||
* Creates a list item node with the given block content.
|
||||
*/
|
||||
function li(content: Node[]) {
|
||||
return list_item.create(null, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position directly after the first occurrence of the given text.
|
||||
*
|
||||
* @throws if no matching text node exists in the document.
|
||||
*/
|
||||
function posAfterText(node: Node, text: string) {
|
||||
let found = -1;
|
||||
node.descendants((child, pos) => {
|
||||
if (found === -1 && child.isText && child.text?.startsWith(text)) {
|
||||
found = pos;
|
||||
}
|
||||
return found === -1;
|
||||
});
|
||||
if (found === -1) {
|
||||
throw new Error(`Text "${text}" not found in document`);
|
||||
}
|
||||
return found + text.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates typing the trigger character of an input rule, mirroring the way
|
||||
* prosemirror-inputrules invokes a rule's handler, and returns the resulting
|
||||
* document (or the unchanged document when the rule does not fire).
|
||||
*/
|
||||
function typeTrigger(
|
||||
rule: InputRule,
|
||||
testDoc: Node,
|
||||
markerInDoc: string,
|
||||
triggerChar: string
|
||||
) {
|
||||
let state = createEditorStateWithSelection(
|
||||
testDoc,
|
||||
posAfterText(testDoc, markerInDoc)
|
||||
);
|
||||
const { from, to } = state.selection;
|
||||
const $from = state.doc.resolve(from);
|
||||
const textBefore =
|
||||
$from.parent.textBetween(
|
||||
Math.max(0, $from.parentOffset - 500),
|
||||
$from.parentOffset,
|
||||
null,
|
||||
""
|
||||
) + triggerChar;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const match = (rule as any).match.exec(textBefore) as RegExpMatchArray | null;
|
||||
if (!match) {
|
||||
return state.doc;
|
||||
}
|
||||
const startPos = from - (match[0].length - triggerChar.length);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tr = (rule as any).handler(state, match, startPos, to);
|
||||
if (tr) {
|
||||
state = state.apply(tr);
|
||||
}
|
||||
return state.doc;
|
||||
}
|
||||
|
||||
const rule = checkboxListInputRule(
|
||||
/^-?\s*(\[\s?\])\s$/i,
|
||||
checkbox_list,
|
||||
checkbox_item
|
||||
);
|
||||
|
||||
describe("checkboxListInputRule", () => {
|
||||
it("converts a plain bullet list to a checklist", () => {
|
||||
const testDoc = doc([
|
||||
bullet_list.create(null, [li([p("[ ]")]), li([p("two")])]),
|
||||
]);
|
||||
|
||||
const result = typeTrigger(rule, testDoc, "[ ]", " ");
|
||||
|
||||
const list = result.firstChild;
|
||||
expect(list?.type.name).toBe("checkbox_list");
|
||||
expect(list?.childCount).toBe(2);
|
||||
expect(list?.child(0).type.name).toBe("checkbox_item");
|
||||
expect(list?.child(0).textContent).toBe("");
|
||||
expect(list?.child(1).textContent).toBe("two");
|
||||
});
|
||||
|
||||
it("converts a plain ordered list to a checklist", () => {
|
||||
const testDoc = doc([
|
||||
ordered_list.create(null, [li([p("[ ]")]), li([p("two")])]),
|
||||
]);
|
||||
|
||||
const result = typeTrigger(rule, testDoc, "[ ]", " ");
|
||||
|
||||
expect(result.firstChild?.type.name).toBe("checkbox_list");
|
||||
});
|
||||
|
||||
it("preserves nesting when converting a list with a nested list", () => {
|
||||
const testDoc = doc([
|
||||
bullet_list.create(null, [
|
||||
li([p("[ ]")]),
|
||||
li([p("two"), bullet_list.create(null, [li([p("nested")])])]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const result = typeTrigger(rule, testDoc, "[ ]", " ");
|
||||
|
||||
const list = result.firstChild;
|
||||
expect(list?.type.name).toBe("checkbox_list");
|
||||
const nested = list?.child(1).child(1);
|
||||
expect(nested?.type.name).toBe("checkbox_list");
|
||||
expect(nested?.child(0).type.name).toBe("checkbox_item");
|
||||
expect(nested?.child(0).textContent).toBe("nested");
|
||||
});
|
||||
|
||||
it("does nothing when already in a checklist", () => {
|
||||
const testDoc = doc([
|
||||
checkbox_list.create(null, [
|
||||
checkbox_item.create(null, p("[ ]")),
|
||||
checkbox_item.create(null, p("two")),
|
||||
]),
|
||||
]);
|
||||
|
||||
const result = typeTrigger(rule, testDoc, "[ ]", " ");
|
||||
|
||||
// The rule should not fire; the marker text is left untouched.
|
||||
expect(result.firstChild?.type.name).toBe("checkbox_list");
|
||||
expect(result.firstChild?.child(0).textContent).toBe("[ ]");
|
||||
});
|
||||
|
||||
it("does nothing when not inside a list", () => {
|
||||
const testDoc = doc([p("[ ]")]);
|
||||
|
||||
const result = typeTrigger(rule, testDoc, "[ ]", " ");
|
||||
|
||||
expect(result.firstChild?.type.name).toBe("paragraph");
|
||||
expect(result.firstChild?.textContent).toBe("[ ]");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
Node as ProsemirrorNode,
|
||||
Attrs,
|
||||
} from "prosemirror-model";
|
||||
import toggleList from "../commands/toggleList";
|
||||
import { findParentNodeClosestToPos } from "../queries/findParentNode";
|
||||
import { isInHeading } from "../queries/isInHeading";
|
||||
import { isList } from "../queries/isList";
|
||||
|
||||
/**
|
||||
* A wrapper for wrappingInputRule that prevents execution inside heading nodes.
|
||||
@@ -31,3 +34,40 @@ export function listWrappingInputRule(
|
||||
return (rule as any).handler(state, match, start, end);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An input rule that converts an existing plain list (bullet or ordered) to a
|
||||
* checklist when the checkbox marker is typed at the start of a list item,
|
||||
* preserving any nested list structure.
|
||||
*
|
||||
* @param regexp the pattern matching the checkbox marker, e.g. "[ ] ".
|
||||
* @param listType the checkbox list node type to convert to.
|
||||
* @param itemType the checkbox item node type to convert items to.
|
||||
* @returns the input rule.
|
||||
*/
|
||||
export function checkboxListInputRule(
|
||||
regexp: RegExp,
|
||||
listType: NodeType,
|
||||
itemType: NodeType
|
||||
): InputRule {
|
||||
return new InputRule(regexp, (state, _match, start, end) => {
|
||||
const { schema } = state;
|
||||
|
||||
// Only act when the selection sits inside a plain (non-checkbox) list —
|
||||
// converting a paragraph is already handled by listWrappingInputRule.
|
||||
const list = findParentNodeClosestToPos(state.selection.$from, (node) =>
|
||||
isList(node, schema)
|
||||
);
|
||||
if (!list || list.node.type === listType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove the typed marker, then convert the list in place.
|
||||
const tr = state.tr.delete(start, end);
|
||||
const after = state.apply(tr);
|
||||
toggleList(listType, itemType)(after, (toggleTr) => {
|
||||
toggleTr.steps.forEach((step) => tr.step(step));
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import { Plugin } from "prosemirror-state";
|
||||
import { v4 as generateUuid } from "uuid";
|
||||
import toggleList from "../commands/toggleList";
|
||||
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { listWrappingInputRule } from "../lib/listInputRule";
|
||||
import {
|
||||
checkboxListInputRule,
|
||||
listWrappingInputRule,
|
||||
} from "../lib/listInputRule";
|
||||
import { findBlockNodes } from "../queries/findChildren";
|
||||
import { CheckboxListView } from "./CheckboxListView";
|
||||
import Node from "./Node";
|
||||
@@ -84,8 +87,14 @@ export default class CheckboxList extends Node {
|
||||
return () => toggleList(type, schema.nodes.checkbox_item);
|
||||
}
|
||||
|
||||
inputRules({ type }: { type: NodeType }) {
|
||||
return [listWrappingInputRule(/^-?\s*(\[\s?\])\s$/i, type)];
|
||||
inputRules({ type, schema }: { type: NodeType; schema: Schema }) {
|
||||
const pattern = /^-?\s*(\[\s?\])\s$/i;
|
||||
return [
|
||||
// Convert an existing plain list to a checklist, keeping nesting intact.
|
||||
checkboxListInputRule(pattern, type, schema.nodes.checkbox_item),
|
||||
// Wrap a plain paragraph into a new checklist.
|
||||
listWrappingInputRule(pattern, type),
|
||||
];
|
||||
}
|
||||
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
|
||||
Reference in New Issue
Block a user