multi-checklist ready for use
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m5s

This commit is contained in:
2026-03-24 16:18:05 -05:00
parent 0f4d374658
commit d33a838aec
6 changed files with 772 additions and 4 deletions
+256
View File
@@ -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<ReturnType> {
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>'
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
},
}
}
},
})
+484
View File
@@ -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<ReturnType> {
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 = () => (
<div class="flex items-center gap-1">
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().toggleMultiTaskListColumnLabels().run()
}}
>
<MoreHorizontal size={14} />
</button>
<div class="flex flex-col gap-1">
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().addCheckboxColumnToMultiTaskList().run()
}}
>
<Plus size={14} />
</button>
<button
type="button"
class="h-7 w-7 p-0 rounded-xl border border-border/60 bg-background/95 hover:bg-muted text-muted-foreground hover:text-foreground shadow-md inline-flex items-center justify-center"
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
editor.chain().focus().removeCheckboxColumnFromMultiTaskList().run()
}}
>
<Minus size={14} />
</button>
</div>
</div>
)
const disposeControls = render(() => <ControlsMenu />, 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()
},
}
}
},
})
+9
View File
@@ -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.",
+15
View File
@@ -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<TaskEditorProps> = (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<TaskEditorProps> = (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",
+2
View File
@@ -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: () => <ListTodo size={16} />, isActive: () => activeFormats().checklist, command: () => props.editor.chain().focus().toggleTaskList().run() },
{ label: "Multi Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().multiChecklist, command: () => props.editor.chain().focus().toggleMultiTaskList().run() },
{ label: "Table", icon: () => <Grid3X3 size={16} />, isActive: () => activeFormats().table, command: () => props.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
]
},
+6 -4
View File
@@ -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) {