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
@@ -13,7 +13,7 @@ npm run dev # esbuild watch
|
|||||||
npm run build # typecheck + bundle + css
|
npm run build # typecheck + bundle + css
|
||||||
npm test # round-trip, smoke
|
npm test # round-trip, smoke
|
||||||
npm run preview # UI in browser, no Obsidian
|
npm run preview # UI in browser, no Obsidian
|
||||||
make install VAULT=/путь/к/vault # build and copy to vault
|
make install VAULT=/path/to/vault # build and copy to vault
|
||||||
make zip # distributable
|
make zip # distributable
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
export type Range = [start: number, end: number];
|
||||||
|
|
||||||
|
export interface Hit {
|
||||||
|
score: number;
|
||||||
|
ranges: Range[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BREAK = /[\s\-_/.:]/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subsequence match with the usual scoring bias: a run of adjacent letters and
|
||||||
|
* a letter that opens a word are worth more than a letter found anywhere. Long
|
||||||
|
* labels lose a sliver so that "web" ranks `web` above `website redesign`.
|
||||||
|
*/
|
||||||
|
export function fuzzy(query: string, text: string): Hit | null {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (!needle) return { score: 0, ranges: [] };
|
||||||
|
|
||||||
|
const hay = text.toLowerCase();
|
||||||
|
const ranges: Range[] = [];
|
||||||
|
let score = 0;
|
||||||
|
let cursor = 0;
|
||||||
|
let previous = -2;
|
||||||
|
|
||||||
|
for (const letter of needle) {
|
||||||
|
const at = hay.indexOf(letter, cursor);
|
||||||
|
if (at === -1) return null;
|
||||||
|
|
||||||
|
let bonus = 1;
|
||||||
|
if (at === previous + 1) bonus += 4;
|
||||||
|
if (at === 0) bonus += 6;
|
||||||
|
else if (BREAK.test(text[at - 1])) bonus += 4;
|
||||||
|
score += bonus;
|
||||||
|
|
||||||
|
const last = ranges[ranges.length - 1];
|
||||||
|
if (last && last[1] === at) last[1] = at + 1;
|
||||||
|
else ranges.push([at, at + 1]);
|
||||||
|
|
||||||
|
previous = at;
|
||||||
|
cursor = at + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score: score - Math.min(text.length, 40) * 0.05, ranges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ranked<T> {
|
||||||
|
item: T;
|
||||||
|
ranges: Range[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the incoming order when the query is empty, so lists stay stable. */
|
||||||
|
export function rank<T>(
|
||||||
|
query: string,
|
||||||
|
items: T[],
|
||||||
|
label: (item: T) => string,
|
||||||
|
): Ranked<T>[] {
|
||||||
|
if (!query.trim()) return items.map((item) => ({ item, ranges: [] }));
|
||||||
|
return items
|
||||||
|
.map((item, index) => ({ item, index, hit: fuzzy(query, label(item)) }))
|
||||||
|
.filter(
|
||||||
|
(entry): entry is { item: T; index: number; hit: Hit } => entry.hit !== null,
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.hit.score - a.hit.score || a.index - b.index)
|
||||||
|
.map((entry) => ({ item: entry.item, ranges: entry.hit.ranges }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Piece {
|
||||||
|
text: string;
|
||||||
|
hit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pieces(text: string, ranges: Range[]): Piece[] {
|
||||||
|
if (ranges.length === 0) return [{ text, hit: false }];
|
||||||
|
const out: Piece[] = [];
|
||||||
|
let at = 0;
|
||||||
|
for (const [start, end] of ranges) {
|
||||||
|
if (start > at) out.push({ text: text.slice(at, start), hit: false });
|
||||||
|
out.push({ text: text.slice(start, end), hit: true });
|
||||||
|
at = end;
|
||||||
|
}
|
||||||
|
if (at < text.length) out.push({ text: text.slice(at), hit: false });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export type Direction = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
|
export interface Rect {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RectOf<T> = (item: T) => Rect;
|
||||||
|
|
||||||
|
interface Band {
|
||||||
|
x: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards in a wrapped run are top-aligned, not stretched, so two on the same
|
||||||
|
* line can have quite different heights. Rows are therefore read from the
|
||||||
|
* bands the cards occupy rather than from their centres, which would put a
|
||||||
|
* short card "above" a tall one sitting right beside it.
|
||||||
|
*/
|
||||||
|
function sameRow(a: Band, b: Band): boolean {
|
||||||
|
const overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||||
|
return overlap > Math.min(a.height, b.height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks a wrapped run of cards as the grid it looks like rather than the flat
|
||||||
|
* list it is: left and right stay on the row, up and down cross to the nearest
|
||||||
|
* card in the column above or below. Falls back to reading order at the ends of
|
||||||
|
* a row, so holding a direction never dead-ends mid-run.
|
||||||
|
*/
|
||||||
|
export function step<T>(
|
||||||
|
items: T[],
|
||||||
|
from: T,
|
||||||
|
direction: Direction,
|
||||||
|
rectOf: RectOf<T>,
|
||||||
|
): T | null {
|
||||||
|
const at = items.indexOf(from);
|
||||||
|
if (at === -1) return items[0] ?? null;
|
||||||
|
|
||||||
|
const bands: Band[] = items.map((item) => {
|
||||||
|
const rect = rectOf(item);
|
||||||
|
return {
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.top + rect.height,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const self = bands[at];
|
||||||
|
const sideways = direction === "left" || direction === "right";
|
||||||
|
const sign = direction === "left" || direction === "up" ? -1 : 1;
|
||||||
|
|
||||||
|
let best: T | null = null;
|
||||||
|
let bestScore = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let index = 0; index < bands.length; index += 1) {
|
||||||
|
if (index === at) continue;
|
||||||
|
const band = bands[index];
|
||||||
|
const together = sameRow(self, band);
|
||||||
|
const dx = band.x - self.x;
|
||||||
|
const dy = band.top - self.top;
|
||||||
|
|
||||||
|
if (sideways) {
|
||||||
|
if (!together) continue;
|
||||||
|
if (Math.sign(dx) !== sign || Math.abs(dx) < 1) continue;
|
||||||
|
const score = Math.abs(dx);
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = items[index];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (together) continue;
|
||||||
|
if (Math.sign(dy) !== sign || Math.abs(dy) < 1) continue;
|
||||||
|
// Nearest row first, then the nearest column inside it.
|
||||||
|
const score = Math.abs(dy) * 4 + Math.abs(dx);
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = items[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best !== null) return best;
|
||||||
|
// At the end of a row, carry on the way reading does.
|
||||||
|
if (sideways) return items[at + sign] ?? null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
/**
|
||||||
|
* One table drives both the dispatcher and the shortcut sheet, so a binding
|
||||||
|
* can never be documented as something it does not do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Scope = "day" | "task" | "tray" | "rail" | "view" | "compose";
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| "dayLeft"
|
||||||
|
| "dayRight"
|
||||||
|
| "dayUp"
|
||||||
|
| "dayDown"
|
||||||
|
| "dayEnter"
|
||||||
|
| "taskNext"
|
||||||
|
| "taskPrev"
|
||||||
|
| "taskExit"
|
||||||
|
| "taskOpen"
|
||||||
|
| "taskToggle"
|
||||||
|
| "taskEdit"
|
||||||
|
| "taskEditRaw"
|
||||||
|
| "taskPriority"
|
||||||
|
| "taskBack"
|
||||||
|
| "taskForward"
|
||||||
|
| "taskWeekBack"
|
||||||
|
| "taskWeekForward"
|
||||||
|
| "taskToday"
|
||||||
|
| "taskUnschedule"
|
||||||
|
| "create"
|
||||||
|
| "undated"
|
||||||
|
| "railNext"
|
||||||
|
| "railPrev"
|
||||||
|
| "railFold"
|
||||||
|
| "railUnfold"
|
||||||
|
| "railChoose"
|
||||||
|
| "trayLeft"
|
||||||
|
| "trayRight"
|
||||||
|
| "trayUp"
|
||||||
|
| "trayDown"
|
||||||
|
| "undo"
|
||||||
|
| "redo"
|
||||||
|
| "today"
|
||||||
|
| "monthPrev"
|
||||||
|
| "monthNext"
|
||||||
|
| "toggleView"
|
||||||
|
| "toggleRail"
|
||||||
|
| "toggleDone"
|
||||||
|
| "search"
|
||||||
|
| "help"
|
||||||
|
| "dismiss";
|
||||||
|
|
||||||
|
export interface Shortcut {
|
||||||
|
/** Normalised combos this binding answers to. Empty for compose-only rows. */
|
||||||
|
combos: string[];
|
||||||
|
/** Key chips as they are drawn on the sheet. */
|
||||||
|
keys: string[];
|
||||||
|
label: string;
|
||||||
|
action: Action | null;
|
||||||
|
scopes: Scope[];
|
||||||
|
group: Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Group = "Moving" | "Panels" | "Writing" | "The selected task";
|
||||||
|
|
||||||
|
export const GROUPS: Group[] = [
|
||||||
|
"Moving",
|
||||||
|
"Panels",
|
||||||
|
"Writing",
|
||||||
|
"The selected task",
|
||||||
|
];
|
||||||
|
|
||||||
|
const APPLE =
|
||||||
|
typeof navigator !== "undefined" && /Mac|iPhone|iPad/u.test(navigator.userAgent);
|
||||||
|
|
||||||
|
export const MOD = APPLE ? "⌘" : "Ctrl";
|
||||||
|
|
||||||
|
export const SHORTCUTS: Shortcut[] = [
|
||||||
|
// Moving
|
||||||
|
{
|
||||||
|
combos: ["h", "arrowleft"],
|
||||||
|
keys: ["h", "←"],
|
||||||
|
label: "A day back",
|
||||||
|
action: "dayLeft",
|
||||||
|
scopes: ["day", "task"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["l", "arrowright"],
|
||||||
|
keys: ["l", "→"],
|
||||||
|
label: "A day forward",
|
||||||
|
action: "dayRight",
|
||||||
|
scopes: ["day", "task"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["k", "arrowup"],
|
||||||
|
keys: ["k", "↑"],
|
||||||
|
label: "A week up",
|
||||||
|
action: "dayUp",
|
||||||
|
scopes: ["day"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["j", "arrowdown"],
|
||||||
|
keys: ["j", "↓"],
|
||||||
|
label: "A week down",
|
||||||
|
action: "dayDown",
|
||||||
|
scopes: ["day"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["enter"],
|
||||||
|
keys: ["⏎"],
|
||||||
|
label: "Into the day's tasks",
|
||||||
|
action: "dayEnter",
|
||||||
|
scopes: ["day"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["j", "arrowdown"],
|
||||||
|
keys: ["j", "↓"],
|
||||||
|
label: "Next task",
|
||||||
|
action: "taskNext",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["k", "arrowup"],
|
||||||
|
keys: ["k", "↑"],
|
||||||
|
label: "Previous task",
|
||||||
|
action: "taskPrev",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["escape"],
|
||||||
|
keys: ["esc"],
|
||||||
|
label: "Back to the days",
|
||||||
|
action: "taskExit",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["escape"],
|
||||||
|
keys: ["esc"],
|
||||||
|
label: "Clear the search",
|
||||||
|
action: "dismiss",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["u"],
|
||||||
|
keys: ["u"],
|
||||||
|
label: "To the undated tray, and back",
|
||||||
|
action: "undated",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["t"],
|
||||||
|
keys: ["t"],
|
||||||
|
label: "Today",
|
||||||
|
action: "today",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["["],
|
||||||
|
keys: ["["],
|
||||||
|
label: "Previous month",
|
||||||
|
action: "monthPrev",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["]"],
|
||||||
|
keys: ["]"],
|
||||||
|
label: "Next month",
|
||||||
|
action: "monthNext",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["v"],
|
||||||
|
keys: ["v"],
|
||||||
|
label: "Swap list and calendar",
|
||||||
|
action: "toggleView",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["b"],
|
||||||
|
keys: ["b"],
|
||||||
|
label: "To the spheres, and back",
|
||||||
|
action: "toggleRail",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["c"],
|
||||||
|
keys: ["c"],
|
||||||
|
label: "Completed tasks",
|
||||||
|
action: "toggleDone",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["/"],
|
||||||
|
keys: ["/"],
|
||||||
|
label: "Search",
|
||||||
|
action: "search",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["?"],
|
||||||
|
keys: ["?"],
|
||||||
|
label: "This sheet",
|
||||||
|
action: "help",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Moving",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Panels
|
||||||
|
{
|
||||||
|
combos: ["j", "arrowdown"],
|
||||||
|
keys: ["j", "↓"],
|
||||||
|
label: "Next row",
|
||||||
|
action: "railNext",
|
||||||
|
scopes: ["rail"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["k", "arrowup"],
|
||||||
|
keys: ["k", "↑"],
|
||||||
|
label: "Previous row",
|
||||||
|
action: "railPrev",
|
||||||
|
scopes: ["rail"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["l", "arrowright"],
|
||||||
|
keys: ["l", "→"],
|
||||||
|
label: "Unfold a sphere",
|
||||||
|
action: "railUnfold",
|
||||||
|
scopes: ["rail"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["h", "arrowleft"],
|
||||||
|
keys: ["h", "←"],
|
||||||
|
label: "Fold it again",
|
||||||
|
action: "railFold",
|
||||||
|
scopes: ["rail"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["enter"],
|
||||||
|
keys: ["⏎"],
|
||||||
|
label: "Filter by the row, and step out",
|
||||||
|
action: "railChoose",
|
||||||
|
scopes: ["rail"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["escape"],
|
||||||
|
keys: ["esc"],
|
||||||
|
label: "Leave the spheres",
|
||||||
|
action: "toggleRail",
|
||||||
|
scopes: ["rail"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["h", "arrowleft"],
|
||||||
|
keys: ["h", "←"],
|
||||||
|
label: "Across the undated cards",
|
||||||
|
action: "trayLeft",
|
||||||
|
scopes: ["tray"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["l", "arrowright"],
|
||||||
|
keys: ["l", "→"],
|
||||||
|
label: "…and back the other way",
|
||||||
|
action: "trayRight",
|
||||||
|
scopes: ["tray"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["k", "arrowup"],
|
||||||
|
keys: ["k", "↑"],
|
||||||
|
label: "A row up, then out of the tray",
|
||||||
|
action: "trayUp",
|
||||||
|
scopes: ["tray"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["j", "arrowdown"],
|
||||||
|
keys: ["j", "↓"],
|
||||||
|
label: "A row down",
|
||||||
|
action: "trayDown",
|
||||||
|
scopes: ["tray"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["escape"],
|
||||||
|
keys: ["esc"],
|
||||||
|
label: "Leave the tray",
|
||||||
|
action: "undated",
|
||||||
|
scopes: ["tray"],
|
||||||
|
group: "Panels",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Writing
|
||||||
|
{
|
||||||
|
combos: ["n"],
|
||||||
|
keys: ["n"],
|
||||||
|
label: "Write a task where the cursor is",
|
||||||
|
action: "create",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: [],
|
||||||
|
keys: ["⇥"],
|
||||||
|
label: "Next field: sphere, project, date, repeat",
|
||||||
|
action: null,
|
||||||
|
scopes: ["compose"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: [],
|
||||||
|
keys: ["↓", "↑"],
|
||||||
|
label: "Walk the matches",
|
||||||
|
action: null,
|
||||||
|
scopes: ["compose"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: [],
|
||||||
|
keys: ["\u2328"],
|
||||||
|
label: "Or just say it: aug 22, in 3 days, every week",
|
||||||
|
action: null,
|
||||||
|
scopes: ["compose"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: [],
|
||||||
|
keys: ["⌃n", "⌃p"],
|
||||||
|
label: "Walk the matches, hands home",
|
||||||
|
action: null,
|
||||||
|
scopes: ["compose"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: [],
|
||||||
|
keys: [`${MOD}j`, `${MOD}k`],
|
||||||
|
label: "Walk the matches, vim fingers",
|
||||||
|
action: null,
|
||||||
|
scopes: ["compose"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: [],
|
||||||
|
keys: ["⏎"],
|
||||||
|
label: "Create it, or save the edit",
|
||||||
|
action: null,
|
||||||
|
scopes: ["compose"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: [],
|
||||||
|
keys: [`${MOD}⏎`],
|
||||||
|
label: "Create it and keep writing",
|
||||||
|
action: null,
|
||||||
|
scopes: ["compose"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: [],
|
||||||
|
keys: ["esc"],
|
||||||
|
label: "Clear the field, then close",
|
||||||
|
action: null,
|
||||||
|
scopes: ["compose"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["meta+z", "ctrl+z"],
|
||||||
|
keys: [`${MOD}z`],
|
||||||
|
label: "Undo the last change",
|
||||||
|
action: "undo",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["meta+shift+z", "ctrl+shift+z"],
|
||||||
|
keys: [`${MOD}\u21e7z`],
|
||||||
|
label: "Redo it",
|
||||||
|
action: "redo",
|
||||||
|
scopes: ["view"],
|
||||||
|
group: "Writing",
|
||||||
|
},
|
||||||
|
|
||||||
|
// The selected task
|
||||||
|
{
|
||||||
|
combos: ["x"],
|
||||||
|
keys: ["x"],
|
||||||
|
label: "Done and back again",
|
||||||
|
action: "taskToggle",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["shift+h"],
|
||||||
|
keys: ["⇧h"],
|
||||||
|
label: "Push a day back",
|
||||||
|
action: "taskBack",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["shift+l"],
|
||||||
|
keys: ["⇧l"],
|
||||||
|
label: "Push a day forward",
|
||||||
|
action: "taskForward",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["shift+k"],
|
||||||
|
keys: ["⇧k"],
|
||||||
|
label: "Push a week back",
|
||||||
|
action: "taskWeekBack",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["shift+j"],
|
||||||
|
keys: ["⇧j"],
|
||||||
|
label: "Push a week forward",
|
||||||
|
action: "taskWeekForward",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["shift+t"],
|
||||||
|
keys: ["⇧t"],
|
||||||
|
label: "Pull onto today",
|
||||||
|
action: "taskToday",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["shift+u"],
|
||||||
|
keys: ["⇧u"],
|
||||||
|
label: "Drop the date",
|
||||||
|
action: "taskUnschedule",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["e"],
|
||||||
|
keys: ["e"],
|
||||||
|
label: "Edit it: text, lane, date, repeat",
|
||||||
|
action: "taskEdit",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["shift+e"],
|
||||||
|
keys: ["⇧e"],
|
||||||
|
label: "Edit the raw line in Tasks",
|
||||||
|
action: "taskEditRaw",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["p"],
|
||||||
|
keys: ["p"],
|
||||||
|
label: "Priority",
|
||||||
|
action: "taskPriority",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
combos: ["enter"],
|
||||||
|
keys: ["⏎"],
|
||||||
|
label: "Open in the note",
|
||||||
|
action: "taskOpen",
|
||||||
|
scopes: ["task"],
|
||||||
|
group: "The selected task",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `event.code` keeps the physical key, so the vim block still answers on a
|
||||||
|
* Cyrillic layout where `event.key` reports "н" instead of "n".
|
||||||
|
*/
|
||||||
|
const BY_CODE: Record<string, string> = {
|
||||||
|
BracketLeft: "[",
|
||||||
|
BracketRight: "]",
|
||||||
|
Slash: "/",
|
||||||
|
Escape: "escape",
|
||||||
|
Enter: "enter",
|
||||||
|
Tab: "tab",
|
||||||
|
ArrowLeft: "arrowleft",
|
||||||
|
ArrowRight: "arrowright",
|
||||||
|
ArrowUp: "arrowup",
|
||||||
|
ArrowDown: "arrowdown",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHIFTED_CODE: Record<string, string> = { Slash: "?" };
|
||||||
|
|
||||||
|
function withModifiers(event: KeyboardEvent, key: string, shift: boolean): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (event.ctrlKey) parts.push("ctrl");
|
||||||
|
if (event.metaKey) parts.push("meta");
|
||||||
|
if (event.altKey) parts.push("alt");
|
||||||
|
if (shift) parts.push("shift");
|
||||||
|
parts.push(key);
|
||||||
|
return parts.join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combos(event: KeyboardEvent): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key !== "shift" && key !== "control" && key !== "meta" && key !== "alt") {
|
||||||
|
// Punctuation already carries the shift in the character itself.
|
||||||
|
out.push(withModifiers(event, key, event.shiftKey && /^[a-z]$/u.test(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = event.code;
|
||||||
|
const letter = /^Key([A-Z])$/u.exec(code);
|
||||||
|
if (letter) out.push(withModifiers(event, letter[1].toLowerCase(), event.shiftKey));
|
||||||
|
else if (event.shiftKey && SHIFTED_CODE[code]) {
|
||||||
|
out.push(withModifiers(event, SHIFTED_CODE[code], false));
|
||||||
|
} else if (BY_CODE[code]) {
|
||||||
|
out.push(withModifiers(event, BY_CODE[code], false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one way to walk a list of matches, wherever one is on screen: arrows for
|
||||||
|
* the undecided, ⌃n/⌃p for the emacs hand, ⌘j/⌘k and ⌃j/⌃k for the vim one.
|
||||||
|
*/
|
||||||
|
export function listStep(event: KeyboardEvent): number | null {
|
||||||
|
const pressed = combos(event);
|
||||||
|
const hit = (...names: string[]): boolean =>
|
||||||
|
names.some((name) => pressed.includes(name));
|
||||||
|
if (hit("arrowdown", "ctrl+n", "ctrl+j", "meta+j")) return 1;
|
||||||
|
if (hit("arrowup", "ctrl+p", "ctrl+k", "meta+k")) return -1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope-specific bindings win over view-wide ones, so `j` walks tasks while a
|
||||||
|
* task is selected and walks weeks while it is not.
|
||||||
|
*/
|
||||||
|
export function resolve(event: KeyboardEvent, scopes: Scope[]): Action | null {
|
||||||
|
const pressed = combos(event);
|
||||||
|
for (const scope of scopes) {
|
||||||
|
for (const shortcut of SHORTCUTS) {
|
||||||
|
if (!shortcut.action) continue;
|
||||||
|
if (!shortcut.scopes.includes(scope)) continue;
|
||||||
|
if (pressed.some((each) => shortcut.combos.includes(each))) {
|
||||||
|
return shortcut.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export interface Box {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Spot {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GAP = 6;
|
||||||
|
const PAD = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar draws its cards twice — once in the agenda and once in the
|
||||||
|
* month grid — and hides whichever the width does not call for. Both answer
|
||||||
|
* the same selector, so the one with an actual box on screen is the one to
|
||||||
|
* hang a panel off.
|
||||||
|
*/
|
||||||
|
export function onScreen(boxes: Box[]): Box | null {
|
||||||
|
return boxes.find((box) => box.width > 0 && box.height > 0) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Under the anchor, flipped above when there is no room, and always kept
|
||||||
|
* inside the window. With nothing to anchor to it sits in the middle, which
|
||||||
|
* is wrong but never looks broken.
|
||||||
|
*/
|
||||||
|
export function place(
|
||||||
|
anchor: Box | null,
|
||||||
|
panel: { width: number; height: number },
|
||||||
|
view: { width: number; height: number },
|
||||||
|
): Spot {
|
||||||
|
if (!anchor) {
|
||||||
|
return {
|
||||||
|
top: Math.max(PAD, (view.height - panel.height) / 2),
|
||||||
|
left: Math.max(PAD, (view.width - panel.width) / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let top = anchor.top + anchor.height + GAP;
|
||||||
|
if (top + panel.height > view.height - PAD) {
|
||||||
|
const above = anchor.top - GAP - panel.height;
|
||||||
|
top = above >= PAD ? above : Math.max(PAD, view.height - PAD - panel.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(PAD, anchor.left),
|
||||||
|
Math.max(PAD, view.width - PAD - panel.width),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { top, left };
|
||||||
|
}
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
import { localKey } from "./due";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Tasks plugin keeps dates as plain `YYYY-MM-DD` and exposes no parser, so
|
||||||
|
* anything typed has to be resolved here. Recurrence is the other way round:
|
||||||
|
* the rule is stored as written and Tasks reads it, so only the shapes worth
|
||||||
|
* tidying get normalised and everything else passes straight through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WEEKDAYS = [
|
||||||
|
"sunday",
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTHS = [
|
||||||
|
"january",
|
||||||
|
"february",
|
||||||
|
"march",
|
||||||
|
"april",
|
||||||
|
"may",
|
||||||
|
"june",
|
||||||
|
"july",
|
||||||
|
"august",
|
||||||
|
"september",
|
||||||
|
"october",
|
||||||
|
"november",
|
||||||
|
"december",
|
||||||
|
];
|
||||||
|
|
||||||
|
function tidy(text: string): string {
|
||||||
|
return text.trim().toLowerCase().replace(/\s+/gu, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function at(year: number, month: number, day: number): Date {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plus(from: Date, days: number): Date {
|
||||||
|
return at(from.getFullYear(), from.getMonth(), from.getDate() + days);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayIndex(word: string): number {
|
||||||
|
return WEEKDAYS.findIndex(
|
||||||
|
(name) => name === word || (word.length >= 3 && name.startsWith(word)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthIndex(word: string): number {
|
||||||
|
return MONTHS.findIndex(
|
||||||
|
(name) => name === word || (word.length >= 3 && name.startsWith(word)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strictly the next one, so "friday" on a Friday means the week after. */
|
||||||
|
function upcoming(from: Date, weekday: number): Date {
|
||||||
|
return plus(from, ((weekday - from.getDay() + 7) % 7) || 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNITS: Record<string, "day" | "week" | "month" | "year"> = {
|
||||||
|
d: "day",
|
||||||
|
day: "day",
|
||||||
|
days: "day",
|
||||||
|
w: "week",
|
||||||
|
week: "week",
|
||||||
|
weeks: "week",
|
||||||
|
m: "month",
|
||||||
|
month: "month",
|
||||||
|
months: "month",
|
||||||
|
y: "year",
|
||||||
|
year: "year",
|
||||||
|
years: "year",
|
||||||
|
};
|
||||||
|
|
||||||
|
function shiftBy(from: Date, count: number, unit: string): Date | null {
|
||||||
|
const kind = UNITS[unit];
|
||||||
|
if (!kind) return null;
|
||||||
|
if (kind === "day") return plus(from, count);
|
||||||
|
if (kind === "week") return plus(from, count * 7);
|
||||||
|
if (kind === "month") {
|
||||||
|
return at(from.getFullYear(), from.getMonth() + count, from.getDate());
|
||||||
|
}
|
||||||
|
return at(from.getFullYear() + count, from.getMonth(), from.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function valid(year: number, month: number, day: number): Date | null {
|
||||||
|
const date = at(year, month, day);
|
||||||
|
const same = date.getFullYear() === year && date.getMonth() === month;
|
||||||
|
return same && date.getDate() === day ? date : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a `YYYY-MM-DD` key, or null when the words mean nothing here. */
|
||||||
|
export function parseWhen(text: string, from = new Date()): string | null {
|
||||||
|
const said = tidy(text);
|
||||||
|
if (!said) return null;
|
||||||
|
|
||||||
|
const today = at(from.getFullYear(), from.getMonth(), from.getDate());
|
||||||
|
|
||||||
|
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/u.exec(said);
|
||||||
|
if (iso) {
|
||||||
|
const date = valid(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
|
||||||
|
return date ? localKey(date) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(today|tod|now)$/u.test(said)) return localKey(today);
|
||||||
|
if (/^(tomorrow|tom|tmr|tmrw)$/u.test(said)) return localKey(plus(today, 1));
|
||||||
|
if (/^(yesterday|yest|yd)$/u.test(said)) return localKey(plus(today, -1));
|
||||||
|
|
||||||
|
const nextUnit = /^next (\w+)$/u.exec(said);
|
||||||
|
if (nextUnit) {
|
||||||
|
const shifted = shiftBy(today, 1, nextUnit[1]);
|
||||||
|
if (shifted) return localKey(shifted);
|
||||||
|
const weekday = weekdayIndex(nextUnit[1]);
|
||||||
|
if (weekday !== -1) return localKey(upcoming(today, weekday));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "in 3 days", "in a week", "3 weeks", "3d"
|
||||||
|
const relative =
|
||||||
|
/^(?:in )?(\d+|a|an) ?([a-z]+)$/u.exec(said) ?? /^(?:in )?(\d+)([dwmy])$/u.exec(said);
|
||||||
|
if (relative) {
|
||||||
|
const count = /^\d+$/u.test(relative[1]) ? Number(relative[1]) : 1;
|
||||||
|
const shifted = shiftBy(today, count, relative[2]);
|
||||||
|
if (shifted) return localKey(shifted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekday = weekdayIndex(said);
|
||||||
|
if (weekday !== -1) return localKey(upcoming(today, weekday));
|
||||||
|
|
||||||
|
// "12 aug", "aug 12", either with an optional year.
|
||||||
|
const dayMonth = /^(\d{1,2}) ([a-z]+)(?: (\d{4}))?$/u.exec(said);
|
||||||
|
const monthDay = /^([a-z]+) (\d{1,2})(?:,? (\d{4}))?$/u.exec(said);
|
||||||
|
const named = dayMonth
|
||||||
|
? { day: Number(dayMonth[1]), word: dayMonth[2], year: dayMonth[3] }
|
||||||
|
: monthDay
|
||||||
|
? { day: Number(monthDay[2]), word: monthDay[1], year: monthDay[3] }
|
||||||
|
: null;
|
||||||
|
if (named) {
|
||||||
|
const month = monthIndex(named.word);
|
||||||
|
if (month !== -1) {
|
||||||
|
const year = named.year ? Number(named.year) : today.getFullYear();
|
||||||
|
const date = valid(year, month, named.day);
|
||||||
|
if (!date) return null;
|
||||||
|
// Without a year, a date already gone means the one coming up.
|
||||||
|
if (!named.year && date < today) {
|
||||||
|
const next = valid(year + 1, month, named.day);
|
||||||
|
return next ? localKey(next) : null;
|
||||||
|
}
|
||||||
|
return localKey(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "12.08" and "12/8" read day first, the way the rest of the view does.
|
||||||
|
const numeric = /^(\d{1,2})[./](\d{1,2})(?:[./](\d{2,4}))?$/u.exec(said);
|
||||||
|
if (numeric) {
|
||||||
|
const day = Number(numeric[1]);
|
||||||
|
const month = Number(numeric[2]) - 1;
|
||||||
|
const year = numeric[3]
|
||||||
|
? Number(numeric[3].length === 2 ? `20${numeric[3]}` : numeric[3])
|
||||||
|
: today.getFullYear();
|
||||||
|
const date = valid(year, month, day);
|
||||||
|
if (!date) return null;
|
||||||
|
if (!numeric[3] && date < today) {
|
||||||
|
const next = valid(year + 1, month, day);
|
||||||
|
return next ? localKey(next) : null;
|
||||||
|
}
|
||||||
|
return localKey(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare number is a day of this month, or the next one if it has passed.
|
||||||
|
const bare = /^(\d{1,2})$/u.exec(said);
|
||||||
|
if (bare) {
|
||||||
|
const day = Number(bare[1]);
|
||||||
|
const here = valid(today.getFullYear(), today.getMonth(), day);
|
||||||
|
if (here && here >= today) return localKey(here);
|
||||||
|
const next = valid(today.getFullYear(), today.getMonth() + 1, day);
|
||||||
|
return next ? localKey(next) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAIN: Record<string, string> = {
|
||||||
|
daily: "every day",
|
||||||
|
weekly: "every week",
|
||||||
|
fortnightly: "every 2 weeks",
|
||||||
|
monthly: "every month",
|
||||||
|
quarterly: "every 3 months",
|
||||||
|
yearly: "every year",
|
||||||
|
annually: "every year",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises the phrasings worth normalising and hands everything else to the
|
||||||
|
* Tasks plugin as typed, which knows far more recurrence than this ever should.
|
||||||
|
*/
|
||||||
|
export function parseRepeat(text: string): string | null {
|
||||||
|
const said = tidy(text);
|
||||||
|
if (!said) return null;
|
||||||
|
if (/^(none|no|never|off|no repeat)$/u.test(said)) return "";
|
||||||
|
|
||||||
|
if (PLAIN[said]) return PLAIN[said];
|
||||||
|
|
||||||
|
const bare = /^(\d+) ([a-z]+)$/u.exec(said);
|
||||||
|
if (bare && UNITS[bare[2]]) {
|
||||||
|
return `every ${bare[1]} ${UNITS[bare[2]]}${bare[1] === "1" ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = UNITS[said];
|
||||||
|
if (single) return `every ${single}`;
|
||||||
|
|
||||||
|
const weekday = weekdayIndex(said.replace(/s$/u, ""));
|
||||||
|
if (weekday !== -1) return `every ${WEEKDAYS[weekday]}`;
|
||||||
|
|
||||||
|
if (said.startsWith("every ")) {
|
||||||
|
const rest = said.slice(6);
|
||||||
|
const day = weekdayIndex(rest.replace(/s$/u, ""));
|
||||||
|
if (day !== -1) return `every ${WEEKDAYS[day]}`;
|
||||||
|
const counted = /^(\d+) ([a-z]+)$/u.exec(rest);
|
||||||
|
if (counted && UNITS[counted[2]]) {
|
||||||
|
return `every ${counted[1]} ${UNITS[counted[2]]}${counted[1] === "1" ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
return said;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+11
-7
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "./settings";
|
} from "./settings";
|
||||||
import type { BeaverCalendarSettings } from "./settings";
|
import type { BeaverCalendarSettings } from "./settings";
|
||||||
import { BoardStore } from "./vault/store.svelte";
|
import { BoardStore } from "./vault/store.svelte";
|
||||||
|
import { transaction } from "./vault/history";
|
||||||
import { archiveDone } from "./vault/mutate";
|
import { archiveDone } from "./vault/mutate";
|
||||||
import { BoardsView, VIEW_TYPE_BOARDS } from "./view";
|
import { BoardsView, VIEW_TYPE_BOARDS } from "./view";
|
||||||
|
|
||||||
@@ -104,13 +105,16 @@ export default class BeaverCalendarPlugin extends Plugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let moved = 0;
|
let moved = 0;
|
||||||
for (const file of files) {
|
// One undo step for the whole sweep, however many boards it touched.
|
||||||
moved += await archiveDone(
|
await transaction("the archive", async () => {
|
||||||
this.app,
|
for (const file of files) {
|
||||||
file.path,
|
moved += await archiveDone(
|
||||||
this.settings.archiveHeading,
|
this.app,
|
||||||
);
|
file.path,
|
||||||
}
|
this.settings.archiveHeading,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
new Notice(
|
new Notice(
|
||||||
moved > 0
|
moved > 0
|
||||||
? `Beaver Calendar: archived ${moved} task(s)`
|
? `Beaver Calendar: archived ${moved} task(s)`
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface BeaverCalendarSettings {
|
|||||||
includeArchive: boolean;
|
includeArchive: boolean;
|
||||||
railCollapsed: boolean;
|
railCollapsed: boolean;
|
||||||
undatedCollapsed: boolean;
|
undatedCollapsed: boolean;
|
||||||
|
/** The lane the last task was written into, so the composer opens there. */
|
||||||
|
lastLanePath: string;
|
||||||
|
lastLaneProject: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: BeaverCalendarSettings = {
|
export const DEFAULT_SETTINGS: BeaverCalendarSettings = {
|
||||||
@@ -30,6 +33,8 @@ export const DEFAULT_SETTINGS: BeaverCalendarSettings = {
|
|||||||
includeArchive: false,
|
includeArchive: false,
|
||||||
railCollapsed: false,
|
railCollapsed: false,
|
||||||
undatedCollapsed: false,
|
undatedCollapsed: false,
|
||||||
|
lastLanePath: "",
|
||||||
|
lastLaneProject: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeFolder(value: string): string {
|
export function normalizeFolder(value: string): string {
|
||||||
|
|||||||
@@ -270,6 +270,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A key, drawn as one: the heavier bottom edge is the keycap, not a shadow. */
|
||||||
|
@utility kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-color: color-mix(in oklab, var(--border) 55%, var(--foreground));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
@utility scrollbar-none {
|
@utility scrollbar-none {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
|
import { Portal } from "bits-ui";
|
||||||
import type { App, Component } from "obsidian";
|
import type { App, Component } from "obsidian";
|
||||||
import type { BeaverCalendarSettings } from "../settings";
|
import type { BeaverCalendarSettings } from "../settings";
|
||||||
import type { BoardStore } from "../vault/store.svelte";
|
import type { BoardStore } from "../vault/store.svelte";
|
||||||
import { createTask } from "../vault/mutate";
|
|
||||||
import { provideView } from "./context";
|
import { provideView } from "./context";
|
||||||
|
import CursorPicker from "./cursor-picker.svelte";
|
||||||
|
import Shortcuts from "./shortcuts.svelte";
|
||||||
import SphereRail from "./sphere-rail.svelte";
|
import SphereRail from "./sphere-rail.svelte";
|
||||||
import TaskCalendar from "./task-calendar.svelte";
|
import TaskCalendar from "./task-calendar.svelte";
|
||||||
|
import TaskEditor from "./task-editor.svelte";
|
||||||
import TaskList from "./task-list.svelte";
|
import TaskList from "./task-list.svelte";
|
||||||
import Toolbar from "./toolbar.svelte";
|
import Toolbar from "./toolbar.svelte";
|
||||||
|
import { handleKey } from "./keyboard";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
app: App;
|
app: App;
|
||||||
@@ -24,79 +28,61 @@
|
|||||||
const layer = document.createElement("div");
|
const layer = document.createElement("div");
|
||||||
layer.className = "bcal-root bcal-portal";
|
layer.className = "bcal-root bcal-portal";
|
||||||
|
|
||||||
|
let root = $state<HTMLElement | null>(null);
|
||||||
|
let search = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
document.body.append(layer);
|
document.body.append(layer);
|
||||||
return () => layer.remove();
|
return () => layer.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
provideView({
|
const view = provideView({
|
||||||
app,
|
app,
|
||||||
store,
|
store,
|
||||||
settings,
|
settings,
|
||||||
component,
|
component,
|
||||||
portal: () => layer,
|
portal: () => layer,
|
||||||
|
searchInput: () => search,
|
||||||
|
registerSearch: (node) => {
|
||||||
|
search = node;
|
||||||
|
},
|
||||||
|
focusRoot: () => root?.focus({ preventScroll: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
let creating = $state(false);
|
// Bound by hand rather than with onkeydown so the container stays a plain
|
||||||
let title = $state("");
|
// 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 onkeydown = (event: KeyboardEvent): void => handleKey(view, event);
|
||||||
const sphere =
|
// A pointer puts the cursor chrome away, and takes the keyboard back from
|
||||||
store.spheres.find((item) => item.path === store.sphereFilter) ??
|
// whatever outside the view was holding it.
|
||||||
store.spheres[0];
|
const onpointerdown = (event: PointerEvent): void => {
|
||||||
if (!sphere) return null;
|
store.keyboard = false;
|
||||||
const project =
|
if (node.contains(document.activeElement)) return;
|
||||||
(store.projectFilter && sphere.projects.includes(store.projectFilter)
|
const target = event.target as HTMLElement | null;
|
||||||
? store.projectFilter
|
if (target?.closest("input, textarea, [contenteditable='true']")) return;
|
||||||
: undefined) ?? sphere.projects[0];
|
node.focus({ preventScroll: true });
|
||||||
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
|
};
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="bcal-root">
|
<div bind:this={root} class="bcal-root outline-none" tabindex="-1">
|
||||||
<div class="bcal-app">
|
<div class="bcal-app">
|
||||||
{#if !store.railCollapsed}
|
{#if !store.railCollapsed}
|
||||||
<SphereRail />
|
<SphereRail />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<Toolbar
|
<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}
|
|
||||||
{#if store.view === "calendar"}
|
{#if store.view === "calendar"}
|
||||||
<TaskCalendar />
|
<TaskCalendar />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -104,4 +90,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CursorPicker />
|
||||||
|
<TaskEditor />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if store.helpOpen}
|
||||||
|
<Portal to={layer}>
|
||||||
|
<Shortcuts />
|
||||||
|
</Portal>
|
||||||
|
{/if}
|
||||||
+7
-1
@@ -9,12 +9,18 @@ export interface ViewContext {
|
|||||||
settings: BeaverCalendarSettings;
|
settings: BeaverCalendarSettings;
|
||||||
component: Component;
|
component: Component;
|
||||||
portal: () => HTMLElement;
|
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");
|
const KEY = Symbol("beaver-calendar");
|
||||||
|
|
||||||
export function provideView(context: ViewContext): void {
|
export function provideView(context: ViewContext): ViewContext {
|
||||||
setContext(KEY, context);
|
setContext(KEY, context);
|
||||||
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useView(): ViewContext {
|
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>
|
<script lang="ts" module>
|
||||||
export interface PickerOption {
|
export type { Option as PickerOption } from "./option-list.svelte";
|
||||||
color?: string;
|
|
||||||
hint?: string;
|
|
||||||
icon?: Component;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Check from "@lucide/svelte/icons/check";
|
|
||||||
import { Popover } from "bits-ui";
|
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 { cn } from "../lib/utils";
|
||||||
|
import OptionList from "./option-list.svelte";
|
||||||
|
import type { Option } from "./option-list.svelte";
|
||||||
import { useView } from "./context";
|
import { useView } from "./context";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,7 +16,7 @@
|
|||||||
label: string;
|
label: string;
|
||||||
onselect: (value: string) => void;
|
onselect: (value: string) => void;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
options: PickerOption[];
|
options: Option[];
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
trigger: Snippet<[{ open: boolean }]>;
|
trigger: Snippet<[{ open: boolean }]>;
|
||||||
@@ -46,36 +42,25 @@
|
|||||||
const view = useView();
|
const view = useView();
|
||||||
|
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
let active = $state(0);
|
let list = $state<OptionList | null>(null);
|
||||||
|
|
||||||
const shown = $derived(
|
|
||||||
query.trim()
|
|
||||||
? options.filter((option) =>
|
|
||||||
option.label.toLowerCase().includes(query.trim().toLowerCase()),
|
|
||||||
)
|
|
||||||
: options,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) query = "";
|
||||||
query = "";
|
|
||||||
active = 0;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function choose(option: PickerOption) {
|
function choose(option: Option) {
|
||||||
onselect(option.value);
|
onselect(option.value);
|
||||||
isOpen = false;
|
isOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onkeydown(event: KeyboardEvent) {
|
function onkeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
const step = listStep(event);
|
||||||
|
if (step !== null) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const delta = event.key === "ArrowDown" ? 1 : -1;
|
list?.move(step);
|
||||||
active = Math.max(0, Math.min(shown.length - 1, active + delta));
|
} else if (event.key === "Enter") {
|
||||||
} else if (event.key === "Enter" && shown[active]) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
choose(shown[active]);
|
list?.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -112,42 +97,14 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if options.length > 0}
|
{#if options.length > 0}
|
||||||
<div class="max-h-64 overflow-y-auto">
|
<OptionList
|
||||||
{#each shown as option, index (option.value)}
|
bind:this={list}
|
||||||
<button
|
class="max-h-64"
|
||||||
class={cn(
|
onchoose={choose}
|
||||||
"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",
|
{options}
|
||||||
index === active && "bg-muted",
|
{query}
|
||||||
)}
|
{value}
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if footer}
|
{#if footer}
|
||||||
<div class={options.length > 0 ? "mt-1 border-border border-t pt-1" : ""}>
|
<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 view = useView();
|
||||||
const store = view.store;
|
const store = view.store;
|
||||||
|
|
||||||
let expanded = $state<Set<string>>(new Set());
|
let box = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
const zone = $derived(dropZone());
|
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) {
|
function toggle(path: string) {
|
||||||
const next = new Set(expanded);
|
const next = new Set(store.railExpanded);
|
||||||
if (next.has(path)) next.delete(path);
|
if (next.has(path)) next.delete(path);
|
||||||
else next.add(path);
|
else next.add(path);
|
||||||
expanded = next;
|
store.railExpanded = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectSphere(path: string) {
|
function selectSphere(path: string) {
|
||||||
@@ -29,14 +44,21 @@
|
|||||||
store.sphereFilter = path;
|
store.sphereFilter = path;
|
||||||
store.projectFilter = store.projectFilter === project ? null : project;
|
store.projectFilter = store.projectFilter === project ? null : project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CURSOR = "bg-secondary/70 inset-ring-1 inset-ring-ring";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<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 overflow-y-auto border-border border-r bg-sidebar/50 py-2"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-current={store.sphereFilter === null}
|
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={() => {
|
onclick={() => {
|
||||||
store.sphereFilter = null;
|
store.sphereFilter = null;
|
||||||
store.projectFilter = null;
|
store.projectFilter = null;
|
||||||
@@ -48,15 +70,18 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#each store.spheres as sphere (sphere.path)}
|
{#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 target = dropTarget.sphere(sphere.path)}
|
||||||
|
{@const row = `sphere:${sphere.path}`}
|
||||||
<div class="mt-1 px-2">
|
<div class="mt-1 px-2">
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex h-7 items-center gap-0.5 rounded-md transition-colors",
|
"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",
|
zone?.target === target && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||||
)}
|
)}
|
||||||
data-drop={target}
|
data-drop={target}
|
||||||
|
data-rail-cursor={here(row)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-label={open ? "Collapse" : "Expand"}
|
aria-label={open ? "Collapse" : "Expand"}
|
||||||
@@ -87,14 +112,17 @@
|
|||||||
<div class="flex flex-col pt-0.5 pl-5">
|
<div class="flex flex-col pt-0.5 pl-5">
|
||||||
{#each sphere.projects as project (project)}
|
{#each sphere.projects as project (project)}
|
||||||
{@const laneTarget = dropTarget.lane(sphere.path, project)}
|
{@const laneTarget = dropTarget.lane(sphere.path, project)}
|
||||||
|
{@const laneRow = `lane:${laneKey(sphere.path, project)}`}
|
||||||
<button
|
<button
|
||||||
aria-current={store.sphereFilter === sphere.path &&
|
aria-current={store.sphereFilter === sphere.path &&
|
||||||
store.projectFilter === project}
|
store.projectFilter === project}
|
||||||
class={cn(
|
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",
|
"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",
|
zone?.target === laneTarget && "bg-secondary/80 inset-ring-2 inset-ring-signal/70",
|
||||||
)}
|
)}
|
||||||
data-drop={laneTarget}
|
data-drop={laneTarget}
|
||||||
|
data-rail-cursor={here(laneRow)}
|
||||||
onclick={() => selectProject(sphere.path, project)}
|
onclick={() => selectProject(sphere.path, project)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
+59
-51
@@ -10,7 +10,7 @@
|
|||||||
import { labelHue } from "../lib/hue";
|
import { labelHue } from "../lib/hue";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { STATUS_META, WEEKDAYS } from "../lib/vocab";
|
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 ContextArea from "./context-area.svelte";
|
||||||
import RowMenu from "./row-menu.svelte";
|
import RowMenu from "./row-menu.svelte";
|
||||||
import { dropTarget, handleDrop } from "./drop";
|
import { dropTarget, handleDrop } from "./drop";
|
||||||
@@ -21,45 +21,45 @@
|
|||||||
const view = useView();
|
const view = useView();
|
||||||
const store = view.store;
|
const store = view.store;
|
||||||
|
|
||||||
let addingDay = $state<string | null>(null);
|
let grid = $state<HTMLElement | null>(null);
|
||||||
let dayTitle = $state("");
|
|
||||||
|
|
||||||
const zone = $derived(dropZone());
|
const zone = $derived(dropZone());
|
||||||
|
const onDay = $derived(store.zone === "day" && store.keyboard);
|
||||||
|
|
||||||
const scope = $derived.by(() => {
|
// Keep whatever the cursor is on inside the scroll port, wherever it moved to.
|
||||||
const sphere =
|
$effect(() => {
|
||||||
store.spheres.find((item) => item.path === store.sphereFilter) ??
|
void store.cursorDay;
|
||||||
store.spheres[0];
|
void store.cursorTask;
|
||||||
if (!sphere) return null;
|
void store.zone;
|
||||||
const project =
|
grid
|
||||||
(store.projectFilter && sphere.projects.includes(store.projectFilter)
|
?.querySelector<HTMLElement>("[data-cursor='true'], [data-here='true']")
|
||||||
? store.projectFilter
|
?.scrollIntoView({ block: "nearest" });
|
||||||
: undefined) ?? sphere.projects[0];
|
|
||||||
return project ? { path: sphere.path, project, sphere: sphere.name } : null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
{#snippet card(task: Task)}
|
{#snippet card(task: Task, inTray = false)}
|
||||||
{@const meta = STATUS_META[task.status]}
|
{@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)}>
|
<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
|
<div
|
||||||
class={cn(
|
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",
|
"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",
|
!anchorDate(task) && "border-dashed hover:border-solid",
|
||||||
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
!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={{
|
use:draggable={{
|
||||||
payload: () => ({ id: task.id, kind: "task" }),
|
payload: () => ({ id: task.id, kind: "task" }),
|
||||||
ondrop: (to, after) => void handleDrop(view, task, to, after),
|
ondrop: (to, after) => void handleDrop(view, task, to, after),
|
||||||
@@ -125,15 +125,23 @@
|
|||||||
|
|
||||||
{#snippet dayCell(day: DaySlot)}
|
{#snippet dayCell(day: DaySlot)}
|
||||||
{@const target = dropTarget.day(day.key)}
|
{@const target = dropTarget.day(day.key)}
|
||||||
|
{@const here = onDay && store.cursorDay === day.key}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class={cn(
|
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",
|
"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.inMonth ? "bg-transparent" : "bg-secondary/25",
|
||||||
day.isToday && "bg-primary/[0.06]",
|
day.isToday && "bg-primary/[0.06]",
|
||||||
|
here && "bg-secondary/40 inset-ring-2 inset-ring-ring/70",
|
||||||
zone?.target === target &&
|
zone?.target === target &&
|
||||||
"bg-secondary/70 inset-ring-2 inset-ring-signal/70",
|
"bg-secondary/70 inset-ring-2 inset-ring-signal/70",
|
||||||
)}
|
)}
|
||||||
data-drop={target}
|
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">
|
<div class="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
@@ -141,47 +149,35 @@
|
|||||||
"rounded-md px-1 text-xs tabular",
|
"rounded-md px-1 text-xs tabular",
|
||||||
day.isToday && "bg-primary font-semibold text-primary-foreground",
|
day.isToday && "bg-primary font-semibold text-primary-foreground",
|
||||||
!day.inMonth && "text-muted-foreground/40",
|
!day.inMonth && "text-muted-foreground/40",
|
||||||
|
here && !day.isToday && "font-semibold text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{day.date.getDate()}
|
{day.date.getDate()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
aria-label="Add task"
|
aria-label={`Add a task on ${day.date.toLocaleDateString("en-US", {
|
||||||
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"
|
day: "numeric",
|
||||||
onclick={() => {
|
month: "long",
|
||||||
addingDay = day.key;
|
})}`}
|
||||||
dayTitle = "";
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
<Plus aria-hidden="true" class="size-3.5" />
|
<Plus aria-hidden="true" class="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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)}
|
{#each day.tasks as task (task.id)}
|
||||||
{@render card(task)}
|
{@render card(task)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/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">
|
<div class="flex shrink-0 items-center gap-1 px-3 py-2">
|
||||||
<h2 class="font-semibold text-sm first-letter:uppercase">
|
<h2 class="font-semibold text-sm first-letter:uppercase">
|
||||||
{store.monthLabel}
|
{store.monthLabel}
|
||||||
@@ -228,6 +224,18 @@
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-auto tabular">{day.tasks.length}</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>
|
</h3>
|
||||||
<div class="flex flex-col gap-1.5 pb-2">
|
<div class="flex flex-col gap-1.5 pb-2">
|
||||||
{#each day.tasks as task (task.id)}
|
{#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"
|
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)}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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());
|
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 {
|
function groupTarget(key: string): string {
|
||||||
return `group:${key}`;
|
return `group:${key}`;
|
||||||
}
|
}
|
||||||
</script>
|
</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)}
|
{#each store.groups as group (group.key)}
|
||||||
<section>
|
<section>
|
||||||
<h2
|
<h2
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ import { tasksApi, warnMissingTasks } from "../tasks-api";
|
|||||||
import type { MenuItem } from "./menu-body.svelte";
|
import type { MenuItem } from "./menu-body.svelte";
|
||||||
import type { ViewContext } from "./context";
|
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);
|
const file = view.app.vault.getAbstractFileByPath(task.path);
|
||||||
if (!(file instanceof TFile)) return;
|
if (!(file instanceof TFile)) return;
|
||||||
const leaf = view.app.workspace.getLeaf("tab");
|
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);
|
const api = tasksApi(view.app);
|
||||||
if (!api) {
|
if (!api) {
|
||||||
warnMissingTasks();
|
warnMissingTasks();
|
||||||
|
|||||||
+50
-34
@@ -1,21 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Archive from "@lucide/svelte/icons/archive";
|
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 { anchorDate, isOpen } from "../model/types";
|
||||||
import type { Task } from "../model/types";
|
import type { Task } from "../model/types";
|
||||||
import { draggable, dropZone } from "../lib/dnd.svelte";
|
import { draggable, dropZone } from "../lib/dnd.svelte";
|
||||||
import { labelHue } from "../lib/hue";
|
import { labelHue } from "../lib/hue";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { STATUS_META } from "../lib/vocab";
|
import { STATUS_META } from "../lib/vocab";
|
||||||
import {
|
import { setStatus, setText } from "../vault/mutate";
|
||||||
moveTask,
|
|
||||||
setDate,
|
|
||||||
setRecurrence,
|
|
||||||
setStatus,
|
|
||||||
setText,
|
|
||||||
} from "../vault/mutate";
|
|
||||||
import ContextArea from "./context-area.svelte";
|
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 PriorityBars from "./priority-bars.svelte";
|
||||||
import RowMenu from "./row-menu.svelte";
|
import RowMenu from "./row-menu.svelte";
|
||||||
import { dropTarget, handleDrop } from "./drop";
|
import { dropTarget, handleDrop } from "./drop";
|
||||||
@@ -41,7 +36,11 @@
|
|||||||
const zone = $derived(dropZone());
|
const zone = $derived(dropZone());
|
||||||
const isDropTarget = $derived(zone?.target === target);
|
const isDropTarget = $derived(zone?.target === target);
|
||||||
const date = $derived(anchorDate(task));
|
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() {
|
function startEdit() {
|
||||||
draft = task.text;
|
draft = task.text;
|
||||||
@@ -70,16 +69,24 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ContextArea class="contents" items={taskMenu(view, task)}>
|
<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
|
<div
|
||||||
class={cn(
|
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",
|
!isOpen(task.status) && "opacity-60 hover:opacity-100",
|
||||||
|
here && "bg-secondary/60 opacity-100 inset-ring-1 inset-ring-ring",
|
||||||
isDropTarget &&
|
isDropTarget &&
|
||||||
(zone?.after
|
(zone?.after
|
||||||
? "shadow-[inset_0_-2px_0_0_var(--signal)]"
|
? "shadow-[inset_0_-2px_0_0_var(--signal)]"
|
||||||
: "shadow-[inset_0_2px_0_0_var(--signal)]"),
|
: "shadow-[inset_0_2px_0_0_var(--signal)]"),
|
||||||
)}
|
)}
|
||||||
|
data-cursor={on}
|
||||||
data-drop={target}
|
data-drop={target}
|
||||||
|
onpointerdown={() => {
|
||||||
|
view.store.cursorTask = task.id;
|
||||||
|
}}
|
||||||
style="grid-template-columns: 1.25rem minmax(0, 1fr) auto;"
|
style="grid-template-columns: 1.25rem minmax(0, 1fr) auto;"
|
||||||
use:draggable={{
|
use:draggable={{
|
||||||
payload: () => ({ id: task.id, kind: "task" }),
|
payload: () => ({ id: task.id, kind: "task" }),
|
||||||
@@ -115,22 +122,18 @@
|
|||||||
{task.project}
|
{task.project}
|
||||||
</span>
|
</span>
|
||||||
{:else if showProject}
|
{:else if showProject}
|
||||||
<LanePicker
|
<button
|
||||||
class="hidden shrink-0 @min-[24rem]:inline-flex"
|
aria-label="Project"
|
||||||
onpick={(path, 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"
|
||||||
void moveTask(view.app, task, { path, project })}
|
data-edit={chip("project")}
|
||||||
path={task.path}
|
data-nodrag
|
||||||
project={task.project}
|
onclick={() =>
|
||||||
|
view.store.edit(task.id, "project", `[data-edit="${chip("project")}"]`)}
|
||||||
|
style="--hue: {labelHue(task.project)}"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{#snippet trigger()}
|
{task.project}
|
||||||
<span
|
</button>
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if editing}
|
{#if editing}
|
||||||
@@ -172,18 +175,31 @@
|
|||||||
<PriorityBars class="shrink-0" priority={task.priority} />
|
<PriorityBars class="shrink-0" priority={task.priority} />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex justify-end @min-[22rem]:w-24">
|
<div class="flex justify-end @min-[22rem]:w-24">
|
||||||
<DuePicker
|
<button
|
||||||
|
aria-label={task.recurrence ? "Date and repeat" : "Date"}
|
||||||
class={cn(
|
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 &&
|
!date &&
|
||||||
"opacity-0 transition-opacity group-hover/task:opacity-100 focus-visible:opacity-100 touch-shown",
|
"opacity-0 transition-opacity group-hover/task:opacity-100 focus-visible:opacity-100 touch-shown",
|
||||||
)}
|
)}
|
||||||
{field}
|
data-edit={chip("date")}
|
||||||
onclear={() => void setDate(view.app, task, field, null)}
|
data-nodrag
|
||||||
onpick={(key) => void setDate(view.app, task, field, key)}
|
onclick={() =>
|
||||||
onrepeat={(rule) => void setRecurrence(view.app, task, rule)}
|
view.store.edit(task.id, "date", `[data-edit="${chip("date")}"]`)}
|
||||||
recurrence={task.recurrence}
|
type="button"
|
||||||
value={date}
|
>
|
||||||
/>
|
{#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>
|
</div>
|
||||||
<RowMenu
|
<RowMenu
|
||||||
class="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/task:opacity-100 touch-shown"
|
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 PanelLeft from "@lucide/svelte/icons/panel-left";
|
||||||
import Plus from "@lucide/svelte/icons/plus";
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
import Search from "@lucide/svelte/icons/search";
|
import Search from "@lucide/svelte/icons/search";
|
||||||
|
import Keyboard from "@lucide/svelte/icons/keyboard";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import type { GroupBy, SortBy, ViewMode } from "../settings";
|
import type { GroupBy, SortBy, ViewMode } from "../settings";
|
||||||
import Picker from "./picker.svelte";
|
import Picker from "./picker.svelte";
|
||||||
import { useView } from "./context";
|
import { useView } from "./context";
|
||||||
|
|
||||||
interface Props {
|
|
||||||
oncreate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { oncreate }: Props = $props();
|
|
||||||
|
|
||||||
const view = useView();
|
const view = useView();
|
||||||
const store = view.store;
|
const store = view.store;
|
||||||
|
|
||||||
@@ -43,112 +38,143 @@
|
|||||||
|
|
||||||
const PILL =
|
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";
|
"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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="flex shrink-0 items-center gap-2 border-border border-b px-3 py-2">
|
||||||
class="flex shrink-0 items-center gap-2 overflow-x-auto border-border border-b px-3 py-2 scrollbar-none"
|
<!-- Filters scroll away on a narrow pane; writing a task never does. -->
|
||||||
>
|
|
||||||
<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
|
<div
|
||||||
class="flex shrink-0 items-center gap-0.5 rounded-4xl border border-border p-0.5"
|
class="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto scrollbar-none"
|
||||||
role="tablist"
|
|
||||||
>
|
>
|
||||||
{#each VIEWS as mode (mode.id)}
|
<button
|
||||||
<button
|
aria-label="Toggle spheres"
|
||||||
aria-selected={store.view === mode.id}
|
aria-pressed={!store.railCollapsed}
|
||||||
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"
|
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
store.view = mode.id;
|
store.railCollapsed = !store.railCollapsed;
|
||||||
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();
|
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}
|
triggerClass={PILL}
|
||||||
value={store.groupBy}
|
value={store.sortBy}
|
||||||
>
|
>
|
||||||
{#snippet trigger()}
|
{#snippet trigger()}
|
||||||
<Group aria-hidden="true" class="size-3.5" />
|
<ArrowDownWideNarrow aria-hidden="true" class="size-3.5" />
|
||||||
<span class="hidden @min-[40rem]:inline">
|
<span class="hidden @min-[40rem]:inline">
|
||||||
{GROUPS.find((item) => item.value === store.groupBy)?.label}
|
{SORTS.find((item) => item.value === store.sortBy)?.label}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Picker>
|
</Picker>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Picker
|
<button
|
||||||
label="Sort by"
|
aria-label="Show completed"
|
||||||
onselect={(value) => {
|
aria-pressed={store.showDone}
|
||||||
store.sortBy = value as SortBy;
|
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
||||||
}}
|
onclick={() => {
|
||||||
options={SORTS}
|
store.showDone = !store.showDone;
|
||||||
triggerClass={PILL}
|
store.savePrefs();
|
||||||
value={store.sortBy}
|
}}
|
||||||
>
|
title="Show completed"
|
||||||
{#snippet trigger()}
|
type="button"
|
||||||
<ArrowDownWideNarrow aria-hidden="true" class="size-3.5" />
|
>
|
||||||
<span class="hidden @min-[40rem]:inline">
|
<CircleCheck aria-hidden="true" class="size-3.5" />
|
||||||
{SORTS.find((item) => item.value === store.sortBy)?.label}
|
</button>
|
||||||
</span>
|
|
||||||
{/snippet}
|
<label
|
||||||
</Picker>
|
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
|
<button
|
||||||
aria-label="Show completed"
|
aria-label="Keyboard shortcuts"
|
||||||
aria-pressed={store.showDone}
|
class={cn(PILL, "px-2 text-muted-foreground")}
|
||||||
class={cn(PILL, "px-2 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground")}
|
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
store.showDone = !store.showDone;
|
store.helpOpen = true;
|
||||||
store.savePrefs();
|
|
||||||
}}
|
}}
|
||||||
title="Show completed"
|
title="Keyboard shortcuts (?)"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<CircleCheck aria-hidden="true" class="size-3.5" />
|
<Keyboard aria-hidden="true" class="size-3.5" />
|
||||||
</button>
|
</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
|
<button
|
||||||
aria-label="New task"
|
aria-label="New task"
|
||||||
class={cn(PILL, "ml-auto bg-primary text-primary-foreground hover:bg-primary/90")}
|
class={cn(PILL, "bg-primary text-primary-foreground hover:bg-primary/90")}
|
||||||
onclick={oncreate}
|
data-composer="toolbar"
|
||||||
|
onclick={() => store.write(null, "[data-composer='toolbar']")}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Plus aria-hidden="true" class="size-3.5" />
|
<Plus aria-hidden="true" class="size-3.5" />
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Notice, TFile } from "obsidian";
|
||||||
|
import type { App } from "obsidian";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo for the vault writes this plugin makes. Obsidian's own undo only covers
|
||||||
|
* the editor, and these edits never go through one, so the whole file content
|
||||||
|
* on either side of a change is what gets kept: it is small next to a note,
|
||||||
|
* and it survives moves between files that a line-level diff would not.
|
||||||
|
*/
|
||||||
|
interface Change {
|
||||||
|
path: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
label: string;
|
||||||
|
changes: Change[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT = 60;
|
||||||
|
|
||||||
|
const past: Entry[] = [];
|
||||||
|
const future: Entry[] = [];
|
||||||
|
let open: Entry | null = null;
|
||||||
|
|
||||||
|
export function record(path: string, before: string, after: string): void {
|
||||||
|
if (before === after) return;
|
||||||
|
if (open) {
|
||||||
|
open.changes.push({ path, before, after });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keep({ label: "change", changes: [{ path, before, after }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function keep(entry: Entry): void {
|
||||||
|
if (entry.changes.length === 0) return;
|
||||||
|
past.push(entry);
|
||||||
|
if (past.length > LIMIT) past.shift();
|
||||||
|
future.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folds every write a single action makes into one undo step. */
|
||||||
|
export async function transaction<T>(
|
||||||
|
label: string,
|
||||||
|
run: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (open) return run();
|
||||||
|
const entry: Entry = { label, changes: [] };
|
||||||
|
open = entry;
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
open = null;
|
||||||
|
keep(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function depth(): { past: number; future: number } {
|
||||||
|
return { past: past.length, future: future.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forget(): void {
|
||||||
|
past.length = 0;
|
||||||
|
future.length = 0;
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replay(
|
||||||
|
app: App,
|
||||||
|
changes: Change[],
|
||||||
|
expected: "before" | "after",
|
||||||
|
): Promise<boolean> {
|
||||||
|
const target = expected === "after" ? "before" : "after";
|
||||||
|
const files: { file: TFile; text: string }[] = [];
|
||||||
|
|
||||||
|
// Check every file first: a half-applied undo is worse than none.
|
||||||
|
for (const change of changes) {
|
||||||
|
const file = app.vault.getAbstractFileByPath(change.path);
|
||||||
|
if (!(file instanceof TFile)) return false;
|
||||||
|
if ((await app.vault.read(file)) !== change[expected]) return false;
|
||||||
|
files.push({ file, text: change[target] });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { file, text } of files.reverse()) {
|
||||||
|
await app.vault.process(file, () => text);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(
|
||||||
|
app: App,
|
||||||
|
from: Entry[],
|
||||||
|
to: Entry[],
|
||||||
|
expected: "before" | "after",
|
||||||
|
nothing: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const entry = from.pop();
|
||||||
|
if (!entry) {
|
||||||
|
new Notice(`Beaver Calendar: ${nothing}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await replay(app, entry.changes, expected)) {
|
||||||
|
to.push(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
from.push(entry);
|
||||||
|
new Notice(
|
||||||
|
"Beaver Calendar: the file changed since, so nothing was touched",
|
||||||
|
6000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function undo(app: App): Promise<void> {
|
||||||
|
return move(app, past, future, "after", "nothing to undo");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redo(app: App): Promise<void> {
|
||||||
|
return move(app, future, past, "before", "nothing to redo");
|
||||||
|
}
|
||||||
+16
-3
@@ -5,6 +5,7 @@ import { NORMAL_PRIORITY } from "../model/types";
|
|||||||
import type { Board, DateField, Priority, Status, Task } from "../model/types";
|
import type { Board, DateField, Priority, Status, Task } from "../model/types";
|
||||||
import { tasksApi, warnMissingTasks } from "../tasks-api";
|
import { tasksApi, warnMissingTasks } from "../tasks-api";
|
||||||
import { today } from "../lib/due";
|
import { today } from "../lib/due";
|
||||||
|
import { record, transaction } from "./history";
|
||||||
import {
|
import {
|
||||||
applyArchive,
|
applyArchive,
|
||||||
applyReorder,
|
applyReorder,
|
||||||
@@ -50,7 +51,9 @@ async function edit(
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
const next = lines.join("\n");
|
||||||
|
record(path, data, next);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
@@ -151,7 +154,16 @@ export interface MoveTarget {
|
|||||||
index?: number;
|
index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveTask(
|
/** Two files change when a task crosses spheres, so undo has to take both. */
|
||||||
|
export function moveTask(
|
||||||
|
app: App,
|
||||||
|
task: Task,
|
||||||
|
target: MoveTarget,
|
||||||
|
): Promise<void> {
|
||||||
|
return transaction("the move", () => applyMove(app, task, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMove(
|
||||||
app: App,
|
app: App,
|
||||||
task: Task,
|
task: Task,
|
||||||
target: MoveTarget,
|
target: MoveTarget,
|
||||||
@@ -216,6 +228,7 @@ export async function createTask(
|
|||||||
project: string,
|
project: string,
|
||||||
text: string,
|
text: string,
|
||||||
due: string | null,
|
due: string | null,
|
||||||
|
recurrence: string | null = null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
@@ -224,7 +237,7 @@ export async function createTask(
|
|||||||
status: " ",
|
status: " ",
|
||||||
text: trimmed,
|
text: trimmed,
|
||||||
priority: NORMAL_PRIORITY,
|
priority: NORMAL_PRIORITY,
|
||||||
recurrence: null,
|
recurrence,
|
||||||
created: null,
|
created: null,
|
||||||
start: null,
|
start: null,
|
||||||
scheduled: null,
|
scheduled: null,
|
||||||
|
|||||||
+348
-7
@@ -3,8 +3,8 @@ import type { App } from "obsidian";
|
|||||||
import { parseBoard, sphereOf } from "../model/board";
|
import { parseBoard, sphereOf } from "../model/board";
|
||||||
import { anchorDate, isOpen, STATUS_ORDER } from "../model/types";
|
import { anchorDate, isOpen, STATUS_ORDER } from "../model/types";
|
||||||
import type { Board, Status, Task } from "../model/types";
|
import type { Board, Status, Task } from "../model/types";
|
||||||
import { daysFromToday, localKey } from "../lib/due";
|
import { daysFromToday, fromKey, localKey, shiftDays } from "../lib/due";
|
||||||
import { laneKey } from "../lib/keys";
|
import { laneKey, parseLaneKey } from "../lib/keys";
|
||||||
import type {
|
import type {
|
||||||
BeaverCalendarSettings,
|
BeaverCalendarSettings,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
@@ -31,6 +31,36 @@ export interface DaySlot {
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Lane {
|
||||||
|
path: string;
|
||||||
|
project: string;
|
||||||
|
sphere: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the keyboard cursor currently lives. */
|
||||||
|
export type Zone = "day" | "task" | "undated" | "rail";
|
||||||
|
|
||||||
|
/** A picker the keyboard asked to open on the selected task. */
|
||||||
|
export type Request = "priority";
|
||||||
|
|
||||||
|
/** Fields of the one editor, in the order tab walks them. */
|
||||||
|
export type Field = "title" | "sphere" | "project" | "date" | "repeat";
|
||||||
|
|
||||||
|
export const FIELDS: Field[] = ["title", "sphere", "project", "date", "repeat"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writing a task and editing one are the same five fields, so they are the
|
||||||
|
* same panel: `mode` only decides what happens on Enter.
|
||||||
|
*/
|
||||||
|
export interface Editing {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
taskId: string | null;
|
||||||
|
date: string | null;
|
||||||
|
/** CSS selector for whatever the panel should sit under. */
|
||||||
|
anchor: string;
|
||||||
|
field: Field;
|
||||||
|
}
|
||||||
|
|
||||||
function dueBucket(key: string | null): { key: string; label: string; rank: number } {
|
function dueBucket(key: string | null): { key: string; label: string; rank: number } {
|
||||||
if (!key) return { key: "none", label: "No date", rank: 5 };
|
if (!key) return { key: "none", label: "No date", rank: 5 };
|
||||||
const diff = daysFromToday(key);
|
const diff = daysFromToday(key);
|
||||||
@@ -77,6 +107,28 @@ export class BoardStore {
|
|||||||
monthKey = $state(localKey(new Date()));
|
monthKey = $state(localKey(new Date()));
|
||||||
selected = $state<string | null>(null);
|
selected = $state<string | null>(null);
|
||||||
|
|
||||||
|
/** Keyboard cursor: a day in the grid, plus a task inside it once entered. */
|
||||||
|
cursorDay = $state(localKey(new Date()));
|
||||||
|
cursorTask = $state<string | null>(null);
|
||||||
|
cursorRail = $state<string | null>(null);
|
||||||
|
zone = $state<Zone>("day");
|
||||||
|
request = $state<Request | null>(null);
|
||||||
|
helpOpen = $state(false);
|
||||||
|
/**
|
||||||
|
* Cursor chrome stays off the screen until a key is actually pressed, so a
|
||||||
|
* mouse session never carries an outline nobody asked for.
|
||||||
|
*/
|
||||||
|
keyboard = $state(false);
|
||||||
|
/** Which spheres are unfolded in the rail. */
|
||||||
|
railExpanded = $state<Set<string>>(new Set());
|
||||||
|
/** Set when `b` had to reveal the rail, so leaving can put it back. */
|
||||||
|
private railWasHidden = false;
|
||||||
|
|
||||||
|
/** The open editor, whether it is writing a task or changing one. */
|
||||||
|
editing = $state<Editing | null>(null);
|
||||||
|
/** The lane a new task lands in unless the composer is told otherwise. */
|
||||||
|
lastLane = $state<Lane | null>(null);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly app: App,
|
private readonly app: App,
|
||||||
private settings: BeaverCalendarSettings,
|
private settings: BeaverCalendarSettings,
|
||||||
@@ -90,6 +142,13 @@ export class BoardStore {
|
|||||||
this.includeArchive = settings.includeArchive;
|
this.includeArchive = settings.includeArchive;
|
||||||
this.railCollapsed = settings.railCollapsed;
|
this.railCollapsed = settings.railCollapsed;
|
||||||
this.undatedCollapsed = settings.undatedCollapsed;
|
this.undatedCollapsed = settings.undatedCollapsed;
|
||||||
|
if (settings.lastLanePath && settings.lastLaneProject) {
|
||||||
|
this.lastLane = {
|
||||||
|
path: settings.lastLanePath,
|
||||||
|
project: settings.lastLaneProject,
|
||||||
|
sphere: sphereOf(settings.lastLanePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get sortBy(): SortBy {
|
get sortBy(): SortBy {
|
||||||
@@ -304,7 +363,7 @@ export class BoardStore {
|
|||||||
undated = $derived.by(() => this.visible.filter((task) => !anchorDate(task)));
|
undated = $derived.by(() => this.visible.filter((task) => !anchorDate(task)));
|
||||||
|
|
||||||
monthDays = $derived.by<DaySlot[]>(() => {
|
monthDays = $derived.by<DaySlot[]>(() => {
|
||||||
const anchor = new Date(this.monthKey);
|
const anchor = fromKey(this.monthKey);
|
||||||
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||||
const offset = (first.getDay() + 6) % 7;
|
const offset = (first.getDay() + 6) % 7;
|
||||||
const start = new Date(first.getTime() - offset * DAY);
|
const start = new Date(first.getTime() - offset * DAY);
|
||||||
@@ -338,21 +397,303 @@ export class BoardStore {
|
|||||||
new Intl.DateTimeFormat("en-US", {
|
new Intl.DateTimeFormat("en-US", {
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date(this.monthKey)),
|
}).format(fromKey(this.monthKey)),
|
||||||
);
|
);
|
||||||
|
|
||||||
stepMonth(delta: number): void {
|
stepMonth(delta: number): void {
|
||||||
const anchor = new Date(this.monthKey);
|
const anchor = fromKey(this.monthKey);
|
||||||
this.monthKey = localKey(
|
const next = new Date(anchor.getFullYear(), anchor.getMonth() + delta, 1);
|
||||||
new Date(anchor.getFullYear(), anchor.getMonth() + delta, 1),
|
this.monthKey = localKey(next);
|
||||||
|
// Keep the cursor on the same day number inside the month it just entered.
|
||||||
|
const day = fromKey(this.cursorDay).getDate();
|
||||||
|
const last = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
|
||||||
|
this.cursorDay = localKey(
|
||||||
|
new Date(next.getFullYear(), next.getMonth(), Math.min(day, last)),
|
||||||
);
|
);
|
||||||
|
this.zone = "day";
|
||||||
|
this.cursorTask = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
goToday(): void {
|
goToday(): void {
|
||||||
this.monthKey = localKey(new Date());
|
this.monthKey = localKey(new Date());
|
||||||
|
this.focusDay(localKey(new Date()));
|
||||||
}
|
}
|
||||||
|
|
||||||
sphereName(path: string): string {
|
sphereName(path: string): string {
|
||||||
return this.boards.get(path)?.sphere ?? sphereOf(path);
|
return this.boards.get(path)?.sphere ?? sphereOf(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Keyboard cursor ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The lane a task lands in when nobody says otherwise. */
|
||||||
|
defaultLane = $derived.by<Lane | null>(() => {
|
||||||
|
const spheres = this.spheres;
|
||||||
|
if (spheres.length === 0) return null;
|
||||||
|
|
||||||
|
// A sidebar filter is an explicit instruction, so it outranks the memory.
|
||||||
|
const pinned = this.sphereFilter
|
||||||
|
? spheres.find((sphere) => sphere.path === this.sphereFilter)
|
||||||
|
: undefined;
|
||||||
|
if (pinned) {
|
||||||
|
const project =
|
||||||
|
(this.projectFilter && pinned.projects.includes(this.projectFilter)
|
||||||
|
? this.projectFilter
|
||||||
|
: undefined) ?? pinned.projects[0];
|
||||||
|
if (project) return { path: pinned.path, project, sphere: pinned.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = this.lastLane;
|
||||||
|
if (last) {
|
||||||
|
const sphere = spheres.find((item) => item.path === last.path);
|
||||||
|
if (sphere?.projects.includes(last.project)) {
|
||||||
|
return { path: sphere.path, project: last.project, sphere: sphere.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = spheres.find((sphere) => sphere.projects.length > 0);
|
||||||
|
return first
|
||||||
|
? { path: first.path, project: first.projects[0], sphere: first.name }
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
|
write(date: string | null, anchor: string): void {
|
||||||
|
this.editing = { mode: "create", taskId: null, date, anchor, field: "title" };
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(taskId: string, field: Field = "title", anchor = "[data-cursor='true']"): void {
|
||||||
|
this.request = null;
|
||||||
|
this.editing = { mode: "edit", taskId, date: null, anchor, field };
|
||||||
|
}
|
||||||
|
|
||||||
|
stopEditing(): void {
|
||||||
|
this.editing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberLane(lane: Lane): void {
|
||||||
|
this.lastLane = lane;
|
||||||
|
this.settings.lastLanePath = lane.path;
|
||||||
|
this.settings.lastLaneProject = lane.project;
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The run the task cursor walks. In the list this has to be the groups laid
|
||||||
|
* end to end rather than `visible`: grouping reorders what is on screen, and
|
||||||
|
* a cursor that walks anything but the drawn order reads as random.
|
||||||
|
*/
|
||||||
|
cursorTasks = $derived.by<Task[]>(() => {
|
||||||
|
if (this.view === "list") return this.groups.flatMap((group) => group.tasks);
|
||||||
|
if (this.zone === "undated") return this.undated;
|
||||||
|
return this.byDay.get(this.cursorDay) ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
focusDay(key: string): void {
|
||||||
|
this.cursorDay = key;
|
||||||
|
this.zone = "day";
|
||||||
|
this.cursorTask = null;
|
||||||
|
this.request = null;
|
||||||
|
const day = fromKey(key);
|
||||||
|
const month = fromKey(this.monthKey);
|
||||||
|
if (
|
||||||
|
day.getMonth() !== month.getMonth() ||
|
||||||
|
day.getFullYear() !== month.getFullYear()
|
||||||
|
) {
|
||||||
|
this.monthKey = localKey(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moveCursor(days: number): void {
|
||||||
|
this.focusDay(shiftDays(this.cursorDay, days));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops into the tasks of the day under the cursor, if it holds any. */
|
||||||
|
enterDay(): boolean {
|
||||||
|
const tasks = this.byDay.get(this.cursorDay) ?? [];
|
||||||
|
if (tasks.length === 0) return false;
|
||||||
|
this.zone = "task";
|
||||||
|
this.cursorTask = tasks[0].id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `u` is a round trip: in and open, then out and shut again. */
|
||||||
|
toggleUndated(): boolean {
|
||||||
|
if (this.zone === "undated") {
|
||||||
|
this.undatedCollapsed = true;
|
||||||
|
this.savePrefs();
|
||||||
|
this.leaveToMain();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.view !== "calendar" || this.undated.length === 0) return false;
|
||||||
|
this.undatedCollapsed = false;
|
||||||
|
this.savePrefs();
|
||||||
|
this.zone = "undated";
|
||||||
|
this.cursorTask = this.undated[0].id;
|
||||||
|
this.request = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `b` is the same round trip, except a rail that was already pinned open
|
||||||
|
* stays open: only the one this opened gets put away again.
|
||||||
|
*/
|
||||||
|
toggleRail(): boolean {
|
||||||
|
if (this.zone === "rail") {
|
||||||
|
if (this.railWasHidden) {
|
||||||
|
this.railCollapsed = true;
|
||||||
|
this.savePrefs();
|
||||||
|
}
|
||||||
|
this.leaveToMain();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.railWasHidden = this.railCollapsed;
|
||||||
|
if (this.railCollapsed) {
|
||||||
|
this.railCollapsed = false;
|
||||||
|
this.savePrefs();
|
||||||
|
}
|
||||||
|
this.zone = "rail";
|
||||||
|
this.request = null;
|
||||||
|
if (!this.cursorRail || !this.railItems.includes(this.cursorRail)) {
|
||||||
|
this.cursorRail = this.railItems[0] ?? null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lands the cursor on the first task the search turned up. */
|
||||||
|
focusFirstMatch(): boolean {
|
||||||
|
const first = this.visible[0];
|
||||||
|
if (!first) return false;
|
||||||
|
this.cursorTask = first.id;
|
||||||
|
this.request = null;
|
||||||
|
if (this.view === "list") {
|
||||||
|
this.zone = "task";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const key = anchorDate(first);
|
||||||
|
if (key) {
|
||||||
|
this.zone = "task";
|
||||||
|
this.focusDayKeepingTask(key);
|
||||||
|
} else {
|
||||||
|
this.undatedCollapsed = false;
|
||||||
|
this.zone = "undated";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hands the cursor back to whichever body the current view shows. */
|
||||||
|
leaveToMain(): void {
|
||||||
|
this.request = null;
|
||||||
|
if (this.view === "list") {
|
||||||
|
this.zone = "task";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.zone = "day";
|
||||||
|
this.cursorTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows the rail cursor walks, in the order they are drawn. */
|
||||||
|
railItems = $derived.by<string[]>(() => {
|
||||||
|
const out = ["all"];
|
||||||
|
for (const sphere of this.spheres) {
|
||||||
|
out.push(`sphere:${sphere.path}`);
|
||||||
|
if (!this.railExpanded.has(sphere.path)) continue;
|
||||||
|
for (const project of sphere.projects) {
|
||||||
|
out.push(`lane:${laneKey(sphere.path, project)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
stepRail(delta: number): void {
|
||||||
|
const rows = this.railItems;
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const at = rows.indexOf(this.cursorRail ?? "");
|
||||||
|
const next = at === -1 ? 0 : Math.min(rows.length - 1, Math.max(0, at + delta));
|
||||||
|
this.cursorRail = rows[next];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `l` unfolds a sphere, `h` folds it or steps out of one of its projects. */
|
||||||
|
foldRail(open: boolean): void {
|
||||||
|
const row = this.cursorRail ?? "";
|
||||||
|
const next = new Set(this.railExpanded);
|
||||||
|
|
||||||
|
if (row.startsWith("sphere:")) {
|
||||||
|
const path = row.slice(7);
|
||||||
|
if (open) next.add(path);
|
||||||
|
else next.delete(path);
|
||||||
|
this.railExpanded = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.startsWith("lane:") && !open) {
|
||||||
|
const lane = parseLaneKey(row.slice(5));
|
||||||
|
if (lane) this.cursorRail = `sphere:${lane.path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the row under the rail cursor as the filter, then steps out. */
|
||||||
|
chooseRail(): void {
|
||||||
|
const row = this.cursorRail ?? "";
|
||||||
|
if (row === "all") {
|
||||||
|
this.sphereFilter = null;
|
||||||
|
this.projectFilter = null;
|
||||||
|
} else if (row.startsWith("sphere:")) {
|
||||||
|
this.sphereFilter = row.slice(7);
|
||||||
|
this.projectFilter = null;
|
||||||
|
} else if (row.startsWith("lane:")) {
|
||||||
|
const lane = parseLaneKey(row.slice(5));
|
||||||
|
if (lane) {
|
||||||
|
this.sphereFilter = lane.path;
|
||||||
|
this.projectFilter = lane.project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.toggleRail();
|
||||||
|
}
|
||||||
|
|
||||||
|
stepTask(delta: number): void {
|
||||||
|
const run = this.cursorTasks;
|
||||||
|
if (run.length === 0) return;
|
||||||
|
const at = run.findIndex((task) => task.id === this.cursorTask);
|
||||||
|
// A filter or a sort can carry the cursor's task off the screen; coming in
|
||||||
|
// from the end the key was pressed towards is the least jarring recovery.
|
||||||
|
const next =
|
||||||
|
at === -1
|
||||||
|
? delta > 0
|
||||||
|
? 0
|
||||||
|
: run.length - 1
|
||||||
|
: Math.min(run.length - 1, Math.max(0, at + delta));
|
||||||
|
this.cursorTask = run[next].id;
|
||||||
|
this.request = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rescheduling rewrites the line in place, so the task keeps its id and the
|
||||||
|
* cursor only has to walk to whichever day it landed on.
|
||||||
|
*/
|
||||||
|
followDate(key: string | null): void {
|
||||||
|
if (this.view !== "calendar") return;
|
||||||
|
if (key === null) {
|
||||||
|
this.zone = "undated";
|
||||||
|
this.undatedCollapsed = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.zone = "task";
|
||||||
|
this.focusDayKeepingTask(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusDayKeepingTask(key: string): void {
|
||||||
|
this.cursorDay = key;
|
||||||
|
const day = fromKey(key);
|
||||||
|
const month = fromKey(this.monthKey);
|
||||||
|
if (
|
||||||
|
day.getMonth() !== month.getMonth() ||
|
||||||
|
day.getFullYear() !== month.getFullYear()
|
||||||
|
) {
|
||||||
|
this.monthKey = localKey(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
taskById(id: string | null): Task | null {
|
||||||
|
if (!id) return null;
|
||||||
|
return this.all.find((task) => task.id === id) ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+18
@@ -29,10 +29,28 @@ export class BoardsView extends ItemView {
|
|||||||
return "calendar-check";
|
return "calendar-check";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coming back from another tab leaves the keyboard wherever Obsidian put it,
|
||||||
|
* which is not inside this view, so the bindings look broken until something
|
||||||
|
* is clicked. Take it back whenever this leaf becomes the active one.
|
||||||
|
*/
|
||||||
|
private takeKeyboard(): void {
|
||||||
|
this.contentEl
|
||||||
|
.querySelector<HTMLElement>(".bcal-root")
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
async onOpen(): Promise<void> {
|
async onOpen(): Promise<void> {
|
||||||
this.contentEl.empty();
|
this.contentEl.empty();
|
||||||
this.contentEl.style.padding = "0";
|
this.contentEl.style.padding = "0";
|
||||||
this.contentEl.style.overflow = "hidden";
|
this.contentEl.style.overflow = "hidden";
|
||||||
|
|
||||||
|
this.registerEvent(
|
||||||
|
this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||||
|
if (leaf === this.leaf) this.takeKeyboard();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await this.plugin.store.reloadAll();
|
await this.plugin.store.reloadAll();
|
||||||
this.root = mount(App, {
|
this.root = mount(App, {
|
||||||
target: this.contentEl,
|
target: this.contentEl,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fuzzy, pieces, rank } from "../src/lib/fuzzy";
|
||||||
|
|
||||||
|
describe("fuzzy", () => {
|
||||||
|
it("matches a subsequence, not just a substring", () => {
|
||||||
|
expect(fuzzy("wsr", "website redesign")).not.toBeNull();
|
||||||
|
expect(fuzzy("zzz", "website redesign")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case insensitive and ignores surrounding space in the query", () => {
|
||||||
|
expect(fuzzy(" WEB ", "website")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns every match for an empty query", () => {
|
||||||
|
expect(fuzzy("", "anything")).toEqual({ score: 0, ranges: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges adjacent letters into one range", () => {
|
||||||
|
expect(fuzzy("web", "website")?.ranges).toEqual([[0, 3]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps separate runs apart", () => {
|
||||||
|
expect(fuzzy("wr", "website redesign")?.ranges).toEqual([
|
||||||
|
[0, 1],
|
||||||
|
[8, 9],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores a word start above a letter buried mid-word", () => {
|
||||||
|
const start = fuzzy("r", "website redesign")?.score ?? 0;
|
||||||
|
const buried = fuzzy("b", "website redesign")?.score ?? 0;
|
||||||
|
expect(start).toBeGreaterThan(buried);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rank", () => {
|
||||||
|
const items = ["web", "website redesign", "errands", "someday"];
|
||||||
|
const label = (item: string): string => item;
|
||||||
|
|
||||||
|
it("keeps the given order when nothing is typed", () => {
|
||||||
|
expect(rank("", items, label).map((hit) => hit.item)).toEqual(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the shorter exact head first", () => {
|
||||||
|
expect(rank("web", items, label)[0].item).toBe("web");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops what does not match at all", () => {
|
||||||
|
expect(rank("qq", items, label)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks ties by the incoming order", () => {
|
||||||
|
expect(rank("e", ["ea", "eb"], label).map((hit) => hit.item)).toEqual([
|
||||||
|
"ea",
|
||||||
|
"eb",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pieces", () => {
|
||||||
|
it("splits a label into matched and unmatched runs", () => {
|
||||||
|
expect(pieces("website", [[0, 3]])).toEqual([
|
||||||
|
{ text: "web", hit: true },
|
||||||
|
{ text: "site", hit: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the whole label when nothing matched", () => {
|
||||||
|
expect(pieces("website", [])).toEqual([{ text: "website", hit: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds the original text exactly", () => {
|
||||||
|
const hit = fuzzy("wr", "website redesign");
|
||||||
|
const parts = pieces("website redesign", hit?.ranges ?? []);
|
||||||
|
expect(parts.map((part) => part.text).join("")).toBe("website redesign");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { step } from "../src/lib/grid";
|
||||||
|
import type { Rect } from "../src/lib/grid";
|
||||||
|
|
||||||
|
/** Three cards per row, 100 wide and 40 tall, the way the tray wraps them. */
|
||||||
|
function tray(count: number): { id: number; rect: Rect }[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
id: index,
|
||||||
|
rect: {
|
||||||
|
left: (index % 3) * 100,
|
||||||
|
top: Math.floor(index / 3) * 40,
|
||||||
|
width: 96,
|
||||||
|
height: 36,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rectOf = (item: { rect: Rect }): Rect => item.rect;
|
||||||
|
const walk = (
|
||||||
|
items: ReturnType<typeof tray>,
|
||||||
|
from: number,
|
||||||
|
direction: Parameters<typeof step>[2],
|
||||||
|
): number | null => step(items, items[from], direction, rectOf)?.id ?? null;
|
||||||
|
|
||||||
|
describe("step", () => {
|
||||||
|
it("moves along a row", () => {
|
||||||
|
const items = tray(6);
|
||||||
|
expect(walk(items, 0, "right")).toBe(1);
|
||||||
|
expect(walk(items, 1, "left")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves down a column, not just to the next card", () => {
|
||||||
|
const items = tray(6);
|
||||||
|
expect(walk(items, 1, "down")).toBe(4);
|
||||||
|
expect(walk(items, 4, "up")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the column when the last row is short", () => {
|
||||||
|
const items = tray(5);
|
||||||
|
expect(walk(items, 1, "down")).toBe(4);
|
||||||
|
expect(walk(items, 2, "down")).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries on to the next row at the end of one", () => {
|
||||||
|
const items = tray(6);
|
||||||
|
expect(walk(items, 2, "right")).toBe(3);
|
||||||
|
expect(walk(items, 3, "left")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the top and the bottom", () => {
|
||||||
|
const items = tray(6);
|
||||||
|
expect(walk(items, 1, "up")).toBeNull();
|
||||||
|
expect(walk(items, 4, "down")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the very ends of the run", () => {
|
||||||
|
const items = tray(6);
|
||||||
|
expect(walk(items, 0, "left")).toBeNull();
|
||||||
|
expect(walk(items, 5, "right")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first card when the cursor is off the run", () => {
|
||||||
|
const items = tray(3);
|
||||||
|
const stray = { id: 99, rect: { left: 0, top: 0, width: 1, height: 1 } };
|
||||||
|
expect(step(items, stray, "right", rectOf)?.id).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a row by the band each card fills, not by its centre", () => {
|
||||||
|
// Top-aligned cards of different heights still sit on one row.
|
||||||
|
const items = [
|
||||||
|
{ id: 0, rect: { left: 0, top: 0, width: 96, height: 67 } },
|
||||||
|
{ id: 1, rect: { left: 100, top: 0, width: 96, height: 67 } },
|
||||||
|
{ id: 2, rect: { left: 200, top: 0, width: 96, height: 51 } },
|
||||||
|
{ id: 3, rect: { left: 0, top: 73, width: 96, height: 51 } },
|
||||||
|
];
|
||||||
|
expect(step(items, items[0], "up", rectOf)).toBeNull();
|
||||||
|
expect(step(items, items[0], "right", rectOf)?.id).toBe(1);
|
||||||
|
expect(step(items, items[2], "left", rectOf)?.id).toBe(1);
|
||||||
|
expect(step(items, items[0], "down", rectOf)?.id).toBe(3);
|
||||||
|
expect(step(items, items[3], "up", rectOf)?.id).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a single card without moving", () => {
|
||||||
|
const items = tray(1);
|
||||||
|
expect(walk(items, 0, "right")).toBeNull();
|
||||||
|
expect(walk(items, 0, "down")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TFile } from "obsidian";
|
||||||
|
import type { App } from "obsidian";
|
||||||
|
import { depth, forget, record, redo, transaction, undo } from "../src/vault/history";
|
||||||
|
|
||||||
|
/** Obsidian hands out TFile instances; history checks for them by identity. */
|
||||||
|
function handle(path: string): TFile {
|
||||||
|
return Object.assign(Object.create(TFile.prototype) as TFile, { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
function vault(seed: Record<string, string>) {
|
||||||
|
const files = new Map(Object.entries(seed));
|
||||||
|
const handles = new Map([...files.keys()].map((path) => [path, handle(path)]));
|
||||||
|
const app = {
|
||||||
|
vault: {
|
||||||
|
getAbstractFileByPath: (path: string) => handles.get(path) ?? null,
|
||||||
|
read: async (file: TFile) => files.get(file.path) ?? "",
|
||||||
|
process: async (file: TFile, fn: (data: string) => string) => {
|
||||||
|
const next = fn(files.get(file.path) ?? "");
|
||||||
|
files.set(file.path, next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as App;
|
||||||
|
return { app, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors what mutate.edit does: write the file, then log both sides. */
|
||||||
|
async function write(
|
||||||
|
{ app, files }: ReturnType<typeof vault>,
|
||||||
|
path: string,
|
||||||
|
next: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const before = files.get(path) ?? "";
|
||||||
|
await app.vault.process(
|
||||||
|
app.vault.getAbstractFileByPath(path) as TFile,
|
||||||
|
() => next,
|
||||||
|
);
|
||||||
|
record(path, before, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("history", () => {
|
||||||
|
beforeEach(() => forget());
|
||||||
|
|
||||||
|
it("puts a single change back", async () => {
|
||||||
|
const store = vault({ "a.md": "one" });
|
||||||
|
await write(store, "a.md", "two");
|
||||||
|
await undo(store.app);
|
||||||
|
expect(store.files.get("a.md")).toBe("one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays what it undid", async () => {
|
||||||
|
const store = vault({ "a.md": "one" });
|
||||||
|
await write(store, "a.md", "two");
|
||||||
|
await undo(store.app);
|
||||||
|
await redo(store.app);
|
||||||
|
expect(store.files.get("a.md")).toBe("two");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks back through several changes one at a time", async () => {
|
||||||
|
const store = vault({ "a.md": "one" });
|
||||||
|
await write(store, "a.md", "two");
|
||||||
|
await write(store, "a.md", "three");
|
||||||
|
await undo(store.app);
|
||||||
|
expect(store.files.get("a.md")).toBe("two");
|
||||||
|
await undo(store.app);
|
||||||
|
expect(store.files.get("a.md")).toBe("one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes both files of a move back together", async () => {
|
||||||
|
const store = vault({ "a.md": "task", "b.md": "" });
|
||||||
|
await transaction("the move", async () => {
|
||||||
|
await write(store, "b.md", "task");
|
||||||
|
await write(store, "a.md", "");
|
||||||
|
});
|
||||||
|
expect(depth().past).toBe(1);
|
||||||
|
await undo(store.app);
|
||||||
|
expect(store.files.get("a.md")).toBe("task");
|
||||||
|
expect(store.files.get("b.md")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a write that changed nothing", async () => {
|
||||||
|
const store = vault({ "a.md": "one" });
|
||||||
|
await write(store, "a.md", "one");
|
||||||
|
expect(depth().past).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to touch a file that moved on underneath it", async () => {
|
||||||
|
const store = vault({ "a.md": "one" });
|
||||||
|
await write(store, "a.md", "two");
|
||||||
|
store.files.set("a.md", "edited elsewhere");
|
||||||
|
await undo(store.app);
|
||||||
|
expect(store.files.get("a.md")).toBe("edited elsewhere");
|
||||||
|
// The step is kept, so a later undo can still work if the file comes back.
|
||||||
|
expect(depth().past).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the redo trail once something new is written", async () => {
|
||||||
|
const store = vault({ "a.md": "one" });
|
||||||
|
await write(store, "a.md", "two");
|
||||||
|
await undo(store.app);
|
||||||
|
expect(depth().future).toBe(1);
|
||||||
|
await write(store, "a.md", "three");
|
||||||
|
expect(depth().future).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so instead of throwing when there is nothing left", async () => {
|
||||||
|
const store = vault({ "a.md": "one" });
|
||||||
|
await expect(undo(store.app)).resolves.toBeUndefined();
|
||||||
|
await expect(redo(store.app)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { GROUPS, listStep, resolve, SHORTCUTS } from "../src/lib/keymap";
|
||||||
|
import type { Scope } from "../src/lib/keymap";
|
||||||
|
|
||||||
|
function press(
|
||||||
|
key: string,
|
||||||
|
init: { code?: string; ctrl?: boolean; meta?: boolean; shift?: boolean } = {},
|
||||||
|
): KeyboardEvent {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
code: init.code ?? "",
|
||||||
|
ctrlKey: init.ctrl ?? false,
|
||||||
|
metaKey: init.meta ?? false,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: init.shift ?? false,
|
||||||
|
} as KeyboardEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY: Scope[] = ["day", "view"];
|
||||||
|
const TASK: Scope[] = ["task", "view"];
|
||||||
|
const TRAY: Scope[] = ["tray", "task", "view"];
|
||||||
|
const RAIL: Scope[] = ["rail", "view"];
|
||||||
|
|
||||||
|
describe("resolve", () => {
|
||||||
|
it("walks days on the grid and tasks inside one", () => {
|
||||||
|
expect(resolve(press("j", { code: "KeyJ" }), DAY)).toBe("dayDown");
|
||||||
|
expect(resolve(press("j", { code: "KeyJ" }), TASK)).toBe("taskNext");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the day with h and l from either scope", () => {
|
||||||
|
expect(resolve(press("h", { code: "KeyH" }), DAY)).toBe("dayLeft");
|
||||||
|
expect(resolve(press("h", { code: "KeyH" }), TASK)).toBe("dayLeft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates a shifted letter from its bare form", () => {
|
||||||
|
expect(resolve(press("H", { code: "KeyH", shift: true }), TASK)).toBe(
|
||||||
|
"taskBack",
|
||||||
|
);
|
||||||
|
expect(resolve(press("h", { code: "KeyH" }), TASK)).toBe("dayLeft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the physical key, so a Cyrillic layout still answers", () => {
|
||||||
|
expect(resolve(press("н", { code: "KeyN" }), DAY)).toBe("create");
|
||||||
|
expect(resolve(press("Т", { code: "KeyT", shift: true }), TASK)).toBe(
|
||||||
|
"taskToday",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to the view scope", () => {
|
||||||
|
expect(resolve(press("/", { code: "Slash" }), DAY)).toBe("search");
|
||||||
|
expect(resolve(press("?", { code: "Slash", shift: true }), DAY)).toBe("help");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("means different things by Enter and Escape per scope", () => {
|
||||||
|
expect(resolve(press("Enter", { code: "Enter" }), DAY)).toBe("dayEnter");
|
||||||
|
expect(resolve(press("Enter", { code: "Enter" }), TASK)).toBe("taskOpen");
|
||||||
|
expect(resolve(press("Escape", { code: "Escape" }), TASK)).toBe("taskExit");
|
||||||
|
expect(resolve(press("Escape", { code: "Escape" }), DAY)).toBe("dismiss");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a modifier the table never asked for", () => {
|
||||||
|
expect(resolve(press("n", { code: "KeyN", meta: true }), DAY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers nothing for an unbound key", () => {
|
||||||
|
expect(resolve(press("q", { code: "KeyQ" }), TASK)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks the tray as a grid while task actions still reach through", () => {
|
||||||
|
expect(resolve(press("h", { code: "KeyH" }), TRAY)).toBe("trayLeft");
|
||||||
|
expect(resolve(press("j", { code: "KeyJ" }), TRAY)).toBe("trayDown");
|
||||||
|
expect(resolve(press("x", { code: "KeyX" }), TRAY)).toBe("taskToggle");
|
||||||
|
expect(resolve(press("L", { code: "KeyL", shift: true }), TRAY)).toBe(
|
||||||
|
"taskForward",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the rail its own reading of the same keys", () => {
|
||||||
|
expect(resolve(press("j", { code: "KeyJ" }), RAIL)).toBe("railNext");
|
||||||
|
expect(resolve(press("l", { code: "KeyL" }), RAIL)).toBe("railUnfold");
|
||||||
|
expect(resolve(press("h", { code: "KeyH" }), RAIL)).toBe("railFold");
|
||||||
|
expect(resolve(press("Enter", { code: "Enter" }), RAIL)).toBe("railChoose");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the panel it opened with the same key", () => {
|
||||||
|
expect(resolve(press("u", { code: "KeyU" }), TRAY)).toBe("undated");
|
||||||
|
expect(resolve(press("u", { code: "KeyU" }), DAY)).toBe("undated");
|
||||||
|
expect(resolve(press("b", { code: "KeyB" }), RAIL)).toBe("toggleRail");
|
||||||
|
expect(resolve(press("b", { code: "KeyB" }), DAY)).toBe("toggleRail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes undo on either modifier, and redo with shift", () => {
|
||||||
|
expect(resolve(press("z", { code: "KeyZ", meta: true }), DAY)).toBe("undo");
|
||||||
|
expect(resolve(press("z", { code: "KeyZ", ctrl: true }), DAY)).toBe("undo");
|
||||||
|
expect(
|
||||||
|
resolve(press("Z", { code: "KeyZ", meta: true, shift: true }), DAY),
|
||||||
|
).toBe("redo");
|
||||||
|
expect(resolve(press("z", { code: "KeyZ" }), DAY)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listStep", () => {
|
||||||
|
it("takes arrows, the emacs pair and the vim pair", () => {
|
||||||
|
expect(listStep(press("ArrowDown", { code: "ArrowDown" }))).toBe(1);
|
||||||
|
expect(listStep(press("ArrowUp", { code: "ArrowUp" }))).toBe(-1);
|
||||||
|
expect(listStep(press("n", { code: "KeyN", ctrl: true }))).toBe(1);
|
||||||
|
expect(listStep(press("p", { code: "KeyP", ctrl: true }))).toBe(-1);
|
||||||
|
expect(listStep(press("j", { code: "KeyJ", meta: true }))).toBe(1);
|
||||||
|
expect(listStep(press("k", { code: "KeyK", meta: true }))).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a bare letter alone so it can be typed", () => {
|
||||||
|
expect(listStep(press("n", { code: "KeyN" }))).toBeNull();
|
||||||
|
expect(listStep(press("j", { code: "KeyJ" }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the sheet and the dispatcher share one table", () => {
|
||||||
|
it("gives every documented binding a group the sheet renders", () => {
|
||||||
|
for (const shortcut of SHORTCUTS) {
|
||||||
|
expect(GROUPS).toContain(shortcut.group);
|
||||||
|
expect(shortcut.keys.length).toBeGreaterThan(0);
|
||||||
|
expect(shortcut.label.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only leaves combos empty on rows the composer handles itself", () => {
|
||||||
|
for (const shortcut of SHORTCUTS) {
|
||||||
|
if (shortcut.combos.length === 0) {
|
||||||
|
expect(shortcut.action).toBeNull();
|
||||||
|
expect(shortcut.scopes).toEqual(["compose"]);
|
||||||
|
} else {
|
||||||
|
expect(shortcut.action).not.toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never binds one combo twice inside a scope", () => {
|
||||||
|
for (const scope of ["day", "task", "tray", "rail", "view"] as Scope[]) {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const shortcut of SHORTCUTS) {
|
||||||
|
if (!shortcut.scopes.includes(scope)) continue;
|
||||||
|
for (const each of shortcut.combos) {
|
||||||
|
expect(
|
||||||
|
seen.has(each),
|
||||||
|
`${each} is bound twice in ${scope}: ${seen.get(each)} and ${shortcut.label}`,
|
||||||
|
).toBe(false);
|
||||||
|
seen.set(each, shortcut.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { onScreen, place } from "../src/lib/place";
|
||||||
|
import type { Box } from "../src/lib/place";
|
||||||
|
|
||||||
|
const VIEW = { width: 1280, height: 800 };
|
||||||
|
const PANEL = { width: 320, height: 200 };
|
||||||
|
const row = (over: Partial<Box> = {}): Box => ({
|
||||||
|
top: 300,
|
||||||
|
left: 400,
|
||||||
|
width: 500,
|
||||||
|
height: 40,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onScreen", () => {
|
||||||
|
it("skips the copy that is hidden and has no box", () => {
|
||||||
|
const hidden = row({ top: 0, left: 0, width: 0, height: 0 });
|
||||||
|
const shown = row();
|
||||||
|
expect(onScreen([hidden, shown])).toBe(shown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the first one when several are on screen", () => {
|
||||||
|
const first = row();
|
||||||
|
expect(onScreen([first, row({ top: 500 })])).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing when every copy is hidden", () => {
|
||||||
|
expect(onScreen([row({ width: 0, height: 0 })])).toBeNull();
|
||||||
|
expect(onScreen([])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("place", () => {
|
||||||
|
it("sits just under the anchor", () => {
|
||||||
|
expect(place(row(), PANEL, VIEW)).toEqual({ top: 346, left: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips above when there is no room below", () => {
|
||||||
|
expect(place(row({ top: 700 }), PANEL, VIEW)).toEqual({ top: 494, left: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays inside the window when there is room neither way", () => {
|
||||||
|
const tall = { width: 320, height: 780 };
|
||||||
|
const spot = place(row({ top: 400 }), tall, VIEW);
|
||||||
|
expect(spot.top).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(spot.top + tall.height).toBeLessThanOrEqual(VIEW.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pulls back from the right edge", () => {
|
||||||
|
expect(place(row({ left: 1200 }), PANEL, VIEW).left).toBe(950);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never goes off the left edge", () => {
|
||||||
|
expect(place(row({ left: -40 }), PANEL, VIEW).left).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centres rather than hiding in the corner with nothing to anchor to", () => {
|
||||||
|
expect(place(null, PANEL, VIEW)).toEqual({ top: 300, left: 480 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+301
-1
@@ -48,6 +48,9 @@ for (const key of Object.getOwnPropertyNames(dom.window)) {
|
|||||||
}
|
}
|
||||||
globalThis.window = dom.window;
|
globalThis.window = dom.window;
|
||||||
globalThis.document = dom.window.document;
|
globalThis.document = dom.window.document;
|
||||||
|
// Node ships its own Event classes; jsdom rejects those, so prefer jsdom's.
|
||||||
|
globalThis.Event = dom.window.Event;
|
||||||
|
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||||
dom.window.Element.prototype.getBoundingClientRect = () => ({
|
dom.window.Element.prototype.getBoundingClientRect = () => ({
|
||||||
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
|
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
|
||||||
toJSON: () => ({}),
|
toJSON: () => ({}),
|
||||||
@@ -56,6 +59,27 @@ dom.window.Element.prototype.setPointerCapture = () => {};
|
|||||||
dom.window.Element.prototype.releasePointerCapture = () => {};
|
dom.window.Element.prototype.releasePointerCapture = () => {};
|
||||||
dom.window.Element.prototype.hasPointerCapture = () => false;
|
dom.window.Element.prototype.hasPointerCapture = () => false;
|
||||||
dom.window.Element.prototype.scrollIntoView = () => {};
|
dom.window.Element.prototype.scrollIntoView = () => {};
|
||||||
|
// jsdom has no Web Animations API; Svelte transitions expect one.
|
||||||
|
dom.window.Element.prototype.animate = () => {
|
||||||
|
const animation = {
|
||||||
|
currentTime: 0,
|
||||||
|
startTime: 0,
|
||||||
|
playState: "finished",
|
||||||
|
effect: { getComputedTiming: () => ({ delay: 0, duration: 0 }) },
|
||||||
|
finished: Promise.resolve(),
|
||||||
|
onfinish: null,
|
||||||
|
cancel() {},
|
||||||
|
finish() {},
|
||||||
|
pause() {},
|
||||||
|
play() {},
|
||||||
|
reverse() {},
|
||||||
|
commitStyles() {},
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
};
|
||||||
|
queueMicrotask(() => animation.onfinish?.());
|
||||||
|
return animation;
|
||||||
|
};
|
||||||
globalThis.ResizeObserver = class {
|
globalThis.ResizeObserver = class {
|
||||||
observe() {}
|
observe() {}
|
||||||
unobserve() {}
|
unobserve() {}
|
||||||
@@ -85,6 +109,9 @@ const app = {
|
|||||||
const next = fn(files.get(file.path));
|
const next = fn(files.get(file.path));
|
||||||
writes.push({ path: file.path, content: next });
|
writes.push({ path: file.path, content: next });
|
||||||
files.set(file.path, next);
|
files.set(file.path, next);
|
||||||
|
// Obsidian fires a modify event after a write; mirror that here so the
|
||||||
|
// store reflects the vault the way it does in the real plugin.
|
||||||
|
store.queueReload(file.path);
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
on: () => ({}),
|
on: () => ({}),
|
||||||
@@ -115,7 +142,8 @@ function check(name, condition, detail = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
|
// Long enough to clear the store's 150 ms reload debounce.
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 220));
|
||||||
|
|
||||||
const store = new BoardStore(app, settings, () => {});
|
const store = new BoardStore(app, settings, () => {});
|
||||||
await store.reloadAll();
|
await store.reloadAll();
|
||||||
@@ -197,6 +225,278 @@ if (button) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The whole point of the composer: pick the day and the lane without ever
|
||||||
|
// touching the sidebar, and write the line straight into that lane.
|
||||||
|
store.view = "calendar";
|
||||||
|
store.sphereFilter = null;
|
||||||
|
store.projectFilter = null;
|
||||||
|
flushSync();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const viewRoot = target.querySelector(".bcal-root");
|
||||||
|
const key = (node, init) =>
|
||||||
|
node.dispatchEvent(
|
||||||
|
new dom.window.KeyboardEvent("keydown", { bubbles: true, ...init }),
|
||||||
|
);
|
||||||
|
|
||||||
|
viewRoot.focus();
|
||||||
|
key(viewRoot, { key: "t", code: "KeyT" });
|
||||||
|
key(viewRoot, { key: "n", code: "KeyN" });
|
||||||
|
flushSync();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const composer = dom.window.document.querySelector(
|
||||||
|
'input[placeholder="What needs doing?"]',
|
||||||
|
);
|
||||||
|
check("n opened the composer on the cursor day", composer !== null);
|
||||||
|
|
||||||
|
if (composer) {
|
||||||
|
// Tab to the sphere field, filter it down, and take the highlighted match.
|
||||||
|
key(composer, { key: "Tab", code: "Tab" });
|
||||||
|
flushSync();
|
||||||
|
const sphereField = dom.window.document.querySelector(
|
||||||
|
'input[placeholder="home"], input[placeholder="personal"], input[placeholder="reading"], input[placeholder="work"]',
|
||||||
|
);
|
||||||
|
check("tab moved into the sphere field", sphereField !== null);
|
||||||
|
|
||||||
|
if (sphereField) {
|
||||||
|
sphereField.value = "work";
|
||||||
|
sphereField.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
key(sphereField, { key: "Tab", code: "Tab" });
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = dom.window.document.querySelector(
|
||||||
|
'input[placeholder="What needs doing?"]',
|
||||||
|
);
|
||||||
|
title.value = "ship the composer";
|
||||||
|
title.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
key(title, { key: "Enter", code: "Enter" });
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const board = files.get(`${FOLDER}/work.md`) ?? "";
|
||||||
|
const line = board.split("\n").find((each) => each.includes("ship the composer"));
|
||||||
|
check("the task landed in the sphere the composer picked", line !== undefined, board.slice(0, 80));
|
||||||
|
check("it carries today's due date", /📅 \d{4}-\d{2}-\d{2}/u.test(line ?? ""), line);
|
||||||
|
check("the composer closed after writing", store.editing === null);
|
||||||
|
check(
|
||||||
|
"the lane is remembered for the next task",
|
||||||
|
store.lastLane?.path === `${FOLDER}/work.md`,
|
||||||
|
JSON.stringify(store.lastLane),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rescheduling from the keyboard keeps the cursor on the task it moved.
|
||||||
|
store.zone = "day";
|
||||||
|
store.cursorTask = null;
|
||||||
|
flushSync();
|
||||||
|
key(viewRoot, { key: "Enter", code: "Enter" });
|
||||||
|
flushSync();
|
||||||
|
const picked = store.cursorTask;
|
||||||
|
check("enter dropped the cursor onto a task", picked !== null, `${picked}`);
|
||||||
|
if (picked) {
|
||||||
|
const before = store.taskById(picked)?.due;
|
||||||
|
key(viewRoot, { key: "L", code: "KeyL", shiftKey: true });
|
||||||
|
await settle();
|
||||||
|
flushSync();
|
||||||
|
const after = store.taskById(store.cursorTask)?.due;
|
||||||
|
check("shift+l pushed the task a day forward", after !== before, `${before} -> ${after}`);
|
||||||
|
check("the day cursor followed it", store.cursorDay === after, `${store.cursorDay}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor chrome is earned by a key press and given up by a click.
|
||||||
|
store.leaveToMain();
|
||||||
|
store.keyboard = false;
|
||||||
|
flushSync();
|
||||||
|
check(
|
||||||
|
"no outline before a key is pressed",
|
||||||
|
target.querySelector("[data-here='true']") === null,
|
||||||
|
);
|
||||||
|
key(viewRoot, { key: "l", code: "KeyL" });
|
||||||
|
flushSync();
|
||||||
|
check(
|
||||||
|
"the outline appears once the keyboard is used",
|
||||||
|
target.querySelector("[data-here='true']") !== null,
|
||||||
|
);
|
||||||
|
viewRoot.dispatchEvent(
|
||||||
|
new dom.window.PointerEvent("pointerdown", { bubbles: true }),
|
||||||
|
);
|
||||||
|
flushSync();
|
||||||
|
check(
|
||||||
|
"and goes away again on a click",
|
||||||
|
target.querySelector("[data-here='true']") === null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// `u` is a round trip: into the tray, then out and shut.
|
||||||
|
key(viewRoot, { key: "u", code: "KeyU" });
|
||||||
|
flushSync();
|
||||||
|
check("u took the cursor into the tray", store.zone === "undated", store.zone);
|
||||||
|
key(viewRoot, { key: "u", code: "KeyU" });
|
||||||
|
flushSync();
|
||||||
|
check("u again handed it back", store.zone === "day", store.zone);
|
||||||
|
check("and closed the tray", store.undatedCollapsed === true);
|
||||||
|
|
||||||
|
// `b` does the same for the spheres, and Enter filters by the row.
|
||||||
|
store.undatedCollapsed = false;
|
||||||
|
key(viewRoot, { key: "b", code: "KeyB" });
|
||||||
|
flushSync();
|
||||||
|
check("b took the cursor into the rail", store.zone === "rail", store.zone);
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
key(viewRoot, { key: "Enter", code: "Enter" });
|
||||||
|
flushSync();
|
||||||
|
check("enter filtered by the row", store.sphereFilter !== null, `${store.sphereFilter}`);
|
||||||
|
check("and stepped back out", store.zone !== "rail", store.zone);
|
||||||
|
|
||||||
|
// The list has no day grid, so its cursor starts on a task from the first key.
|
||||||
|
store.view = "list";
|
||||||
|
store.leaveToMain();
|
||||||
|
store.cursorTask = null;
|
||||||
|
store.sphereFilter = null;
|
||||||
|
flushSync();
|
||||||
|
await settle();
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
flushSync();
|
||||||
|
check("j picks up the first task in the list", store.cursorTask !== null);
|
||||||
|
|
||||||
|
// Grouping reorders the list, so the cursor has to follow the drawn order and
|
||||||
|
// not the flat sorted one underneath it.
|
||||||
|
for (const grouping of ["sphere", "project", "due", "status"]) {
|
||||||
|
store.groupBy = grouping;
|
||||||
|
store.cursorTask = null;
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const drawn = [...target.querySelectorAll("[data-drop^='task:']")].map(
|
||||||
|
(row) => row.getAttribute("data-drop").slice(5),
|
||||||
|
);
|
||||||
|
const walked = [];
|
||||||
|
for (let i = 0; i < drawn.length; i += 1) {
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
flushSync();
|
||||||
|
walked.push(store.cursorTask);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
`j walks the list in drawn order, grouped by ${grouping}`,
|
||||||
|
walked.join("|") === drawn.join("|"),
|
||||||
|
`${walked.length} of ${drawn.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
key(viewRoot, { key: "k", code: "KeyK" });
|
||||||
|
flushSync();
|
||||||
|
check(
|
||||||
|
`k comes back up the same way, grouped by ${grouping}`,
|
||||||
|
store.cursorTask === drawn[drawn.length - 2],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// d, m and p summon a panel with no trigger behind them; if it fails to mount,
|
||||||
|
// the view goes on swallowing every key and the keyboard looks dead.
|
||||||
|
const panelOf = () => dom.window.document.querySelector("[role='dialog']");
|
||||||
|
for (const [name, init] of [
|
||||||
|
["p", { key: "p", code: "KeyP" }],
|
||||||
|
["e", { key: "e", code: "KeyE" }],
|
||||||
|
]) {
|
||||||
|
store.cursorTask = null;
|
||||||
|
store.request = null;
|
||||||
|
flushSync();
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
key(viewRoot, init);
|
||||||
|
flushSync();
|
||||||
|
check(`${name} opened its panel`, panelOf() !== null);
|
||||||
|
|
||||||
|
panelOf()?.dispatchEvent(
|
||||||
|
new dom.window.KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
key: "Escape",
|
||||||
|
code: "Escape",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
flushSync();
|
||||||
|
check(
|
||||||
|
`${name} let go on escape`,
|
||||||
|
store.request === null && store.editing === null && panelOf() === null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = store.cursorTask;
|
||||||
|
key(viewRoot, { key: "j", code: "KeyJ" });
|
||||||
|
flushSync();
|
||||||
|
check(`the keyboard answers again after ${name}`, store.cursorTask !== before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The date field takes words, which is the whole point of tabbing to it.
|
||||||
|
{
|
||||||
|
store.view = "list";
|
||||||
|
store.leaveToMain();
|
||||||
|
flushSync();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
key(viewRoot, { key: "n", code: "KeyN" });
|
||||||
|
flushSync();
|
||||||
|
const panel = panelOf();
|
||||||
|
check("n opened the editor", panel !== null);
|
||||||
|
|
||||||
|
const type = (node, value) => {
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
const tab = () => {
|
||||||
|
(dom.window.document.activeElement ?? panel).dispatchEvent(
|
||||||
|
new dom.window.KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
key: "Tab",
|
||||||
|
code: "Tab",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
flushSync();
|
||||||
|
};
|
||||||
|
|
||||||
|
type(panel.querySelector("input"), "flight to lisbon");
|
||||||
|
tab(); // sphere
|
||||||
|
tab(); // project
|
||||||
|
tab(); // date
|
||||||
|
const dateField = dom.window.document.activeElement;
|
||||||
|
check("three tabs land on the date", dateField?.placeholder !== undefined);
|
||||||
|
|
||||||
|
type(dateField, "in 3 days");
|
||||||
|
check(
|
||||||
|
"the words are read back before they are taken",
|
||||||
|
panel.textContent.includes(
|
||||||
|
new Intl.DateTimeFormat("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
}).format(new Date(Date.now() + 3 * 86_400_000)),
|
||||||
|
),
|
||||||
|
panel.textContent.slice(0, 120),
|
||||||
|
);
|
||||||
|
|
||||||
|
dateField.dispatchEvent(
|
||||||
|
new dom.window.KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
key: "Enter",
|
||||||
|
code: "Enter",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const wanted = new Date(Date.now() + 3 * 86_400_000);
|
||||||
|
const stamp = `${wanted.getFullYear()}-${String(wanted.getMonth() + 1).padStart(2, "0")}-${String(wanted.getDate()).padStart(2, "0")}`;
|
||||||
|
const written = [...files.values()]
|
||||||
|
.flatMap((board) => board.split("\n"))
|
||||||
|
.find((line) => line.includes("flight to lisbon"));
|
||||||
|
check("the task was written with the date those words meant", written?.includes(stamp), written);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request whose task vanished must not leave the view deaf.
|
||||||
|
store.request = "priority";
|
||||||
|
store.cursorTask = "nothing/at/all#0";
|
||||||
|
flushSync();
|
||||||
|
check("a request with no task clears itself", store.request === null);
|
||||||
|
|
||||||
await unmount(instance);
|
await unmount(instance);
|
||||||
|
|
||||||
console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`);
|
console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`);
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseRepeat, parseWhen } from "../src/lib/when";
|
||||||
|
|
||||||
|
// A Wednesday, so weekday arithmetic has somewhere to go in both directions.
|
||||||
|
const WED = new Date(2026, 7, 12);
|
||||||
|
const on = (text: string): string | null => parseWhen(text, WED);
|
||||||
|
|
||||||
|
describe("parseWhen", () => {
|
||||||
|
it("takes an ISO date as written", () => {
|
||||||
|
expect(on("2026-08-20")).toBe("2026-08-20");
|
||||||
|
expect(on("2026-8-3")).toBe("2026-08-03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a date that does not exist", () => {
|
||||||
|
expect(on("2026-02-30")).toBeNull();
|
||||||
|
expect(on("2026-13-01")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("knows the words for the days around now", () => {
|
||||||
|
expect(on("today")).toBe("2026-08-12");
|
||||||
|
expect(on("tod")).toBe("2026-08-12");
|
||||||
|
expect(on("tomorrow")).toBe("2026-08-13");
|
||||||
|
expect(on("tmr")).toBe("2026-08-13");
|
||||||
|
expect(on("yesterday")).toBe("2026-08-11");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts forward in days, weeks and months", () => {
|
||||||
|
expect(on("in 3 days")).toBe("2026-08-15");
|
||||||
|
expect(on("3 days")).toBe("2026-08-15");
|
||||||
|
expect(on("3d")).toBe("2026-08-15");
|
||||||
|
expect(on("in a week")).toBe("2026-08-19");
|
||||||
|
expect(on("2w")).toBe("2026-08-26");
|
||||||
|
expect(on("in 1 month")).toBe("2026-09-12");
|
||||||
|
expect(on("next week")).toBe("2026-08-19");
|
||||||
|
expect(on("next year")).toBe("2027-08-12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a weekday as the next one, never today", () => {
|
||||||
|
expect(on("friday")).toBe("2026-08-14");
|
||||||
|
expect(on("fri")).toBe("2026-08-14");
|
||||||
|
expect(on("monday")).toBe("2026-08-17");
|
||||||
|
expect(on("wednesday")).toBe("2026-08-19");
|
||||||
|
expect(on("next friday")).toBe("2026-08-14");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a day and a month either way round", () => {
|
||||||
|
expect(on("20 aug")).toBe("2026-08-20");
|
||||||
|
expect(on("aug 20")).toBe("2026-08-20");
|
||||||
|
expect(on("20 august")).toBe("2026-08-20");
|
||||||
|
expect(on("20 aug 2027")).toBe("2027-08-20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls a date that has already gone into next year", () => {
|
||||||
|
expect(on("1 aug")).toBe("2027-08-01");
|
||||||
|
expect(on("1 aug 2026")).toBe("2026-08-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a numeric date day first", () => {
|
||||||
|
expect(on("20.08")).toBe("2026-08-20");
|
||||||
|
expect(on("20/8")).toBe("2026-08-20");
|
||||||
|
expect(on("20.08.27")).toBe("2027-08-20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a bare number as a day of the month", () => {
|
||||||
|
expect(on("20")).toBe("2026-08-20");
|
||||||
|
expect(on("12")).toBe("2026-08-12");
|
||||||
|
expect(on("3")).toBe("2026-09-03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing when the words mean nothing", () => {
|
||||||
|
expect(on("")).toBeNull();
|
||||||
|
expect(on(" ")).toBeNull();
|
||||||
|
expect(on("buy milk")).toBeNull();
|
||||||
|
expect(on("someday")).toBeNull();
|
||||||
|
expect(on("99")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores case and extra spaces", () => {
|
||||||
|
expect(on(" In 3 Days ")).toBe("2026-08-15");
|
||||||
|
expect(on("TOMORROW")).toBe("2026-08-13");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseRepeat", () => {
|
||||||
|
it("normalises the everyday phrasings", () => {
|
||||||
|
expect(parseRepeat("daily")).toBe("every day");
|
||||||
|
expect(parseRepeat("weekly")).toBe("every week");
|
||||||
|
expect(parseRepeat("monthly")).toBe("every month");
|
||||||
|
expect(parseRepeat("yearly")).toBe("every year");
|
||||||
|
expect(parseRepeat("fortnightly")).toBe("every 2 weeks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a bare unit or a count", () => {
|
||||||
|
expect(parseRepeat("week")).toBe("every week");
|
||||||
|
expect(parseRepeat("2 weeks")).toBe("every 2 weeks");
|
||||||
|
expect(parseRepeat("every 3 days")).toBe("every 3 days");
|
||||||
|
expect(parseRepeat("every 1 day")).toBe("every 1 day");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a weekday with or without the every", () => {
|
||||||
|
expect(parseRepeat("monday")).toBe("every monday");
|
||||||
|
expect(parseRepeat("mondays")).toBe("every monday");
|
||||||
|
expect(parseRepeat("every tue")).toBe("every tuesday");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears on the words for nothing", () => {
|
||||||
|
expect(parseRepeat("none")).toBe("");
|
||||||
|
expect(parseRepeat("never")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes an unknown every-rule through for Tasks to read", () => {
|
||||||
|
expect(parseRepeat("every 2nd wednesday")).toBe("every 2nd wednesday");
|
||||||
|
expect(parseRepeat("every month on the last")).toBe("every month on the last");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing when it is not a rule at all", () => {
|
||||||
|
expect(parseRepeat("")).toBeNull();
|
||||||
|
expect(parseRepeat("buy milk")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user