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 { await setDate(view.app, task, dateField(task), key); view.store.followDate(key); } function shift(view: ViewContext, task: Task, days: number): Promise { 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("[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 { return run(view, action); } async function run(view: ViewContext, action: Action): Promise { 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(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); }