From b05553c4322488713827f8e8ea05b52e8951fda5 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Mon, 16 Mar 2026 16:36:45 +0900 Subject: [PATCH] fix: restore tab indentation across split task lists - detect task list selections by ancestor nodes instead of relying on contiguous ranges - merge task lists separated by an empty paragraph before retrying indent and outdent - add regression coverage for ranged task-list indentation across split lists --- packages/tiptap/src/editor/index.tsx | 9 +- .../src/shared/custom-list-keymap.test.ts | 339 ++++++++++++++++++ .../tiptap/src/shared/custom-list-keymap.ts | 230 ++++++++++-- packages/tiptap/src/shared/index.ts | 1 + 4 files changed, 537 insertions(+), 42 deletions(-) create mode 100644 packages/tiptap/src/shared/custom-list-keymap.test.ts diff --git a/packages/tiptap/src/editor/index.tsx b/packages/tiptap/src/editor/index.tsx index 9612670bf3..80c2ae2e1b 100644 --- a/packages/tiptap/src/editor/index.tsx +++ b/packages/tiptap/src/editor/index.tsx @@ -1,6 +1,5 @@ import "../../styles.css"; -import { isNodeActive } from "@tiptap/core"; import { EditorContent, type JSONContent, @@ -162,9 +161,7 @@ const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>( } if (event.key === "Tab" && event.shiftKey) { - const isInListItem = - isNodeActive(state, "listItem") || - isNodeActive(state, "taskItem"); + const isInListItem = shared.isSelectionInListItem(state); if (!isInListItem && isInFirstBlock && onNavigateToTitle) { event.preventDefault(); onNavigateToTitle(); @@ -180,9 +177,7 @@ const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>( } if (event.key === "Tab") { - const isInListItem = - isNodeActive(state, "listItem") || - isNodeActive(state, "taskItem"); + const isInListItem = shared.isSelectionInListItem(state); if (isInListItem) { return false; } diff --git a/packages/tiptap/src/shared/custom-list-keymap.test.ts b/packages/tiptap/src/shared/custom-list-keymap.test.ts new file mode 100644 index 0000000000..63dd57ca5e --- /dev/null +++ b/packages/tiptap/src/shared/custom-list-keymap.test.ts @@ -0,0 +1,339 @@ +import { Editor, type JSONContent } from "@tiptap/core"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, test } from "vitest"; + +import CustomListKeymap, { + getSelectedListItemNames, + isSelectionInListItem, + liftSelectedListItems, + sinkSelectedListItems, +} from "./custom-list-keymap"; + +const TASK_LIST_CONTENT: JSONContent = { + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "one" }], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "two" }], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "three" }], + }, + ], + }, + ], + }, + ], +}; + +const SPLIT_TASK_LIST_CONTENT: JSONContent = { + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "one" }], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "two" }], + }, + ], + }, + ], + }, + { type: "paragraph" }, + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "three" }], + }, + ], + }, + ], + }, + ], +}; + +const editors: Editor[] = []; + +function createEditor(content = TASK_LIST_CONTENT): Editor { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ listKeymap: false }), + TaskList, + TaskItem.configure({ nested: true }), + CustomListKeymap, + ], + content, + }); + + editors.push(editor); + + return editor; +} + +function getTextPos(editor: Editor, text: string): number { + let matchPos = -1; + + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === text) { + matchPos = pos; + return false; + } + + return undefined; + }); + + if (matchPos === -1) { + throw new Error(`Missing text node: ${text}`); + } + + return matchPos + 1; +} + +function setCursor(editor: Editor, text: string) { + const pos = getTextPos(editor, text); + + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos)), + ); +} + +function setRange(editor: Editor, startText: string, endText: string) { + const from = getTextPos(editor, startText); + const to = getTextPos(editor, endText) + endText.length - 1; + + editor.view.dispatch( + editor.state.tr.setSelection( + TextSelection.create(editor.state.doc, from, to), + ), + ); +} + +afterEach(() => { + while (editors.length > 0) { + editors.pop()?.destroy(); + } +}); + +describe("custom list keymap", () => { + test("detects task item selections for cursors and ranges", () => { + const editor = createEditor(); + + setCursor(editor, "two"); + expect(getSelectedListItemNames(editor.state)).toEqual(["taskItem"]); + expect(isSelectionInListItem(editor.state)).toBe(true); + + setRange(editor, "two", "three"); + expect(getSelectedListItemNames(editor.state)).toEqual(["taskItem"]); + expect(isSelectionInListItem(editor.state)).toBe(true); + }); + + test("sinks a single task item", () => { + const editor = createEditor(); + + setCursor(editor, "two"); + + expect(sinkSelectedListItems(editor)).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "one" }], + }, + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "two" }], + }, + ], + }, + ], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "three" }], + }, + ], + }, + ], + }, + ], + }); + }); + + test("sinks and lifts a ranged task item selection", () => { + const editor = createEditor(); + + setRange(editor, "two", "three"); + + expect(sinkSelectedListItems(editor)).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "one" }], + }, + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "two" }], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "three" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }); + + expect(liftSelectedListItems(editor)).toBe(true); + expect(editor.getJSON()).toEqual(TASK_LIST_CONTENT); + }); + + test("joins split task lists before indenting a ranged selection", () => { + const editor = createEditor(SPLIT_TASK_LIST_CONTENT); + + setRange(editor, "two", "three"); + + expect(sinkSelectedListItems(editor)).toBe(true); + expect(editor.getJSON()).toEqual({ + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "one" }], + }, + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "two" }], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "three" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/tiptap/src/shared/custom-list-keymap.ts b/packages/tiptap/src/shared/custom-list-keymap.ts index 81d9638778..05a4138c10 100644 --- a/packages/tiptap/src/shared/custom-list-keymap.ts +++ b/packages/tiptap/src/shared/custom-list-keymap.ts @@ -1,13 +1,187 @@ +import type { Editor } from "@tiptap/core"; import { isNodeActive } from "@tiptap/core"; import { ListKeymap } from "@tiptap/extension-list-keymap"; +import type { EditorState } from "@tiptap/pm/state"; import { canJoin } from "@tiptap/pm/transform"; +const LIST_ITEM_NAMES = ["taskItem", "listItem"] as const; +type ListItemName = (typeof LIST_ITEM_NAMES)[number]; + +const LIST_WRAPPER_NAMES: Record = { + taskItem: ["taskList"], + listItem: ["bulletList", "orderedList"], +}; + +type JoinRange = { + from: number; + to: number; + joinPos: number; +}; + +function isListItemName(name: string): name is ListItemName { + return LIST_ITEM_NAMES.includes(name as ListItemName); +} + +function getClosestListItemName(state: EditorState): ListItemName | null { + const { $from } = state.selection; + + for (let depth = $from.depth; depth > 0; depth--) { + const nodeName = $from.node(depth).type.name; + + if (isListItemName(nodeName) && state.schema.nodes[nodeName]) { + return nodeName; + } + } + + return null; +} + +export function getSelectedListItemNames(state: EditorState): ListItemName[] { + const matchedNames = new Set(); + const { selection, doc, schema } = state; + const { from, to, $from, $to } = selection; + + const addAncestorMatches = (resolvedPos: typeof $from) => { + for (let depth = resolvedPos.depth; depth > 0; depth--) { + const nodeName = resolvedPos.node(depth).type.name; + + if (isListItemName(nodeName) && schema.nodes[nodeName]) { + matchedNames.add(nodeName); + } + } + }; + + addAncestorMatches($from); + addAncestorMatches($to); + + doc.nodesBetween(from, to, (node) => { + if (node.isText) { + return; + } + + const nodeName = node.type.name; + if (isListItemName(nodeName) && schema.nodes[nodeName]) { + matchedNames.add(nodeName); + } + }); + + return LIST_ITEM_NAMES.filter((nodeName) => matchedNames.has(nodeName)); +} + +export function isSelectionInListItem(state: EditorState): boolean { + return getSelectedListItemNames(state).length > 0; +} + +function joinSeparatedListOnce( + editor: Editor, + listItemName: ListItemName, +): boolean { + const { state } = editor; + const { doc, schema, selection } = state; + const paragraphType = schema.nodes.paragraph; + const listWrapperTypes = LIST_WRAPPER_NAMES[listItemName] + .map((wrapperName) => schema.nodes[wrapperName]) + .filter(Boolean); + + if (!paragraphType || listWrapperTypes.length === 0) { + return false; + } + + let joinRange: JoinRange | undefined; + + doc.nodesBetween(selection.from, selection.to, (node, pos) => { + if (joinRange || node.type !== paragraphType || node.content.size !== 0) { + return; + } + + const $before = doc.resolve(pos); + const nodeBefore = $before.nodeBefore; + const $after = doc.resolve(pos + node.nodeSize); + const nodeAfter = $after.nodeAfter; + + if ( + !nodeBefore || + !nodeAfter || + nodeBefore.type !== nodeAfter.type || + !listWrapperTypes.includes(nodeBefore.type) + ) { + return; + } + + joinRange = { + from: pos, + to: pos + node.nodeSize, + joinPos: pos, + }; + + return false; + }); + + if (joinRange === undefined) { + return false; + } + + const { from, to, joinPos } = joinRange; + + return editor + .chain() + .command(({ tr }) => { + tr.delete(from, to); + if (canJoin(tr.doc, joinPos)) { + tr.join(joinPos); + } + return true; + }) + .run(); +} + +function joinSeparatedLists( + editor: Editor, + listItemName: ListItemName, +): boolean { + let joined = false; + + while (joinSeparatedListOnce(editor, listItemName)) { + joined = true; + } + + return joined; +} + +function runListCommand( + editor: Editor, + command: "sinkListItem" | "liftListItem", +): boolean { + const runCommand = (listItemName: ListItemName) => + command === "sinkListItem" + ? editor.chain().sinkListItem(listItemName).run() + : editor.chain().liftListItem(listItemName).run(); + + for (const listItemName of getSelectedListItemNames(editor.state)) { + if (runCommand(listItemName)) { + return true; + } + + if (joinSeparatedLists(editor, listItemName) && runCommand(listItemName)) { + return true; + } + } + + return false; +} + +export function sinkSelectedListItems(editor: Editor): boolean { + return runListCommand(editor, "sinkListItem"); +} + +export function liftSelectedListItems(editor: Editor): boolean { + return runListCommand(editor, "liftListItem"); +} + export const CustomListKeymap = ListKeymap.extend({ addKeyboardShortcuts() { const originalShortcuts = this.parent?.() ?? {}; - const getListItemType = () => this.editor.schema.nodes.listItem; - const tryJoinLists = (editor: typeof this.editor): boolean => { const { state } = editor; const { selection, doc, schema } = state; @@ -17,14 +191,18 @@ export const CustomListKeymap = ListKeymap.extend({ return false; } - const orderedListType = schema.nodes.orderedList; - const bulletListType = schema.nodes.bulletList; - if (!orderedListType && !bulletListType) { + const listWrapperTypes = [ + schema.nodes.taskList, + schema.nodes.orderedList, + schema.nodes.bulletList, + ].filter(Boolean); + + if (listWrapperTypes.length === 0) { return false; } - const isListType = (type: typeof orderedListType) => - type === orderedListType || type === bulletListType; + const isListType = (type: (typeof listWrapperTypes)[number]) => + listWrapperTypes.includes(type); const currentNode = $from.parent; const isEmptyParagraph = @@ -104,17 +282,17 @@ export const CustomListKeymap = ListKeymap.extend({ const editor = this.editor; const state = editor.state; const { selection } = state; - const listNodeType = getListItemType(); + const listItemName = getClosestListItemName(state); - if (!listNodeType) { + if (!listItemName) { return false; } if ( - isNodeActive(state, listNodeType.name) && + isNodeActive(state, listItemName) && selection.$from.parent.content.size === 0 ) { - return editor.chain().liftListItem(listNodeType.name).run(); + return editor.chain().liftListItem(listItemName).run(); } return originalShortcuts.Enter @@ -125,15 +303,15 @@ export const CustomListKeymap = ListKeymap.extend({ Backspace: ({ editor }) => { const state = editor.state; const { selection } = state; - const listNodeType = getListItemType(); + const listItemName = getClosestListItemName(state); - if (listNodeType) { + if (listItemName) { if ( - isNodeActive(state, listNodeType.name) && + isNodeActive(state, listItemName) && selection.$from.parentOffset === 0 && selection.$from.parent.content.size === 0 ) { - return editor.chain().liftListItem(listNodeType.name).run(); + return editor.chain().liftListItem(listItemName).run(); } } @@ -149,29 +327,11 @@ export const CustomListKeymap = ListKeymap.extend({ }, Tab: () => { - const listNodeType = getListItemType(); - if (!listNodeType) { - return false; - } - - if (isNodeActive(this.editor.state, listNodeType.name)) { - return this.editor.chain().sinkListItem(listNodeType.name).run(); - } - - return false; + return sinkSelectedListItems(this.editor); }, "Shift-Tab": () => { - const listNodeType = getListItemType(); - if (!listNodeType) { - return false; - } - - if (isNodeActive(this.editor.state, listNodeType.name)) { - return this.editor.chain().liftListItem(listNodeType.name).run(); - } - - return false; + return liftSelectedListItems(this.editor); }, }; }, diff --git a/packages/tiptap/src/shared/index.ts b/packages/tiptap/src/shared/index.ts index 9f8e57926b..4748468bff 100644 --- a/packages/tiptap/src/shared/index.ts +++ b/packages/tiptap/src/shared/index.ts @@ -1,5 +1,6 @@ export * from "./animation"; export * from "./clip"; +export * from "./custom-list-keymap"; export * from "./extensions"; export * from "./hashtag"; export * from "./schema-validation";