]> git.scottworley.com Git - vopamoi/blame - vopamoi.ts
Clarify license
[vopamoi] / vopamoi.ts
CommitLineData
1212dfa1
SW
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
121d9948
SW
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;
21const MAX_SAFE_INTEGER = 9007199254740991;
22
23// A sane split that splits N *times*, leaving the last chunk unsplit.
24function 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
27c67784
SW
32// A clock that never goes backwards; monotonic.
33function 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}
46const clock = Clock();
47
c70b3eed
SW
48// Returns a promise for a hue based on a hash of the string
49function 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
ef12457b
SW
54function 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");
b6712c31 64 task.setAttribute("id", timestamp);
ef12457b
SW
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 }
7b574407 110 } else {
ef12457b
SW
111 target.getElementsByClassName("desc")[0].textContent = newDescription;
112 }
113 return target;
114 },
115
b9f7e989
SW
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
ef12457b
SW
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 }
b6712c31 160 return parseFloat(task.getAttribute("id")!);
ef12457b
SW
161 },
162
163 getTask: function (createTimestamp: string) {
b6712c31 164 return document.getElementById(createTimestamp);
ef12457b
SW
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);
1804fd5a
SW
202 return;
203 }
ef12457b
SW
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}
214const model = Model();
f1afad9b 215
d03daa19 216function Log(prefix: string = "vp-") {
60a63831
SW
217 var next_log_index = 0;
218 return {
e88c099c 219 apply: function (entry: string) {
60a63831
SW
220 const [timestamp, command, data] = splitN(entry, " ", 2);
221 if (command == "Create") {
ef12457b 222 return model.addTask(timestamp, data);
60a63831 223 }
7b574407
SW
224 if (command == "Edit") {
225 const [createTimestamp, description] = splitN(data, " ", 1);
ef12457b 226 return model.edit(createTimestamp, description);
7b574407 227 }
b9f7e989
SW
228 if (command == "EditContent") {
229 const [createTimestamp, content] = splitN(data, " ", 1);
230 return model.editContent(createTimestamp, content);
231 }
68a72fde
SW
232 if (command == "Priority") {
233 const [createTimestamp, newPriority] = splitN(data, " ", 1);
ef12457b 234 return model.setPriority(createTimestamp, parseFloat(newPriority));
68a72fde 235 }
6a5644f3
SW
236 if (command == "State") {
237 const [createTimestamp, state] = splitN(data, " ", 1);
ef12457b 238 return model.setState(timestamp, createTimestamp, state);
6a5644f3 239 }
7b5b90b9
SW
240 if (command == "Tag") {
241 const [createTimestamp, tag] = splitN(data, " ", 1);
ef12457b 242 return model.addTag(createTimestamp, tag);
7b5b90b9 243 }
0726872b
SW
244 if (command == "Untag") {
245 const [createTimestamp, tag] = splitN(data, " ", 1);
ef12457b 246 return model.removeTag(createTimestamp, tag);
0726872b 247 }
60a63831
SW
248 },
249
e88c099c 250 record: function (entry: string) {
d03daa19 251 window.localStorage.setItem(`${prefix}${next_log_index++}`, entry);
60a63831
SW
252 },
253
e88c099c
SW
254 recordAndApply: function (entry: string) {
255 this.record(entry);
6d01c406 256 return this.apply(entry);
60a63831
SW
257 },
258
259 replay: function () {
9db534f6 260 document.getElementById("tasks")!.style.display = "none";
60a63831 261 while (true) {
d03daa19 262 const entry = window.localStorage.getItem(`${prefix}${next_log_index}`);
60a63831
SW
263 if (entry === null) {
264 break;
265 }
e88c099c 266 this.apply(entry);
60a63831
SW
267 next_log_index++;
268 }
9db534f6 269 document.getElementById("tasks")!.style.display = "";
60a63831
SW
270 },
271 };
d03daa19
SW
272}
273const log = Log();
262705dd 274
b56a37d3 275function UI() {
0d1c27a8
SW
276 const undoLog: string[][] = [];
277 const redoLog: string[][] = [];
76825ecd 278 function perform(forward: string, reverse: string) {
0d1c27a8 279 undoLog.push([reverse, forward]);
76825ecd
SW
280 return log.recordAndApply(`${clock.now()} ${forward}`);
281 }
b56a37d3
SW
282 return {
283 addTask: function (description: string): Element {
284 const now = clock.now();
0d1c27a8 285 undoLog.push([`State ${now} deleted`, `State ${now} todo`]);
b56a37d3
SW
286 return <Element>log.recordAndApply(`${now} Create ${description}`);
287 },
7b5b90b9 288 addTag: function (createTimestamp: string, tag: string) {
76825ecd 289 return perform(`Tag ${createTimestamp} ${tag}`, `Untag ${createTimestamp} ${tag}`);
7b5b90b9 290 },
b56a37d3 291 edit: function (createTimestamp: string, newDescription: string, oldDescription: string) {
76825ecd 292 return perform(`Edit ${createTimestamp} ${newDescription}`, `Edit ${createTimestamp} ${oldDescription}`);
b56a37d3 293 },
b9f7e989
SW
294 editContent: function (createTimestamp: string, newContent: string, oldContent: string) {
295 return perform(`EditContent ${createTimestamp} ${newContent}`, `EditContent ${createTimestamp} ${oldContent}`);
296 },
b5f15e0e 297 removeTag: function (createTimestamp: string, tag: string) {
76825ecd 298 return perform(`Untag ${createTimestamp} ${tag}`, `Tag ${createTimestamp} ${tag}`);
b5f15e0e 299 },
b56a37d3 300 setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) {
76825ecd 301 return perform(`Priority ${createTimestamp} ${newPriority}`, `Priority ${createTimestamp} ${oldPriority}`);
b56a37d3
SW
302 },
303 setState: function (createTimestamp: string, newState: string, oldState: string) {
76825ecd 304 return perform(`State ${createTimestamp} ${newState}`, `State ${createTimestamp} ${oldState}`);
b56a37d3
SW
305 },
306 undo: function () {
0d1c27a8
SW
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]}`);
b56a37d3
SW
318 }
319 },
320 };
321}
322const ui = UI();
e88c099c 323
ad72cd51
SW
324enum CommitOrAbort {
325 Commit,
326 Abort,
327}
328
e0c49063
SW
329interface TagFilter {
330 description: string;
331 include: (task: Element) => boolean;
332}
333
ada060d7 334function BrowserUI() {
c2226333
SW
335 const viewColors: { [key: string]: string } = {
336 all: "Gold",
337 cancelled: "Red",
338 deleted: "Black",
339 done: "LawnGreen",
340 "someday-maybe": "DeepSkyBlue",
e26a06f9 341 todo: "rgb(0 0 0 / 0)",
c2226333
SW
342 waiting: "MediumOrchid",
343 };
e0c49063 344 var currentTagFilter: TagFilter | null = null;
868667c1 345 var currentViewState = "todo";
a59fbe41 346 var taskFocusedBeforeJumpingToInput: HTMLElement | null = null;
09cd65ad 347 var lastTagNameEntered = "";
ada060d7
SW
348 return {
349 addTask: function (event: KeyboardEvent) {
350 const input = <HTMLInputElement>document.getElementById("taskName");
fb19ac80
SW
351 if (input.value.match(/^ *$/)) return;
352 const task = ui.addTask(input.value);
cddbdce1 353 if (currentViewState === "todo" || currentViewState === "all") {
fb19ac80
SW
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")) {
88bd89ef 361 this.makeBottomPriority(task);
bc7996fe 362 }
ada060d7 363 },
09657615 364
ada060d7 365 beginEdit: function (event: Event) {
32808c9a 366 const task = this.currentTask();
ada060d7
SW
367 if (!task) return;
368 const input = document.createElement("input");
26737687
SW
369 const desc = task.getElementsByClassName("desc")[0];
370 const oldDescription = desc.textContent!;
ada060d7
SW
371 task.setAttribute("data-description", oldDescription);
372 input.value = oldDescription;
373 input.addEventListener("blur", this.completeEdit, { once: true });
26737687 374 desc.textContent = "";
7b5b90b9
SW
375 task.insertBefore(input, task.firstChild);
376 input.focus();
377 event.preventDefault();
378 },
379
b9f7e989
SW
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
7b5b90b9 395 beginTagEdit: function (event: Event) {
32808c9a 396 const task = this.currentTask();
7b5b90b9
SW
397 if (!task) return;
398 const input = document.createElement("input");
399 input.classList.add("tag");
400 input.addEventListener("blur", this.completeTagEdit, { once: true });
09cd65ad 401 input.value = lastTagNameEntered;
ada060d7
SW
402 task.appendChild(input);
403 input.focus();
09cd65ad 404 input.select();
ada060d7
SW
405 event.preventDefault();
406 },
7b574407 407
ad72cd51 408 completeEdit: function (event: Event, resolution: CommitOrAbort = CommitOrAbort.Commit) {
ada060d7
SW
409 const input = event.target as HTMLInputElement;
410 const task = input.parentElement!;
26737687 411 const desc = task.getElementsByClassName("desc")[0];
ada060d7
SW
412 const oldDescription = task.getAttribute("data-description")!;
413 const newDescription = input.value;
414 input.removeEventListener("blur", this.completeEdit);
132921e6 415 task.removeChild(input);
ada060d7
SW
416 task.removeAttribute("data-description");
417 task.focus();
fb19ac80 418 if (resolution === CommitOrAbort.Abort || newDescription.match(/^ *$/) || newDescription === oldDescription) {
26737687 419 desc.textContent = oldDescription;
ada060d7 420 } else {
b6712c31 421 ui.edit(task.getAttribute("id")!, newDescription, oldDescription);
ada060d7
SW
422 }
423 },
7b574407 424
b9f7e989
SW
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
7b5b90b9
SW
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();
ef12457b 449 if (resolution === CommitOrAbort.Commit && !newTagName.match(/^ *$/) && !model.hasTag(task, newTagName)) {
b6712c31 450 ui.addTag(task.getAttribute("id")!, newTagName);
09cd65ad 451 lastTagNameEntered = newTagName;
e1eb33ad 452 }
7b5b90b9
SW
453 },
454
5800003c
SW
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
32808c9a
SW
466 currentTask: function (): HTMLElement | null {
467 var target = document.activeElement;
468 if (!target) return null;
469 if (target.classList.contains("tag")) target = target.parentElement!;
55c0520e 470 if (!target.classList.contains("task")) return null;
32808c9a
SW
471 return target as HTMLElement;
472 },
473
f36b20d6
SW
474 firstVisibleTask: function (root: Element | null = null) {
475 if (root === null) root = document.body;
476 for (const task of root.getElementsByClassName("task")) {
cddbdce1 477 const state = task.getAttribute("data-state");
312acaa8
SW
478 if (
479 task instanceof HTMLElement &&
480 (state === currentViewState || (currentViewState === "all" && state !== "deleted")) &&
481 !task.classList.contains("hide")
482 ) {
ada060d7
SW
483 return task;
484 }
65a7510d 485 }
ada060d7 486 },
caa93fd1 487
ada060d7 488 focusTaskNameInput: function (event: Event) {
32808c9a 489 taskFocusedBeforeJumpingToInput = this.currentTask();
ada060d7 490 document.getElementById("taskName")!.focus();
a1aa43d8 491 window.scroll(0, 0);
ada060d7
SW
492 event.preventDefault();
493 },
09657615 494
ada060d7
SW
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;
cddbdce1 502 const state = cursor.getAttribute("data-state")!;
312acaa8
SW
503 if (
504 (state === currentViewState || (currentViewState === "all" && state !== "deleted")) &&
505 !cursor.classList.contains("hide")
506 ) {
ada060d7
SW
507 offset -= increment;
508 valid_cursor = cursor;
509 }
510 if (Math.abs(offset) < 0.5) break;
5fa4704c 511 }
ada060d7
SW
512 return valid_cursor;
513 },
23be73e3 514
40025d12
SW
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
88bd89ef 522 makeBottomPriority: function (task: Element | null = null) {
32808c9a 523 if (!task) task = this.currentTask();
88bd89ef
SW
524 if (!task) return;
525 this.setPriority(task, document.getElementById("tasks")!.lastElementChild, null);
526 },
527
55a4baa8 528 makeTopPriority: function (task: Element | null = null) {
32808c9a 529 if (!task) task = this.currentTask();
55a4baa8 530 if (!task) return;
b6712c31 531 ui.setPriority(task.getAttribute("id")!, clock.now(), model.getPriority(task));
88bd89ef 532 task instanceof HTMLElement && task.focus();
792b8a18
SW
533 },
534
cadeba34
SW
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 () {
f36b20d6
SW
544 const active = this.currentTask();
545 if (!active) return false;
546 (this.firstVisibleTask(active) as HTMLElement | null)?.focus();
cadeba34
SW
547 },
548
109d4bc2 549 moveCursorVertically: function (offset: number): boolean {
55c0520e
SW
550 let active = this.currentTask();
551 if (!active) {
552 this.firstVisibleTask()?.focus();
553 active = this.currentTask();
554 }
ada060d7
SW
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 },
01f41859 563
ada060d7 564 moveTask: function (offset: number) {
32808c9a 565 const active = this.currentTask();
ada060d7
SW
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 },
68a72fde 577
b5f15e0e 578 removeTag: function () {
5800003c 579 const target = this.currentTag();
4ccaa1d6 580 if (!target) return;
b6712c31 581 ui.removeTag(target.parentElement!.getAttribute("id")!, target.textContent!);
b5f15e0e
SW
582 },
583
312acaa8 584 resetTagView: function () {
b91a45d9 585 currentTagFilter = null;
246ed965 586 this.setTitle();
3a164930
SW
587 const taskList = document.getElementById("tasks")!;
588 for (const task of Array.from(document.getElementsByClassName("task"))) {
312acaa8 589 task.classList.remove("hide");
3a164930 590 if (task.parentElement !== taskList) {
ef12457b 591 model.insertInPriorityOrder(task, taskList);
3a164930 592 }
312acaa8
SW
593 }
594 },
595
58b569ce
SW
596 resetView: function () {
597 this.setView("todo");
312acaa8 598 this.resetTagView();
58b569ce
SW
599 },
600
a59fbe41
SW
601 returnFocusAfterInput: function (): boolean {
602 if (taskFocusedBeforeJumpingToInput) {
603 taskFocusedBeforeJumpingToInput.focus();
604 return true;
605 }
606 return false;
607 },
608
ada060d7
SW
609 // Change task's priority to be between other tasks a and b.
610 setPriority: function (task: Element, a: Element | null, b: Element | null) {
ef12457b
SW
611 const aPriority = a === null ? clock.now() : model.getPriority(a);
612 const bPriority = b === null ? 0 : model.getPriority(b);
88bd89ef
SW
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);
ada060d7 617 const newPriorityRounded = Math.round(newPriority);
88bd89ef 618 const okToRound = aPriority > newPriorityRounded && newPriorityRounded > bPriority;
b6712c31 619 ui.setPriority(task.getAttribute("id")!, okToRound ? newPriorityRounded : newPriority, model.getPriority(task));
ada060d7
SW
620 task instanceof HTMLElement && task.focus();
621 },
68a72fde 622
ada060d7 623 setState: function (newState: string) {
32808c9a 624 const task = this.currentTask();
ada060d7
SW
625 if (!task) return;
626 const oldState = task.getAttribute("data-state")!;
627 if (newState === oldState) return;
b6712c31 628 const createTimestamp = task.getAttribute("id")!;
cddbdce1 629 if (currentViewState !== "all" || newState == "deleted") {
109d4bc2 630 this.moveCursorVertically(1) || this.moveCursorVertically(-1);
cddbdce1 631 }
b56a37d3 632 return ui.setState(createTimestamp, newState, oldState);
ada060d7 633 },
43f3cc0c 634
e0c49063 635 setTagFilter: function (filter: TagFilter) {
b91a45d9 636 if (currentTagFilter !== null) {
3a164930
SW
637 this.resetTagView();
638 }
639
640 const tasksWithTag = new Map();
312acaa8 641 for (const task of document.getElementsByClassName("task")) {
e0c49063 642 if (filter.include(task)) {
ef12457b 643 tasksWithTag.set(task.getElementsByClassName("desc")[0].textContent, [model.getPriority(task), task]);
3a164930
SW
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"))) {
e0c49063 661 if (filter.include(task)) {
312acaa8
SW
662 task.classList.remove("hide");
663 } else {
3a164930
SW
664 const superTask = highestPrioritySuperTask(task);
665 if (superTask !== null) {
ef12457b 666 model.insertInPriorityOrder(task, superTask);
3a164930
SW
667 } else {
668 task.classList.add("hide");
669 }
312acaa8
SW
670 }
671 }
3a164930 672
e0c49063 673 currentTagFilter = filter;
246ed965
SW
674 this.setTitle();
675 },
676
06c45212
SW
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 }
e0c49063 683 this.setTagFilter({description: tag, include: task => !!model.hasTag(task, tag!)});
06c45212
SW
684 },
685
246ed965 686 setTitle: function () {
e0c49063 687 document.title = "Vopamoi: " + currentViewState + (currentTagFilter ? ": " + currentTagFilter.description : "");
312acaa8
SW
688 },
689
c2226333 690 setView: function (state: string) {
868667c1 691 const sheet = (document.getElementById("viewStyle") as HTMLStyleElement).sheet!;
cddbdce1
SW
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 }
c2226333 697 sheet.insertRule(`:root { --view-state-indicator-color: ${viewColors[state]}; }`);
4c532769
SW
698 sheet.removeRule(2);
699 sheet.removeRule(2);
868667c1 700 currentViewState = state;
246ed965 701 this.setTitle();
32808c9a 702 if (this.currentTask()?.getAttribute("data-state") !== state) {
868667c1
SW
703 this.firstVisibleTask()?.focus();
704 }
705 },
706
84849dfa 707 setUntaggedView: function () {
f9aba985 708 this.setTagFilter({description: "(untagged)", include: task => task.getElementsByClassName("tag").length === 0});
84849dfa
SW
709 },
710
e26a06f9
SW
711 toggleDark: function () {
712 document.body.classList.toggle("dark");
713 this.setView(currentViewState);
714 },
715
ada060d7 716 undo: function () {
b56a37d3 717 const ret = ui.undo();
ada060d7
SW
718 if (ret && ret instanceof HTMLElement) ret.focus();
719 },
0d1c27a8
SW
720 redo: function () {
721 const ret = ui.redo();
722 if (ret && ret instanceof HTMLElement) ret.focus();
723 },
ada060d7
SW
724 };
725}
726const browserUI = BrowserUI();
06ee32a1 727
90381b6d 728const scrollIncrement = 60;
e94e9f27 729enum InputState {
02c8a409 730 Root,
36ddfad1 731 S,
02c8a409 732 V,
36ddfad1 733 VS,
e94e9f27 734}
02c8a409 735var inputState = InputState.Root;
36fa06f4 736var inputCount: number | null = null;
e94e9f27 737
f1afad9b 738function handleKey(event: any) {
f1d8d0ed 739 if (["Alt", "Control", "Meta", "Shift"].includes(event.key)) return;
b9f7e989
SW
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") {
7b574407 744 if (event.target.id === "taskName") {
ada060d7 745 if (event.key == "Enter") return browserUI.addTask(event);
a59fbe41 746 if (event.key == "Escape") return browserUI.returnFocusAfterInput();
7b5b90b9
SW
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);
7b574407 750 } else {
ada060d7 751 if (event.key == "Enter") return browserUI.completeEdit(event);
ad72cd51 752 if (event.key == "Escape") return browserUI.completeEdit(event, CommitOrAbort.Abort);
7b574407 753 }
a26b1f4b 754 } else {
02c8a409 755 if (inputState === InputState.Root) {
36fa06f4
SW
756 if ("0" <= event.key && event.key <= "9") {
757 return (inputCount = (inputCount ?? 0) * 10 + parseInt(event.key));
758 }
759 try {
90381b6d
SW
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 {
cadeba34
SW
764 if (event.key == "h") return browserUI.moveCursorLeft();
765 if (event.key == "l") return browserUI.moveCursorRight();
109d4bc2
SW
766 if (event.key == "j") return browserUI.moveCursorVertically(inputCount ?? 1);
767 if (event.key == "k") return browserUI.moveCursorVertically(-(inputCount ?? 1));
90381b6d
SW
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);
62819648 773 if (event.key == "C") return browserUI.setState("cancelled");
90381b6d
SW
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();
b9f7e989 782 if (event.key == "E") return browserUI.beginEditContent(event);
90381b6d
SW
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 }
36fa06f4
SW
787 } finally {
788 inputCount = null;
789 }
36ddfad1
SW
790 } else if (inputState === InputState.S) {
791 inputState = InputState.Root;
792 if (event.key == "m") return browserUI.setState("someday-maybe");
02c8a409
SW
793 } else if (inputState === InputState.V) {
794 inputState = InputState.Root;
c2226333 795 if (event.key == "a") return browserUI.setView("all");
62819648 796 if (event.key == "C") return browserUI.setView("cancelled");
1b63569c 797 if (event.key == "c") return browserUI.setTagView("comp");
e26a06f9 798 if (event.key == "D") return browserUI.toggleDark();
c2226333 799 if (event.key == "d") return browserUI.setView("done");
1b63569c
SW
800 if (event.key == "e") return browserUI.setTagView("errand");
801 if (event.key == "h") return browserUI.setTagView("home");
84849dfa 802 if (event.key == "i") return browserUI.setUntaggedView();
68d69314 803 if (event.key == "p") return browserUI.setTagView("Project");
c2226333 804 if (event.key == "q") return browserUI.setView("todo");
36ddfad1 805 if (event.key == "s") return (inputState = InputState.VS);
312acaa8
SW
806 if (event.key == "T") return browserUI.resetTagView();
807 if (event.key == "t") return browserUI.setTagView();
84849dfa 808 if (event.key == "u") return browserUI.setUntaggedView();
58b569ce 809 if (event.key == "v") return browserUI.resetView();
c2226333
SW
810 if (event.key == "w") return browserUI.setView("waiting");
811 if (event.key == "x") return browserUI.setView("deleted");
1b63569c 812 if (event.key == "z") return browserUI.setTagView("zombie");
36ddfad1
SW
813 } else if (inputState === InputState.VS) {
814 inputState = InputState.Root;
c2226333 815 if (event.key == "m") return browserUI.setView("someday-maybe");
e94e9f27 816 }
f1afad9b
SW
817 }
818}
819
f1afad9b 820function browserInit() {
d03daa19 821 log.replay();
246ed965 822 browserUI.setTitle();
ada060d7 823 browserUI.firstVisibleTask()?.focus();
bd267c29 824 document.body.addEventListener("keydown", handleKey, { capture: false });
f1afad9b 825}