diff --git a/src/components/MultiTaskItem.ts b/src/components/MultiTaskItem.ts new file mode 100644 index 0000000..bec510a --- /dev/null +++ b/src/components/MultiTaskItem.ts @@ -0,0 +1,256 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +const MIN_CHECKBOXES = 1 + +const normalizeCheckStates = (value: unknown): boolean[] => { + if (Array.isArray(value)) { + const states = value.map(Boolean) + return states.length >= MIN_CHECKBOXES + ? states + : [...states, ...Array.from({ length: MIN_CHECKBOXES - states.length }, () => false)] + } + + return Array.from({ length: MIN_CHECKBOXES }, () => false) +} + +const parseCheckStates = (rawValue: string | null): boolean[] => { + if (!rawValue) { + return normalizeCheckStates(null) + } + + try { + return normalizeCheckStates(JSON.parse(rawValue)) + } catch { + return normalizeCheckStates( + rawValue + .split(',') + .map(value => value.trim()) + .filter(Boolean) + .map(value => value === 'true' || value === '1') + ) + } +} + +declare module '@tiptap/core' { + interface Commands { + multiTaskItem: { + addCheckboxToMultiTaskItem: () => ReturnType + removeCheckboxFromMultiTaskItem: () => ReturnType + } + } +} + +export const MultiTaskItem = Node.create({ + name: 'multiTaskItem', + + addOptions() { + return { + nested: true, + HTMLAttributes: {}, + } + }, + + content: 'paragraph block*', + + defining: true, + + addAttributes() { + return { + checkStates: { + default: normalizeCheckStates(null), + keepOnSplit: true, + parseHTML: element => parseCheckStates(element.getAttribute('data-check-states')), + renderHTML: attributes => ({ + 'data-check-states': JSON.stringify(normalizeCheckStates(attributes.checkStates)), + }), + }, + } + }, + + parseHTML() { + return [ + { + tag: 'li[data-type="multiTaskItem"]', + priority: 51, + }, + ] + }, + + renderHTML({ node, HTMLAttributes }) { + const states = normalizeCheckStates(node.attrs.checkStates) + const controlGroup = [ + 'div', + { 'data-multi-task-controls': 'true' }, + ...states.map(state => [ + 'label', + [ + 'input', + { + type: 'checkbox', + checked: state ? 'checked' : null, + }, + ], + ['span', ''], + ]), + ] + + return [ + 'li', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + 'data-type': 'multiTaskItem', + }), + controlGroup, + ['div', 0], + ] + }, + + addKeyboardShortcuts() { + return { + Enter: () => this.editor.commands.splitListItem(this.name), + 'Shift-Tab': () => this.editor.commands.liftListItem(this.name), + Tab: () => this.editor.commands.sinkListItem(this.name), + } + }, + + addCommands() { + const updateCheckStates = (transform: (states: boolean[]) => boolean[]) => { + return () => ({ state, commands }: any) => { + const { $from } = state.selection + + for (let depth = $from.depth; depth > 0; depth -= 1) { + const node = $from.node(depth) + if (node.type.name !== this.name) { + continue + } + + const position = $from.before(depth) + return commands.command(({ tr }: any) => { + tr.setNodeMarkup(position, undefined, { + ...node.attrs, + checkStates: normalizeCheckStates(transform(normalizeCheckStates(node.attrs.checkStates))), + }) + return true + }) + } + + return false + } + } + + return { + addCheckboxToMultiTaskItem: updateCheckStates(states => [...states, false]), + removeCheckboxFromMultiTaskItem: updateCheckStates(states => states.length > MIN_CHECKBOXES ? states.slice(0, -1) : states), + } + }, + + addNodeView() { + return ({ node, getPos, editor }) => { + let currentNode = node + + const dom = document.createElement('li') + dom.classList.add('flex', 'items-start', 'group', 'relative', 'my-1') + dom.dataset.type = 'multiTaskItem' + + const controls = document.createElement('div') + controls.contentEditable = 'false' + controls.classList.add('flex', 'items-center', 'mr-3', 'select-none', 'shrink-0') + + const checkboxRow = document.createElement('div') + checkboxRow.classList.add('flex', 'items-center', 'gap-2') + + const content = document.createElement('div') + content.classList.add('flex-1', 'min-w-0', 'min-h-[1.5rem]', 'tiptap-task-content') + + const updateNode = (nextStates: boolean[]) => { + if (typeof getPos !== 'function') { + return + } + + editor.commands.command(({ tr }) => { + const position = getPos() + if (typeof position !== 'number') { + return false + } + + tr.setNodeMarkup(position, undefined, { + ...currentNode.attrs, + checkStates: normalizeCheckStates(nextStates), + }) + return true + }) + } + + const createCheckbox = (checked: boolean, index: number) => { + const wrapper = document.createElement('div') + wrapper.classList.add('w-5', 'flex', 'justify-center') + + const button = document.createElement('button') + button.type = 'button' + button.classList.add( + 'w-5', 'h-5', + 'flex', 'items-center', 'justify-center', + 'rounded-md', 'border-[1.5px]', + 'transition-colors', 'duration-300', 'cursor-pointer', + 'shrink-0' + ) + button.setAttribute('aria-label', `Toggle checkbox ${index + 1}`) + + const checkIcon = document.createElement('div') + checkIcon.innerHTML = '' + checkIcon.classList.add('transition-opacity', 'duration-300', 'ease-in-out') + button.appendChild(checkIcon) + + const updateCheckboxVisuals = (isChecked: boolean) => { + if (isChecked) { + button.classList.add('bg-primary', 'border-primary', 'text-primary-foreground') + button.classList.remove('border-muted-foreground/30', 'bg-transparent', 'text-transparent') + checkIcon.style.opacity = '1' + } else { + button.classList.remove('bg-primary', 'border-primary', 'text-primary-foreground') + button.classList.add('border-muted-foreground/30', 'bg-transparent', 'text-transparent') + checkIcon.style.opacity = '0' + } + } + + updateCheckboxVisuals(checked) + + button.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + const states = normalizeCheckStates(currentNode.attrs.checkStates) + states[index] = !states[index] + updateNode(states) + }) + + wrapper.appendChild(button) + + return wrapper + } + + const renderCheckboxes = (states: boolean[]) => { + checkboxRow.replaceChildren(...states.map((state, index) => createCheckbox(state, index))) + } + + renderCheckboxes(normalizeCheckStates(currentNode.attrs.checkStates)) + + controls.appendChild(checkboxRow) + + dom.appendChild(controls) + dom.appendChild(content) + + return { + dom, + contentDOM: content, + update: updatedNode => { + if (updatedNode.type.name !== this.name) { + return false + } + + currentNode = updatedNode + renderCheckboxes(normalizeCheckStates(updatedNode.attrs.checkStates)) + return true + }, + } + } + }, +}) diff --git a/src/components/MultiTaskList.tsx b/src/components/MultiTaskList.tsx new file mode 100644 index 0000000..d8ea20b --- /dev/null +++ b/src/components/MultiTaskList.tsx @@ -0,0 +1,484 @@ +import { Node, mergeAttributes } from '@tiptap/core' +import type { Component } from 'solid-js' +import { render } from 'solid-js/web' +import { Plus, Minus, MoreHorizontal } from 'lucide-solid' + +const normalizeCheckStates = (value: unknown): boolean[] => { + if (Array.isArray(value)) { + return value.map(Boolean) + } + + return [false] +} + +const getColumnCountFromListNode = (listNode: any): number => { + let columnCount = 1 + + listNode?.descendants((descendant: any) => { + if (descendant.type?.name === 'multiTaskItem') { + columnCount = Math.max(columnCount, normalizeCheckStates(descendant.attrs?.checkStates).length) + } + }) + + return columnCount +} + +const normalizeColumnLabels = (value: unknown, columnCount: number): string[] => { + const labels = Array.isArray(value) + ? value.map(label => typeof label === 'string' ? label : '') + : [] + + if (labels.length >= columnCount) { + return labels.slice(0, columnCount) + } + + return [...labels, ...Array.from({ length: columnCount - labels.length }, () => '')] +} + +const parseColumnLabels = (rawValue: string | null, columnCount: number): string[] => { + if (!rawValue) { + return normalizeColumnLabels([], columnCount) + } + + try { + return normalizeColumnLabels(JSON.parse(rawValue), columnCount) + } catch { + return normalizeColumnLabels( + rawValue.split(',').map(value => value.trim()), + columnCount, + ) + } +} + +const findTopmostListDepth = ($from: any, listName: string): number => { + let listDepth = -1 + + for (let depth = $from.depth; depth > 0; depth -= 1) { + if ($from.node(depth).type.name === listName) { + listDepth = depth + } + } + + return listDepth +} + +declare module '@tiptap/core' { + interface Commands { + multiTaskList: { + toggleMultiTaskList: () => ReturnType + setMultiTaskList: () => ReturnType + unsetMultiTaskList: () => ReturnType + addCheckboxColumnToMultiTaskList: () => ReturnType + removeCheckboxColumnFromMultiTaskList: () => ReturnType + toggleMultiTaskListColumnLabels: () => ReturnType + setMultiTaskListColumnLabel: (index: number, label: string) => ReturnType + } + } +} + +export const MultiTaskList = Node.create({ + name: 'multiTaskList', + + addOptions() { + return { + HTMLAttributes: {}, + itemTypeName: 'multiTaskItem', + } + }, + + group: 'block list', + + content: 'multiTaskItem+', + + addAttributes() { + return { + columnLabels: { + default: [''], + parseHTML: element => parseColumnLabels(element.getAttribute('data-column-labels'), 1), + renderHTML: attributes => ({ + 'data-column-labels': JSON.stringify(attributes.columnLabels ?? ['']), + }), + }, + showColumnLabels: { + default: false, + parseHTML: element => element.getAttribute('data-show-column-labels') === 'true', + renderHTML: attributes => ({ + 'data-show-column-labels': attributes.showColumnLabels ? 'true' : 'false', + }), + }, + } + }, + + parseHTML() { + return [ + { + tag: 'ul[data-type="multiTaskList"]', + priority: 51, + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'ul', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + 'data-type': 'multiTaskList', + }), + 0, + ] + }, + + addCommands() { + const updateDescendantItems = (tr: any, listNode: any, listPos: number, transformStates: (states: boolean[]) => boolean[]) => { + listNode.descendants((descendant: any, descendantPos: number) => { + if (descendant.type.name !== this.options.itemTypeName) { + return + } + + tr.setNodeMarkup(listPos + 1 + descendantPos, undefined, { + ...descendant.attrs, + checkStates: transformStates(normalizeCheckStates(descendant.attrs.checkStates)), + }) + }) + } + + const updateCurrentList = (transformStates: (states: boolean[]) => boolean[], transformLabels?: (labels: string[], columnCount: number, showColumnLabels: boolean) => { labels: string[]; showColumnLabels?: boolean }) => { + return () => ({ state, tr, dispatch }: any) => { + const { $from } = state.selection + const listDepth = findTopmostListDepth($from, this.name) + + if (listDepth === -1) { + return false + } + + const listPos = $from.before(listDepth) + const listNode = state.doc.nodeAt(listPos) + + if (!listNode) { + return false + } + + const columnCount = getColumnCountFromListNode(listNode) + const nextColumnCount = transformStates(Array.from({ length: columnCount }, () => false)).length + const currentLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount) + const currentShowColumnLabels = Boolean(listNode.attrs?.showColumnLabels) + const transformed = transformLabels + ? transformLabels(currentLabels, nextColumnCount, currentShowColumnLabels) + : { labels: currentLabels, showColumnLabels: currentShowColumnLabels } + const nextLabels = normalizeColumnLabels(transformed.labels, nextColumnCount) + const nextShowColumnLabels = transformed.showColumnLabels ?? currentShowColumnLabels + + tr.setNodeMarkup(listPos, undefined, { + ...listNode.attrs, + columnLabels: nextLabels, + showColumnLabels: nextShowColumnLabels, + }) + + updateDescendantItems(tr, listNode, listPos, transformStates) + + if (dispatch) { + dispatch(tr) + } + + return true + } + } + + return { + toggleMultiTaskList: () => ({ commands }) => { + return commands.toggleList(this.name, this.options.itemTypeName) + }, + setMultiTaskList: () => ({ commands }) => { + return commands.wrapInList(this.name) + }, + unsetMultiTaskList: () => ({ commands }) => { + return commands.liftListItem(this.options.itemTypeName) + }, + addCheckboxColumnToMultiTaskList: updateCurrentList( + states => [...states, false], + (labels, _columnCount, showColumnLabels) => ({ labels: [...labels, ''], showColumnLabels }), + ), + removeCheckboxColumnFromMultiTaskList: updateCurrentList( + states => (states.length > 1 ? states.slice(0, -1) : states), + (labels, columnCount, showColumnLabels) => ({ labels: labels.slice(0, columnCount), showColumnLabels }), + ), + toggleMultiTaskListColumnLabels: () => ({ state, tr, dispatch }: any) => { + const { $from } = state.selection + const listDepth = findTopmostListDepth($from, this.name) + + if (listDepth === -1) { + return false + } + + const listPos = $from.before(listDepth) + const listNode = state.doc.nodeAt(listPos) + + if (!listNode) { + return false + } + + const columnCount = getColumnCountFromListNode(listNode) + const currentLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount) + + tr.setNodeMarkup(listPos, undefined, { + ...listNode.attrs, + columnLabels: currentLabels, + showColumnLabels: !listNode.attrs?.showColumnLabels, + }) + + if (dispatch) { + dispatch(tr) + } + + return true + }, + setMultiTaskListColumnLabel: (index: number, label: string) => ({ state, tr, dispatch }: any) => { + const { $from } = state.selection + const listDepth = findTopmostListDepth($from, this.name) + + if (listDepth === -1) { + return false + } + + const listPos = $from.before(listDepth) + const listNode = state.doc.nodeAt(listPos) + + if (!listNode) { + return false + } + + const columnCount = getColumnCountFromListNode(listNode) + const nextLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount) + nextLabels[index] = label + + tr.setNodeMarkup(listPos, undefined, { + ...listNode.attrs, + columnLabels: nextLabels, + showColumnLabels: true, + }) + + if (dispatch) { + dispatch(tr) + } + + return true + }, + } + }, + + addNodeView() { + return ({ editor, node, getPos }) => { + let currentNode = node + const getCurrentListPosition = () => { + if (typeof getPos === 'function') { + const position = getPos() + return typeof position === 'number' ? position : null + } + return null + } + + const isRootList = () => { + const position = getCurrentListPosition() + if (position === null) { + return true + } + + const resolvedPos = editor.state.doc.resolve(position + 1) + let seenCurrentList = false + + for (let depth = resolvedPos.depth; depth > 0; depth -= 1) { + if (resolvedPos.node(depth).type.name !== this.name) { + continue + } + + if (!seenCurrentList) { + seenCurrentList = true + continue + } + + return false + } + + if (!seenCurrentList) { + return true + } + + return true + } + + const dom = document.createElement('div') + dom.classList.add('relative', 'group/multi-task-list') + + const header = document.createElement('div') + header.classList.add('mb-1') + + const controls = document.createElement('div') + controls.contentEditable = 'false' + controls.classList.add( + 'absolute', 'right-0', 'top-0', 'z-[220]', + 'opacity-70', 'transition-opacity', + 'hover:opacity-100', 'group-hover/multi-task-list:opacity-100', + 'pointer-events-auto' + ) + const ControlsMenu: Component = () => ( +
+ +
+ + +
+
+ ) + + const disposeControls = render(() => , controls) + + const contentDOM = document.createElement('ul') + contentDOM.dataset.type = 'multiTaskList' + contentDOM.classList.add('not-prose', 'pl-2') + + const renderHeader = () => { + const columnCount = getColumnCountFromListNode(currentNode) + const labels = normalizeColumnLabels(currentNode.attrs?.columnLabels, columnCount) + const showColumnLabels = Boolean(currentNode.attrs?.showColumnLabels) + const rootList = isRootList() + contentDOM.dataset.showColumnLabels = rootList && showColumnLabels ? 'true' : 'false' + + if (!rootList || !showColumnLabels) { + header.replaceChildren() + header.classList.add('hidden') + return + } + + header.classList.remove('hidden') + + const row = document.createElement('div') + row.classList.add('flex', 'items-end', 'pr-10') + + const labelsRow = document.createElement('div') + labelsRow.classList.add('flex', 'items-center', 'gap-2', 'mr-3', 'select-none', 'shrink-0') + + labels.forEach((label, index) => { + const cell = document.createElement('input') + cell.type = 'text' + cell.value = label + cell.placeholder = `Col ${index + 1}` + cell.classList.add( + 'w-5', 'bg-transparent', 'border-b', 'border-border/60', + 'text-center', 'text-[9px]', 'font-semibold', 'uppercase', + 'tracking-wide', 'text-muted-foreground', 'outline-none', + 'focus:border-primary', 'placeholder:text-muted-foreground/40' + ) + const commitLabel = () => { + const position = getCurrentListPosition() + if (position === null) { + return + } + + editor.commands.command(({ tr }) => { + const listNode = editor.state.doc.nodeAt(position) + if (!listNode) { + return false + } + + const columnCount = getColumnCountFromListNode(listNode) + const nextLabels = normalizeColumnLabels(listNode.attrs?.columnLabels, columnCount) + nextLabels[index] = cell.value + + tr.setNodeMarkup(position, undefined, { + ...listNode.attrs, + columnLabels: nextLabels, + showColumnLabels: true, + }) + + return true + }) + } + cell.addEventListener('mousedown', event => { + event.stopPropagation() + }) + cell.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault() + cell.blur() + } + }) + cell.addEventListener('blur', () => { + commitLabel() + }) + labelsRow.appendChild(cell) + }) + + const spacer = document.createElement('div') + spacer.classList.add('flex-1', 'min-w-0') + + row.appendChild(labelsRow) + row.appendChild(spacer) + header.replaceChildren(row) + } + + renderHeader() + controls.style.display = isRootList() ? 'block' : 'none' + + dom.appendChild(header) + dom.appendChild(controls) + dom.appendChild(contentDOM) + + return { + dom, + contentDOM, + stopEvent: event => + controls.contains(event.target as globalThis.Node | null) || + header.contains(event.target as globalThis.Node | null), + update: updatedNode => { + if (updatedNode.type.name !== this.name) { + return false + } + + currentNode = updatedNode + controls.style.display = isRootList() ? 'block' : 'none' + renderHeader() + return true + }, + destroy: () => { + disposeControls() + }, + } + } + }, +}) diff --git a/src/components/SlashMenu.tsx b/src/components/SlashMenu.tsx index 3a2175f..5f73624 100644 --- a/src/components/SlashMenu.tsx +++ b/src/components/SlashMenu.tsx @@ -63,6 +63,15 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] editor.chain().focus().deleteRange(range).toggleTaskList().run(); }, }, + { + title: "Multi Checklist", + description: "Insert a row with multiple checkboxes.", + aliases: ["multi", "checkboxes", "multi-checklist"], + icon: ListTodo, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).toggleMultiTaskList().run(); + }, + }, { title: "Code Block", description: "Capture a code snippet.", diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index 6178e80..9616a3f 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -4,6 +4,8 @@ import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import TaskList from "@tiptap/extension-task-list"; import { CustomTaskItem } from "./CustomTaskItem"; +import { MultiTaskList } from "./MultiTaskList.tsx"; +import { MultiTaskItem } from "./MultiTaskItem"; import { CustomImage as Image } from "@/lib/extensions/image"; import Underline from "@tiptap/extension-underline"; import { X } from "lucide-solid"; @@ -132,6 +134,17 @@ export const TaskEditor: Component = (props) => { class: "flex gap-2 items-start my-1", }, }), + MultiTaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2", + }, + }), + MultiTaskItem.configure({ + nested: true, + HTMLAttributes: { + class: "flex gap-2 items-start my-1", + }, + }), SlashCommands.configure({ suggestion, }), @@ -262,6 +275,8 @@ export const TaskEditor: Component = (props) => { // Custom Task List Styling "[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0", "[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start", + "[&_ul[data-type='multiTaskList']]:list-none [&_ul[data-type='multiTaskList']]:p-0", + "[&_li[data-type='multiTaskItem']]:flex [&_li[data-type='multiTaskItem']]:items-start", "[&_.tiptap-task-content]:flex-1 [&_.tiptap-task-content]:min-w-0 [&_.tiptap-task-content]:min-h-[1.5rem]", "[&_.tiptap-task-content>p]:min-h-[1.5rem] [&_.tiptap-task-content>p]:m-0 [&_.tiptap-task-content>p]:w-full", "[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block", diff --git a/src/components/TextBubbleMenu.tsx b/src/components/TextBubbleMenu.tsx index 5f62095..2c56d8c 100644 --- a/src/components/TextBubbleMenu.tsx +++ b/src/components/TextBubbleMenu.tsx @@ -56,6 +56,7 @@ export const TextBubbleMenu: Component<{ editor: Editor }> = (props) => { h2: editor.isActive('heading', { level: 2 }), h3: editor.isActive('heading', { level: 3 }), checklist: editor.isActive('taskList'), + multiChecklist: editor.isActive('multiTaskList'), table: editor.isActive('table'), }); }; @@ -103,6 +104,7 @@ export const TextBubbleMenu: Component<{ editor: Editor }> = (props) => { name: "Blocks", actions: [ { label: "Checklist", icon: () => , isActive: () => activeFormats().checklist, command: () => props.editor.chain().focus().toggleTaskList().run() }, + { label: "Multi Checklist", icon: () => , isActive: () => activeFormats().multiChecklist, command: () => props.editor.chain().focus().toggleMultiTaskList().run() }, { label: "Table", icon: () => , isActive: () => activeFormats().table, command: () => props.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, ] }, diff --git a/src/lib/mobile-indent.ts b/src/lib/mobile-indent.ts index f18f778..83bd7cd 100644 --- a/src/lib/mobile-indent.ts +++ b/src/lib/mobile-indent.ts @@ -34,12 +34,13 @@ export const MobileIndent = Extension.create({ if (!parentNode) return false; const parentType = parentNode.type.name; - const isListItem = parentType === 'taskItem' || parentType === 'listItem'; + const isListItem = parentType === 'taskItem' || parentType === 'multiTaskItem' || parentType === 'listItem'; if (!isListItem) return false; // Sink the list item (indent) const sunk = editor.chain().sinkListItem('taskItem').run() || + editor.chain().sinkListItem('multiTaskItem').run() || editor.chain().sinkListItem('listItem').run(); // Return true to prevent the space from being inserted @@ -64,7 +65,7 @@ export const MobileIndent = Extension.create({ if (!parentNode) return false; const parentType = parentNode.type.name; - const isListItem = parentType === 'taskItem' || parentType === 'listItem'; + const isListItem = parentType === 'taskItem' || parentType === 'multiTaskItem' || parentType === 'listItem'; if (!isListItem) return false; @@ -74,13 +75,14 @@ export const MobileIndent = Extension.create({ const greatGrandparent = $from.node(-3); // If grandparent is a list and greatGrandparent is also a list item, we're nested - const grandparentIsList = grandparent?.type.name === 'taskList' || grandparent?.type.name === 'bulletList' || grandparent?.type.name === 'orderedList'; - const greatGrandparentIsListItem = greatGrandparent?.type.name === 'taskItem' || greatGrandparent?.type.name === 'listItem'; + const grandparentIsList = grandparent?.type.name === 'taskList' || grandparent?.type.name === 'multiTaskList' || grandparent?.type.name === 'bulletList' || grandparent?.type.name === 'orderedList'; + const greatGrandparentIsListItem = greatGrandparent?.type.name === 'taskItem' || greatGrandparent?.type.name === 'multiTaskItem' || greatGrandparent?.type.name === 'listItem'; if (!grandparentIsList || !greatGrandparentIsListItem) return false; // Lift the list item (outdent) const lifted = editor.chain().liftListItem('taskItem').run() || + editor.chain().liftListItem('multiTaskItem').run() || editor.chain().liftListItem('listItem').run(); if (lifted) {