]> git.scottworley.com Git - vopamoi/blob - vopamoi.ts
Clarify license
[vopamoi] / vopamoi.ts
1 // vopamoi: vi-flavored todo organizer
2 // Copyright (C) 2023 Scott Worley <scottworley@scottworley.com>
3 //
4 // This program is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, version 3.
7 //
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16
17 // Typescript doesn't know about MAX_SAFE_INTEGER?? This was supposed to be
18 // fixed in typescript 2.0.1 in 2016, but is not working for me in typescript
19 // 4.2.4 in 2022. :( https://github.com/microsoft/TypeScript/issues/9937
20 //const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
21 const MAX_SAFE_INTEGER = 9007199254740991;
22
23 // A sane split that splits N *times*, leaving the last chunk unsplit.
24 function splitN(str: string, delimiter: string, limit: number = MAX_SAFE_INTEGER): string[] {
25 if (limit < 1) {
26 return [str];
27 }
28 const at = str.indexOf(delimiter);
29 return at === -1 ? [str] : [str.substring(0, at)].concat(splitN(str.substring(at + delimiter.length), delimiter, limit - 1));
30 }
31
32 // A clock that never goes backwards; monotonic.
33 function Clock() {
34 var previousNow = Date.now();
35 return {
36 now: function (): number {
37 const now = Date.now();
38 if (now > previousNow) {
39 previousNow = now;
40 return now;
41 }
42 return ++previousNow;
43 },
44 };
45 }
46 const clock = Clock();
47
48 // Returns a promise for a hue based on a hash of the string
49 function hashHue(str: string) {
50 // Using crypto for this is overkill
51 return crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)).then((buf) => (new Uint16Array(buf)[0] * 360) / 2 ** 16);
52 }
53
54 function Model() {
55 return {
56 addTask: function (timestamp: string, description: string): Element {
57 const task = document.createElement("div");
58 const desc = document.createElement("span");
59 desc.textContent = description;
60 desc.classList.add("desc");
61 task.appendChild(desc);
62 task.classList.add("task");
63 task.setAttribute("tabindex", "0");
64 task.setAttribute("id", timestamp);
65 task.setAttribute("data-state", "todo");
66 const tasks = document.getElementById("tasks")!;
67 tasks.insertBefore(task, tasks.firstElementChild);
68 return task;
69 },
70
71 addTag: function (createTimestamp: string, tagName: string): Element | null {
72 const task = this.getTask(createTimestamp);
73 if (!task) return null;
74 const existingTag = this.hasTag(task, tagName);
75 if (existingTag) return existingTag;
76 const tag = document.createElement("span");
77 tag.appendChild(document.createTextNode(tagName));
78 tag.classList.add("tag");
79 tag.setAttribute("tabindex", "0");
80 hashHue(tagName).then((hue) => (tag.style.backgroundColor = `hsl(${hue},90%,45%)`));
81 for (const child of task.getElementsByClassName("tag")) {
82 if (tagName > child.textContent!) {
83 task.insertBefore(tag, child);
84 return tag;
85 }
86 }
87 task.insertBefore(tag, task.getElementsByClassName("desc")[0]!);
88 return tag;
89 },
90
91 edit: function (createTimestamp: string, newDescription: string): Element | null {
92 const target = this.getTask(createTimestamp);
93 if (!target) return null;
94 if (target.hasAttribute("data-description")) {
95 // Oh no: An edit has arrived from a replica while a local edit is in progress.
96 const input = target.getElementsByTagName("input")[0]!;
97 if (
98 input.value === target.getAttribute("data-description") &&
99 input.selectionStart === input.value.length &&
100 input.selectionEnd === input.value.length
101 ) {
102 // No local changes have actually been made yet. Change the contents of the edit box!
103 input.value = newDescription;
104 } else {
105 // No great options.
106 // Prefer not to interrupt the local user's edit.
107 // The remote edit is mostly lost; this mostly becomes last-write-wins.
108 target.setAttribute("data-description", newDescription);
109 }
110 } else {
111 target.getElementsByClassName("desc")[0].textContent = newDescription;
112 }
113 return target;
114 },
115
116 editContent: function (createTimestamp: string, newContent: string): Element | null {
117 const target = this.getTask(createTimestamp);
118 if (!target) return null;
119 if (target.hasAttribute("data-content")) {
120 // Oh no: An edit has arrived from a replica while a local edit is in progress.
121 const input = target.getElementsByTagName("textarea")[0]!;
122 if (
123 input.value === target.getAttribute("data-content") &&
124 input.selectionStart === input.value.length &&
125 input.selectionEnd === input.value.length
126 ) {
127 // No local changes have actually been made yet. Change the contents of the edit box!
128 input.value = newContent;
129 } else {
130 // No great options.
131 // Prefer not to interrupt the local user's edit.
132 // The remote edit is mostly lost; this mostly becomes last-write-wins.
133 target.setAttribute("data-content", newContent);
134 }
135 } else {
136 var content = target.getElementsByClassName("content")[0];
137 if (!content) {
138 content = document.createElement("div");
139 content.classList.add("content");
140 target.appendChild(content);
141 }
142 content.textContent = newContent;
143 }
144 return target;
145 },
146
147 hasTag: function (task: Element, tag: string): Element | null {
148 for (const child of task.getElementsByClassName("tag")) {
149 if (child.textContent === tag) {
150 return child;
151 }
152 }
153 return null;
154 },
155
156 getPriority: function (task: Element): number {
157 if (task.hasAttribute("data-priority")) {
158 return parseFloat(task.getAttribute("data-priority")!);
159 }
160 return parseFloat(task.getAttribute("id")!);
161 },
162
163 getTask: function (createTimestamp: string) {
164 return document.getElementById(createTimestamp);
165 },
166
167 insertInPriorityOrder: function (task: Element, dest: Element) {
168 const priority = this.getPriority(task);
169 for (const t of dest.children) {
170 if (t !== task && this.getPriority(t) < priority) {
171 dest.insertBefore(task, t);
172 return;
173 }
174 }
175 dest.appendChild(task);
176 },
177
178 removeTag: function (createTimestamp: string, tagName: string) {
179 const task = this.getTask(createTimestamp);
180 if (!task) return null;
181 const tag = this.hasTag(task, tagName);
182 if (!tag) return;
183 task.removeChild(tag);
184 if (task instanceof HTMLElement) task.focus();
185 },
186
187 setPriority: function (createTimestamp: string, priority: number): Element | null {
188 const target = this.getTask(createTimestamp);
189 if (!target) return null;
190 target.setAttribute("data-priority", `${priority}`);
191 this.insertInPriorityOrder(target, target.parentElement!);
192 return target;
193 },
194
195 setState: function (stateTimestamp: string, createTimestamp: string, state: string) {
196 const task = this.getTask(createTimestamp);
197 if (!task) return;
198 task.setAttribute("data-state", state);
199 var date = task.getElementsByClassName("statedate")[0];
200 if (state === "todo") {
201 task.removeChild(date);
202 return;
203 }
204 if (!date) {
205 date = document.createElement("span");
206 date.classList.add("statedate");
207 task.insertBefore(date, task.firstChild);
208 }
209 const d = new Date(parseInt(stateTimestamp));
210 date.textContent = `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, "0")}-${`${d.getDate()}`.padStart(2, "0")}`;
211 },
212 };
213 }
214 const model = Model();
215
216 function Log(prefix: string = "vp-") {
217 var next_log_index = 0;
218 return {
219 apply: function (entry: string) {
220 const [timestamp, command, data] = splitN(entry, " ", 2);
221 if (command == "Create") {
222 return model.addTask(timestamp, data);
223 }
224 if (command == "Edit") {
225 const [createTimestamp, description] = splitN(data, " ", 1);
226 return model.edit(createTimestamp, description);
227 }
228 if (command == "EditContent") {
229 const [createTimestamp, content] = splitN(data, " ", 1);
230 return model.editContent(createTimestamp, content);
231 }
232 if (command == "Priority") {
233 const [createTimestamp, newPriority] = splitN(data, " ", 1);
234 return model.setPriority(createTimestamp, parseFloat(newPriority));
235 }
236 if (command == "State") {
237 const [createTimestamp, state] = splitN(data, " ", 1);
238 return model.setState(timestamp, createTimestamp, state);
239 }
240 if (command == "Tag") {
241 const [createTimestamp, tag] = splitN(data, " ", 1);
242 return model.addTag(createTimestamp, tag);
243 }
244 if (command == "Untag") {
245 const [createTimestamp, tag] = splitN(data, " ", 1);
246 return model.removeTag(createTimestamp, tag);
247 }
248 },
249
250 record: function (entry: string) {
251 window.localStorage.setItem(`${prefix}${next_log_index++}`, entry);
252 },
253
254 recordAndApply: function (entry: string) {
255 this.record(entry);
256 return this.apply(entry);
257 },
258
259 replay: function () {
260 document.getElementById("tasks")!.style.display = "none";
261 while (true) {
262 const entry = window.localStorage.getItem(`${prefix}${next_log_index}`);
263 if (entry === null) {
264 break;
265 }
266 this.apply(entry);
267 next_log_index++;
268 }
269 document.getElementById("tasks")!.style.display = "";
270 },
271 };
272 }
273 const log = Log();
274
275 function UI() {
276 const undoLog: string[][] = [];
277 const redoLog: string[][] = [];
278 function perform(forward: string, reverse: string) {
279 undoLog.push([reverse, forward]);
280 return log.recordAndApply(`${clock.now()} ${forward}`);
281 }
282 return {
283 addTask: function (description: string): Element {
284 const now = clock.now();
285 undoLog.push([`State ${now} deleted`, `State ${now} todo`]);
286 return <Element>log.recordAndApply(`${now} Create ${description}`);
287 },
288 addTag: function (createTimestamp: string, tag: string) {
289 return perform(`Tag ${createTimestamp} ${tag}`, `Untag ${createTimestamp} ${tag}`);
290 },
291 edit: function (createTimestamp: string, newDescription: string, oldDescription: string) {
292 return perform(`Edit ${createTimestamp} ${newDescription}`, `Edit ${createTimestamp} ${oldDescription}`);
293 },
294 editContent: function (createTimestamp: string, newContent: string, oldContent: string) {
295 return perform(`EditContent ${createTimestamp} ${newContent}`, `EditContent ${createTimestamp} ${oldContent}`);
296 },
297 removeTag: function (createTimestamp: string, tag: string) {
298 return perform(`Untag ${createTimestamp} ${tag}`, `Tag ${createTimestamp} ${tag}`);
299 },
300 setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) {
301 return perform(`Priority ${createTimestamp} ${newPriority}`, `Priority ${createTimestamp} ${oldPriority}`);
302 },
303 setState: function (createTimestamp: string, newState: string, oldState: string) {
304 return perform(`State ${createTimestamp} ${newState}`, `State ${createTimestamp} ${oldState}`);
305 },
306 undo: function () {
307 const entry = undoLog.pop();
308 if (entry) {
309 redoLog.push(entry);
310 return log.recordAndApply(`${clock.now()} ${entry[0]}`);
311 }
312 },
313 redo: function () {
314 const entry = redoLog.pop();
315 if (entry) {
316 undoLog.push(entry);
317 return log.recordAndApply(`${clock.now()} ${entry[1]}`);
318 }
319 },
320 };
321 }
322 const ui = UI();
323
324 enum CommitOrAbort {
325 Commit,
326 Abort,
327 }
328
329 interface TagFilter {
330 description: string;
331 include: (task: Element) => boolean;
332 }
333
334 function BrowserUI() {
335 const viewColors: { [key: string]: string } = {
336 all: "Gold",
337 cancelled: "Red",
338 deleted: "Black",
339 done: "LawnGreen",
340 "someday-maybe": "DeepSkyBlue",
341 todo: "rgb(0 0 0 / 0)",
342 waiting: "MediumOrchid",
343 };
344 var currentTagFilter: TagFilter | null = null;
345 var currentViewState = "todo";
346 var taskFocusedBeforeJumpingToInput: HTMLElement | null = null;
347 var lastTagNameEntered = "";
348 return {
349 addTask: function (event: KeyboardEvent) {
350 const input = <HTMLInputElement>document.getElementById("taskName");
351 if (input.value.match(/^ *$/)) return;
352 const task = ui.addTask(input.value);
353 if (currentViewState === "todo" || currentViewState === "all") {
354 task instanceof HTMLElement && task.focus();
355 } else if (this.returnFocusAfterInput()) {
356 } else {
357 this.firstVisibleTask()?.focus();
358 }
359 input.value = "";
360 if (event.getModifierState("Control")) {
361 this.makeBottomPriority(task);
362 }
363 },
364
365 beginEdit: function (event: Event) {
366 const task = this.currentTask();
367 if (!task) return;
368 const input = document.createElement("input");
369 const desc = task.getElementsByClassName("desc")[0];
370 const oldDescription = desc.textContent!;
371 task.setAttribute("data-description", oldDescription);
372 input.value = oldDescription;
373 input.addEventListener("blur", this.completeEdit, { once: true });
374 desc.textContent = "";
375 task.insertBefore(input, task.firstChild);
376 input.focus();
377 event.preventDefault();
378 },
379
380 beginEditContent: function (event: Event) {
381 const task = this.currentTask();
382 if (!task) return;
383 const input = document.createElement("textarea");
384 const content = task.getElementsByClassName("content")[0];
385 const oldContent = content?.textContent ?? "";
386 task.setAttribute("data-content", oldContent);
387 input.value = oldContent;
388 input.addEventListener("blur", this.completeContentEdit, { once: true });
389 if (content) content.textContent = "";
390 task.appendChild(input);
391 input.focus();
392 event.preventDefault();
393 },
394
395 beginTagEdit: function (event: Event) {
396 const task = this.currentTask();
397 if (!task) return;
398 const input = document.createElement("input");
399 input.classList.add("tag");
400 input.addEventListener("blur", this.completeTagEdit, { once: true });
401 input.value = lastTagNameEntered;
402 task.appendChild(input);
403 input.focus();
404 input.select();
405 event.preventDefault();
406 },
407
408 completeEdit: function (event: Event, resolution: CommitOrAbort = CommitOrAbort.Commit) {
409 const input = event.target as HTMLInputElement;
410 const task = input.parentElement!;
411 const desc = task.getElementsByClassName("desc")[0];
412 const oldDescription = task.getAttribute("data-description")!;
413 const newDescription = input.value;
414 input.removeEventListener("blur", this.completeEdit);
415 task.removeChild(input);
416 task.removeAttribute("data-description");
417 task.focus();
418 if (resolution === CommitOrAbort.Abort || newDescription.match(/^ *$/) || newDescription === oldDescription) {
419 desc.textContent = oldDescription;
420 } else {
421 ui.edit(task.getAttribute("id")!, newDescription, oldDescription);
422 }
423 },
424
425 completeContentEdit: function (event: Event, resolution: CommitOrAbort = CommitOrAbort.Commit) {
426 const input = event.target as HTMLInputElement;
427 const task = input.parentElement!;
428 const content = task.getElementsByClassName("content")[0];
429 const oldContent = task.getAttribute("data-content")!;
430 const newContent = input.value;
431 input.removeEventListener("blur", this.completeContentEdit);
432 task.removeChild(input);
433 task.removeAttribute("data-content");
434 task.focus();
435 if (resolution === CommitOrAbort.Abort || newContent === oldContent) {
436 if (content) content.textContent = oldContent;
437 } else {
438 ui.editContent(task.getAttribute("id")!, newContent, oldContent);
439 }
440 },
441
442 completeTagEdit: function (event: Event, resolution: CommitOrAbort = CommitOrAbort.Commit) {
443 const input = event.target as HTMLInputElement;
444 const task = input.parentElement!;
445 const newTagName = input.value;
446 input.removeEventListener("blur", this.completeTagEdit);
447 task.removeChild(input);
448 task.focus();
449 if (resolution === CommitOrAbort.Commit && !newTagName.match(/^ *$/) && !model.hasTag(task, newTagName)) {
450 ui.addTag(task.getAttribute("id")!, newTagName);
451 lastTagNameEntered = newTagName;
452 }
453 },
454
455 currentTag: function (): Element | null {
456 var target = document.activeElement;
457 if (!target) return null;
458 if (target.classList.contains("task")) {
459 const tags = target.getElementsByClassName("tag");
460 target = tags[tags.length - 1];
461 }
462 if (!target || !target.classList.contains("tag")) return null;
463 return target;
464 },
465
466 currentTask: function (): HTMLElement | null {
467 var target = document.activeElement;
468 if (!target) return null;
469 if (target.classList.contains("tag")) target = target.parentElement!;
470 if (!target.classList.contains("task")) return null;
471 return target as HTMLElement;
472 },
473
474 firstVisibleTask: function (root: Element | null = null) {
475 if (root === null) root = document.body;
476 for (const task of root.getElementsByClassName("task")) {
477 const state = task.getAttribute("data-state");
478 if (
479 task instanceof HTMLElement &&
480 (state === currentViewState || (currentViewState === "all" && state !== "deleted")) &&
481 !task.classList.contains("hide")
482 ) {
483 return task;
484 }
485 }
486 },
487
488 focusTaskNameInput: function (event: Event) {
489 taskFocusedBeforeJumpingToInput = this.currentTask();
490 document.getElementById("taskName")!.focus();
491 window.scroll(0, 0);
492 event.preventDefault();
493 },
494
495 visibleTaskAtOffset(task: Element, offset: number): Element {
496 var cursor: Element | null = task;
497 var valid_cursor = cursor;
498 const increment = offset / Math.abs(offset);
499 while (true) {
500 cursor = increment > 0 ? cursor.nextElementSibling : cursor.previousElementSibling;
501 if (!cursor || !(cursor instanceof HTMLElement)) break;
502 const state = cursor.getAttribute("data-state")!;
503 if (
504 (state === currentViewState || (currentViewState === "all" && state !== "deleted")) &&
505 !cursor.classList.contains("hide")
506 ) {
507 offset -= increment;
508 valid_cursor = cursor;
509 }
510 if (Math.abs(offset) < 0.5) break;
511 }
512 return valid_cursor;
513 },
514
515 jumpCursor: function (position: number) {
516 const first = this.firstVisibleTask();
517 if (!first) return;
518 const dest = this.visibleTaskAtOffset(first, position - 1);
519 if (dest instanceof HTMLElement) dest.focus();
520 },
521
522 makeBottomPriority: function (task: Element | null = null) {
523 if (!task) task = this.currentTask();
524 if (!task) return;
525 this.setPriority(task, document.getElementById("tasks")!.lastElementChild, null);
526 },
527
528 makeTopPriority: function (task: Element | null = null) {
529 if (!task) task = this.currentTask();
530 if (!task) return;
531 ui.setPriority(task.getAttribute("id")!, clock.now(), model.getPriority(task));
532 task instanceof HTMLElement && task.focus();
533 },
534
535 moveCursorLeft: function () {
536 const active = this.currentTask();
537 if (!active) return false;
538 if (active.parentElement!.classList.contains("task")) {
539 active.parentElement!.focus();
540 }
541 },
542
543 moveCursorRight: function () {
544 const active = this.currentTask();
545 if (!active) return false;
546 (this.firstVisibleTask(active) as HTMLElement | null)?.focus();
547 },
548
549 moveCursorVertically: function (offset: number): boolean {
550 let active = this.currentTask();
551 if (!active) {
552 this.firstVisibleTask()?.focus();
553 active = this.currentTask();
554 }
555 if (!active) return false;
556 const dest = this.visibleTaskAtOffset(active, offset);
557 if (dest !== active && dest instanceof HTMLElement) {
558 dest.focus();
559 return true;
560 }
561 return false;
562 },
563
564 moveTask: function (offset: number) {
565 const active = this.currentTask();
566 if (!active) return;
567 const dest = this.visibleTaskAtOffset(active, offset);
568 if (dest === active) return; // Already extremal
569 var onePastDest: Element | null = this.visibleTaskAtOffset(dest, offset / Math.abs(offset));
570 if (onePastDest == dest) onePastDest = null; // Will become extremal
571 if (offset > 0) {
572 this.setPriority(active, dest, onePastDest);
573 } else {
574 this.setPriority(active, onePastDest, dest);
575 }
576 },
577
578 removeTag: function () {
579 const target = this.currentTag();
580 if (!target) return;
581 ui.removeTag(target.parentElement!.getAttribute("id")!, target.textContent!);
582 },
583
584 resetTagView: function () {
585 currentTagFilter = null;
586 this.setTitle();
587 const taskList = document.getElementById("tasks")!;
588 for (const task of Array.from(document.getElementsByClassName("task"))) {
589 task.classList.remove("hide");
590 if (task.parentElement !== taskList) {
591 model.insertInPriorityOrder(task, taskList);
592 }
593 }
594 },
595
596 resetView: function () {
597 this.setView("todo");
598 this.resetTagView();
599 },
600
601 returnFocusAfterInput: function (): boolean {
602 if (taskFocusedBeforeJumpingToInput) {
603 taskFocusedBeforeJumpingToInput.focus();
604 return true;
605 }
606 return false;
607 },
608
609 // Change task's priority to be between other tasks a and b.
610 setPriority: function (task: Element, a: Element | null, b: Element | null) {
611 const aPriority = a === null ? clock.now() : model.getPriority(a);
612 const bPriority = b === null ? 0 : model.getPriority(b);
613 console.assert(aPriority > bPriority, aPriority, ">", bPriority);
614 const span = aPriority - bPriority;
615 const newPriority = bPriority + 0.1 * span + 0.8 * span * Math.random();
616 console.assert(aPriority > newPriority && newPriority > bPriority, aPriority, ">", newPriority, ">", bPriority);
617 const newPriorityRounded = Math.round(newPriority);
618 const okToRound = aPriority > newPriorityRounded && newPriorityRounded > bPriority;
619 ui.setPriority(task.getAttribute("id")!, okToRound ? newPriorityRounded : newPriority, model.getPriority(task));
620 task instanceof HTMLElement && task.focus();
621 },
622
623 setState: function (newState: string) {
624 const task = this.currentTask();
625 if (!task) return;
626 const oldState = task.getAttribute("data-state")!;
627 if (newState === oldState) return;
628 const createTimestamp = task.getAttribute("id")!;
629 if (currentViewState !== "all" || newState == "deleted") {
630 this.moveCursorVertically(1) || this.moveCursorVertically(-1);
631 }
632 return ui.setState(createTimestamp, newState, oldState);
633 },
634
635 setTagFilter: function (filter: TagFilter) {
636 if (currentTagFilter !== null) {
637 this.resetTagView();
638 }
639
640 const tasksWithTag = new Map();
641 for (const task of document.getElementsByClassName("task")) {
642 if (filter.include(task)) {
643 tasksWithTag.set(task.getElementsByClassName("desc")[0].textContent, [model.getPriority(task), task]);
644 }
645 }
646
647 function highestPrioritySuperTask(t: Element) {
648 var maxPriority = -1;
649 var superTask = null;
650 for (const child of t.getElementsByClassName("tag")) {
651 const e = tasksWithTag.get(child.textContent);
652 if (e !== undefined && e[0] > maxPriority) {
653 maxPriority = e[0];
654 superTask = e[1];
655 }
656 }
657 return superTask;
658 }
659
660 for (const task of Array.from(document.getElementsByClassName("task"))) {
661 if (filter.include(task)) {
662 task.classList.remove("hide");
663 } else {
664 const superTask = highestPrioritySuperTask(task);
665 if (superTask !== null) {
666 model.insertInPriorityOrder(task, superTask);
667 } else {
668 task.classList.add("hide");
669 }
670 }
671 }
672
673 currentTagFilter = filter;
674 this.setTitle();
675 },
676
677 setTagView: function (tag: string | null = null) {
678 if (tag === null) {
679 const target = this.currentTag();
680 if (!target) return;
681 tag = target.textContent!;
682 }
683 this.setTagFilter({description: tag, include: task => !!model.hasTag(task, tag!)});
684 },
685
686 setTitle: function () {
687 document.title = "Vopamoi: " + currentViewState + (currentTagFilter ? ": " + currentTagFilter.description : "");
688 },
689
690 setView: function (state: string) {
691 const sheet = (document.getElementById("viewStyle") as HTMLStyleElement).sheet!;
692 if (state === "all") {
693 sheet.insertRule(`.task[data-state=deleted] { display: none }`);
694 } else {
695 sheet.insertRule(`.task:not([data-state=${state}]) { display: none }`);
696 }
697 sheet.insertRule(`:root { --view-state-indicator-color: ${viewColors[state]}; }`);
698 sheet.removeRule(2);
699 sheet.removeRule(2);
700 currentViewState = state;
701 this.setTitle();
702 if (this.currentTask()?.getAttribute("data-state") !== state) {
703 this.firstVisibleTask()?.focus();
704 }
705 },
706
707 setUntaggedView: function () {
708 this.setTagFilter({description: "(untagged)", include: task => task.getElementsByClassName("tag").length === 0});
709 },
710
711 toggleDark: function () {
712 document.body.classList.toggle("dark");
713 this.setView(currentViewState);
714 },
715
716 undo: function () {
717 const ret = ui.undo();
718 if (ret && ret instanceof HTMLElement) ret.focus();
719 },
720 redo: function () {
721 const ret = ui.redo();
722 if (ret && ret instanceof HTMLElement) ret.focus();
723 },
724 };
725 }
726 const browserUI = BrowserUI();
727
728 const scrollIncrement = 60;
729 enum InputState {
730 Root,
731 S,
732 V,
733 VS,
734 }
735 var inputState = InputState.Root;
736 var inputCount: number | null = null;
737
738 function handleKey(event: any) {
739 if (["Alt", "Control", "Meta", "Shift"].includes(event.key)) return;
740 if (event.target.tagName === "TEXTAREA") {
741 if (event.key == "Enter" && event.ctrlKey) return browserUI.completeContentEdit(event);
742 if (event.key == "Escape") return browserUI.completeContentEdit(event, CommitOrAbort.Abort);
743 } else if (event.target.tagName === "INPUT") {
744 if (event.target.id === "taskName") {
745 if (event.key == "Enter") return browserUI.addTask(event);
746 if (event.key == "Escape") return browserUI.returnFocusAfterInput();
747 } else if (event.target.classList.contains("tag")) {
748 if (event.key == "Enter") return browserUI.completeTagEdit(event);
749 if (event.key == "Escape") return browserUI.completeTagEdit(event, CommitOrAbort.Abort);
750 } else {
751 if (event.key == "Enter") return browserUI.completeEdit(event);
752 if (event.key == "Escape") return browserUI.completeEdit(event, CommitOrAbort.Abort);
753 }
754 } else {
755 if (inputState === InputState.Root) {
756 if ("0" <= event.key && event.key <= "9") {
757 return (inputCount = (inputCount ?? 0) * 10 + parseInt(event.key));
758 }
759 try {
760 if (event.ctrlKey) {
761 if (event.key == "e") return window.scrollBy(0, (inputCount ?? 1) * scrollIncrement);
762 if (event.key == "y") return window.scrollBy(0, (inputCount ?? 1) * -scrollIncrement);
763 } else {
764 if (event.key == "h") return browserUI.moveCursorLeft();
765 if (event.key == "l") return browserUI.moveCursorRight();
766 if (event.key == "j") return browserUI.moveCursorVertically(inputCount ?? 1);
767 if (event.key == "k") return browserUI.moveCursorVertically(-(inputCount ?? 1));
768 if (event.key == "J") return browserUI.moveTask(inputCount ?? 1);
769 if (event.key == "K") return browserUI.moveTask(-(inputCount ?? 1));
770 if (event.key == "G") return browserUI.jumpCursor(inputCount ?? MAX_SAFE_INTEGER);
771 if (event.key == "T") return browserUI.makeTopPriority();
772 if (event.key == "n") return browserUI.focusTaskNameInput(event);
773 if (event.key == "C") return browserUI.setState("cancelled");
774 if (event.key == "d") return browserUI.setState("done");
775 if (event.key == "q") return browserUI.setState("todo");
776 if (event.key == "s") return (inputState = InputState.S);
777 if (event.key == "w") return browserUI.setState("waiting");
778 if (event.key == "X") return browserUI.setState("deleted");
779 if (event.key == "x") return browserUI.removeTag();
780 if (event.key == "u") return browserUI.undo();
781 if (event.key == "r") return browserUI.redo();
782 if (event.key == "E") return browserUI.beginEditContent(event);
783 if (event.key == "e") return browserUI.beginEdit(event);
784 if (event.key == "t") return browserUI.beginTagEdit(event);
785 if (event.key == "v") return (inputState = InputState.V);
786 }
787 } finally {
788 inputCount = null;
789 }
790 } else if (inputState === InputState.S) {
791 inputState = InputState.Root;
792 if (event.key == "m") return browserUI.setState("someday-maybe");
793 } else if (inputState === InputState.V) {
794 inputState = InputState.Root;
795 if (event.key == "a") return browserUI.setView("all");
796 if (event.key == "C") return browserUI.setView("cancelled");
797 if (event.key == "c") return browserUI.setTagView("comp");
798 if (event.key == "D") return browserUI.toggleDark();
799 if (event.key == "d") return browserUI.setView("done");
800 if (event.key == "e") return browserUI.setTagView("errand");
801 if (event.key == "h") return browserUI.setTagView("home");
802 if (event.key == "i") return browserUI.setUntaggedView();
803 if (event.key == "p") return browserUI.setTagView("Project");
804 if (event.key == "q") return browserUI.setView("todo");
805 if (event.key == "s") return (inputState = InputState.VS);
806 if (event.key == "T") return browserUI.resetTagView();
807 if (event.key == "t") return browserUI.setTagView();
808 if (event.key == "u") return browserUI.setUntaggedView();
809 if (event.key == "v") return browserUI.resetView();
810 if (event.key == "w") return browserUI.setView("waiting");
811 if (event.key == "x") return browserUI.setView("deleted");
812 if (event.key == "z") return browserUI.setTagView("zombie");
813 } else if (inputState === InputState.VS) {
814 inputState = InputState.Root;
815 if (event.key == "m") return browserUI.setView("someday-maybe");
816 }
817 }
818 }
819
820 function browserInit() {
821 log.replay();
822 browserUI.setTitle();
823 browserUI.firstVisibleTask()?.focus();
824 document.body.addEventListener("keydown", handleKey, { capture: false });
825 }