301 lines
7.9 KiB
TypeScript
301 lines
7.9 KiB
TypeScript
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();
|
|
}
|
|
|
|
/**
|
|
* Runs one action as if its key had been pressed, so a button drawn anywhere
|
|
* does exactly what the sheet says the key does.
|
|
*/
|
|
export function perform(view: ViewContext, action: Action): Promise<void> {
|
|
return run(view, action);
|
|
}
|
|
|
|
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) {
|
|
store.peeking = false;
|
|
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) {
|
|
store.peeking = false;
|
|
await editInTasks(view, task);
|
|
}
|
|
return;
|
|
case "taskPriority":
|
|
if (task) store.request = "priority";
|
|
return;
|
|
case "taskPeek":
|
|
store.peek();
|
|
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 "railToggleRow":
|
|
store.toggleRailRow();
|
|
return;
|
|
case "railLeave":
|
|
store.leaveRail();
|
|
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;
|
|
}
|
|
|
|
// The quick look is a lens, not a layer: every key still reaches the task
|
|
// underneath, and only Escape is taken to put the lens away.
|
|
if (store.peeking && event.key === "Escape") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
store.peeking = false;
|
|
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);
|
|
}
|