import { Decoration, EditorView, WidgetType } from '@codemirror/view'; import { SyntaxNodeRef } from '@lezer/common'; import makeReplaceExtension from './utils/makeInlineReplaceExtension'; import toggleCheckboxAt from '../../utils/markdown/toggleCheckboxAt'; const checkboxClassName = 'cm-ext-checkbox-toggle'; class CheckboxWidget extends WidgetType { public constructor(private checked: boolean, private depth: number, private label: string) { super(); } public eq(other: CheckboxWidget) { return other.checked === this.checked && other.depth === this.depth && other.label === this.label; } private applyContainerClasses(container: HTMLElement) { container.classList.add(checkboxClassName); for (const className of [...container.classList]) { if (className.startsWith('-depth-')) { container.classList.remove(className); } } container.classList.add(`-depth-${this.depth}`); } public toDOM(view: EditorView) { const container = document.createElement('span'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = this.checked; checkbox.ariaLabel = this.label; checkbox.title = this.label; container.appendChild(checkbox); checkbox.oninput = () => { const checkboxPosition = view.posAtDOM(container); view.dispatch({ selection: { anchor: view.state.doc.lineAt(checkboxPosition).from }, userEvent: 'select.checkbox', }); toggleCheckboxAt(checkboxPosition)(view); }; this.applyContainerClasses(container); return container; } public updateDOM(dom: HTMLElement): boolean { this.applyContainerClasses(dom); const input = dom.querySelector('input'); if (input) { input.checked = this.checked; return true; } return false; } public ignoreEvent() { return false; } } const completedTaskClassName = 'cm-md-completed-item'; const completedListItemDecoration = Decoration.line({ class: completedTaskClassName, isFullLine: true }); const replaceCheckboxes = [ EditorView.theme({ [`& .${checkboxClassName}`]: { display: 'inline-flex', alignItems: 'center', verticalAlign: 'middle', transform: 'translateY(calc(-0.05em - 1px))', '& > input': { appearance: 'none', WebkitAppearance: 'none', fontSize: 'inherit', width: '1.15em', height: '1.15em', borderRadius: '50%', border: '1.5px solid var(--joplin-color-faded, #8a8a8e)', backgroundColor: 'transparent', margin: '0 8px 0 2px', verticalAlign: 'middle', cursor: 'pointer', outline: 'none', boxSizing: 'border-box', transition: 'all 0.18s cubic-bezier(0.4, 0, 0.2, 1)', position: 'relative', display: 'inline-block', '&:hover': { borderColor: 'var(--joplin-color-accent, #007aff)', backgroundColor: 'rgba(0, 122, 255, 0.08)', }, '&:checked': { backgroundColor: 'var(--joplin-color-accent, #007aff)', borderColor: 'var(--joplin-color-accent, #007aff)', backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none' stroke='white' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 7.5 5.8 10.3 11 4.5'%3E%3C/polyline%3E%3C/svg%3E")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: '75% 75%', animation: 'cm-checkbox-pop 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)', }, }, '&:not(.-depth-1) > input': { marginInlineStart: 0, }, }, '@keyframes cm-checkbox-pop': { '0%': { transform: 'scale(0.8)' }, '60%': { transform: 'scale(1.15)' }, '100%': { transform: 'scale(1)' }, }, [`& .${completedTaskClassName}`]: { textDecoration: 'line-through', textDecorationColor: 'var(--joplin-color-faded, #8a8a8e)', opacity: 0.55, transition: 'opacity 0.2s ease, text-decoration-color 0.2s ease', }, }), EditorView.domEventHandlers({ mousedown: (event) => { const target = event.target as Element; if (target.nodeName === 'INPUT' && target.parentElement?.classList?.contains(checkboxClassName)) { // Let the checkbox handle the event return true; } return false; }, }), makeReplaceExtension({ getRevealStrategy: (node, state) => { if (node.name === 'TaskMarker') { const container = node.node.parent?.parent; const listMarker = container?.getChild('ListMark'); // Intersection check logic similar to nodeIntersectsSelection but with custom range const selection = state.selection.main; const rangeFrom = listMarker ? listMarker.from : node.from; const rangeTo = node.to; if (selection.empty && selection.from === rangeFrom) return false; const rangeContains = (point: number) => point >= rangeFrom && point <= rangeTo; const selectionContains = (point: number) => point >= selection.from && point <= selection.to; // Reveal if cursor touches the checkbox or the list bullet point return rangeContains(selection.from) || rangeContains(selection.to) || selectionContains(rangeFrom) || selectionContains(rangeTo); } return 'line'; }, createDecoration: (node, state, parentTags) => { const markerIsChecked = (marker: SyntaxNodeRef) => { const content = state.doc.sliceString(marker.from, marker.to); return content.toLowerCase().indexOf('x') !== -1; }; if (node.name === 'TaskMarker') { const containerLine = state.doc.lineAt(node.from); const labelText = state.doc.sliceString(node.to, containerLine.to); return new CheckboxWidget(markerIsChecked(node), parentTags.get('ListItem') ?? 0, labelText); } else if (node.name === 'Task') { const marker = node.node.getChild('TaskMarker'); if (marker && markerIsChecked(marker)) { return completedListItemDecoration; } } return null; }, getDecorationRange: (node, state) => { if (node.name === 'TaskMarker') { const container = node.node.parent?.parent; const listMarker = container?.getChild('ListMark'); if (!listMarker) { return null; } return [listMarker.from, node.to]; } else if (node.name === 'Task') { const taskLine = state.doc.lineAt(node.from); return [taskLine.from]; } return null; }, }), ]; export default replaceCheckboxes;