* Fix (tasks): Toggle dropdown on re-click and narrow mark decoration range
- Clicking the dropdown icon now dismisses the completion menu if it
is already open (`closeCompletion`).
- The `data-task-state` mark decoration now covers only the inner state
text (excluding brackets). This prevents CodeMirror cursor/editing
issues when the caret is inside the brackets and allows bracket-aware
CSS styling. Example:
```space-style
[data-task-state="DONE"] { color: green; }
[data-task-state="PLANNED"] { color: orange; }
[data-task-state] { font-weight: bold; }
```
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
* Refactor (tasks): Unify task state data attribute to `data-task-state`
Rename `data-state` to `data-task-state` on extended task state spans in
Markdown renderer and update the corresponding places.
Previously the editor (CodeMirror mark decoration) used
`data-task-state` while the widget HTML renderer used `data-state`,
requiring duplicate CSS selectors. Now a single attribute selector works
everywhere (for example tasks rendered using `template.taskItem` also
follow the styling now):
```css
[data-task-state="PLANNED"] { /* cool CSS here */; }
```
Note: Checkbox inputs (`[ ]`/`[x]`/`[X]`) still use `data-state` since
they follow a different code path.
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
---------
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
215 lines
6.3 KiB
TypeScript
215 lines
6.3 KiB
TypeScript
import { syntaxTree } from "@codemirror/language";
|
|
import {
|
|
startCompletion,
|
|
closeCompletion,
|
|
completionStatus,
|
|
} from "@codemirror/autocomplete";
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tiny widget placed right after an extended task's `]`.
|
|
* Click places cursor inside `[...]` and triggers autocomplete.
|
|
*/
|
|
class TaskDropdownWidget extends WidgetType {
|
|
constructor(
|
|
// Absolute position of the `[` (start of state content inside brackets)
|
|
readonly stateFrom: number,
|
|
// Absolute position of the `]` (end of TaskState node)
|
|
readonly stateTo: number,
|
|
readonly getView: () => EditorView | null,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
toDOM(): HTMLElement {
|
|
const span = document.createElement("span");
|
|
span.className = "sb-task-dropdown";
|
|
span.textContent = "\u25BE"; // Black Down-Pointing Small Triangle
|
|
span.addEventListener("mousedown", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
span.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const view = this.getView();
|
|
if (!view) return;
|
|
if (completionStatus(view.state)) {
|
|
closeCompletion(view);
|
|
return;
|
|
}
|
|
const cursorPos = this.stateTo - 1;
|
|
view.dispatch({
|
|
selection: { anchor: cursorPos },
|
|
});
|
|
view.focus();
|
|
startCompletion(view);
|
|
});
|
|
return span;
|
|
}
|
|
|
|
eq(other: TaskDropdownWidget): boolean {
|
|
return this.stateFrom === other.stateFrom && this.stateTo === other.stateTo;
|
|
}
|
|
}
|
|
|
|
export function taskListPlugin({
|
|
onCheckboxClick,
|
|
getView,
|
|
doneStates,
|
|
}: {
|
|
onCheckboxClick: (pos: number) => void;
|
|
getView: () => EditorView | null;
|
|
doneStates?: Set<string>;
|
|
}) {
|
|
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;
|
|
// Track TaskState end position for strikethrough start
|
|
let taskStateEnd = -1;
|
|
|
|
node.toTree().iterate({
|
|
enter: (ref) => iterateInner(ref.type, ref.from, ref.to),
|
|
});
|
|
|
|
if (checkboxStatus === true) {
|
|
// Skip whitespace after TaskState
|
|
let strikeFrom = taskStateEnd !== -1 ? taskStateEnd : from;
|
|
while (
|
|
strikeFrom < to &&
|
|
" \t".includes(state.sliceDoc(strikeFrom, strikeFrom + 1))
|
|
) {
|
|
strikeFrom++;
|
|
}
|
|
if (strikeFrom < to) {
|
|
widgets.push(
|
|
Decoration.mark({
|
|
tagName: "span",
|
|
class: "cm-task-checked",
|
|
}).range(strikeFrom, to),
|
|
);
|
|
}
|
|
}
|
|
|
|
function iterateInner(type: NodeType, nfrom: number, nto: number) {
|
|
if (type.name !== "TaskState") return;
|
|
taskStateEnd = from + nto;
|
|
const checkbox = state.sliceDoc(from + nfrom, from + nto);
|
|
if (checkbox === "[x]" || checkbox === "[X]") {
|
|
checkboxStatus = true;
|
|
} else if (checkbox === "[ ]") {
|
|
checkboxStatus = false;
|
|
}
|
|
if (checkboxStatus === undefined) {
|
|
const stateText = checkbox.slice(1, -1);
|
|
if (doneStates?.has(stateText)) {
|
|
checkboxStatus = true;
|
|
}
|
|
// Mark only the inner state text, not the brackets
|
|
widgets.push(
|
|
Decoration.mark({
|
|
attributes: { "data-task-state": stateText },
|
|
}).range(from + nfrom + 1, from + nto - 1),
|
|
);
|
|
// Always show dropdown
|
|
const absTo = from + nto;
|
|
widgets.push(
|
|
Decoration.widget({
|
|
widget: new TaskDropdownWidget(from + nfrom, absTo, getView),
|
|
side: 1,
|
|
}).range(absTo),
|
|
);
|
|
return;
|
|
}
|
|
if (isCursorInRange(state, [from + nfrom, from + nto])) 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);
|
|
});
|
|
}
|