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();
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user