feat(*): one task editor, vim navigation and undo
This commit is contained in:
1 parent
0873cbb366
commit
83ca32d98e
37 files changed
+4043
-505
No files matched your search
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { Portal } from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
import { onScreen, place } from "../lib/place";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
/** CSS selector for whatever this should sit under. */
|
||||
anchor: string;
|
||||
/** Off when the panel's contents put the caret somewhere themselves. */
|
||||
autofocus?: boolean;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
onclose: () => void;
|
||||
onkeydown?: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
anchor,
|
||||
autofocus = true,
|
||||
children,
|
||||
class: className,
|
||||
onclose,
|
||||
onkeydown,
|
||||
}: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
|
||||
let panel = $state<HTMLElement | null>(null);
|
||||
let at = $state({ top: 0, left: 0 });
|
||||
let placed = $state(false);
|
||||
|
||||
/**
|
||||
* Placed by hand rather than by a floating layer: these panels are summoned
|
||||
* by a key press, and a floating layer wants a trigger element to hang off.
|
||||
*/
|
||||
function measure(): void {
|
||||
const node = panel;
|
||||
if (!node) return;
|
||||
|
||||
const boxes = [...document.querySelectorAll(anchor)].map((element) =>
|
||||
element.getBoundingClientRect(),
|
||||
);
|
||||
at = place(onScreen(boxes), node.getBoundingClientRect(), {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
placed = true;
|
||||
}
|
||||
|
||||
function mount(node: HTMLElement) {
|
||||
panel = node;
|
||||
measure();
|
||||
if (autofocus) {
|
||||
(node.querySelector<HTMLElement>("input") ?? node).focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
const outside = (event: PointerEvent): void => {
|
||||
if (!node.contains(event.target as Node)) onclose();
|
||||
};
|
||||
const reflow = (): void => measure();
|
||||
document.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("resize", reflow);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener("pointerdown", outside, true);
|
||||
window.removeEventListener("resize", reflow);
|
||||
panel = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function keys(event: KeyboardEvent): void {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape" && !event.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
onclose();
|
||||
return;
|
||||
}
|
||||
onkeydown?.(event);
|
||||
}
|
||||
|
||||
/** Re-measures once the body has grown or shrunk under the cursor. */
|
||||
export function reposition(): void {
|
||||
measure();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Portal to={view.portal()}>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class={cn(
|
||||
"fixed z-50 overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/45%)] outline-none",
|
||||
className,
|
||||
)}
|
||||
onkeydown={keys}
|
||||
role="dialog"
|
||||
style="top: {at.top}px; left: {at.left}px; opacity: {placed ? 1 : 0}"
|
||||
tabindex="-1"
|
||||
use:mount
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</Portal>
|
||||
+47
-53
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Portal } from "bits-ui";
|
||||
import type { App, Component } from "obsidian";
|
||||
import type { BeaverCalendarSettings } from "../settings";
|
||||
import type { BoardStore } from "../vault/store.svelte";
|
||||
import { createTask } from "../vault/mutate";
|
||||
import { provideView } from "./context";
|
||||
import CursorPicker from "./cursor-picker.svelte";
|
||||
import Shortcuts from "./shortcuts.svelte";
|
||||
import SphereRail from "./sphere-rail.svelte";
|
||||
import TaskCalendar from "./task-calendar.svelte";
|
||||
import TaskEditor from "./task-editor.svelte";
|
||||
import TaskList from "./task-list.svelte";
|
||||
import Toolbar from "./toolbar.svelte";
|
||||
import { handleKey } from "./keyboard";
|
||||
|
||||
interface Props {
|
||||
app: App;
|
||||
@@ -24,79 +28,61 @@
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "bcal-root bcal-portal";
|
||||
|
||||
let root = $state<HTMLElement | null>(null);
|
||||
let search = $state<HTMLInputElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
document.body.append(layer);
|
||||
return () => layer.remove();
|
||||
});
|
||||
|
||||
provideView({
|
||||
const view = provideView({
|
||||
app,
|
||||
store,
|
||||
settings,
|
||||
component,
|
||||
portal: () => layer,
|
||||
searchInput: () => search,
|
||||
registerSearch: (node) => {
|
||||
search = node;
|
||||
},
|
||||
focusRoot: () => root?.focus({ preventScroll: true }),
|
||||
});
|
||||
|
||||
let creating = $state(false);
|
||||
let title = $state("");
|
||||
// Bound by hand rather than with onkeydown so the container stays a plain
|
||||
// element: the view has just been opened, so it may as well answer keys.
|
||||
$effect(() => {
|
||||
const node = root;
|
||||
if (!node) return;
|
||||
|
||||
const scope = $derived.by(() => {
|
||||
const sphere =
|
||||
store.spheres.find((item) => item.path === store.sphereFilter) ??
|
||||
store.spheres[0];
|
||||
if (!sphere) return null;
|
||||
const project =
|
||||
(store.projectFilter && sphere.projects.includes(store.projectFilter)
|
||||
? store.projectFilter
|
||||
: undefined) ?? sphere.projects[0];
|
||||
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
|
||||
const onkeydown = (event: KeyboardEvent): void => handleKey(view, event);
|
||||
// A pointer puts the cursor chrome away, and takes the keyboard back from
|
||||
// whatever outside the view was holding it.
|
||||
const onpointerdown = (event: PointerEvent): void => {
|
||||
store.keyboard = false;
|
||||
if (node.contains(document.activeElement)) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest("input, textarea, [contenteditable='true']")) return;
|
||||
node.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
node.addEventListener("keydown", onkeydown);
|
||||
node.addEventListener("pointerdown", onpointerdown, true);
|
||||
node.focus({ preventScroll: true });
|
||||
return () => {
|
||||
node.removeEventListener("keydown", onkeydown);
|
||||
node.removeEventListener("pointerdown", onpointerdown, true);
|
||||
};
|
||||
});
|
||||
|
||||
async function commit() {
|
||||
const text = title.trim();
|
||||
title = "";
|
||||
creating = false;
|
||||
if (!text || !scope) return;
|
||||
await createTask(app, scope.path, scope.project, text, null);
|
||||
}
|
||||
|
||||
function focus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bcal-root">
|
||||
<div bind:this={root} class="bcal-root outline-none" tabindex="-1">
|
||||
<div class="bcal-app">
|
||||
{#if !store.railCollapsed}
|
||||
<SphereRail />
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<Toolbar
|
||||
oncreate={() => {
|
||||
creating = true;
|
||||
title = "";
|
||||
}}
|
||||
/>
|
||||
{#if creating}
|
||||
<div class="shrink-0 border-border border-b px-3 py-2">
|
||||
<input
|
||||
class="h-8 w-full rounded-md border border-ring bg-background px-2 text-sm outline-none"
|
||||
onblur={commit}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === "Enter") void commit();
|
||||
else if (event.key === "Escape") {
|
||||
creating = false;
|
||||
title = "";
|
||||
}
|
||||
}}
|
||||
placeholder={scope
|
||||
? `Task in ${scope.sphere} / ${scope.project}`
|
||||
: "Create a board in the boards folder first"}
|
||||
use:focus
|
||||
bind:value={title}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<Toolbar />
|
||||
{#if store.view === "calendar"}
|
||||
<TaskCalendar />
|
||||
{:else}
|
||||
@@ -104,4 +90,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<CursorPicker />
|
||||
<TaskEditor />
|
||||
</div>
|
||||
|
||||
{#if store.helpOpen}
|
||||
<Portal to={layer}>
|
||||
<Shortcuts />
|
||||
</Portal>
|
||||
{/if}
|
||||
+7
-1
@@ -9,12 +9,18 @@ export interface ViewContext {
|
||||
settings: BeaverCalendarSettings;
|
||||
component: Component;
|
||||
portal: () => HTMLElement;
|
||||
/** The toolbar search box, so `/` can reach it from anywhere in the view. */
|
||||
searchInput: () => HTMLInputElement | null;
|
||||
registerSearch: (node: HTMLInputElement | null) => void;
|
||||
/** Hands the keyboard back to the view after a layer above it closes. */
|
||||
focusRoot: () => void;
|
||||
}
|
||||
|
||||
const KEY = Symbol("beaver-calendar");
|
||||
|
||||
export function provideView(context: ViewContext): void {
|
||||
export function provideView(context: ViewContext): ViewContext {
|
||||
setContext(KEY, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useView(): ViewContext {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { combos, listStep } from "../lib/keymap";
|
||||
import { PRIORITY_LABEL, PRIORITY_ORDER } from "../lib/vocab";
|
||||
import type { Priority } from "../model/types";
|
||||
import { setPriority } from "../vault/mutate";
|
||||
import AnchoredPanel from "./anchored-panel.svelte";
|
||||
import OptionList from "./option-list.svelte";
|
||||
import type { Option } from "./option-list.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
let list = $state<OptionList | null>(null);
|
||||
|
||||
const task = $derived(store.taskById(store.cursorTask));
|
||||
const open = $derived(store.request !== null && task !== null);
|
||||
|
||||
// A request whose task went away would otherwise sit there with nothing on
|
||||
// screen, and the view swallows every key while one is open.
|
||||
$effect(() => {
|
||||
if (store.request && !task) close();
|
||||
});
|
||||
|
||||
const priorities = $derived<Option[]>(
|
||||
PRIORITY_ORDER.map((value) => ({
|
||||
value: String(value),
|
||||
label: PRIORITY_LABEL[value],
|
||||
})),
|
||||
);
|
||||
|
||||
function close(): void {
|
||||
store.request = null;
|
||||
view.focusRoot();
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent): void {
|
||||
// Nothing here is typed into, so the bare vim keys are free to steer.
|
||||
const pressed = combos(event);
|
||||
const step =
|
||||
listStep(event) ??
|
||||
(pressed.includes("j") ? 1 : pressed.includes("k") ? -1 : null);
|
||||
|
||||
if (step !== null) {
|
||||
event.preventDefault();
|
||||
list?.move(step);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
list?.commit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open && task}
|
||||
<AnchoredPanel
|
||||
anchor="[data-cursor='true']"
|
||||
class="w-56 p-1"
|
||||
onclose={close}
|
||||
{onkeydown}
|
||||
>
|
||||
<OptionList
|
||||
bind:this={list}
|
||||
onchoose={(option) => {
|
||||
void setPriority(view.app, task, Number(option.value) as Priority);
|
||||
close();
|
||||
}}
|
||||
options={priorities}
|
||||
value={String(task.priority)}
|
||||
/>
|
||||
</AnchoredPanel>
|
||||
{/if}
|
||||
@@ -1,139 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||
import Repeat from "@lucide/svelte/icons/repeat";
|
||||
import { Popover } from "bits-ui";
|
||||
import { dueLabel, dueTone, nextFriday, shiftDays, today } from "../lib/due";
|
||||
import { cn } from "../lib/utils";
|
||||
import DayGrid from "./day-grid.svelte";
|
||||
import type { DayTone } from "./day-grid.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
field?: "due" | "scheduled";
|
||||
onclear: () => void;
|
||||
onpick: (key: string) => void;
|
||||
onrepeat?: (rule: string | null) => void;
|
||||
recurrence?: string | null;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
value,
|
||||
recurrence = null,
|
||||
onpick,
|
||||
onclear,
|
||||
onrepeat,
|
||||
field = "due",
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
let open = $state(false);
|
||||
|
||||
const tone = $derived(dueTone(value));
|
||||
const label = $derived(dueLabel(value));
|
||||
|
||||
const RULES = [
|
||||
{ label: "None", value: "" },
|
||||
{ label: "Daily", value: "every day" },
|
||||
{ label: "Weekly", value: "every week" },
|
||||
{ label: "Monthly", value: "every month" },
|
||||
];
|
||||
|
||||
const QUICK = $derived([
|
||||
{ label: "Today", key: today() },
|
||||
{ label: "Tomorrow", key: shiftDays(today(), 1) },
|
||||
{ label: "Friday", key: nextFriday() },
|
||||
{ label: "In a week", key: shiftDays(today(), 7) },
|
||||
]);
|
||||
|
||||
function dayTone(key: string): DayTone {
|
||||
return key === value ? "solo" : "none";
|
||||
}
|
||||
|
||||
function pick(key: string) {
|
||||
onpick(key);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
aria-label={field === "due" ? "Due" : "Scheduled"}
|
||||
class={cn(
|
||||
"inline-flex h-6 items-center gap-1 rounded-md px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
|
||||
tone === "overdue" && "text-destructive",
|
||||
tone === "soon" && "text-status-snooze",
|
||||
tone === "none" && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
data-nodrag
|
||||
>
|
||||
{#if recurrence}
|
||||
<Repeat aria-hidden="true" class="size-3" />
|
||||
{:else}
|
||||
<CalendarDays aria-hidden="true" class="size-3" />
|
||||
{/if}
|
||||
{#if label}
|
||||
<span class="tabular">{label}</span>
|
||||
{/if}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal to={view.portal()}>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class="z-50 w-fit rounded-lg border border-border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-1 pb-2">
|
||||
{#each QUICK as quick (quick.label)}
|
||||
<button
|
||||
class="h-7 rounded-md border border-border px-2 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => pick(quick.key)}
|
||||
type="button"
|
||||
>
|
||||
{quick.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<DayGrid anchor={value} onpick={pick} tone={dayTone} />
|
||||
|
||||
{#if onrepeat}
|
||||
{@const known = RULES.some((rule) => rule.value === (recurrence ?? ""))}
|
||||
<div class="mt-2 border-border border-t pt-2">
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
{#each RULES as rule (rule.value)}
|
||||
<button
|
||||
aria-pressed={(recurrence ?? "") === rule.value}
|
||||
class="h-7 rounded-md border border-border px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-secondary aria-pressed:text-foreground"
|
||||
onclick={() => onrepeat?.(rule.value || null)}
|
||||
type="button"
|
||||
>
|
||||
{rule.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if recurrence && !known}
|
||||
<p class="pt-1.5 text-center text-muted-foreground text-[11px]">
|
||||
Custom rule: {recurrence}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if value}
|
||||
<button
|
||||
class="mt-2 h-7 w-full rounded-md text-muted-foreground text-xs outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => {
|
||||
onclear();
|
||||
open = false;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Clear date
|
||||
</button>
|
||||
{/if}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { laneKey, parseLaneKey } from "../lib/keys";
|
||||
import { cn } from "../lib/utils";
|
||||
import Picker from "./picker.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onpick: (path: string, project: string) => void;
|
||||
path: string;
|
||||
project: string;
|
||||
trigger: Snippet<[{ open: boolean }]>;
|
||||
}
|
||||
|
||||
let { path, project, onpick, trigger, class: className }: Props = $props();
|
||||
|
||||
const renderTrigger = $derived(trigger);
|
||||
|
||||
const view = useView();
|
||||
|
||||
const options = $derived(
|
||||
view.store.spheres.flatMap((sphere) =>
|
||||
sphere.projects.map((name) => ({
|
||||
label: name,
|
||||
hint: sphere.name,
|
||||
value: laneKey(sphere.path, name),
|
||||
})),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Picker
|
||||
label="Move to"
|
||||
onselect={(value) => {
|
||||
const lane = parseLaneKey(value);
|
||||
if (lane) onpick(lane.path, lane.project);
|
||||
}}
|
||||
{options}
|
||||
searchable
|
||||
triggerClass={cn("max-w-full", className)}
|
||||
value={laneKey(path, project)}
|
||||
>
|
||||
{#snippet trigger(state)}
|
||||
{@render renderTrigger(state)}
|
||||
{/snippet}
|
||||
</Picker>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts" module>
|
||||
import type { Component } from "svelte";
|
||||
|
||||
export interface Option {
|
||||
color?: string;
|
||||
hint?: string;
|
||||
icon?: Component;
|
||||
label: string;
|
||||
/** A coloured dot, used where an icon would be noise. */
|
||||
dot?: string;
|
||||
value: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import { pieces, rank } from "../lib/fuzzy";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
empty?: string;
|
||||
onchoose: (option: Option) => void;
|
||||
options: Option[];
|
||||
query?: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
options,
|
||||
query = "",
|
||||
value = null,
|
||||
onchoose,
|
||||
empty = "Nothing found",
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
// null means "wherever the current value is", so a list that was never
|
||||
// touched commits back to itself instead of jumping to the first row.
|
||||
let active = $state<number | null>(null);
|
||||
let seen = $state<string | null>(null);
|
||||
let box = $state<HTMLElement | null>(null);
|
||||
|
||||
const shown = $derived(rank(query, options, (option) => option.label));
|
||||
const index = $derived.by(() => {
|
||||
if (shown.length === 0) return 0;
|
||||
if (active !== null) return Math.min(active, shown.length - 1);
|
||||
const at = shown.findIndex((hit) => hit.item.value === value);
|
||||
return at === -1 ? 0 : at;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (query !== seen) {
|
||||
seen = query;
|
||||
active = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void index;
|
||||
box
|
||||
?.querySelector<HTMLElement>("[data-active='true']")
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
export function move(delta: number): void {
|
||||
if (shown.length === 0) return;
|
||||
// Wrapping keeps a long hold on ⌃n from dead-ending at the last match.
|
||||
active = (index + delta + shown.length) % shown.length;
|
||||
}
|
||||
|
||||
export function commit(): boolean {
|
||||
const option = shown[index]?.item;
|
||||
if (!option) return false;
|
||||
onchoose(option);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function highlighted(): Option | null {
|
||||
return shown[index]?.item ?? null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={box} class={cn("max-h-56 overflow-y-auto", className)}>
|
||||
{#each shown as { item, ranges }, at (item.value)}
|
||||
<button
|
||||
class={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
|
||||
at === index && "bg-muted",
|
||||
)}
|
||||
data-active={at === index}
|
||||
onclick={() => onchoose(item)}
|
||||
onmouseenter={() => {
|
||||
active = at;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if item.dot}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full"
|
||||
style="background: {item.dot}"
|
||||
></span>
|
||||
{:else if item.icon}
|
||||
<item.icon
|
||||
aria-hidden="true"
|
||||
class="size-4 shrink-0"
|
||||
style={item.color ? `color: ${item.color}` : undefined}
|
||||
/>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{#each pieces(item.label, ranges) as piece, part (part)}<span
|
||||
class={piece.hit ? "text-signal" : undefined}>{piece.text}</span
|
||||
>{/each}
|
||||
</span>
|
||||
{#if item.hint}
|
||||
<span class="shrink-0 text-muted-foreground text-xs">{item.hint}</span>
|
||||
{/if}
|
||||
{#if item.value === value}
|
||||
<Check aria-hidden="true" class="size-3.5 shrink-0 text-signal" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="px-2 py-3 text-center text-muted-foreground text-xs">{empty}</p>
|
||||
{/each}
|
||||
</div>
|
||||
+22
-65
@@ -1,18 +1,14 @@
|
||||
<script lang="ts" module>
|
||||
export interface PickerOption {
|
||||
color?: string;
|
||||
hint?: string;
|
||||
icon?: Component;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
export type { Option as PickerOption } from "./option-list.svelte";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import { Popover } from "bits-ui";
|
||||
import type { Component, Snippet } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import { listStep } from "../lib/keymap";
|
||||
import { cn } from "../lib/utils";
|
||||
import OptionList from "./option-list.svelte";
|
||||
import type { Option } from "./option-list.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
@@ -20,7 +16,7 @@
|
||||
label: string;
|
||||
onselect: (value: string) => void;
|
||||
open?: boolean;
|
||||
options: PickerOption[];
|
||||
options: Option[];
|
||||
searchable?: boolean;
|
||||
title?: string;
|
||||
trigger: Snippet<[{ open: boolean }]>;
|
||||
@@ -46,36 +42,25 @@
|
||||
const view = useView();
|
||||
|
||||
let query = $state("");
|
||||
let active = $state(0);
|
||||
|
||||
const shown = $derived(
|
||||
query.trim()
|
||||
? options.filter((option) =>
|
||||
option.label.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
)
|
||||
: options,
|
||||
);
|
||||
let list = $state<OptionList | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
query = "";
|
||||
active = 0;
|
||||
}
|
||||
if (!isOpen) query = "";
|
||||
});
|
||||
|
||||
function choose(option: PickerOption) {
|
||||
function choose(option: Option) {
|
||||
onselect(option.value);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
const step = listStep(event);
|
||||
if (step !== null) {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowDown" ? 1 : -1;
|
||||
active = Math.max(0, Math.min(shown.length - 1, active + delta));
|
||||
} else if (event.key === "Enter" && shown[active]) {
|
||||
list?.move(step);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
choose(shown[active]);
|
||||
list?.commit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -112,42 +97,14 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if options.length > 0}
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each shown as option, index (option.value)}
|
||||
<button
|
||||
class={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted",
|
||||
index === active && "bg-muted",
|
||||
)}
|
||||
onclick={() => choose(option)}
|
||||
onmouseenter={() => {
|
||||
active = index;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if option.icon}
|
||||
<option.icon
|
||||
aria-hidden="true"
|
||||
class="size-4 shrink-0"
|
||||
style={option.color ? `color: ${option.color}` : undefined}
|
||||
/>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
{#if option.hint}
|
||||
<span class="shrink-0 text-muted-foreground text-xs tabular">
|
||||
{option.hint}
|
||||
</span>
|
||||
{/if}
|
||||
{#if option.value === value}
|
||||
<Check aria-hidden="true" class="size-3.5 shrink-0 text-signal" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="px-2 py-3 text-center text-muted-foreground text-xs">
|
||||
Nothing found
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
<OptionList
|
||||
bind:this={list}
|
||||
class="max-h-64"
|
||||
onchoose={choose}
|
||||
{options}
|
||||
{query}
|
||||
{value}
|
||||
/>
|
||||
{/if}
|
||||
{#if footer}
|
||||
<div class={options.length > 0 ? "mt-1 border-border border-t pt-1" : ""}>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { GROUPS, SHORTCUTS } from "../lib/keymap";
|
||||
import type { Group } from "../lib/keymap";
|
||||
import { useView } from "./context";
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
function close(): void {
|
||||
store.helpOpen = false;
|
||||
}
|
||||
|
||||
function grab(node: HTMLElement) {
|
||||
node.focus({ preventScroll: true });
|
||||
return { destroy: () => view.focusRoot() };
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent): void {
|
||||
if (event.key !== "Escape" && event.key !== "?") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
}
|
||||
|
||||
function rows(group: Group) {
|
||||
return SHORTCUTS.filter((shortcut) => shortcut.group === group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only Moving needs the qualifier: it is the one group where the same key
|
||||
* does two things. The other groups say their scope in the heading.
|
||||
*/
|
||||
function scope(shortcut: (typeof SHORTCUTS)[number]): string {
|
||||
if (shortcut.group !== "Moving") return "";
|
||||
if (shortcut.scopes.includes("task")) return "on a task";
|
||||
if (shortcut.scopes.includes("day")) return "on a day";
|
||||
return "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 grid place-items-center bg-black/45 p-4 outline-none backdrop-blur-[2px]"
|
||||
onclick={close}
|
||||
{onkeydown}
|
||||
role="presentation"
|
||||
tabindex="-1"
|
||||
transition:fade={{ duration: 120 }}
|
||||
use:grab
|
||||
>
|
||||
<div
|
||||
class="max-h-full w-full max-w-5xl overflow-y-auto rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_24px_64px_-16px_rgb(0_0_0/55%)] @container"
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={() => {}}
|
||||
role="presentation"
|
||||
transition:fly={{ duration: 180, easing: cubicOut, y: 8 }}
|
||||
>
|
||||
<div class="flex items-baseline gap-3 px-5 pt-4 pb-3">
|
||||
<h2 class="font-semibold text-base">Keyboard</h2>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
The vim block reads the physical key, so it answers on a Cyrillic layout too.
|
||||
</p>
|
||||
<button
|
||||
class="ml-auto shrink-0 rounded-md px-2 py-1 text-muted-foreground text-xs outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={close}
|
||||
type="button"
|
||||
>
|
||||
esc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="gap-x-8 px-5 pb-5 @min-[34rem]:columns-2 @min-[58rem]:columns-3">
|
||||
{#each GROUPS as group (group)}
|
||||
<section class="mb-4 min-w-0 break-inside-avoid">
|
||||
<h3
|
||||
class="pb-1.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-[0.09em]"
|
||||
>
|
||||
{group}
|
||||
</h3>
|
||||
<dl class="grid grid-cols-[1fr_auto] items-center gap-x-3">
|
||||
{#each rows(group) as shortcut, index (`${shortcut.label}-${index}`)}
|
||||
<dt
|
||||
class="min-w-0 truncate border-border/40 border-t py-1 text-xs first:border-t-0"
|
||||
title={shortcut.label}
|
||||
>
|
||||
{shortcut.label}
|
||||
{#if scope(shortcut)}
|
||||
<span class="text-muted-foreground/70">{scope(shortcut)}</span>
|
||||
{/if}
|
||||
</dt>
|
||||
<dd
|
||||
class="flex shrink-0 items-center gap-1 border-border/40 border-t py-1 [&:nth-child(2)]:border-t-0"
|
||||
>
|
||||
{#each shortcut.keys as key (key)}
|
||||
<kbd class="kbd">{key}</kbd>
|
||||
{/each}
|
||||
</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,14 +10,29 @@
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
let expanded = $state<Set<string>>(new Set());
|
||||
let box = $state<HTMLElement | null>(null);
|
||||
|
||||
const zone = $derived(dropZone());
|
||||
/** The rail only draws a cursor while the keyboard is the one steering. */
|
||||
const steering = $derived(store.keyboard && store.zone === "rail");
|
||||
|
||||
$effect(() => {
|
||||
void store.cursorRail;
|
||||
if (!steering) return;
|
||||
box
|
||||
?.querySelector<HTMLElement>("[data-rail-cursor='true']")
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
function here(id: string): boolean {
|
||||
return steering && store.cursorRail === id;
|
||||
}
|
||||
|
||||
function toggle(path: string) {
|
||||
const next = new Set(expanded);
|
||||
const next = new Set(store.railExpanded);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
expanded = next;
|
||||
store.railExpanded = next;
|
||||
}
|
||||
|
||||
function selectSphere(path: string) {
|
||||
@@ -29,14 +44,21 @@
|
||||
store.sphereFilter = path;
|
||||
store.projectFilter = store.projectFilter === project ? null : project;
|
||||
}
|
||||
|
||||
const CURSOR = "bg-secondary/70 inset-ring-1 inset-ring-ring";
|
||||
</script>
|
||||
|
||||
<nav
|
||||
bind:this={box}
|
||||
class="flex w-52 shrink-0 flex-col overflow-y-auto border-border border-r bg-sidebar/50 py-2"
|
||||
>
|
||||
<button
|
||||
aria-current={store.sphereFilter === null}
|
||||
class="mx-2 flex h-7 items-center rounded-md px-2 text-left font-medium text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary"
|
||||
class={cn(
|
||||
"mx-2 flex h-7 items-center rounded-md px-2 text-left font-medium text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary",
|
||||
here("all") && CURSOR,
|
||||
)}
|
||||
data-rail-cursor={here("all")}
|
||||
onclick={() => {
|
||||
store.sphereFilter = null;
|
||||
store.projectFilter = null;
|
||||
@@ -48,15 +70,18 @@
|
||||
</button>
|
||||
|
||||
{#each store.spheres as sphere (sphere.path)}
|
||||
{@const open = expanded.has(sphere.path)}
|
||||
{@const open = store.railExpanded.has(sphere.path)}
|
||||
{@const target = dropTarget.sphere(sphere.path)}
|
||||
{@const row = `sphere:${sphere.path}`}
|
||||
<div class="mt-1 px-2">
|
||||
<div
|
||||
class={cn(
|
||||
"flex h-7 items-center gap-0.5 rounded-md transition-colors",
|
||||
here(row) && CURSOR,
|
||||
zone?.target === target && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||
)}
|
||||
data-drop={target}
|
||||
data-rail-cursor={here(row)}
|
||||
>
|
||||
<button
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
@@ -87,14 +112,17 @@
|
||||
<div class="flex flex-col pt-0.5 pl-5">
|
||||
{#each sphere.projects as project (project)}
|
||||
{@const laneTarget = dropTarget.lane(sphere.path, project)}
|
||||
{@const laneRow = `lane:${laneKey(sphere.path, project)}`}
|
||||
<button
|
||||
aria-current={store.sphereFilter === sphere.path &&
|
||||
store.projectFilter === project}
|
||||
class={cn(
|
||||
"flex h-6 items-center gap-1.5 rounded-md px-1 text-left text-xs outline-none transition-colors hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring aria-[current=true]:bg-secondary",
|
||||
here(laneRow) && CURSOR,
|
||||
zone?.target === laneTarget && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||
)}
|
||||
data-drop={laneTarget}
|
||||
data-rail-cursor={here(laneRow)}
|
||||
onclick={() => selectProject(sphere.path, project)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
+59
-51
@@ -10,7 +10,7 @@
|
||||
import { labelHue } from "../lib/hue";
|
||||
import { cn } from "../lib/utils";
|
||||
import { STATUS_META, WEEKDAYS } from "../lib/vocab";
|
||||
import { createTask, setStatus } from "../vault/mutate";
|
||||
import { setStatus } from "../vault/mutate";
|
||||
import ContextArea from "./context-area.svelte";
|
||||
import RowMenu from "./row-menu.svelte";
|
||||
import { dropTarget, handleDrop } from "./drop";
|
||||
@@ -21,45 +21,45 @@
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
let addingDay = $state<string | null>(null);
|
||||
let dayTitle = $state("");
|
||||
let grid = $state<HTMLElement | null>(null);
|
||||
|
||||
const zone = $derived(dropZone());
|
||||
const onDay = $derived(store.zone === "day" && store.keyboard);
|
||||
|
||||
const scope = $derived.by(() => {
|
||||
const sphere =
|
||||
store.spheres.find((item) => item.path === store.sphereFilter) ??
|
||||
store.spheres[0];
|
||||
if (!sphere) return null;
|
||||
const project =
|
||||
(store.projectFilter && sphere.projects.includes(store.projectFilter)
|
||||
? store.projectFilter
|
||||
: undefined) ?? sphere.projects[0];
|
||||
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
|
||||
// Keep whatever the cursor is on inside the scroll port, wherever it moved to.
|
||||
$effect(() => {
|
||||
void store.cursorDay;
|
||||
void store.cursorTask;
|
||||
void store.zone;
|
||||
grid
|
||||
?.querySelector<HTMLElement>("[data-cursor='true'], [data-here='true']")
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
async function addOnDay(key: string) {
|
||||
const title = dayTitle.trim();
|
||||
dayTitle = "";
|
||||
addingDay = null;
|
||||
if (!title || !scope) return;
|
||||
await createTask(view.app, scope.path, scope.project, title, key);
|
||||
}
|
||||
|
||||
function focus(node: HTMLInputElement) {
|
||||
node.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet card(task: Task)}
|
||||
{#snippet card(task: Task, inTray = false)}
|
||||
{@const meta = STATUS_META[task.status]}
|
||||
{@const on = store.cursorTask === task.id && store.zone !== "day"}
|
||||
{@const here = on && store.keyboard}
|
||||
<ContextArea class="contents" items={taskMenu(view, task)}>
|
||||
<!-- Selecting by pointer mirrors the keyboard cursor; every action it
|
||||
unlocks also has its own control inside the card. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={cn(
|
||||
"group/card flex min-w-0 cursor-grab select-none flex-col gap-1 rounded-md border border-border bg-card p-1.5 transition-colors hover:border-border/90 hover:bg-secondary/40",
|
||||
!anchorDate(task) && "border-dashed hover:border-solid",
|
||||
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
||||
here && "border-ring bg-secondary/50 opacity-100 ring-1 ring-ring",
|
||||
)}
|
||||
data-cursor={on}
|
||||
data-tray-card={inTray ? task.id : undefined}
|
||||
onpointerdown={() => {
|
||||
const key = anchorDate(task);
|
||||
store.cursorTask = task.id;
|
||||
store.zone = key ? "task" : "undated";
|
||||
if (key) store.cursorDay = key;
|
||||
}}
|
||||
use:draggable={{
|
||||
payload: () => ({ id: task.id, kind: "task" }),
|
||||
ondrop: (to, after) => void handleDrop(view, task, to, after),
|
||||
@@ -125,15 +125,23 @@
|
||||
|
||||
{#snippet dayCell(day: DaySlot)}
|
||||
{@const target = dropTarget.day(day.key)}
|
||||
{@const here = onDay && store.cursorDay === day.key}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={cn(
|
||||
"group/day flex min-h-[7rem] min-w-0 flex-col gap-1 border-border/40 border-r border-b p-1.5 transition-colors last:border-r-0",
|
||||
day.inMonth ? "bg-transparent" : "bg-secondary/25",
|
||||
day.isToday && "bg-primary/[0.06]",
|
||||
here && "bg-secondary/40 inset-ring-2 inset-ring-ring/70",
|
||||
zone?.target === target &&
|
||||
"bg-secondary/70 inset-ring-2 inset-ring-signal/70",
|
||||
)}
|
||||
data-drop={target}
|
||||
data-here={here}
|
||||
onpointerdown={(event) => {
|
||||
if ((event.target as HTMLElement).closest("[data-nodrag], .group\\/card")) return;
|
||||
store.focusDay(day.key);
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
@@ -141,47 +149,35 @@
|
||||
"rounded-md px-1 text-xs tabular",
|
||||
day.isToday && "bg-primary font-semibold text-primary-foreground",
|
||||
!day.inMonth && "text-muted-foreground/40",
|
||||
here && !day.isToday && "font-semibold text-foreground",
|
||||
)}
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Add task"
|
||||
class="ml-auto grid size-5 place-items-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover/day:opacity-100 touch-shown"
|
||||
onclick={() => {
|
||||
addingDay = day.key;
|
||||
dayTitle = "";
|
||||
}}
|
||||
aria-label={`Add a task on ${day.date.toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}`}
|
||||
class={cn(
|
||||
"ml-auto grid size-5 place-items-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover/day:opacity-100 touch-shown",
|
||||
here && "opacity-100",
|
||||
)}
|
||||
data-composer={`day:${day.key}`}
|
||||
onclick={() => store.write(day.key, `[data-composer="day:${day.key}"]`)}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if addingDay === day.key}
|
||||
<input
|
||||
class="h-6 w-full shrink-0 rounded-md border border-ring bg-background px-1.5 text-xs outline-none"
|
||||
onblur={() => void addOnDay(day.key)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === "Enter") void addOnDay(day.key);
|
||||
else if (event.key === "Escape") {
|
||||
addingDay = null;
|
||||
dayTitle = "";
|
||||
}
|
||||
}}
|
||||
placeholder={scope ? `Task in ${scope.project}` : "Task"}
|
||||
use:focus
|
||||
bind:value={dayTitle}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each day.tasks as task (task.id)}
|
||||
{@render card(task)}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div bind:this={grid} class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex shrink-0 items-center gap-1 px-3 py-2">
|
||||
<h2 class="font-semibold text-sm first-letter:uppercase">
|
||||
{store.monthLabel}
|
||||
@@ -228,6 +224,18 @@
|
||||
})}
|
||||
</span>
|
||||
<span class="ml-auto tabular">{day.tasks.length}</span>
|
||||
<button
|
||||
aria-label={`Add a task on ${day.date.toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}`}
|
||||
class="grid size-5 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground"
|
||||
data-composer={`agenda:${day.key}`}
|
||||
onclick={() => store.write(day.key, `[data-composer="agenda:${day.key}"]`)}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1.5 pb-2">
|
||||
{#each day.tasks as task (task.id)}
|
||||
@@ -295,7 +303,7 @@
|
||||
class="flex max-h-44 flex-wrap gap-1.5 overflow-y-auto px-3 pt-0.5 pb-2"
|
||||
>
|
||||
{#each store.undated as task (task.id)}
|
||||
<div class="w-48">{@render card(task)}</div>
|
||||
<div class="w-48">{@render card(task, true)}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
<script lang="ts">
|
||||
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||
import CalendarOff from "@lucide/svelte/icons/calendar-off";
|
||||
import CornerDownLeft from "@lucide/svelte/icons/corner-down-left";
|
||||
import Repeat from "@lucide/svelte/icons/repeat";
|
||||
import { slide } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { dueLabel, dueTone, fromKey, nextFriday, shiftDays, today } from "../lib/due";
|
||||
import { labelHue } from "../lib/hue";
|
||||
import { listStep, MOD } from "../lib/keymap";
|
||||
import { cn } from "../lib/utils";
|
||||
import { parseRepeat, parseWhen } from "../lib/when";
|
||||
import { serializeTaskLine } from "../model/serialize";
|
||||
import { anchorDate } from "../model/types";
|
||||
import type { Task } from "../model/types";
|
||||
import { transaction } from "../vault/history";
|
||||
import { createTask, moveTask, replaceRaw } from "../vault/mutate";
|
||||
import { FIELDS } from "../vault/store.svelte";
|
||||
import type { Editing, Field, Lane } from "../vault/store.svelte";
|
||||
import AnchoredPanel from "./anchored-panel.svelte";
|
||||
import DayGrid from "./day-grid.svelte";
|
||||
import OptionList from "./option-list.svelte";
|
||||
import type { Option } from "./option-list.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
let field = $state<Field>("title");
|
||||
let title = $state("");
|
||||
let lane = $state<Lane | null>(null);
|
||||
let due = $state<string | null>(null);
|
||||
let repeat = $state<string | null>(null);
|
||||
let query = $state("");
|
||||
let list = $state<OptionList | null>(null);
|
||||
let panel = $state<AnchoredPanel | null>(null);
|
||||
let inputs: Partial<Record<Field, HTMLInputElement>> = $state({});
|
||||
|
||||
const editing = $derived(store.editing);
|
||||
const task = $derived(
|
||||
editing?.mode === "edit" ? store.taskById(editing.taskId) : null,
|
||||
);
|
||||
|
||||
const spheres = $derived(
|
||||
store.spheres.filter((sphere) => sphere.projects.length > 0),
|
||||
);
|
||||
const ready = $derived(spheres.length > 0);
|
||||
|
||||
const long = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
|
||||
// ── what is on offer in each field ───────────────────────────────────────
|
||||
|
||||
const sphereOptions = $derived<Option[]>(
|
||||
spheres.map((sphere) => ({
|
||||
value: sphere.path,
|
||||
label: sphere.name,
|
||||
hint: String(store.inScopeCount.byPath.get(sphere.path) ?? 0),
|
||||
})),
|
||||
);
|
||||
|
||||
const projectOptions = $derived<Option[]>(
|
||||
(spheres.find((sphere) => sphere.path === lane?.path)?.projects ?? []).map(
|
||||
(project) => ({
|
||||
value: project,
|
||||
label: project,
|
||||
dot: `oklch(0.62 0.12 ${labelHue(project)})`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const dateOptions = $derived<Option[]>([
|
||||
{ value: "", label: "No date" },
|
||||
{ value: today(), label: "Today", hint: dueLabel(today()) },
|
||||
{ value: shiftDays(today(), 1), label: "Tomorrow" },
|
||||
{ value: nextFriday(), label: "Friday", hint: dueLabel(nextFriday()) },
|
||||
{
|
||||
value: shiftDays(today(), 7),
|
||||
label: "In a week",
|
||||
hint: dueLabel(shiftDays(today(), 7)),
|
||||
},
|
||||
]);
|
||||
|
||||
const repeatOptions = $derived<Option[]>([
|
||||
{ value: "", label: "No repeat" },
|
||||
{ value: "every day", label: "Every day" },
|
||||
{ value: "every week", label: "Every week" },
|
||||
{ value: "every month", label: "Every month" },
|
||||
{ value: "every year", label: "Every year" },
|
||||
]);
|
||||
|
||||
/** What the words in the field add up to, shown before they are taken. */
|
||||
const read = $derived.by(() => {
|
||||
if (field === "date") {
|
||||
const key = parseWhen(query);
|
||||
return key ? { value: key, label: long.format(fromKey(key)) } : null;
|
||||
}
|
||||
if (field === "repeat") {
|
||||
const rule = parseRepeat(query);
|
||||
if (rule === null) return null;
|
||||
return { value: rule, label: rule || "No repeat" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const options = $derived(
|
||||
field === "sphere"
|
||||
? sphereOptions
|
||||
: field === "project"
|
||||
? projectOptions
|
||||
: field === "date"
|
||||
? dateOptions
|
||||
: field === "repeat"
|
||||
? repeatOptions
|
||||
: [],
|
||||
);
|
||||
|
||||
// Once the words have been understood the list stops filtering: it is there
|
||||
// for reference, and the reading above it is what Enter will take.
|
||||
const listQuery = $derived(read ? "" : query);
|
||||
|
||||
const selected = $derived(
|
||||
field === "sphere"
|
||||
? (lane?.path ?? null)
|
||||
: field === "project"
|
||||
? (lane?.project ?? null)
|
||||
: field === "date"
|
||||
? (due ?? "")
|
||||
: field === "repeat"
|
||||
? (repeat ?? "")
|
||||
: null,
|
||||
);
|
||||
|
||||
// ── opening and closing ──────────────────────────────────────────────────
|
||||
|
||||
let opened = $state<Editing | null>(null);
|
||||
$effect(() => {
|
||||
const next = store.editing;
|
||||
if (next === opened) return;
|
||||
opened = next;
|
||||
if (next) load(next);
|
||||
});
|
||||
|
||||
function load(next: NonNullable<typeof store.editing>): void {
|
||||
query = "";
|
||||
field = next.field;
|
||||
if (next.mode === "edit") {
|
||||
const target = store.taskById(next.taskId);
|
||||
title = target?.text ?? "";
|
||||
due = target ? anchorDate(target) : null;
|
||||
repeat = target?.recurrence ?? null;
|
||||
lane = target
|
||||
? { path: target.path, project: target.project, sphere: target.sphere }
|
||||
: store.defaultLane;
|
||||
return;
|
||||
}
|
||||
title = "";
|
||||
due = next.date;
|
||||
repeat = null;
|
||||
lane = store.defaultLane;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!store.editing) return;
|
||||
const node = inputs[field];
|
||||
node?.focus();
|
||||
node?.select();
|
||||
});
|
||||
|
||||
// The body changes height as fields swap, so the panel re-measures with it.
|
||||
$effect(() => {
|
||||
void field;
|
||||
void read;
|
||||
panel?.reposition();
|
||||
});
|
||||
|
||||
function close(): void {
|
||||
store.stopEditing();
|
||||
view.focusRoot();
|
||||
}
|
||||
|
||||
// ── moving between the fields ────────────────────────────────────────────
|
||||
|
||||
function pickSphere(path: string): void {
|
||||
const sphere = spheres.find((item) => item.path === path);
|
||||
if (!sphere) return;
|
||||
const project =
|
||||
lane && sphere.projects.includes(lane.project)
|
||||
? lane.project
|
||||
: sphere.projects[0];
|
||||
lane = { path: sphere.path, project, sphere: sphere.name };
|
||||
}
|
||||
|
||||
function choose(option: Option): void {
|
||||
take(option.value);
|
||||
}
|
||||
|
||||
function take(value: string): void {
|
||||
if (field === "sphere") pickSphere(value);
|
||||
else if (field === "project" && lane) lane = { ...lane, project: value };
|
||||
else if (field === "date") due = value || null;
|
||||
else if (field === "repeat") repeat = value || null;
|
||||
query = "";
|
||||
}
|
||||
|
||||
/** Leaving a field takes what it is showing; typing is never discarded. */
|
||||
function settle(): void {
|
||||
if (field === "title") return;
|
||||
if (read) {
|
||||
take(read.value);
|
||||
return;
|
||||
}
|
||||
list?.commit();
|
||||
}
|
||||
|
||||
function go(delta: number): void {
|
||||
settle();
|
||||
const at = FIELDS.indexOf(field);
|
||||
field = FIELDS[(at + delta + FIELDS.length) % FIELDS.length];
|
||||
query = "";
|
||||
}
|
||||
|
||||
// ── writing it down ──────────────────────────────────────────────────────
|
||||
|
||||
async function save(again: boolean): Promise<void> {
|
||||
settle();
|
||||
const text = title.trim();
|
||||
const target = lane;
|
||||
if (!text || !target) return;
|
||||
|
||||
const current = task;
|
||||
const when = due;
|
||||
const rule = repeat;
|
||||
|
||||
if (editing?.mode === "edit" && current) {
|
||||
close();
|
||||
await applyEdit(current, text, target, when, rule);
|
||||
return;
|
||||
}
|
||||
|
||||
title = "";
|
||||
if (again) {
|
||||
field = "title";
|
||||
query = "";
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
store.rememberLane(target);
|
||||
await createTask(view.app, target.path, target.project, text, when, rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* One write for the line itself and, only if the lane changed, one move —
|
||||
* folded into a single undo step.
|
||||
*/
|
||||
async function applyEdit(
|
||||
current: Task,
|
||||
text: string,
|
||||
target: Lane,
|
||||
when: string | null,
|
||||
rule: string | null,
|
||||
): Promise<void> {
|
||||
const field = !current.due && current.scheduled ? "scheduled" : "due";
|
||||
const next: Task = {
|
||||
...current,
|
||||
text,
|
||||
recurrence: rule,
|
||||
due: field === "due" ? when : current.due,
|
||||
scheduled: field === "scheduled" ? when : current.scheduled,
|
||||
};
|
||||
const line = serializeTaskLine(next);
|
||||
const moved = target.path !== current.path || target.project !== current.project;
|
||||
if (line === current.raw && !moved) return;
|
||||
|
||||
await transaction("the edit", async () => {
|
||||
if (line !== current.raw) await replaceRaw(view.app, current, line);
|
||||
if (moved) {
|
||||
await moveTask(
|
||||
view.app,
|
||||
{ ...next, raw: line },
|
||||
{ path: target.path, project: target.project },
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!moved) store.followDate(when);
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
go(event.shiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (query) query = "";
|
||||
else close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void save((event.metaKey || event.ctrlKey) && editing?.mode === "create");
|
||||
return;
|
||||
}
|
||||
|
||||
const step = listStep(event);
|
||||
if (step !== null && field !== "title") {
|
||||
event.preventDefault();
|
||||
list?.move(step);
|
||||
}
|
||||
}
|
||||
|
||||
const CHIP =
|
||||
"inline-flex h-6 w-full min-w-0 items-center gap-1 rounded-md border px-1.5 text-[11px] outline-none transition-colors";
|
||||
const IDLE =
|
||||
"border-border bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
const LIVE = "border-ring bg-background text-foreground";
|
||||
const ENTRY =
|
||||
"w-full min-w-0 bg-transparent outline-none placeholder:text-muted-foreground";
|
||||
const HINT = "whitespace-nowrap";
|
||||
|
||||
const NEXT: Record<Field, string> = {
|
||||
title: "sphere",
|
||||
sphere: "project",
|
||||
project: "date",
|
||||
date: "repeat",
|
||||
repeat: "title",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
<AnchoredPanel
|
||||
bind:this={panel}
|
||||
anchor={editing.anchor}
|
||||
autofocus={false}
|
||||
class="w-[21rem] max-w-[calc(100vw-1.5rem)]"
|
||||
onclose={close}
|
||||
{onkeydown}
|
||||
>
|
||||
{#if ready}
|
||||
<input
|
||||
bind:this={inputs.title}
|
||||
class="h-10 w-full bg-transparent px-3 font-medium text-sm outline-none placeholder:font-normal placeholder:text-muted-foreground"
|
||||
onfocus={() => {
|
||||
field = "title";
|
||||
}}
|
||||
placeholder={editing.mode === "edit" ? "Task" : "What needs doing?"}
|
||||
bind:value={title}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto_auto] items-center gap-1 border-border/70 border-t px-2 py-1.5"
|
||||
>
|
||||
{#if field === "sphere"}
|
||||
<span class={cn(CHIP, LIVE)}>
|
||||
<input
|
||||
bind:this={inputs.sphere}
|
||||
class={ENTRY}
|
||||
placeholder={lane?.sphere ?? "Sphere"}
|
||||
bind:value={query}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
class={cn(CHIP, IDLE)}
|
||||
onclick={() => {
|
||||
field = "sphere";
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span class="truncate">{lane?.sphere ?? "Sphere"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span aria-hidden="true" class="shrink-0 text-[11px] text-muted-foreground/50">
|
||||
/
|
||||
</span>
|
||||
|
||||
{#if field === "project"}
|
||||
<span class={cn(CHIP, LIVE)}>
|
||||
<input
|
||||
bind:this={inputs.project}
|
||||
class={ENTRY}
|
||||
placeholder={lane?.project ?? "Project"}
|
||||
bind:value={query}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
class={cn(CHIP, "hue-chip")}
|
||||
onclick={() => {
|
||||
field = "project";
|
||||
}}
|
||||
style="--hue: {labelHue(lane?.project ?? '')}"
|
||||
type="button"
|
||||
>
|
||||
<span class="truncate">{lane?.project ?? "Project"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if field === "date"}
|
||||
<span class={cn(CHIP, LIVE, "w-24")}>
|
||||
<CalendarDays aria-hidden="true" class="size-3 shrink-0" />
|
||||
<input
|
||||
bind:this={inputs.date}
|
||||
class={ENTRY}
|
||||
placeholder={due ? dueLabel(due) : "When"}
|
||||
bind:value={query}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
class={cn(
|
||||
CHIP,
|
||||
IDLE,
|
||||
"w-auto shrink-0",
|
||||
due && dueTone(due) === "overdue" && "text-destructive",
|
||||
due && dueTone(due) === "soon" && "text-status-snooze",
|
||||
)}
|
||||
onclick={() => {
|
||||
field = "date";
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if due}
|
||||
<CalendarDays aria-hidden="true" class="size-3" />
|
||||
{:else}
|
||||
<CalendarOff aria-hidden="true" class="size-3" />
|
||||
{/if}
|
||||
<span class="tabular">{due ? dueLabel(due) : "No date"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if field === "repeat"}
|
||||
<span class={cn(CHIP, LIVE, "w-20")}>
|
||||
<Repeat aria-hidden="true" class="size-3 shrink-0" />
|
||||
<input
|
||||
bind:this={inputs.repeat}
|
||||
class={ENTRY}
|
||||
placeholder={repeat ?? "Never"}
|
||||
bind:value={query}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
aria-label="Repeat"
|
||||
class={cn(CHIP, IDLE, "w-auto shrink-0", !repeat && "px-1")}
|
||||
onclick={() => {
|
||||
field = "repeat";
|
||||
}}
|
||||
title={repeat ?? "No repeat"}
|
||||
type="button"
|
||||
>
|
||||
<Repeat aria-hidden="true" class={cn("size-3", !repeat && "opacity-60")} />
|
||||
{#if repeat}
|
||||
<span class="max-w-20 truncate">{repeat.replace(/^every /u, "")}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if field !== "title"}
|
||||
<div
|
||||
class="border-border/70 border-t"
|
||||
transition:slide={{ duration: 140, easing: cubicOut }}
|
||||
>
|
||||
{#if read}
|
||||
<div
|
||||
class="flex items-center gap-1.5 border-border/70 border-b bg-secondary/25 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<CornerDownLeft aria-hidden="true" class="size-3 shrink-0 text-signal" />
|
||||
<span class="min-w-0 truncate font-medium">{read.label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-1">
|
||||
<OptionList
|
||||
bind:this={list}
|
||||
empty={field === "project" ? "No project by that name" : "Nothing found"}
|
||||
onchoose={choose}
|
||||
{options}
|
||||
query={listQuery}
|
||||
value={selected}
|
||||
/>
|
||||
{#if field === "date"}
|
||||
<div class="border-border/70 border-t px-1 pt-2 pb-1">
|
||||
<DayGrid
|
||||
anchor={due}
|
||||
onpick={(key) => {
|
||||
due = key;
|
||||
query = "";
|
||||
}}
|
||||
tone={(key) => (key === due ? "solo" : "none")}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1 border-border/70 border-t bg-secondary/25 px-2.5 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span class={HINT}>
|
||||
<kbd class="kbd">⏎</kbd>
|
||||
{editing.mode === "edit" ? "save" : "create"}
|
||||
</span>
|
||||
<span class={HINT}><kbd class="kbd">⇥</kbd> {NEXT[field]}</span>
|
||||
{#if field !== "title"}
|
||||
<span class={HINT}>
|
||||
<kbd class="kbd">↑</kbd><kbd class="kbd">↓</kbd> pick
|
||||
</span>
|
||||
{/if}
|
||||
{#if editing.mode === "create"}
|
||||
<span class={cn(HINT, "ml-auto")}>
|
||||
<kbd class="kbd">{MOD}⏎</kbd> keep writing
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-3 py-3 text-muted-foreground text-xs leading-relaxed">
|
||||
No boards yet. Every markdown file in
|
||||
<span class="text-foreground">{view.settings.boardsFolder || "the vault"}</span>
|
||||
is a sphere, and every <span class="text-foreground">##</span> heading inside
|
||||
it is a project.
|
||||
</p>
|
||||
{/if}
|
||||
</AnchoredPanel>
|
||||
{/if}
|
||||
+10
-1
@@ -9,12 +9,21 @@
|
||||
|
||||
const zone = $derived(dropZone());
|
||||
|
||||
let box = $state<HTMLElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
void store.cursorTask;
|
||||
box
|
||||
?.querySelector<HTMLElement>("[data-cursor='true']")
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
function groupTarget(key: string): string {
|
||||
return `group:${key}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div bind:this={box} class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#each store.groups as group (group.key)}
|
||||
<section>
|
||||
<h2
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ import { tasksApi, warnMissingTasks } from "../tasks-api";
|
||||
import type { MenuItem } from "./menu-body.svelte";
|
||||
import type { ViewContext } from "./context";
|
||||
|
||||
async function revealInFile(view: ViewContext, task: Task): Promise<void> {
|
||||
export async function revealInFile(view: ViewContext, task: Task): Promise<void> {
|
||||
const file = view.app.vault.getAbstractFileByPath(task.path);
|
||||
if (!(file instanceof TFile)) return;
|
||||
const leaf = view.app.workspace.getLeaf("tab");
|
||||
@@ -25,7 +25,7 @@ async function revealInFile(view: ViewContext, task: Task): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function editInTasks(view: ViewContext, task: Task): Promise<void> {
|
||||
export async function editInTasks(view: ViewContext, task: Task): Promise<void> {
|
||||
const api = tasksApi(view.app);
|
||||
if (!api) {
|
||||
warnMissingTasks();
|
||||
|
||||
+50
-34
@@ -1,21 +1,16 @@
|
||||
<script lang="ts">
|
||||
import Archive from "@lucide/svelte/icons/archive";
|
||||
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||
import Repeat from "@lucide/svelte/icons/repeat";
|
||||
import { dueLabel, dueTone } from "../lib/due";
|
||||
import { anchorDate, isOpen } from "../model/types";
|
||||
import type { Task } from "../model/types";
|
||||
import { draggable, dropZone } from "../lib/dnd.svelte";
|
||||
import { labelHue } from "../lib/hue";
|
||||
import { cn } from "../lib/utils";
|
||||
import { STATUS_META } from "../lib/vocab";
|
||||
import {
|
||||
moveTask,
|
||||
setDate,
|
||||
setRecurrence,
|
||||
setStatus,
|
||||
setText,
|
||||
} from "../vault/mutate";
|
||||
import { setStatus, setText } from "../vault/mutate";
|
||||
import ContextArea from "./context-area.svelte";
|
||||
import DuePicker from "./due-picker.svelte";
|
||||
import LanePicker from "./lane-picker.svelte";
|
||||
import PriorityBars from "./priority-bars.svelte";
|
||||
import RowMenu from "./row-menu.svelte";
|
||||
import { dropTarget, handleDrop } from "./drop";
|
||||
@@ -41,7 +36,11 @@
|
||||
const zone = $derived(dropZone());
|
||||
const isDropTarget = $derived(zone?.target === target);
|
||||
const date = $derived(anchorDate(task));
|
||||
const field = $derived(!task.due && task.scheduled ? "scheduled" : "due");
|
||||
const tone = $derived(dueTone(date));
|
||||
/** The editor hangs off the chip that opened it, not the whole row. */
|
||||
const chip = (field: string): string => `${field}:${task.id}`;
|
||||
const on = $derived(view.store.cursorTask === task.id);
|
||||
const here = $derived(on && view.store.keyboard);
|
||||
|
||||
function startEdit() {
|
||||
draft = task.text;
|
||||
@@ -70,16 +69,24 @@
|
||||
</script>
|
||||
|
||||
<ContextArea class="contents" items={taskMenu(view, task)}>
|
||||
<!-- scroll-mt clears the sticky group header when the cursor scrolls a row
|
||||
into view, which would otherwise land it half underneath. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={cn(
|
||||
"group/task grid select-none items-center gap-2 border-border/60 border-b px-3 transition-colors hover:bg-secondary/50",
|
||||
"group/task grid scroll-mt-8 select-none items-center gap-2 border-border/60 border-b px-3 transition-colors hover:bg-secondary/50",
|
||||
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
||||
here && "bg-secondary/60 opacity-100 inset-ring-1 inset-ring-ring",
|
||||
isDropTarget &&
|
||||
(zone?.after
|
||||
? "shadow-[inset_0_-2px_0_0_var(--signal)]"
|
||||
: "shadow-[inset_0_2px_0_0_var(--signal)]"),
|
||||
)}
|
||||
data-cursor={on}
|
||||
data-drop={target}
|
||||
onpointerdown={() => {
|
||||
view.store.cursorTask = task.id;
|
||||
}}
|
||||
style="grid-template-columns: 1.25rem minmax(0, 1fr) auto;"
|
||||
use:draggable={{
|
||||
payload: () => ({ id: task.id, kind: "task" }),
|
||||
@@ -115,22 +122,18 @@
|
||||
{task.project}
|
||||
</span>
|
||||
{:else if showProject}
|
||||
<LanePicker
|
||||
class="hidden shrink-0 @min-[24rem]:inline-flex"
|
||||
onpick={(path, project) =>
|
||||
void moveTask(view.app, task, { path, project })}
|
||||
path={task.path}
|
||||
project={task.project}
|
||||
<button
|
||||
aria-label="Project"
|
||||
class="hue-chip hidden max-w-32 shrink-0 truncate rounded-md border px-1.5 py-0.5 text-[11px] outline-none transition-[filter] hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring @min-[24rem]:inline-block"
|
||||
data-edit={chip("project")}
|
||||
data-nodrag
|
||||
onclick={() =>
|
||||
view.store.edit(task.id, "project", `[data-edit="${chip("project")}"]`)}
|
||||
style="--hue: {labelHue(task.project)}"
|
||||
type="button"
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<span
|
||||
class="hue-chip max-w-32 truncate rounded-md border px-1.5 py-0.5 text-[11px]"
|
||||
style="--hue: {labelHue(task.project)}"
|
||||
>
|
||||
{task.project}
|
||||
</span>
|
||||
{/snippet}
|
||||
</LanePicker>
|
||||
{task.project}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
@@ -172,18 +175,31 @@
|
||||
<PriorityBars class="shrink-0" priority={task.priority} />
|
||||
{/if}
|
||||
<div class="flex justify-end @min-[22rem]:w-24">
|
||||
<DuePicker
|
||||
<button
|
||||
aria-label={task.recurrence ? "Date and repeat" : "Date"}
|
||||
class={cn(
|
||||
"inline-flex h-6 items-center gap-1 rounded-md px-1 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
|
||||
tone === "overdue" && "text-destructive",
|
||||
tone === "soon" && "text-status-snooze",
|
||||
tone === "none" && "text-muted-foreground",
|
||||
!date &&
|
||||
"opacity-0 transition-opacity group-hover/task:opacity-100 focus-visible:opacity-100 touch-shown",
|
||||
)}
|
||||
{field}
|
||||
onclear={() => void setDate(view.app, task, field, null)}
|
||||
onpick={(key) => void setDate(view.app, task, field, key)}
|
||||
onrepeat={(rule) => void setRecurrence(view.app, task, rule)}
|
||||
recurrence={task.recurrence}
|
||||
value={date}
|
||||
/>
|
||||
data-edit={chip("date")}
|
||||
data-nodrag
|
||||
onclick={() =>
|
||||
view.store.edit(task.id, "date", `[data-edit="${chip("date")}"]`)}
|
||||
type="button"
|
||||
>
|
||||
{#if task.recurrence}
|
||||
<Repeat aria-hidden="true" class="size-3" />
|
||||
{:else}
|
||||
<CalendarDays aria-hidden="true" class="size-3" />
|
||||
{/if}
|
||||
{#if dueLabel(date)}
|
||||
<span class="tabular">{dueLabel(date)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<RowMenu
|
||||
class="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/task:opacity-100 touch-shown"
|
||||
|
||||
+114
-88
@@ -7,17 +7,12 @@
|
||||
import PanelLeft from "@lucide/svelte/icons/panel-left";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import Keyboard from "@lucide/svelte/icons/keyboard";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { GroupBy, SortBy, ViewMode } from "../settings";
|
||||
import Picker from "./picker.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
interface Props {
|
||||
oncreate: () => void;
|
||||
}
|
||||
|
||||
let { oncreate }: Props = $props();
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
@@ -43,112 +38,143 @@
|
||||
|
||||
const PILL =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 font-medium text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
function register(node: HTMLInputElement) {
|
||||
view.registerSearch(node);
|
||||
return { destroy: () => view.registerSearch(null) };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 overflow-x-auto border-border border-b px-3 py-2 scrollbar-none"
|
||||
>
|
||||
<button
|
||||
aria-label="Toggle spheres"
|
||||
aria-pressed={!store.railCollapsed}
|
||||
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||
onclick={() => {
|
||||
store.railCollapsed = !store.railCollapsed;
|
||||
store.savePrefs();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PanelLeft aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2 border-border border-b px-3 py-2">
|
||||
<!-- Filters scroll away on a narrow pane; writing a task never does. -->
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-0.5 rounded-4xl border border-border p-0.5"
|
||||
role="tablist"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto scrollbar-none"
|
||||
>
|
||||
{#each VIEWS as mode (mode.id)}
|
||||
<button
|
||||
aria-selected={store.view === mode.id}
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-4xl px-2.5 font-medium text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring aria-selected:bg-primary aria-selected:text-primary-foreground"
|
||||
onclick={() => {
|
||||
store.view = mode.id;
|
||||
store.savePrefs();
|
||||
}}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<mode.icon aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[34rem]:inline">{mode.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if store.view === "list"}
|
||||
<Picker
|
||||
label="Group by"
|
||||
onselect={(value) => {
|
||||
store.groupBy = value as GroupBy;
|
||||
<button
|
||||
aria-label="Toggle spheres"
|
||||
aria-pressed={!store.railCollapsed}
|
||||
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||
onclick={() => {
|
||||
store.railCollapsed = !store.railCollapsed;
|
||||
store.savePrefs();
|
||||
}}
|
||||
options={GROUPS}
|
||||
type="button"
|
||||
>
|
||||
<PanelLeft aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-0.5 rounded-4xl border border-border p-0.5"
|
||||
role="tablist"
|
||||
>
|
||||
{#each VIEWS as mode (mode.id)}
|
||||
<button
|
||||
aria-selected={store.view === mode.id}
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-4xl px-2.5 font-medium text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring aria-selected:bg-primary aria-selected:text-primary-foreground"
|
||||
onclick={() => {
|
||||
store.view = mode.id;
|
||||
store.savePrefs();
|
||||
}}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<mode.icon aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[34rem]:inline">{mode.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if store.view === "list"}
|
||||
<Picker
|
||||
label="Group by"
|
||||
onselect={(value) => {
|
||||
store.groupBy = value as GroupBy;
|
||||
store.savePrefs();
|
||||
}}
|
||||
options={GROUPS}
|
||||
triggerClass={PILL}
|
||||
value={store.groupBy}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Group aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[40rem]:inline">
|
||||
{GROUPS.find((item) => item.value === store.groupBy)?.label}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Picker>
|
||||
{/if}
|
||||
|
||||
<Picker
|
||||
label="Sort by"
|
||||
onselect={(value) => {
|
||||
store.sortBy = value as SortBy;
|
||||
}}
|
||||
options={SORTS}
|
||||
triggerClass={PILL}
|
||||
value={store.groupBy}
|
||||
value={store.sortBy}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Group aria-hidden="true" class="size-3.5" />
|
||||
<ArrowDownWideNarrow aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[40rem]:inline">
|
||||
{GROUPS.find((item) => item.value === store.groupBy)?.label}
|
||||
{SORTS.find((item) => item.value === store.sortBy)?.label}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Picker>
|
||||
{/if}
|
||||
|
||||
<Picker
|
||||
label="Sort by"
|
||||
onselect={(value) => {
|
||||
store.sortBy = value as SortBy;
|
||||
}}
|
||||
options={SORTS}
|
||||
triggerClass={PILL}
|
||||
value={store.sortBy}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<ArrowDownWideNarrow aria-hidden="true" class="size-3.5" />
|
||||
<span class="hidden @min-[40rem]:inline">
|
||||
{SORTS.find((item) => item.value === store.sortBy)?.label}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Picker>
|
||||
<button
|
||||
aria-label="Show completed"
|
||||
aria-pressed={store.showDone}
|
||||
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||
onclick={() => {
|
||||
store.showDone = !store.showDone;
|
||||
store.savePrefs();
|
||||
}}
|
||||
title="Show completed"
|
||||
type="button"
|
||||
>
|
||||
<CircleCheck aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<label
|
||||
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 focus-within:ring-2 focus-within:ring-ring"
|
||||
>
|
||||
<Search aria-hidden="true" class="size-3.5 text-muted-foreground" />
|
||||
<input
|
||||
class="w-24 min-w-0 bg-transparent text-xs outline-none transition-[width] placeholder:text-muted-foreground focus:w-40"
|
||||
onkeydown={(event) => {
|
||||
// Enter hands the keyboard back with the cursor already on the first
|
||||
// result, so what the search found can be walked from there.
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
store.keyboard = true;
|
||||
store.focusFirstMatch();
|
||||
view.focusRoot();
|
||||
}}
|
||||
placeholder="Search"
|
||||
type="text"
|
||||
use:register
|
||||
bind:value={store.search}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Show completed"
|
||||
aria-pressed={store.showDone}
|
||||
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||
aria-label="Keyboard shortcuts"
|
||||
class={cn(PILL, "px-2 text-muted-foreground")}
|
||||
onclick={() => {
|
||||
store.showDone = !store.showDone;
|
||||
store.savePrefs();
|
||||
store.helpOpen = true;
|
||||
}}
|
||||
title="Show completed"
|
||||
title="Keyboard shortcuts (?)"
|
||||
type="button"
|
||||
>
|
||||
<CircleCheck aria-hidden="true" class="size-3.5" />
|
||||
<Keyboard aria-hidden="true" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<label
|
||||
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-4xl border border-border px-2.5 focus-within:ring-2 focus-within:ring-ring"
|
||||
>
|
||||
<Search aria-hidden="true" class="size-3.5 text-muted-foreground" />
|
||||
<input
|
||||
class="w-24 min-w-0 bg-transparent text-xs outline-none transition-[width] placeholder:text-muted-foreground focus:w-40"
|
||||
placeholder="Search"
|
||||
type="text"
|
||||
bind:value={store.search}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
aria-label="New task"
|
||||
class={cn(PILL, "ml-auto bg-primary text-primary-foreground hover:bg-primary/90")}
|
||||
onclick={oncreate}
|
||||
class={cn(PILL, "bg-primary text-primary-foreground hover:bg-primary/90")}
|
||||
data-composer="toolbar"
|
||||
onclick={() => store.write(null, "[data-composer='toolbar']")}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" class="size-3.5" />
|
||||
|
||||
Reference in new issue
Block a user