multi-checklist ready for use
This commit is contained in:
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -63,6 +63,15 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
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",
|
title: "Code Block",
|
||||||
description: "Capture a code snippet.",
|
description: "Capture a code snippet.",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import StarterKit from "@tiptap/starter-kit";
|
|||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import { CustomTaskItem } from "./CustomTaskItem";
|
import { CustomTaskItem } from "./CustomTaskItem";
|
||||||
|
import { MultiTaskList } from "./MultiTaskList.tsx";
|
||||||
|
import { MultiTaskItem } from "./MultiTaskItem";
|
||||||
import { CustomImage as Image } from "@/lib/extensions/image";
|
import { CustomImage as Image } from "@/lib/extensions/image";
|
||||||
import Underline from "@tiptap/extension-underline";
|
import Underline from "@tiptap/extension-underline";
|
||||||
import { X } from "lucide-solid";
|
import { X } from "lucide-solid";
|
||||||
@@ -132,6 +134,17 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
class: "flex gap-2 items-start my-1",
|
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({
|
SlashCommands.configure({
|
||||||
suggestion,
|
suggestion,
|
||||||
}),
|
}),
|
||||||
@@ -262,6 +275,8 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
// Custom Task List Styling
|
// Custom Task List Styling
|
||||||
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
|
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
|
||||||
"[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start",
|
"[&_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]: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]: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",
|
"[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const TextBubbleMenu: Component<{ editor: Editor }> = (props) => {
|
|||||||
h2: editor.isActive('heading', { level: 2 }),
|
h2: editor.isActive('heading', { level: 2 }),
|
||||||
h3: editor.isActive('heading', { level: 3 }),
|
h3: editor.isActive('heading', { level: 3 }),
|
||||||
checklist: editor.isActive('taskList'),
|
checklist: editor.isActive('taskList'),
|
||||||
|
multiChecklist: editor.isActive('multiTaskList'),
|
||||||
table: editor.isActive('table'),
|
table: editor.isActive('table'),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -103,6 +104,7 @@ export const TextBubbleMenu: Component<{ editor: Editor }> = (props) => {
|
|||||||
name: "Blocks",
|
name: "Blocks",
|
||||||
actions: [
|
actions: [
|
||||||
{ label: "Checklist", icon: () => <ListTodo size={16} />, isActive: () => activeFormats().checklist, command: () => props.editor.chain().focus().toggleTaskList().run() },
|
{ 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() },
|
{ label: "Table", icon: () => <Grid3X3 size={16} />, isActive: () => activeFormats().table, command: () => props.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ export const MobileIndent = Extension.create({
|
|||||||
if (!parentNode) return false;
|
if (!parentNode) return false;
|
||||||
|
|
||||||
const parentType = parentNode.type.name;
|
const parentType = parentNode.type.name;
|
||||||
const isListItem = parentType === 'taskItem' || parentType === 'listItem';
|
const isListItem = parentType === 'taskItem' || parentType === 'multiTaskItem' || parentType === 'listItem';
|
||||||
|
|
||||||
if (!isListItem) return false;
|
if (!isListItem) return false;
|
||||||
|
|
||||||
// Sink the list item (indent)
|
// Sink the list item (indent)
|
||||||
const sunk = editor.chain().sinkListItem('taskItem').run() ||
|
const sunk = editor.chain().sinkListItem('taskItem').run() ||
|
||||||
|
editor.chain().sinkListItem('multiTaskItem').run() ||
|
||||||
editor.chain().sinkListItem('listItem').run();
|
editor.chain().sinkListItem('listItem').run();
|
||||||
|
|
||||||
// Return true to prevent the space from being inserted
|
// Return true to prevent the space from being inserted
|
||||||
@@ -64,7 +65,7 @@ export const MobileIndent = Extension.create({
|
|||||||
if (!parentNode) return false;
|
if (!parentNode) return false;
|
||||||
|
|
||||||
const parentType = parentNode.type.name;
|
const parentType = parentNode.type.name;
|
||||||
const isListItem = parentType === 'taskItem' || parentType === 'listItem';
|
const isListItem = parentType === 'taskItem' || parentType === 'multiTaskItem' || parentType === 'listItem';
|
||||||
|
|
||||||
if (!isListItem) return false;
|
if (!isListItem) return false;
|
||||||
|
|
||||||
@@ -74,13 +75,14 @@ export const MobileIndent = Extension.create({
|
|||||||
const greatGrandparent = $from.node(-3);
|
const greatGrandparent = $from.node(-3);
|
||||||
|
|
||||||
// If grandparent is a list and greatGrandparent is also a list item, we're nested
|
// 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 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 === 'listItem';
|
const greatGrandparentIsListItem = greatGrandparent?.type.name === 'taskItem' || greatGrandparent?.type.name === 'multiTaskItem' || greatGrandparent?.type.name === 'listItem';
|
||||||
|
|
||||||
if (!grandparentIsList || !greatGrandparentIsListItem) return false;
|
if (!grandparentIsList || !greatGrandparentIsListItem) return false;
|
||||||
|
|
||||||
// Lift the list item (outdent)
|
// Lift the list item (outdent)
|
||||||
const lifted = editor.chain().liftListItem('taskItem').run() ||
|
const lifted = editor.chain().liftListItem('taskItem').run() ||
|
||||||
|
editor.chain().liftListItem('multiTaskItem').run() ||
|
||||||
editor.chain().liftListItem('listItem').run();
|
editor.chain().liftListItem('listItem').run();
|
||||||
|
|
||||||
if (lifted) {
|
if (lifted) {
|
||||||
|
|||||||
Reference in New Issue
Block a user