feat(*): one task editor, vim navigation and undo
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user