feat(ui,store): live today clock, rail as lane filter, b toggle, quick look on space
This commit is contained in:
1 parent
83ca32d98e
commit
0a7742eb5e
19 files changed
+1236
-192
No files matched your search
@@ -10,6 +10,7 @@
|
||||
import TaskCalendar from "./task-calendar.svelte";
|
||||
import TaskEditor from "./task-editor.svelte";
|
||||
import TaskList from "./task-list.svelte";
|
||||
import TaskPeek from "./task-peek.svelte";
|
||||
import Toolbar from "./toolbar.svelte";
|
||||
import { handleKey } from "./keyboard";
|
||||
|
||||
@@ -47,8 +48,12 @@
|
||||
search = node;
|
||||
},
|
||||
focusRoot: () => root?.focus({ preventScroll: true }),
|
||||
rootBox: () => root?.getBoundingClientRect() ?? null,
|
||||
});
|
||||
|
||||
// The view can stay open for weeks; the clock keeps "today" meaning today.
|
||||
$effect(() => store.watchClock());
|
||||
|
||||
// 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(() => {
|
||||
@@ -92,6 +97,7 @@
|
||||
</div>
|
||||
<CursorPicker />
|
||||
<TaskEditor />
|
||||
<TaskPeek />
|
||||
</div>
|
||||
|
||||
{#if store.helpOpen}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface ViewContext {
|
||||
registerSearch: (node: HTMLInputElement | null) => void;
|
||||
/** Hands the keyboard back to the view after a layer above it closes. */
|
||||
focusRoot: () => void;
|
||||
/** Where the view sits on screen, for layers that centre on it. */
|
||||
rootBox: () => DOMRect | null;
|
||||
}
|
||||
|
||||
const KEY = Symbol("beaver-calendar");
|
||||
|
||||
@@ -13,14 +13,16 @@
|
||||
anchor: string | null;
|
||||
onhover?: (key: string) => void;
|
||||
onpick: (key: string) => void;
|
||||
/** The store's key for today, so the mark moves with the clock. */
|
||||
today?: string;
|
||||
tone: (key: string) => DayTone;
|
||||
}
|
||||
|
||||
let { anchor, tone, onpick, onhover }: Props = $props();
|
||||
let { anchor, tone, onpick, onhover, today }: Props = $props();
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const monthName = new Intl.DateTimeFormat("en-US", { month: "long" });
|
||||
const todayKey = localKey(new Date());
|
||||
const todayKey = $derived(today ?? localKey(new Date()));
|
||||
|
||||
let picked = $state<Date | null>(null);
|
||||
|
||||
|
||||
+34
-2
@@ -67,6 +67,14 @@ function stepTray(view: ViewContext, direction: Direction): void {
|
||||
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);
|
||||
@@ -100,7 +108,10 @@ async function run(view: ViewContext, action: Action): Promise<void> {
|
||||
else store.cursorTask = null;
|
||||
return;
|
||||
case "taskOpen":
|
||||
if (task) await revealInFile(view, task);
|
||||
if (task) {
|
||||
store.peeking = false;
|
||||
await revealInFile(view, task);
|
||||
}
|
||||
return;
|
||||
case "taskToggle":
|
||||
if (task) await setStatus(app, task, isOpen(task.status) ? "x" : " ");
|
||||
@@ -109,11 +120,17 @@ async function run(view: ViewContext, action: Action): Promise<void> {
|
||||
if (task) store.edit(task.id);
|
||||
return;
|
||||
case "taskEditRaw":
|
||||
if (task) await editInTasks(view, task);
|
||||
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;
|
||||
@@ -161,6 +178,12 @@ async function run(view: ViewContext, action: Action): Promise<void> {
|
||||
case "railChoose":
|
||||
store.chooseRail();
|
||||
return;
|
||||
case "railToggleRow":
|
||||
store.toggleRailRow();
|
||||
return;
|
||||
case "railLeave":
|
||||
store.leaveRail();
|
||||
return;
|
||||
|
||||
case "create":
|
||||
openComposer(view);
|
||||
@@ -258,6 +281,15 @@ export function handleKey(view: ViewContext, event: KeyboardEvent): void {
|
||||
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();
|
||||
|
||||
+186
-92
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import Minus from "@lucide/svelte/icons/minus";
|
||||
import { labelHue } from "../lib/hue";
|
||||
import { dropZone } from "../lib/dnd.svelte";
|
||||
import { laneKey } from "../lib/keys";
|
||||
@@ -28,117 +30,209 @@
|
||||
return steering && store.cursorRail === id;
|
||||
}
|
||||
|
||||
function toggle(path: string) {
|
||||
function unfold(path: string) {
|
||||
const next = new Set(store.railExpanded);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
store.railExpanded = next;
|
||||
}
|
||||
|
||||
function selectSphere(path: string) {
|
||||
store.sphereFilter = store.sphereFilter === path ? null : path;
|
||||
store.projectFilter = null;
|
||||
/** The name solos; with a modifier held it flips instead, like the box. */
|
||||
function additive(event: MouseEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey || event.shiftKey;
|
||||
}
|
||||
|
||||
function selectProject(path: string, project: string) {
|
||||
store.sphereFilter = path;
|
||||
store.projectFilter = store.projectFilter === project ? null : project;
|
||||
function onSphere(event: MouseEvent, path: string) {
|
||||
if (additive(event)) store.toggleSphere(path);
|
||||
else store.soloSphere(path);
|
||||
}
|
||||
|
||||
function onLane(event: MouseEvent, path: string, project: string) {
|
||||
if (additive(event)) store.toggleLane(path, project);
|
||||
else store.soloLane(path, project);
|
||||
}
|
||||
|
||||
const MOD = /Mac|iPhone|iPad/u.test(navigator.userAgent) ? "⌘" : "Ctrl";
|
||||
|
||||
const CURSOR = "bg-secondary/70 inset-ring-1 inset-ring-ring";
|
||||
const ROW =
|
||||
"flex h-7 min-w-0 flex-1 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";
|
||||
const BOX =
|
||||
"grid size-[15px] shrink-0 place-items-center rounded-[4px] border outline-none transition-[background-color,border-color,transform] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-90";
|
||||
</script>
|
||||
|
||||
{#snippet gate(state: "all" | "some" | "none", hue: number | null, label: string, flip: () => void)}
|
||||
<button
|
||||
aria-label={label}
|
||||
aria-pressed={state !== "none"}
|
||||
class={cn(
|
||||
BOX,
|
||||
state === "none" && "border-border bg-transparent hover:border-foreground/40",
|
||||
state !== "none" && hue === null && "border-primary bg-primary text-primary-foreground",
|
||||
state !== "none" && hue !== null && "gate-hue",
|
||||
)}
|
||||
data-nodrag
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
flip();
|
||||
}}
|
||||
style={hue !== null ? `--hue: ${hue}` : undefined}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{#if state === "all"}
|
||||
<Check aria-hidden="true" class="size-2.5" strokeWidth={3} />
|
||||
{:else if state === "some"}
|
||||
<Minus aria-hidden="true" class="size-2.5" strokeWidth={3} />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<nav
|
||||
bind:this={box}
|
||||
class="flex w-52 shrink-0 flex-col overflow-y-auto border-border border-r bg-sidebar/50 py-2"
|
||||
class="flex w-52 shrink-0 flex-col border-border border-r bg-sidebar/50"
|
||||
>
|
||||
<button
|
||||
aria-current={store.sphereFilter === null}
|
||||
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;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span class="flex-1 truncate">All spheres</span>
|
||||
<span class="text-muted-foreground tabular">{store.all.length}</span>
|
||||
</button>
|
||||
|
||||
{#each store.spheres as sphere (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)}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
<div
|
||||
class={cn(
|
||||
"mx-2 flex h-7 items-center gap-1.5 rounded-md pr-2 pl-[1.6rem] transition-colors",
|
||||
here("all") && CURSOR,
|
||||
)}
|
||||
data-rail-cursor={here("all")}
|
||||
>
|
||||
{@render gate(
|
||||
store.railState,
|
||||
null,
|
||||
store.railState === "all" ? "Show nothing" : "Show everything",
|
||||
() => store.toggleRailRow(),
|
||||
)}
|
||||
<button
|
||||
class="flex h-7 min-w-0 flex-1 items-center rounded-md text-left font-medium text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => store.filterAll()}
|
||||
title="Everything through"
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => toggle(sphere.path)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
class={cn("size-3 transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
aria-current={store.sphereFilter === sphere.path &&
|
||||
store.projectFilter === null}
|
||||
class="flex h-7 min-w-0 flex-1 items-center rounded-md px-1 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"
|
||||
onclick={() => selectSphere(sphere.path)}
|
||||
type="button"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{sphere.name}</span>
|
||||
<span class="shrink-0 text-muted-foreground tabular">
|
||||
{store.inScopeCount.byPath.get(sphere.path) ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="flex-1 truncate">All spheres</span>
|
||||
<span class="text-muted-foreground tabular">{store.all.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="size-1.5 shrink-0 rounded-full"
|
||||
style="background: oklch(0.6 0.12 {labelHue(project)})"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1 truncate">{project}</span>
|
||||
<span class="shrink-0 text-muted-foreground tabular">
|
||||
{store.inScopeCount.byProject.get(laneKey(sphere.path, project)) ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each store.spheres as sphere (sphere.path)}
|
||||
{@const open = store.railExpanded.has(sphere.path)}
|
||||
{@const target = dropTarget.sphere(sphere.path)}
|
||||
{@const row = `sphere:${sphere.path}`}
|
||||
{@const state = store.sphereState(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-expanded={open}
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => unfold(sphere.path)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
class={cn("size-3 transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
{@render gate(
|
||||
state,
|
||||
null,
|
||||
state === "all" ? `Hide ${sphere.name}` : `Show ${sphere.name}`,
|
||||
() => store.toggleSphere(sphere.path),
|
||||
)}
|
||||
<button
|
||||
class={cn(ROW, "font-medium", state === "none" && "text-muted-foreground")}
|
||||
onclick={(event) => onSphere(event, sphere.path)}
|
||||
title={`Only ${sphere.name} · ${MOD}-click to add or remove`}
|
||||
type="button"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{sphere.name}</span>
|
||||
<span class="shrink-0 text-muted-foreground tabular">
|
||||
{store.inScopeCount.byPath.get(sphere.path) ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="flex flex-col pt-0.5 pl-[1.6rem]">
|
||||
{#each sphere.projects as project (project)}
|
||||
{@const laneTarget = dropTarget.lane(sphere.path, project)}
|
||||
{@const laneRow = `lane:${laneKey(sphere.path, project)}`}
|
||||
{@const on = store.laneState(sphere.path, project)}
|
||||
<div
|
||||
class={cn(
|
||||
"flex h-6 items-center gap-1.5 rounded-md pl-[3px] transition-colors",
|
||||
here(laneRow) && CURSOR,
|
||||
zone?.target === laneTarget && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||
)}
|
||||
data-drop={laneTarget}
|
||||
data-rail-cursor={here(laneRow)}
|
||||
>
|
||||
{@render gate(
|
||||
on ? "all" : "none",
|
||||
labelHue(project),
|
||||
on ? `Hide ${project}` : `Show ${project}`,
|
||||
() => store.toggleLane(sphere.path, project),
|
||||
)}
|
||||
<button
|
||||
class={cn(ROW, "h-6", !on && "text-muted-foreground")}
|
||||
onclick={(event) => onLane(event, sphere.path, project)}
|
||||
title={`Only ${project} · ${MOD}-click to add or remove`}
|
||||
type="button"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{project}</span>
|
||||
<span class="shrink-0 text-muted-foreground tabular">
|
||||
{store.inScopeCount.byProject.get(laneKey(sphere.path, project)) ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- What the gate is doing, and while steering, the three keys that work it. -->
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-1 border-border/70 border-t px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if store.railState === "all"}
|
||||
<span class="truncate">Everything through</span>
|
||||
{:else if store.railState === "none"}
|
||||
<span class="truncate">Nothing through</span>
|
||||
{:else}
|
||||
<span class="truncate tabular">
|
||||
{store.railCount.on} of {store.railCount.all} lanes
|
||||
</span>
|
||||
{/if}
|
||||
{#if store.railState !== "all"}
|
||||
<button
|
||||
class="ml-auto shrink-0 rounded px-1 text-foreground/80 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => store.filterAll()}
|
||||
type="button"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if steering}
|
||||
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<span class="whitespace-nowrap"><kbd class="kbd">⏎</kbd> only</span>
|
||||
<span class="whitespace-nowrap"><kbd class="kbd">␣</kbd> flip</span>
|
||||
<span class="whitespace-nowrap"><kbd class="kbd">b</kbd> shut</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
+25
-17
@@ -5,7 +5,7 @@
|
||||
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 { dueLabel, dueTone, fromKey, nextFriday, shiftDays } from "../lib/due";
|
||||
import { labelHue } from "../lib/hue";
|
||||
import { listStep, MOD } from "../lib/keymap";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -51,6 +51,7 @@
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
const monthDay = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short" });
|
||||
|
||||
// ── what is on offer in each field ───────────────────────────────────────
|
||||
|
||||
@@ -72,17 +73,23 @@
|
||||
),
|
||||
);
|
||||
|
||||
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)),
|
||||
},
|
||||
]);
|
||||
// Read off the store's clock, not the wall: a view that stays open for
|
||||
// weeks would otherwise keep offering the day it was opened on.
|
||||
const dateOptions = $derived.by<Option[]>(() => {
|
||||
const now = store.today;
|
||||
const on = (key: string, label: string): Option => ({
|
||||
value: key,
|
||||
label,
|
||||
hint: monthDay.format(fromKey(key)),
|
||||
});
|
||||
return [
|
||||
{ value: "", label: "No date" },
|
||||
on(now, "Today"),
|
||||
on(shiftDays(now, 1), "Tomorrow"),
|
||||
on(nextFriday(now), "Friday"),
|
||||
on(shiftDays(now, 7), "In a week"),
|
||||
];
|
||||
});
|
||||
|
||||
const repeatOptions = $derived<Option[]>([
|
||||
{ value: "", label: "No repeat" },
|
||||
@@ -95,7 +102,7 @@
|
||||
/** 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);
|
||||
const key = parseWhen(query, fromKey(store.today));
|
||||
return key ? { value: key, label: long.format(fromKey(key)) } : null;
|
||||
}
|
||||
if (field === "repeat") {
|
||||
@@ -409,7 +416,7 @@
|
||||
<input
|
||||
bind:this={inputs.date}
|
||||
class={ENTRY}
|
||||
placeholder={due ? dueLabel(due) : "When"}
|
||||
placeholder={due ? dueLabel(due, store.today) : "When"}
|
||||
bind:value={query}
|
||||
/>
|
||||
</span>
|
||||
@@ -419,8 +426,8 @@
|
||||
CHIP,
|
||||
IDLE,
|
||||
"w-auto shrink-0",
|
||||
due && dueTone(due) === "overdue" && "text-destructive",
|
||||
due && dueTone(due) === "soon" && "text-status-snooze",
|
||||
due && dueTone(due, store.today) === "overdue" && "text-destructive",
|
||||
due && dueTone(due, store.today) === "soon" && "text-status-snooze",
|
||||
)}
|
||||
onclick={() => {
|
||||
field = "date";
|
||||
@@ -432,7 +439,7 @@
|
||||
{:else}
|
||||
<CalendarOff aria-hidden="true" class="size-3" />
|
||||
{/if}
|
||||
<span class="tabular">{due ? dueLabel(due) : "No date"}</span>
|
||||
<span class="tabular">{due ? dueLabel(due, store.today) : "No date"}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -490,6 +497,7 @@
|
||||
<div class="border-border/70 border-t px-1 pt-2 pb-1">
|
||||
<DayGrid
|
||||
anchor={due}
|
||||
today={store.today}
|
||||
onpick={(key) => {
|
||||
due = key;
|
||||
query = "";
|
||||
|
||||
@@ -49,7 +49,14 @@
|
||||
</section>
|
||||
{:else}
|
||||
<p class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{store.search.trim() ? "Nothing matches" : "No tasks"}
|
||||
{#if store.railState === "none"}
|
||||
Nothing let through — tick a sphere in the rail, or press
|
||||
<kbd class="kbd">b</kbd> and <kbd class="kbd">⏎</kbd> for everything
|
||||
{:else if store.search.trim()}
|
||||
Nothing matches
|
||||
{:else}
|
||||
No tasks
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -2,6 +2,7 @@ import { Notice, TFile } from "obsidian";
|
||||
import ExternalLink from "@lucide/svelte/icons/external-link";
|
||||
import FileText from "@lucide/svelte/icons/file-text";
|
||||
import PenLine from "@lucide/svelte/icons/pen-line";
|
||||
import ScanEye from "@lucide/svelte/icons/scan-eye";
|
||||
import { STATUS_META } from "../lib/vocab";
|
||||
import { STATUS_ORDER } from "../model/types";
|
||||
import type { Task } from "../model/types";
|
||||
@@ -47,6 +48,16 @@ export function taskMenu(view: ViewContext, task: Task): MenuItem[] {
|
||||
|
||||
return [
|
||||
...statuses,
|
||||
{
|
||||
icon: ScanEye,
|
||||
label: "Quick look",
|
||||
hint: "space",
|
||||
onselect: () => {
|
||||
view.store.cursorTask = task.id;
|
||||
view.store.keyboard = true;
|
||||
if (!view.store.peeking) view.store.peek();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: PenLine,
|
||||
label: "Edit in Tasks",
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<script lang="ts">
|
||||
import { Portal } from "bits-ui";
|
||||
import Archive from "@lucide/svelte/icons/archive";
|
||||
import CalendarCheck from "@lucide/svelte/icons/calendar-check";
|
||||
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
|
||||
import CalendarDays from "@lucide/svelte/icons/calendar-days";
|
||||
import CalendarOff from "@lucide/svelte/icons/calendar-off";
|
||||
import CalendarPlus from "@lucide/svelte/icons/calendar-plus";
|
||||
import Hash from "@lucide/svelte/icons/hash";
|
||||
import Play from "@lucide/svelte/icons/play";
|
||||
import Repeat from "@lucide/svelte/icons/repeat";
|
||||
import { cubicIn } from "svelte/easing";
|
||||
import type { TransitionConfig } from "svelte/transition";
|
||||
import { dueLabel, dueTone, fromKey } from "../lib/due";
|
||||
import { labelHue } from "../lib/hue";
|
||||
import type { Action } from "../lib/keymap";
|
||||
import { onScreen } from "../lib/place";
|
||||
import type { Box } from "../lib/place";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PRIORITY_LABEL, STATUS_META } from "../lib/vocab";
|
||||
import { isOpen } from "../model/types";
|
||||
import { handleKey, perform } from "./keyboard";
|
||||
import { markdown } from "./markdown";
|
||||
import PriorityBars from "./priority-bars.svelte";
|
||||
import { useView } from "./context";
|
||||
|
||||
const view = useView();
|
||||
const store = view.store;
|
||||
|
||||
const task = $derived(store.taskById(store.cursorTask));
|
||||
const open = $derived(store.peeking && task !== null);
|
||||
|
||||
// The cursor walked off the last task, or the task was written away: a
|
||||
// lens with nothing under it goes away too.
|
||||
$effect(() => {
|
||||
if (store.peeking && !task) store.peeking = false;
|
||||
});
|
||||
|
||||
const meta = $derived(task ? STATUS_META[task.status] : null);
|
||||
const long = new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const short = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short" });
|
||||
|
||||
/** The card's sub-lines, with the one tab of card indent taken off. */
|
||||
const notes = $derived(
|
||||
task ? task.body.map((line) => line.replace(/^\t/u, "")).join("\n").trim() : "",
|
||||
);
|
||||
|
||||
/** Says the date twice, once as a day and once against today. */
|
||||
function when(key: string): string {
|
||||
const label = dueLabel(key, store.today);
|
||||
const full = long.format(fromKey(key));
|
||||
return /^[a-z]/u.test(label) ? `${full} · ${label}` : full;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
action: Action;
|
||||
keys: string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
const rows = $derived<Row[]>(
|
||||
task
|
||||
? [
|
||||
{ action: "taskToggle", keys: ["x"], label: isOpen(task.status) ? "done" : "reopen" },
|
||||
{ action: "taskEdit", keys: ["e"], label: "edit" },
|
||||
{ action: "taskPriority", keys: ["p"], label: "priority" },
|
||||
{ action: "taskBack", keys: ["⇧h"], label: "−day" },
|
||||
{ action: "taskForward", keys: ["⇧l"], label: "+day" },
|
||||
{ action: "taskToday", keys: ["⇧t"], label: "today" },
|
||||
{ action: "taskOpen", keys: ["⏎"], label: "open note" },
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
// ── where it sits, and how it arrives ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Centred on the view rather than the window: the view is one pane among
|
||||
* several, and the lens belongs over the tasks it is reading. A touch above
|
||||
* the middle, the way a window feels centred rather than measures it.
|
||||
*/
|
||||
function centre(node: HTMLElement): void {
|
||||
const box = view.rootBox() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
const width = node.offsetWidth;
|
||||
const height = node.offsetHeight;
|
||||
const left = box.left + (box.width - width) / 2;
|
||||
const top = box.top + (box.height - height) * 0.42;
|
||||
node.style.left = `${Math.max(8, Math.min(left, window.innerWidth - width - 8))}px`;
|
||||
node.style.top = `${Math.max(8, Math.min(top, window.innerHeight - height - 8))}px`;
|
||||
}
|
||||
|
||||
/** The row or card under the cursor, wherever the current view draws it. */
|
||||
function origin(): Box | null {
|
||||
return onScreen(
|
||||
[...document.querySelectorAll("[data-cursor='true']")].map((element) =>
|
||||
element.getBoundingClientRect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A fast settle with the faintest overshoot, so it lands rather than stops. */
|
||||
function land(t: number): number {
|
||||
const s = 0.55;
|
||||
const x = t - 1;
|
||||
return x * x * ((s + 1) * x + s) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grows out of the task under the cursor and shrinks back into it: the
|
||||
* same gesture Quick Look makes from a Finder row, so the eye never has to
|
||||
* find where the lens came from.
|
||||
*/
|
||||
function zoom(node: HTMLElement, { out = false } = {}): TransitionConfig {
|
||||
centre(node);
|
||||
const to = node.getBoundingClientRect();
|
||||
const from = origin();
|
||||
if (!from || to.width === 0 || to.height === 0) {
|
||||
return {
|
||||
duration: out ? 140 : 200,
|
||||
easing: out ? cubicIn : land,
|
||||
css: (t) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
|
||||
};
|
||||
}
|
||||
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
|
||||
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
|
||||
const sx = Math.max(0.04, from.width / to.width);
|
||||
const sy = Math.max(0.04, from.height / to.height);
|
||||
return {
|
||||
duration: out ? 170 : 260,
|
||||
easing: out ? cubicIn : land,
|
||||
css: (t, u) =>
|
||||
`transform: translate(${dx * u}px, ${dy * u}px) scale(${1 - (1 - sx) * u}, ${1 - (1 - sy) * u});` +
|
||||
`opacity: ${Math.min(1, t * 2.2)};` +
|
||||
(out ? "" : `filter: blur(${6 * u}px);`),
|
||||
};
|
||||
}
|
||||
|
||||
function mount(node: HTMLElement) {
|
||||
centre(node);
|
||||
node.focus({ preventScroll: true });
|
||||
|
||||
const watcher =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(() => centre(node));
|
||||
watcher?.observe(node);
|
||||
const reflow = (): void => centre(node);
|
||||
window.addEventListener("resize", reflow);
|
||||
|
||||
// A click on another task moves the lens rather than closing it; a click
|
||||
// anywhere else puts it away.
|
||||
const outside = (event: PointerEvent): void => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target || node.contains(target)) return;
|
||||
if (target.closest("[data-cursor]")) return;
|
||||
store.peeking = false;
|
||||
};
|
||||
document.addEventListener("pointerdown", outside, true);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
watcher?.disconnect();
|
||||
window.removeEventListener("resize", reflow);
|
||||
document.removeEventListener("pointerdown", outside, true);
|
||||
// Only take the keyboard back if the lens had it; an editor that has
|
||||
// just opened keeps its caret.
|
||||
const active = document.activeElement;
|
||||
if (!active || active === document.body || node.contains(active)) {
|
||||
view.focusRoot();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keys pressed with the lens focused go where they always go. */
|
||||
function onkeydown(event: KeyboardEvent): void {
|
||||
handleKey(view, event);
|
||||
}
|
||||
|
||||
function act(action: Action): void {
|
||||
void perform(view, action);
|
||||
}
|
||||
|
||||
const CHIP =
|
||||
"inline-flex h-[22px] max-w-40 shrink-0 items-center gap-1 truncate rounded-md border px-1.5 text-[11px]";
|
||||
</script>
|
||||
|
||||
{#if open && task && meta}
|
||||
<Portal to={view.portal()}>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
aria-label="Quick look"
|
||||
class="fixed z-50 flex max-h-[min(72vh,40rem)] w-[36rem] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_24px_64px_-16px_rgb(0_0_0/55%),0_2px_8px_-2px_rgb(0_0_0/25%)] outline-none will-change-transform"
|
||||
in:zoom
|
||||
out:zoom={{ out: true }}
|
||||
{onkeydown}
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
use:mount
|
||||
>
|
||||
<div class="flex items-start gap-2.5 px-4 pt-3.5 pb-2">
|
||||
<button
|
||||
aria-label={`Status: ${meta.label}`}
|
||||
class="mt-px grid size-6 shrink-0 place-items-center rounded-md outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => act("taskToggle")}
|
||||
title={meta.label}
|
||||
type="button"
|
||||
>
|
||||
<meta.icon aria-hidden="true" class="size-[18px]" style="color: {meta.color}" />
|
||||
</button>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<span class={cn(CHIP, "border-border bg-secondary/40 font-medium text-muted-foreground")}>
|
||||
{task.sphere}
|
||||
</span>
|
||||
{#if task.inArchive}
|
||||
<span class={cn(CHIP, "border-border border-dashed text-muted-foreground")}>
|
||||
<Archive aria-hidden="true" class="size-3" />
|
||||
{task.project}
|
||||
</span>
|
||||
{:else}
|
||||
<span class={cn(CHIP, "hue-chip")} style="--hue: {labelHue(task.project)}">
|
||||
{task.project}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.priority !== 2}
|
||||
<span
|
||||
class={cn(CHIP, "border-transparent pl-0 text-muted-foreground")}
|
||||
title={PRIORITY_LABEL[task.priority]}
|
||||
>
|
||||
<PriorityBars priority={task.priority} />
|
||||
{PRIORITY_LABEL[task.priority]}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="shrink-0 pt-1 text-[10px] text-muted-foreground/70 tabular"
|
||||
title={task.path}
|
||||
>
|
||||
{task.path.slice(task.path.lastIndexOf("/") + 1)}:{task.line + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 overflow-y-auto px-4 pb-3">
|
||||
<div
|
||||
class={cn(
|
||||
"bcal-prose font-medium text-[15px] leading-[1.5]",
|
||||
!isOpen(task.status) && "text-muted-foreground line-through decoration-muted-foreground/50",
|
||||
)}
|
||||
onclick={(event) => {
|
||||
const anchor = (event.target as HTMLElement).closest("a");
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
const href = anchor.getAttribute("href") ?? "";
|
||||
if (anchor.classList.contains("internal-link")) {
|
||||
void view.app.workspace.openLinkText(href, task.path, event.metaKey || event.ctrlKey);
|
||||
} else if (href) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
}}
|
||||
onkeydown={() => {}}
|
||||
role="presentation"
|
||||
use:markdown={{
|
||||
app: view.app,
|
||||
component: view.component,
|
||||
sourcePath: task.path,
|
||||
text: task.text,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{#if notes}
|
||||
<div
|
||||
class="bcal-prose mt-2.5 border-border border-l pl-3 text-[13px] text-foreground/85 leading-relaxed"
|
||||
use:markdown={{
|
||||
app: view.app,
|
||||
component: view.component,
|
||||
sourcePath: task.path,
|
||||
text: notes,
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1 border-border/70 border-t px-4 py-2 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{#if task.due}
|
||||
{@const tone = dueTone(task.due, store.today)}
|
||||
<span
|
||||
class={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
tone === "overdue" && "text-destructive",
|
||||
tone === "soon" && "text-status-snooze",
|
||||
)}
|
||||
>
|
||||
<CalendarDays aria-hidden="true" class="size-3" />
|
||||
<span>due {when(task.due)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.scheduled}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<CalendarClock aria-hidden="true" class="size-3" />
|
||||
<span>scheduled {when(task.scheduled)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.start}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Play aria-hidden="true" class="size-3" />
|
||||
<span>from {short.format(fromKey(task.start))}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.recurrence}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Repeat aria-hidden="true" class="size-3" />
|
||||
<span>{task.recurrence}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.done}
|
||||
<span class="inline-flex items-center gap-1 text-status-done">
|
||||
<CalendarCheck aria-hidden="true" class="size-3" />
|
||||
<span>done {short.format(fromKey(task.done))}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.created}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground/70">
|
||||
<CalendarPlus aria-hidden="true" class="size-3" />
|
||||
<span>added {short.format(fromKey(task.created))}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if !task.due && !task.scheduled && !task.start && !task.recurrence && !task.done}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<CalendarOff aria-hidden="true" class="size-3" />
|
||||
<span>No date</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.tags.length > 0}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Hash aria-hidden="true" class="size-3" />
|
||||
<span class="truncate">{task.tags.join(" ")}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{#each rows as row (row.action)}
|
||||
<button
|
||||
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onclick={() => act(row.action)}
|
||||
type="button"
|
||||
>
|
||||
{#each row.keys as key (key)}
|
||||
<kbd class="kbd">{key}</kbd>
|
||||
{/each}
|
||||
{row.label}
|
||||
</button>
|
||||
{/each}
|
||||
<span class="ml-auto whitespace-nowrap px-1.5">
|
||||
<kbd class="kbd">␣</kbd> close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
@@ -36,7 +36,8 @@
|
||||
const zone = $derived(dropZone());
|
||||
const isDropTarget = $derived(zone?.target === target);
|
||||
const date = $derived(anchorDate(task));
|
||||
const tone = $derived(dueTone(date));
|
||||
const tone = $derived(dueTone(date, view.store.today));
|
||||
const label = $derived(dueLabel(date, view.store.today));
|
||||
/** 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);
|
||||
@@ -196,8 +197,8 @@
|
||||
{:else}
|
||||
<CalendarDays aria-hidden="true" class="size-3" />
|
||||
{/if}
|
||||
{#if dueLabel(date)}
|
||||
<span class="tabular">{dueLabel(date)}</span>
|
||||
{#if label}
|
||||
<span class="tabular">{label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+11
-1
@@ -53,14 +53,24 @@
|
||||
<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")}
|
||||
class={cn(PILL, "relative px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||
onclick={() => {
|
||||
store.railCollapsed = !store.railCollapsed;
|
||||
store.savePrefs();
|
||||
}}
|
||||
title={store.railState === "all"
|
||||
? "Spheres (b)"
|
||||
: `Spheres (b) · ${store.railCount.on} of ${store.railCount.all} lanes through`}
|
||||
type="button"
|
||||
>
|
||||
<PanelLeft aria-hidden="true" class="size-3.5" />
|
||||
{#if store.railCollapsed && store.railState !== "all"}
|
||||
<!-- The rail is shut but still filtering: say so where it was. -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -top-px -right-px size-2 rounded-full border border-background bg-signal"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user