The date field would not open at all on some days: on a Friday "Friday" and "In a week" resolve to the same day, and the option list was keyed by the day it writes, so Svelte tore it down with each_key_duplicate. On a desktop this took the whole view with it - the same bug, not two. Duplicate offers are dropped (the more specific name wins) and the list is keyed by position, so no caller can tear it down that way again. Panels are placed against the visual viewport rather than the window, which a phone's keyboard shrinks without resizing anything: they scroll inside what is left, re-place as the keyboard animates in, and come back into view when the keyboard covers the very row they hang off - visible beats staying next to the anchor. The quick look centres the same way. Its actions are thumb-sized where the pointer is coarse, with the keycaps hidden - there is no keyboard to press them on - and it gains "no date" and a close button, since a phone has no Escape. Both menus gain a plain "Edit" beside "Edit in Tasks"; it was only ever reachable by pressing e.
402 lines
15 KiB
Svelte
402 lines
15 KiB
Svelte
<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, viewport } 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;
|
||
}
|
||
|
||
// Every row is a button first and a key second: on a phone the keys are not
|
||
// there to be pressed, so the lens has to be operable by thumb alone.
|
||
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: "taskUnschedule", keys: ["⇧x"], label: "no date" },
|
||
{ 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 win = node.ownerDocument.defaultView ?? window;
|
||
const area = viewport(win);
|
||
const box =
|
||
view.rootBox() ??
|
||
new DOMRect(area.left ?? 0, area.top ?? 0, area.width, area.height);
|
||
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;
|
||
// Kept inside what is actually visible: a phone's keyboard shrinks the
|
||
// viewport without touching the window, and the lens must stay reachable.
|
||
const minLeft = (area.left ?? 0) + 8;
|
||
const maxLeft = (area.left ?? 0) + area.width - width - 8;
|
||
const minTop = (area.top ?? 0) + 8;
|
||
const maxTop = (area.top ?? 0) + area.height - height - 8;
|
||
node.style.left = `${Math.max(minLeft, Math.min(left, Math.max(minLeft, maxLeft)))}px`;
|
||
node.style.top = `${Math.max(minTop, Math.min(top, Math.max(minTop, maxTop)))}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 keyboard (an editor opening behind the lens) moves the visible area
|
||
// without resizing the window.
|
||
const vv = (node.ownerDocument.defaultView ?? window).visualViewport;
|
||
vv?.addEventListener("resize", reflow);
|
||
vv?.addEventListener("scroll", 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);
|
||
vv?.removeEventListener("resize", reflow);
|
||
vv?.removeEventListener("scroll", 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 pointer-coarse:gap-x-1.5 pointer-coarse:gap-y-1.5 pointer-coarse:p-2"
|
||
>
|
||
{#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 pointer-coarse:h-9 pointer-coarse:border pointer-coarse:border-border pointer-coarse:bg-popover pointer-coarse:px-3 pointer-coarse:text-xs pointer-coarse:text-foreground"
|
||
onclick={() => act(row.action)}
|
||
type="button"
|
||
>
|
||
{#each row.keys as key (key)}
|
||
<kbd class="kbd pointer-coarse:hidden">{key}</kbd>
|
||
{/each}
|
||
{row.label}
|
||
</button>
|
||
{/each}
|
||
<span class="ml-auto whitespace-nowrap px-1.5 pointer-coarse:hidden">
|
||
<kbd class="kbd">␣</kbd> close
|
||
</span>
|
||
<!-- No Escape key on a phone, so the lens needs a way out by thumb. -->
|
||
<button
|
||
class="ml-auto hidden h-9 items-center rounded-md border border-border bg-popover px-3 text-foreground text-xs pointer-coarse:inline-flex"
|
||
onclick={() => {
|
||
store.peeking = false;
|
||
}}
|
||
type="button"
|
||
>
|
||
close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Portal>
|
||
{/if}
|