Files
plainleaf/client/codemirror/task.ts
T

132 lines
3.9 KiB
TypeScript

import { syntaxTree } from "@codemirror/language";
import { Decoration, type EditorView, WidgetType } from "@codemirror/view";
import type { NodeType } from "@lezer/common";
import { decoratorStateField, isCursorInRange } from "./util.ts";
/**
* Widget to render checkbox for a task list item.
*/
class CheckboxWidget extends WidgetType {
private dom?: HTMLElement;
constructor(
public checked: boolean,
readonly fallbackPos: number,
readonly clickCallback: (pos: number) => void,
readonly getView: () => EditorView | null,
) {
super();
}
toDOM(): HTMLElement {
const wrap = document.createElement("span");
wrap.classList.add("sb-checkbox");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = this.checked;
checkbox.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
});
checkbox.addEventListener("mouseup", (e) => {
e.stopPropagation();
// Resolve the current document position at click time which
// prevents stale position corruption when the document has been
// edited since the decoration was created
let pos = this.fallbackPos;
const view = this.getView();
if (view && this.dom) {
try {
const domPos = view.posAtDOM(this.dom, 0);
pos = domPos + 1;
} catch {
// Use fallback
}
}
this.clickCallback(pos);
});
// Touch handling for mobile
let touchCount = 0;
checkbox.addEventListener("touchmove", () => {
touchCount++;
});
checkbox.addEventListener("touchend", (e) => {
if (touchCount === 0) {
e.stopPropagation();
e.preventDefault();
let pos = this.fallbackPos;
const view = this.getView();
if (view && this.dom) {
try {
pos = view.posAtDOM(this.dom, 0) + 1;
} catch {
// use fallback
}
}
this.clickCallback(pos);
}
touchCount = 0;
});
wrap.appendChild(checkbox);
this.dom = wrap;
return wrap;
}
}
export function taskListPlugin({
onCheckboxClick,
getView,
}: {
onCheckboxClick: (pos: number) => void;
getView: () => EditorView | null;
}) {
return decoratorStateField((state) => {
const widgets: any[] = [];
syntaxTree(state).iterate({
enter({ type, from, to, node }) {
if (type.name !== "Task") return;
// true/false if this is a checkbox, undefined when it's a custom-status task
let checkboxStatus: boolean | undefined;
// Iterate inside the task node to find the checkbox
node.toTree().iterate({
enter: (ref) => iterateInner(ref.type, ref.from, ref.to),
});
if (checkboxStatus === true) {
widgets.push(
Decoration.mark({
tagName: "span",
class: "cm-task-checked",
}).range(from, to),
);
}
function iterateInner(type: NodeType, nfrom: number, nto: number) {
if (type.name !== "TaskState") return;
if (isCursorInRange(state, [from + nfrom, from + nto])) return;
const checkbox = state.sliceDoc(from + nfrom, from + nto);
// Checkbox is checked if it has a 'x' in between the []
if (checkbox === "[x]" || checkbox === "[X]") {
checkboxStatus = true;
} else if (checkbox === "[ ]") {
checkboxStatus = false;
}
if (checkboxStatus === undefined) {
// Not replacing it with a widget
return;
}
const dec = Decoration.replace({
widget: new CheckboxWidget(
checkboxStatus,
from + nfrom + 1,
onCheckboxClick,
getView,
),
});
widgets.push(dec.range(from + nfrom, from + nto));
}
},
});
return Decoration.set(widgets, true);
});
}