feat(*): one task editor, vim navigation and undo
This commit is contained in:
+301
-1
@@ -48,6 +48,9 @@ for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
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 = () => ({
|
||||
bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0,
|
||||
toJSON: () => ({}),
|
||||
@@ -56,6 +59,27 @@ dom.window.Element.prototype.setPointerCapture = () => {};
|
||||
dom.window.Element.prototype.releasePointerCapture = () => {};
|
||||
dom.window.Element.prototype.hasPointerCapture = () => false;
|
||||
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 {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
@@ -85,6 +109,9 @@ const app = {
|
||||
const next = fn(files.get(file.path));
|
||||
writes.push({ path: file.path, content: 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;
|
||||
},
|
||||
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, () => {});
|
||||
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);
|
||||
|
||||
console.log(failures.length === 0 ? "\nsmoke: all good" : `\nsmoke: ${failures.length} FAILED`);
|
||||
|
||||
Reference in New Issue
Block a user