feat(*): one task editor, vim navigation and undo

This commit is contained in:
hh
2026-08-15 03:13:50 +02:00
parent 0873cbb366
commit 83ca32d98e
37 changed files with 4043 additions and 505 deletions
+268
View File
@@ -0,0 +1,268 @@
import { resolve } from "../lib/keymap";
import type { Action, Scope } from "../lib/keymap";
import { shiftDays, today } from "../lib/due";
import { step } from "../lib/grid";
import type { Direction } from "../lib/grid";
import { anchorDate, isOpen } from "../model/types";
import type { Task } from "../model/types";
import { redo, undo } from "../vault/history";
import { setDate, setStatus } from "../vault/mutate";
import { editInTasks, revealInFile } from "./task-menu";
import type { ViewContext } from "./context";
function isTyping(target: EventTarget | null): boolean {
const node = target as HTMLElement | null;
if (!node || typeof node.tagName !== "string") return false;
return (
node.tagName === "INPUT" ||
node.tagName === "TEXTAREA" ||
node.isContentEditable === true
);
}
function dateField(task: Task): "due" | "scheduled" {
return !task.due && task.scheduled ? "scheduled" : "due";
}
async function reschedule(
view: ViewContext,
task: Task,
key: string | null,
): Promise<void> {
await setDate(view.app, task, dateField(task), key);
view.store.followDate(key);
}
function shift(view: ViewContext, task: Task, days: number): Promise<void> {
return reschedule(view, task, shiftDays(anchorDate(task) ?? today(), days));
}
/**
* The undated tray wraps its cards, so it reads as a grid even though the DOM
* only knows a list. Measuring what is actually on screen is the one honest
* way to walk it.
*/
function stepTray(view: ViewContext, direction: Direction): void {
const { store } = view;
const cards = [
...document.querySelectorAll<HTMLElement>("[data-tray-card]"),
];
const current = cards.find(
(card) => card.dataset.trayCard === store.cursorTask,
);
if (!current) {
if (cards.length > 0) store.cursorTask = cards[0].dataset.trayCard ?? null;
return;
}
const next = step(cards, current, direction, (card) =>
card.getBoundingClientRect(),
);
if (next) {
store.cursorTask = next.dataset.trayCard ?? null;
return;
}
// Off the top of the tray is the way back into the month.
if (direction === "up") store.toggleUndated();
}
async function run(view: ViewContext, action: Action): Promise<void> {
const { app, store } = view;
const task = store.taskById(store.cursorTask);
const grid = store.view === "calendar";
switch (action) {
case "dayLeft":
if (grid) store.moveCursor(-1);
return;
case "dayRight":
if (grid) store.moveCursor(1);
return;
case "dayUp":
if (grid) store.moveCursor(-7);
return;
case "dayDown":
if (grid) store.moveCursor(7);
return;
case "dayEnter":
store.enterDay();
return;
case "taskNext":
store.stepTask(1);
return;
case "taskPrev":
store.stepTask(-1);
return;
case "taskExit":
if (grid) store.focusDay(store.cursorDay);
else store.cursorTask = null;
return;
case "taskOpen":
if (task) await revealInFile(view, task);
return;
case "taskToggle":
if (task) await setStatus(app, task, isOpen(task.status) ? "x" : " ");
return;
case "taskEdit":
if (task) store.edit(task.id);
return;
case "taskEditRaw":
if (task) await editInTasks(view, task);
return;
case "taskPriority":
if (task) store.request = "priority";
return;
case "taskBack":
if (task) await shift(view, task, -1);
return;
case "taskForward":
if (task) await shift(view, task, 1);
return;
case "taskWeekBack":
if (task) await shift(view, task, -7);
return;
case "taskWeekForward":
if (task) await shift(view, task, 7);
return;
case "taskToday":
if (task) await reschedule(view, task, today());
return;
case "taskUnschedule":
if (task) await reschedule(view, task, null);
return;
case "trayLeft":
stepTray(view, "left");
return;
case "trayRight":
stepTray(view, "right");
return;
case "trayUp":
stepTray(view, "up");
return;
case "trayDown":
stepTray(view, "down");
return;
case "railNext":
store.stepRail(1);
return;
case "railPrev":
store.stepRail(-1);
return;
case "railUnfold":
store.foldRail(true);
return;
case "railFold":
store.foldRail(false);
return;
case "railChoose":
store.chooseRail();
return;
case "create":
openComposer(view);
return;
case "undated":
store.toggleUndated();
return;
case "toggleRail":
store.toggleRail();
return;
case "undo":
await undo(app);
return;
case "redo":
await redo(app);
return;
case "today":
store.goToday();
return;
case "monthPrev":
store.stepMonth(-1);
return;
case "monthNext":
store.stepMonth(1);
return;
case "toggleView":
store.view = store.view === "calendar" ? "list" : "calendar";
store.leaveToMain();
store.savePrefs();
return;
case "toggleDone":
store.showDone = !store.showDone;
store.savePrefs();
return;
case "search":
view.searchInput()?.focus();
return;
case "help":
store.helpOpen = true;
return;
case "dismiss":
if (store.search) store.search = "";
else store.leaveToMain();
return;
}
}
/** Writing belongs on the day it writes into, wherever that day is drawn. */
function openComposer(view: ViewContext): void {
const { store } = view;
if (store.view !== "calendar") {
store.write(null, "[data-composer='toolbar']");
return;
}
// The month grid is not rendered on a narrow pane, so the panel hangs off
// the toolbar instead when the day it belongs to has no box on screen.
const selector = `[data-composer="day:${store.cursorDay}"]`;
const trigger = document.querySelector<HTMLElement>(selector);
store.write(
store.cursorDay,
trigger?.offsetParent ? selector : "[data-composer='toolbar']",
);
}
/** The list has no day grid, so its cursor lives on tasks from the first key. */
function scopesFor(view: ViewContext): Scope[] {
const { store } = view;
if (store.zone === "rail") return ["rail", "view"];
if (store.zone === "undated") return ["tray", "task", "view"];
if (store.view === "list") return ["task", "view"];
if (store.zone === "task") return ["task", "view"];
return ["day", "view"];
}
export function handleKey(view: ViewContext, event: KeyboardEvent): void {
const { store } = view;
if (event.defaultPrevented) return;
if (store.editing) return;
if (isTyping(event.target)) {
if (event.key === "Escape") (event.target as HTMLElement).blur();
return;
}
if (store.helpOpen) {
if (event.key === "Escape" || event.key === "?") {
event.preventDefault();
store.helpOpen = false;
}
return;
}
if (store.request) {
if (event.key === "Escape") store.request = null;
return;
}
const action = resolve(event, scopesFor(view));
if (!action) return;
event.preventDefault();
event.stopPropagation();
// The first key press is what earns the cursor its outline.
store.keyboard = true;
void run(view, action);
}