From 7dcd916735fa4df2d8c27ccef144be0081bbe737 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 02:28:10 -0800 Subject: [PATCH 001/100] Create tasks --- vopamoi.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index cb2d50e..0d2944c 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -1,7 +1,18 @@ -var tasks = []; +var tasks: string[] = []; + +function createViewTask(task: string) { + const viewTask = document.createElement("div"); + viewTask.appendChild(document.createTextNode(task)); + return viewTask; +} + +function updateView() { + tasks.forEach((t) => document.getElementById(t) || document.body.appendChild(createViewTask(t))); +} function addTask(task: string) { tasks.push(task); + updateView(); } function browserCreateTask(form: any) { -- 2.44.1 From 28568279f452686693f5e92182ce83651afbc174 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 02:36:51 -0800 Subject: [PATCH 002/100] Tasks can be focused --- vopamoi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vopamoi.ts b/vopamoi.ts index 0d2944c..5967c7d 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -3,6 +3,7 @@ var tasks: string[] = []; function createViewTask(task: string) { const viewTask = document.createElement("div"); viewTask.appendChild(document.createTextNode(task)); + viewTask.setAttribute("tabindex", "0"); return viewTask; } -- 2.44.1 From 9e79bdbb90ffcb5929f76a4fb6860ed6396ac9de Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 11:43:08 -0800 Subject: [PATCH 003/100] No MVMVC; DOM *is* the model. --- vopamoi.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 5967c7d..cfa1186 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -1,19 +1,12 @@ -var tasks: string[] = []; - -function createViewTask(task: string) { - const viewTask = document.createElement("div"); - viewTask.appendChild(document.createTextNode(task)); - viewTask.setAttribute("tabindex", "0"); - return viewTask; -} - -function updateView() { - tasks.forEach((t) => document.getElementById(t) || document.body.appendChild(createViewTask(t))); +function createTask(description: string) { + const task = document.createElement("div"); + task.appendChild(document.createTextNode(description)); + task.setAttribute("tabindex", "0"); + return task; } -function addTask(task: string) { - tasks.push(task); - updateView(); +function addTask(description: string) { + document.body.appendChild(createTask(description)); } function browserCreateTask(form: any) { -- 2.44.1 From f1afad9b15dd2e672dc83bf6469532683f9c33ed Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 13:31:52 -0800 Subject: [PATCH 004/100] j/k movement keys --- index.html | 2 +- vopamoi.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 5721f96..5ed2498 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - +
diff --git a/vopamoi.ts b/vopamoi.ts index cfa1186..a0b2cfb 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -9,8 +9,30 @@ function addTask(description: string) { document.body.appendChild(createTask(description)); } +function moveCursor(offset: number) { + var active = document.activeElement; + if (offset === 1 && active) { + active = active.nextElementSibling; + } + if (offset === -1 && active) { + active = active.previousElementSibling; + } + if (active && active instanceof HTMLElement) active.focus(); +} + +function handleKey(event: any) { + if (event.target.tagName !== "INPUT") { + if (event.key == "j") moveCursor(1); + if (event.key == "k") moveCursor(-1); + } +} + function browserCreateTask(form: any) { addTask(form.taskName.value); form.taskName.value = ""; return false; } + +function browserInit() { + document.body.addEventListener("keydown", handleKey, { capture: false }); +} -- 2.44.1 From 13c97b9931434fe043c29c296792121cdf91e807 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 14:20:22 -0800 Subject: [PATCH 005/100] Call some things "Model", to distinguish them from "Log" --- vopamoi.ts | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index a0b2cfb..1961dd9 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -1,34 +1,36 @@ -function createTask(description: string) { - const task = document.createElement("div"); - task.appendChild(document.createTextNode(description)); - task.setAttribute("tabindex", "0"); - return task; -} +const Model = { + createTask: function (description: string) { + const task = document.createElement("div"); + task.appendChild(document.createTextNode(description)); + task.setAttribute("tabindex", "0"); + return task; + }, -function addTask(description: string) { - document.body.appendChild(createTask(description)); -} + addTask: function (description: string) { + document.body.appendChild(this.createTask(description)); + }, -function moveCursor(offset: number) { - var active = document.activeElement; - if (offset === 1 && active) { - active = active.nextElementSibling; - } - if (offset === -1 && active) { - active = active.previousElementSibling; - } - if (active && active instanceof HTMLElement) active.focus(); -} + moveCursor: function (offset: number) { + var active = document.activeElement; + if (offset === 1 && active) { + active = active.nextElementSibling; + } + if (offset === -1 && active) { + active = active.previousElementSibling; + } + if (active && active instanceof HTMLElement) active.focus(); + }, +}; function handleKey(event: any) { if (event.target.tagName !== "INPUT") { - if (event.key == "j") moveCursor(1); - if (event.key == "k") moveCursor(-1); + if (event.key == "j") Model.moveCursor(1); + if (event.key == "k") Model.moveCursor(-1); } } function browserCreateTask(form: any) { - addTask(form.taskName.value); + Model.addTask(form.taskName.value); form.taskName.value = ""; return false; } -- 2.44.1 From f69ff5261a6e77cfba0daf8eb4c164d0c57c2602 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 14:28:35 -0800 Subject: [PATCH 006/100] New tasks get focus. --- vopamoi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index 1961dd9..2567e85 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -7,7 +7,7 @@ const Model = { }, addTask: function (description: string) { - document.body.appendChild(this.createTask(description)); + document.body.appendChild(this.createTask(description)).focus(); }, moveCursor: function (offset: number) { -- 2.44.1 From 75a42da4667eceebea699c52d85fa4b7c3e6a0bc Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 14:29:23 -0800 Subject: [PATCH 007/100] "c" to "Create" tasks --- index.html | 2 +- vopamoi.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 5ed2498..afb9231 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@
- +
diff --git a/vopamoi.ts b/vopamoi.ts index 2567e85..8db2865 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -26,6 +26,10 @@ function handleKey(event: any) { if (event.target.tagName !== "INPUT") { if (event.key == "j") Model.moveCursor(1); if (event.key == "k") Model.moveCursor(-1); + if (event.key == "c") { + document.getElementById("taskName")!.focus(); + event.preventDefault(); + } } } -- 2.44.1 From f1b121ab0699da579cd8de7f5a0c891c2389f549 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 14:33:34 -0800 Subject: [PATCH 008/100] No empty-string-named tasks --- vopamoi.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index 8db2865..a3f6ce4 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -34,7 +34,9 @@ function handleKey(event: any) { } function browserCreateTask(form: any) { - Model.addTask(form.taskName.value); + if (form.taskName.value) { + Model.addTask(form.taskName.value); + } form.taskName.value = ""; return false; } -- 2.44.1 From 262705dd315ec4bf7088cc3ec9013dc32f3e3bd3 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 15:53:04 -0800 Subject: [PATCH 009/100] Writes go through the Log --- vopamoi.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index a3f6ce4..505781d 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -22,6 +22,12 @@ const Model = { }, }; +const Log = { + addTask: function (description: string) { + Model.addTask(description); + }, +}; + function handleKey(event: any) { if (event.target.tagName !== "INPUT") { if (event.key == "j") Model.moveCursor(1); @@ -35,7 +41,7 @@ function handleKey(event: any) { function browserCreateTask(form: any) { if (form.taskName.value) { - Model.addTask(form.taskName.value); + Log.addTask(form.taskName.value); } form.taskName.value = ""; return false; -- 2.44.1 From 121d9948cc637922ae4f894b10c1635d73de15f2 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 21:47:53 -0800 Subject: [PATCH 010/100] Build and consume Log entries --- vopamoi.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index 505781d..6cfdbf2 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -1,3 +1,18 @@ +// Typescript doesn't know about MAX_SAFE_INTEGER?? This was supposed to be +// fixed in typescript 2.0.1 in 2016, but is not working for me in typescript +// 4.2.4 in 2022. :( https://github.com/microsoft/TypeScript/issues/9937 +//const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +const MAX_SAFE_INTEGER = 9007199254740991; + +// A sane split that splits N *times*, leaving the last chunk unsplit. +function splitN(str: string, delimiter: string, limit: number = MAX_SAFE_INTEGER): string[] { + if (limit < 1) { + return [str]; + } + const at = str.indexOf(delimiter); + return at === -1 ? [str] : [str.substring(0, at)].concat(splitN(str.substring(at + delimiter.length), delimiter, limit - 1)); +} + const Model = { createTask: function (description: string) { const task = document.createElement("div"); @@ -24,7 +39,14 @@ const Model = { const Log = { addTask: function (description: string) { - Model.addTask(description); + this.applyLogEntry(`${Date.now()} Create ${description}`); + }, + + applyLogEntry: function (entry: string) { + const [timestamp, command, data] = splitN(entry, " ", 2); + if (command == "Create") { + Model.addTask(data); + } }, }; -- 2.44.1 From 60a63831b05de43349b166eb449df42ce076ee47 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 22:13:35 -0800 Subject: [PATCH 011/100] Persistence --- vopamoi.ts | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 6cfdbf2..4d6cd86 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -37,18 +37,41 @@ const Model = { }, }; -const Log = { - addTask: function (description: string) { - this.applyLogEntry(`${Date.now()} Create ${description}`); - }, +const Log = (function () { + var next_log_index = 0; + return { + addTask: function (description: string) { + this.recordAndApplyLogEntry(`${Date.now()} Create ${description}`); + }, - applyLogEntry: function (entry: string) { - const [timestamp, command, data] = splitN(entry, " ", 2); - if (command == "Create") { - Model.addTask(data); - } - }, -}; + applyLogEntry: function (entry: string) { + const [timestamp, command, data] = splitN(entry, " ", 2); + if (command == "Create") { + Model.addTask(data); + } + }, + + recordLogEntry: function (entry: string) { + window.localStorage.setItem(`${next_log_index++}`, entry); + }, + + recordAndApplyLogEntry: function (entry: string) { + this.recordLogEntry(entry); + this.applyLogEntry(entry); + }, + + replay: function () { + while (true) { + const entry = window.localStorage.getItem(`${next_log_index}`); + if (entry === null) { + break; + } + this.applyLogEntry(entry); + next_log_index++; + } + }, + }; +})(); function handleKey(event: any) { if (event.target.tagName !== "INPUT") { @@ -71,4 +94,5 @@ function browserCreateTask(form: any) { function browserInit() { document.body.addEventListener("keydown", handleKey, { capture: false }); + Log.replay(); } -- 2.44.1 From e88c099c05a14bc0851cbab622938cdd4e8c6cc9 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 22:17:51 -0800 Subject: [PATCH 012/100] Keep Log narrowly about log stuff --- vopamoi.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 4d6cd86..654a7a8 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -40,24 +40,20 @@ const Model = { const Log = (function () { var next_log_index = 0; return { - addTask: function (description: string) { - this.recordAndApplyLogEntry(`${Date.now()} Create ${description}`); - }, - - applyLogEntry: function (entry: string) { + apply: function (entry: string) { const [timestamp, command, data] = splitN(entry, " ", 2); if (command == "Create") { Model.addTask(data); } }, - recordLogEntry: function (entry: string) { + record: function (entry: string) { window.localStorage.setItem(`${next_log_index++}`, entry); }, - recordAndApplyLogEntry: function (entry: string) { - this.recordLogEntry(entry); - this.applyLogEntry(entry); + recordAndApply: function (entry: string) { + this.record(entry); + this.apply(entry); }, replay: function () { @@ -66,13 +62,19 @@ const Log = (function () { if (entry === null) { break; } - this.applyLogEntry(entry); + this.apply(entry); next_log_index++; } }, }; })(); +const UI = { + addTask: function (description: string) { + Log.recordAndApply(`${Date.now()} Create ${description}`); + }, +}; + function handleKey(event: any) { if (event.target.tagName !== "INPUT") { if (event.key == "j") Model.moveCursor(1); @@ -86,7 +88,7 @@ function handleKey(event: any) { function browserCreateTask(form: any) { if (form.taskName.value) { - Log.addTask(form.taskName.value); + UI.addTask(form.taskName.value); } form.taskName.value = ""; return false; -- 2.44.1 From 06ee32a10fa00f28eaae8a6f7e948b422e1861cd Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 22:24:05 -0800 Subject: [PATCH 013/100] Keep handleKey lean --- vopamoi.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 654a7a8..ab58d6d 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -75,14 +75,16 @@ const UI = { }, }; +function focusTaskNameInput(event: any) { + document.getElementById("taskName")!.focus(); + event.preventDefault(); +} + function handleKey(event: any) { if (event.target.tagName !== "INPUT") { - if (event.key == "j") Model.moveCursor(1); - if (event.key == "k") Model.moveCursor(-1); - if (event.key == "c") { - document.getElementById("taskName")!.focus(); - event.preventDefault(); - } + if (event.key == "j") return Model.moveCursor(1); + if (event.key == "k") return Model.moveCursor(-1); + if (event.key == "c") return focusTaskNameInput(event); } } -- 2.44.1 From 4101e1b1dab33f7fcdfd4a26edab2e79b66716d6 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 22:29:14 -0800 Subject: [PATCH 014/100] Keep the creation timestamp in the DOM --- vopamoi.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index ab58d6d..4e69d0f 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -14,15 +14,16 @@ function splitN(str: string, delimiter: string, limit: number = MAX_SAFE_INTEGER } const Model = { - createTask: function (description: string) { + createTask: function (timestamp: string, description: string) { const task = document.createElement("div"); task.appendChild(document.createTextNode(description)); task.setAttribute("tabindex", "0"); + task.setAttribute("data-created", timestamp); return task; }, - addTask: function (description: string) { - document.body.appendChild(this.createTask(description)).focus(); + addTask: function (timestamp: string, description: string) { + document.body.appendChild(this.createTask(timestamp, description)).focus(); }, moveCursor: function (offset: number) { @@ -43,7 +44,7 @@ const Log = (function () { apply: function (entry: string) { const [timestamp, command, data] = splitN(entry, " ", 2); if (command == "Create") { - Model.addTask(data); + Model.addTask(timestamp, data); } }, -- 2.44.1 From 7523dc894af60222f05795a43dcae6ecee0b4daa Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 22:35:52 -0800 Subject: [PATCH 015/100] moveCursor returns whether it was able to or not --- vopamoi.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 4e69d0f..bc7acb7 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -26,7 +26,7 @@ const Model = { document.body.appendChild(this.createTask(timestamp, description)).focus(); }, - moveCursor: function (offset: number) { + moveCursor: function (offset: number): boolean { var active = document.activeElement; if (offset === 1 && active) { active = active.nextElementSibling; @@ -34,7 +34,11 @@ const Model = { if (offset === -1 && active) { active = active.previousElementSibling; } - if (active && active instanceof HTMLElement) active.focus(); + if (active && active instanceof HTMLElement) { + active.focus(); + return true; + } + return false; }, }; -- 2.44.1 From caa93fd1819b0d57029de637029daf0bc867d17e Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 22:47:55 -0800 Subject: [PATCH 016/100] BrowserUI --- index.html | 2 +- vopamoi.ts | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index afb9231..2a82c98 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ -
+
diff --git a/vopamoi.ts b/vopamoi.ts index bc7acb7..f127da9 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -80,6 +80,16 @@ const UI = { }, }; +const BrowserUI = { + addTask: function (form: any) { + if (form.taskName.value) { + UI.addTask(form.taskName.value); + form.taskName.value = ""; + } + return false; + }, +}; + function focusTaskNameInput(event: any) { document.getElementById("taskName")!.focus(); event.preventDefault(); @@ -93,14 +103,6 @@ function handleKey(event: any) { } } -function browserCreateTask(form: any) { - if (form.taskName.value) { - UI.addTask(form.taskName.value); - } - form.taskName.value = ""; - return false; -} - function browserInit() { document.body.addEventListener("keydown", handleKey, { capture: false }); Log.replay(); -- 2.44.1 From 799f4e8974924591b197be553e8c03947debdb33 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 23:45:42 -0800 Subject: [PATCH 017/100] Destroy tasks --- Makefile | 2 +- vopamoi.ts | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 46292dc..36635cc 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ all: $(objs) .PHONY: all clean %.js: %.ts - tsc --strict $< + tsc --strict --target ES2020 $< clean: -rm $(objs) diff --git a/vopamoi.ts b/vopamoi.ts index f127da9..3ff3918 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -17,6 +17,7 @@ const Model = { createTask: function (timestamp: string, description: string) { const task = document.createElement("div"); task.appendChild(document.createTextNode(description)); + task.setAttribute("class", "task"); task.setAttribute("tabindex", "0"); task.setAttribute("data-created", timestamp); return task; @@ -26,6 +27,19 @@ const Model = { document.body.appendChild(this.createTask(timestamp, description)).focus(); }, + getTask: function (createTimestamp: string) { + for (const task of document.getElementsByClassName("task")) { + if (task.getAttribute("data-created") === createTimestamp) { + return task; + } + } + }, + + destroyTask: function (createTimestamp: string) { + const task = this.getTask(createTimestamp); + task!.parentElement!.removeChild(task!); + }, + moveCursor: function (offset: number): boolean { var active = document.activeElement; if (offset === 1 && active) { @@ -50,6 +64,9 @@ const Log = (function () { if (command == "Create") { Model.addTask(timestamp, data); } + if (command == "Destroy") { + Model.destroyTask(data.split(" ", 1)[0]); + } }, record: function (entry: string) { @@ -78,6 +95,9 @@ const UI = { addTask: function (description: string) { Log.recordAndApply(`${Date.now()} Create ${description}`); }, + destroyTask: function (createTimestamp: string) { + Log.recordAndApply(`${Date.now()} Destroy ${createTimestamp} ${Model.getTask(createTimestamp)?.textContent}`); + }, }; const BrowserUI = { @@ -88,6 +108,10 @@ const BrowserUI = { } return false; }, + destroyTask: function () { + const createTimestamp = document.activeElement?.getAttribute("data-created"); + return createTimestamp && UI.destroyTask(createTimestamp); + }, }; function focusTaskNameInput(event: any) { @@ -100,6 +124,7 @@ function handleKey(event: any) { if (event.key == "j") return Model.moveCursor(1); if (event.key == "k") return Model.moveCursor(-1); if (event.key == "c") return focusTaskNameInput(event); + if (event.key == "X") return BrowserUI.destroyTask(); } } -- 2.44.1 From 09657615b47a9634b20a1ab5abf2172d1575f56c Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 23:51:50 -0800 Subject: [PATCH 018/100] Move focusTaskNameInput and moveCursor into BrowserUI --- vopamoi.ts | 47 ++++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 3ff3918..56c4813 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -39,21 +39,6 @@ const Model = { const task = this.getTask(createTimestamp); task!.parentElement!.removeChild(task!); }, - - moveCursor: function (offset: number): boolean { - var active = document.activeElement; - if (offset === 1 && active) { - active = active.nextElementSibling; - } - if (offset === -1 && active) { - active = active.previousElementSibling; - } - if (active && active instanceof HTMLElement) { - active.focus(); - return true; - } - return false; - }, }; const Log = (function () { @@ -108,22 +93,38 @@ const BrowserUI = { } return false; }, + destroyTask: function () { const createTimestamp = document.activeElement?.getAttribute("data-created"); return createTimestamp && UI.destroyTask(createTimestamp); }, -}; -function focusTaskNameInput(event: any) { - document.getElementById("taskName")!.focus(); - event.preventDefault(); -} + focusTaskNameInput: function (event: any) { + document.getElementById("taskName")!.focus(); + event.preventDefault(); + }, + + moveCursor: function (offset: number): boolean { + var active = document.activeElement; + if (offset === 1 && active) { + active = active.nextElementSibling; + } + if (offset === -1 && active) { + active = active.previousElementSibling; + } + if (active && active instanceof HTMLElement) { + active.focus(); + return true; + } + return false; + }, +}; function handleKey(event: any) { if (event.target.tagName !== "INPUT") { - if (event.key == "j") return Model.moveCursor(1); - if (event.key == "k") return Model.moveCursor(-1); - if (event.key == "c") return focusTaskNameInput(event); + if (event.key == "j") return BrowserUI.moveCursor(1); + if (event.key == "k") return BrowserUI.moveCursor(-1); + if (event.key == "c") return BrowserUI.focusTaskNameInput(event); if (event.key == "X") return BrowserUI.destroyTask(); } } -- 2.44.1 From 615358984666136f37c15c87b78ab96b541d60bf Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Tue, 25 Jan 2022 23:54:34 -0800 Subject: [PATCH 019/100] Leave something focused after destroying a task --- vopamoi.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index 56c4813..151c5e0 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -96,7 +96,8 @@ const BrowserUI = { destroyTask: function () { const createTimestamp = document.activeElement?.getAttribute("data-created"); - return createTimestamp && UI.destroyTask(createTimestamp); + this.moveCursor(1) || this.moveCursor(-1); + return UI.destroyTask(createTimestamp!); }, focusTaskNameInput: function (event: any) { -- 2.44.1 From 01f418592aab444588d6d64d53aecf70644e45c0 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 01:01:48 -0800 Subject: [PATCH 020/100] Set tasks "done" (or "waiting", "cancelled", "someday-maybe") --- vopamoi.ts | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 151c5e0..2b38dd8 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -37,7 +37,19 @@ const Model = { destroyTask: function (createTimestamp: string) { const task = this.getTask(createTimestamp); - task!.parentElement!.removeChild(task!); + if (task) { + task.parentElement!.removeChild(task); + } + }, + + setState: function (stateTimestamp: string, createTimestamp: string, state: string) { + const task = this.getTask(createTimestamp); + if (task) { + task.setAttribute(`data-${state}`, stateTimestamp); + if (task instanceof HTMLElement) { + task.style.display = "none"; // Until view filtering + } + } }, }; @@ -52,6 +64,10 @@ const Log = (function () { if (command == "Destroy") { Model.destroyTask(data.split(" ", 1)[0]); } + if (command == "State") { + const [createTimestamp, state] = splitN(data, " ", 1); + Model.setState(timestamp, createTimestamp, state); + } }, record: function (entry: string) { @@ -83,6 +99,9 @@ const UI = { destroyTask: function (createTimestamp: string) { Log.recordAndApply(`${Date.now()} Destroy ${createTimestamp} ${Model.getTask(createTimestamp)?.textContent}`); }, + setState: function (createTimestamp: string, state: string) { + Log.recordAndApply(`${Date.now()} State ${createTimestamp} ${state}`); + }, }; const BrowserUI = { @@ -119,13 +138,23 @@ const BrowserUI = { } return false; }, + + setState: function (state: string) { + const createTimestamp = document.activeElement?.getAttribute("data-created"); + this.moveCursor(1) || this.moveCursor(-1); + return UI.setState(createTimestamp!, state); + }, }; function handleKey(event: any) { if (event.target.tagName !== "INPUT") { if (event.key == "j") return BrowserUI.moveCursor(1); if (event.key == "k") return BrowserUI.moveCursor(-1); - if (event.key == "c") return BrowserUI.focusTaskNameInput(event); + if (event.key == "n") return BrowserUI.focusTaskNameInput(event); + if (event.key == "s") return BrowserUI.setState("someday-maybe"); + if (event.key == "w") return BrowserUI.setState("waiting"); + if (event.key == "d") return BrowserUI.setState("done"); + if (event.key == "c") return BrowserUI.setState("cancelled"); if (event.key == "X") return BrowserUI.destroyTask(); } } -- 2.44.1 From d03daa19edde54d43693dca896f6fabade18d326 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 09:45:41 -0800 Subject: [PATCH 021/100] Use a prefix for localStorage keys --- vopamoi.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 2b38dd8..59c2875 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -53,7 +53,7 @@ const Model = { }, }; -const Log = (function () { +function Log(prefix: string = "vp-") { var next_log_index = 0; return { apply: function (entry: string) { @@ -71,7 +71,7 @@ const Log = (function () { }, record: function (entry: string) { - window.localStorage.setItem(`${next_log_index++}`, entry); + window.localStorage.setItem(`${prefix}${next_log_index++}`, entry); }, recordAndApply: function (entry: string) { @@ -81,7 +81,7 @@ const Log = (function () { replay: function () { while (true) { - const entry = window.localStorage.getItem(`${next_log_index}`); + const entry = window.localStorage.getItem(`${prefix}${next_log_index}`); if (entry === null) { break; } @@ -90,17 +90,18 @@ const Log = (function () { } }, }; -})(); +} +const log = Log(); const UI = { addTask: function (description: string) { - Log.recordAndApply(`${Date.now()} Create ${description}`); + log.recordAndApply(`${Date.now()} Create ${description}`); }, destroyTask: function (createTimestamp: string) { - Log.recordAndApply(`${Date.now()} Destroy ${createTimestamp} ${Model.getTask(createTimestamp)?.textContent}`); + log.recordAndApply(`${Date.now()} Destroy ${createTimestamp} ${Model.getTask(createTimestamp)?.textContent}`); }, setState: function (createTimestamp: string, state: string) { - Log.recordAndApply(`${Date.now()} State ${createTimestamp} ${state}`); + log.recordAndApply(`${Date.now()} State ${createTimestamp} ${state}`); }, }; @@ -161,5 +162,5 @@ function handleKey(event: any) { function browserInit() { document.body.addEventListener("keydown", handleKey, { capture: false }); - Log.replay(); + log.replay(); } -- 2.44.1 From 5fa4704c5a919a16c0d41fe1f92eaa83d236a66b Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 10:44:48 -0800 Subject: [PATCH 022/100] Fix moving focus over completed tasks It sure would be nice if we could just trigger whatever the browser does for tab and shift-tab key presses, instead of doing all this. --- vopamoi.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 59c2875..b90b895 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -126,15 +126,22 @@ const BrowserUI = { }, moveCursor: function (offset: number): boolean { - var active = document.activeElement; - if (offset === 1 && active) { - active = active.nextElementSibling; - } - if (offset === -1 && active) { - active = active.previousElementSibling; + var initial_cursor = document.activeElement; + if (!initial_cursor) return false; + var cursor: Element | null = initial_cursor; + var valid_cursor = cursor; + const increment = offset / Math.abs(offset); + while (true) { + cursor = increment > 0 ? cursor.nextElementSibling : cursor.previousElementSibling; + if (!cursor || !(cursor instanceof HTMLElement)) break; + if (cursor.style.display !== "none") { + offset -= increment; + valid_cursor = cursor; + } + if (Math.abs(offset) < 0.5) break; } - if (active && active instanceof HTMLElement) { - active.focus(); + if (valid_cursor !== initial_cursor && valid_cursor instanceof HTMLElement) { + valid_cursor.focus(); return true; } return false; -- 2.44.1 From 23be73e31d2e432bb56323937bab90fafba13b91 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 10:59:16 -0800 Subject: [PATCH 023/100] Factor out task offset logic --- vopamoi.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index b90b895..3042add 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -125,10 +125,8 @@ const BrowserUI = { event.preventDefault(); }, - moveCursor: function (offset: number): boolean { - var initial_cursor = document.activeElement; - if (!initial_cursor) return false; - var cursor: Element | null = initial_cursor; + visibleTaskAtOffset(task: Element, offset: number): Element { + var cursor: Element | null = task; var valid_cursor = cursor; const increment = offset / Math.abs(offset); while (true) { @@ -140,8 +138,15 @@ const BrowserUI = { } if (Math.abs(offset) < 0.5) break; } - if (valid_cursor !== initial_cursor && valid_cursor instanceof HTMLElement) { - valid_cursor.focus(); + return valid_cursor; + }, + + moveCursor: function (offset: number): boolean { + const active = document.activeElement; + if (!active) return false; + const dest = this.visibleTaskAtOffset(active, offset); + if (dest !== active && dest instanceof HTMLElement) { + dest.focus(); return true; } return false; -- 2.44.1 From 412e36630caf4f0133959e1641d8a85ba8f5fcc6 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 11:31:02 -0800 Subject: [PATCH 024/100] Sort Model methods --- vopamoi.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 3042add..ad1051a 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -14,6 +14,10 @@ function splitN(str: string, delimiter: string, limit: number = MAX_SAFE_INTEGER } const Model = { + addTask: function (timestamp: string, description: string) { + document.body.appendChild(this.createTask(timestamp, description)).focus(); + }, + createTask: function (timestamp: string, description: string) { const task = document.createElement("div"); task.appendChild(document.createTextNode(description)); @@ -23,8 +27,11 @@ const Model = { return task; }, - addTask: function (timestamp: string, description: string) { - document.body.appendChild(this.createTask(timestamp, description)).focus(); + destroyTask: function (createTimestamp: string) { + const task = this.getTask(createTimestamp); + if (task) { + task.parentElement!.removeChild(task); + } }, getTask: function (createTimestamp: string) { @@ -35,13 +42,6 @@ const Model = { } }, - destroyTask: function (createTimestamp: string) { - const task = this.getTask(createTimestamp); - if (task) { - task.parentElement!.removeChild(task); - } - }, - setState: function (stateTimestamp: string, createTimestamp: string, state: string) { const task = this.getTask(createTimestamp); if (task) { -- 2.44.1 From 4546c7b76e0168ef69cf9d046bfdac7fadac5f9d Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 12:50:10 -0800 Subject: [PATCH 025/100] Put tasks in a div --- index.html | 1 + vopamoi.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 2a82c98..91908e3 100644 --- a/index.html +++ b/index.html @@ -7,5 +7,6 @@
+
diff --git a/vopamoi.ts b/vopamoi.ts index ad1051a..725f3a0 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -15,7 +15,7 @@ function splitN(str: string, delimiter: string, limit: number = MAX_SAFE_INTEGER const Model = { addTask: function (timestamp: string, description: string) { - document.body.appendChild(this.createTask(timestamp, description)).focus(); + document.getElementById("tasks")!.appendChild(this.createTask(timestamp, description)).focus(); }, createTask: function (timestamp: string, description: string) { -- 2.44.1 From 68a72fde2851bd9c176b8862708b71707c1d3ea1 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 12:55:28 -0800 Subject: [PATCH 026/100] Re-order tasks --- vopamoi.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/vopamoi.ts b/vopamoi.ts index 725f3a0..b899c71 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -34,6 +34,13 @@ const Model = { } }, + getPriority: function (task: Element): number { + if (task.hasAttribute("data-priority")) { + return parseFloat(task.getAttribute("data-priority")!); + } + return parseFloat(task.getAttribute("data-created")!); + }, + getTask: function (createTimestamp: string) { for (const task of document.getElementsByClassName("task")) { if (task.getAttribute("data-created") === createTimestamp) { @@ -42,6 +49,21 @@ const Model = { } }, + setPriority: function (createTimestamp: string, priority: number) { + const target = this.getTask(createTimestamp); + if (!target) return; + target.setAttribute("data-priority", `${priority}`); + for (const task of document.getElementsByClassName("task")) { + if (task !== target && this.getPriority(task) > priority) { + task.parentElement!.insertBefore(target, task); + target instanceof HTMLElement && target.focus(); + return; + } + } + document.getElementById("tasks")!.appendChild(target); + target instanceof HTMLElement && target.focus(); + }, + setState: function (stateTimestamp: string, createTimestamp: string, state: string) { const task = this.getTask(createTimestamp); if (task) { @@ -68,6 +90,10 @@ function Log(prefix: string = "vp-") { const [createTimestamp, state] = splitN(data, " ", 1); Model.setState(timestamp, createTimestamp, state); } + if (command == "Priority") { + const [createTimestamp, newPriority] = splitN(data, " ", 1); + Model.setPriority(createTimestamp, parseFloat(newPriority)); + } }, record: function (entry: string) { @@ -100,6 +126,9 @@ const UI = { destroyTask: function (createTimestamp: string) { log.recordAndApply(`${Date.now()} Destroy ${createTimestamp} ${Model.getTask(createTimestamp)?.textContent}`); }, + setPriority: function (createTimestamp: string, priority: number) { + log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${priority}`); + }, setState: function (createTimestamp: string, state: string) { log.recordAndApply(`${Date.now()} State ${createTimestamp} ${state}`); }, @@ -152,6 +181,33 @@ const BrowserUI = { return false; }, + moveTask: function (offset: number) { + const active = document.activeElement; + if (!active) return; + const dest = this.visibleTaskAtOffset(active, offset); + if (dest === active) return; // Already extremal + var onePastDest: Element | null = this.visibleTaskAtOffset(dest, offset / Math.abs(offset)); + if (onePastDest == dest) onePastDest = null; // Will become extremal + if (offset > 0) { + this.setPriority(active, dest, onePastDest); + } else { + this.setPriority(active, onePastDest, dest); + } + }, + + // Change task's priority to be between other tasks a and b. + setPriority: function (task: Element, a: Element | null, b: Element | null) { + const aPriority = a === null ? 0 : Model.getPriority(a); + const bPriority = b === null ? Date.now() : Model.getPriority(b); + console.assert(aPriority < bPriority, aPriority, "<", bPriority); + const span = bPriority - aPriority; + const newPriority = aPriority + 0.1 * span + 0.8 * span * Math.random(); + console.assert(aPriority < newPriority && newPriority < bPriority, aPriority, "<", newPriority, "<", bPriority); + const newPriorityRounded = Math.round(newPriority); + const okToRound = aPriority < newPriorityRounded && newPriorityRounded < bPriority; + UI.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority); + }, + setState: function (state: string) { const createTimestamp = document.activeElement?.getAttribute("data-created"); this.moveCursor(1) || this.moveCursor(-1); @@ -163,6 +219,8 @@ function handleKey(event: any) { if (event.target.tagName !== "INPUT") { if (event.key == "j") return BrowserUI.moveCursor(1); if (event.key == "k") return BrowserUI.moveCursor(-1); + if (event.key == "J") return BrowserUI.moveTask(1); + if (event.key == "K") return BrowserUI.moveTask(-1); if (event.key == "n") return BrowserUI.focusTaskNameInput(event); if (event.key == "s") return BrowserUI.setState("someday-maybe"); if (event.key == "w") return BrowserUI.setState("waiting"); -- 2.44.1 From ef7ebad4fdeec65f894d080e48fb43d372dec250 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 13:00:15 -0800 Subject: [PATCH 027/100] Eliminate createTask. It's part of addTask In the spirit of maintaining the invariant: all tasks are in the DOM; no loose, floating tasks. --- vopamoi.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index b899c71..ab06155 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -15,16 +15,13 @@ function splitN(str: string, delimiter: string, limit: number = MAX_SAFE_INTEGER const Model = { addTask: function (timestamp: string, description: string) { - document.getElementById("tasks")!.appendChild(this.createTask(timestamp, description)).focus(); - }, - - createTask: function (timestamp: string, description: string) { const task = document.createElement("div"); task.appendChild(document.createTextNode(description)); task.setAttribute("class", "task"); task.setAttribute("tabindex", "0"); task.setAttribute("data-created", timestamp); - return task; + document.getElementById("tasks")!.appendChild(task); + task.focus(); }, destroyTask: function (createTimestamp: string) { -- 2.44.1 From a26b1f4b541b3cfad00999530e8e4c9e613db806 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 13:11:09 -0800 Subject: [PATCH 028/100] Initiate task creation with Enter key rather than form submit This will let us detect modifiers on the keystroke. --- index.html | 4 +--- vopamoi.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index 91908e3..ac8cb53 100644 --- a/index.html +++ b/index.html @@ -4,9 +4,7 @@ -
- -
+
diff --git a/vopamoi.ts b/vopamoi.ts index ab06155..fe3d491 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -132,12 +132,12 @@ const UI = { }; const BrowserUI = { - addTask: function (form: any) { - if (form.taskName.value) { - UI.addTask(form.taskName.value); - form.taskName.value = ""; + addTask: function () { + const input = document.getElementById("taskName"); + if (input.value) { + UI.addTask(input.value); + input.value = ""; } - return false; }, destroyTask: function () { @@ -213,7 +213,9 @@ const BrowserUI = { }; function handleKey(event: any) { - if (event.target.tagName !== "INPUT") { + if (event.target.tagName === "INPUT") { + if (event.key == "Enter") return BrowserUI.addTask(); + } else { if (event.key == "j") return BrowserUI.moveCursor(1); if (event.key == "k") return BrowserUI.moveCursor(-1); if (event.key == "J") return BrowserUI.moveTask(1); -- 2.44.1 From 6d01c40696f0612a6352f21320f417ccf528ab40 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 13:25:06 -0800 Subject: [PATCH 029/100] Allow Model to return values through UI --- vopamoi.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index fe3d491..ba5ae85 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -14,7 +14,7 @@ function splitN(str: string, delimiter: string, limit: number = MAX_SAFE_INTEGER } const Model = { - addTask: function (timestamp: string, description: string) { + addTask: function (timestamp: string, description: string): Element { const task = document.createElement("div"); task.appendChild(document.createTextNode(description)); task.setAttribute("class", "task"); @@ -22,6 +22,7 @@ const Model = { task.setAttribute("data-created", timestamp); document.getElementById("tasks")!.appendChild(task); task.focus(); + return task; }, destroyTask: function (createTimestamp: string) { @@ -78,18 +79,18 @@ function Log(prefix: string = "vp-") { apply: function (entry: string) { const [timestamp, command, data] = splitN(entry, " ", 2); if (command == "Create") { - Model.addTask(timestamp, data); + return Model.addTask(timestamp, data); } if (command == "Destroy") { - Model.destroyTask(data.split(" ", 1)[0]); + return Model.destroyTask(data.split(" ", 1)[0]); } if (command == "State") { const [createTimestamp, state] = splitN(data, " ", 1); - Model.setState(timestamp, createTimestamp, state); + return Model.setState(timestamp, createTimestamp, state); } if (command == "Priority") { const [createTimestamp, newPriority] = splitN(data, " ", 1); - Model.setPriority(createTimestamp, parseFloat(newPriority)); + return Model.setPriority(createTimestamp, parseFloat(newPriority)); } }, @@ -99,7 +100,7 @@ function Log(prefix: string = "vp-") { recordAndApply: function (entry: string) { this.record(entry); - this.apply(entry); + return this.apply(entry); }, replay: function () { @@ -117,17 +118,17 @@ function Log(prefix: string = "vp-") { const log = Log(); const UI = { - addTask: function (description: string) { - log.recordAndApply(`${Date.now()} Create ${description}`); + addTask: function (description: string): Element { + return log.recordAndApply(`${Date.now()} Create ${description}`); }, destroyTask: function (createTimestamp: string) { - log.recordAndApply(`${Date.now()} Destroy ${createTimestamp} ${Model.getTask(createTimestamp)?.textContent}`); + return log.recordAndApply(`${Date.now()} Destroy ${createTimestamp} ${Model.getTask(createTimestamp)?.textContent}`); }, setPriority: function (createTimestamp: string, priority: number) { - log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${priority}`); + return log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${priority}`); }, setState: function (createTimestamp: string, state: string) { - log.recordAndApply(`${Date.now()} State ${createTimestamp} ${state}`); + return log.recordAndApply(`${Date.now()} State ${createTimestamp} ${state}`); }, }; @@ -135,7 +136,7 @@ const BrowserUI = { addTask: function () { const input = document.getElementById("taskName"); if (input.value) { - UI.addTask(input.value); + const task = UI.addTask(input.value); input.value = ""; } }, -- 2.44.1 From bc7996fe80c54f0a7a45e2153a27df466434938c Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 13:25:47 -0800 Subject: [PATCH 030/100] Control-enter to make new tasks top-priority --- vopamoi.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index ba5ae85..fa55920 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -133,11 +133,14 @@ const UI = { }; const BrowserUI = { - addTask: function () { + addTask: function (event: KeyboardEvent) { const input = document.getElementById("taskName"); if (input.value) { const task = UI.addTask(input.value); input.value = ""; + if (event.getModifierState("Control")) { + this.setPriority(task, null, document.getElementsByClassName("task")[0]); + } } }, @@ -147,7 +150,7 @@ const BrowserUI = { return UI.destroyTask(createTimestamp!); }, - focusTaskNameInput: function (event: any) { + focusTaskNameInput: function (event: Event) { document.getElementById("taskName")!.focus(); event.preventDefault(); }, @@ -215,7 +218,7 @@ const BrowserUI = { function handleKey(event: any) { if (event.target.tagName === "INPUT") { - if (event.key == "Enter") return BrowserUI.addTask(); + if (event.key == "Enter") return BrowserUI.addTask(event); } else { if (event.key == "j") return BrowserUI.moveCursor(1); if (event.key == "k") return BrowserUI.moveCursor(-1); -- 2.44.1 From 65a7510df0f96d4168aa33b3b3be9c6f16b2160c Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 13:46:03 -0800 Subject: [PATCH 031/100] Start with focus on top task --- vopamoi.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index fa55920..7497e54 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -144,10 +144,12 @@ const BrowserUI = { } }, - destroyTask: function () { - const createTimestamp = document.activeElement?.getAttribute("data-created"); - this.moveCursor(1) || this.moveCursor(-1); - return UI.destroyTask(createTimestamp!); + firstVisibleTask: function () { + for (const task of document.getElementsByClassName("task")) { + if (task instanceof HTMLElement && task.style.display !== "none") { + return task; + } + } }, focusTaskNameInput: function (event: Event) { @@ -236,4 +238,5 @@ function handleKey(event: any) { function browserInit() { document.body.addEventListener("keydown", handleKey, { capture: false }); log.replay(); + BrowserUI.firstVisibleTask()?.focus(); } -- 2.44.1 From 4e05a0a2a1072be98d271ac60de9426125e179e4 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 13:59:25 -0800 Subject: [PATCH 032/100] Full-width input box --- index.html | 1 + vopamoi.css | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 vopamoi.css diff --git a/index.html b/index.html index ac8cb53..55c3b35 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + diff --git a/vopamoi.css b/vopamoi.css new file mode 100644 index 0000000..d8365cb --- /dev/null +++ b/vopamoi.css @@ -0,0 +1,3 @@ +input { + width: calc(100% - 8px); /* 8px to account for the default padding and border */ +} -- 2.44.1 From 45cbd5e56cd386532ed16ded6c1b3c7cd6adfef6 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 14:17:16 -0800 Subject: [PATCH 033/100] Never mind about Destroy. Deleting is just state "deleted" It doesn't get erased from the log anyway. --- vopamoi.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 7497e54..5e645b8 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -25,13 +25,6 @@ const Model = { return task; }, - destroyTask: function (createTimestamp: string) { - const task = this.getTask(createTimestamp); - if (task) { - task.parentElement!.removeChild(task); - } - }, - getPriority: function (task: Element): number { if (task.hasAttribute("data-priority")) { return parseFloat(task.getAttribute("data-priority")!); @@ -81,9 +74,6 @@ function Log(prefix: string = "vp-") { if (command == "Create") { return Model.addTask(timestamp, data); } - if (command == "Destroy") { - return Model.destroyTask(data.split(" ", 1)[0]); - } if (command == "State") { const [createTimestamp, state] = splitN(data, " ", 1); return Model.setState(timestamp, createTimestamp, state); @@ -121,9 +111,6 @@ const UI = { addTask: function (description: string): Element { return log.recordAndApply(`${Date.now()} Create ${description}`); }, - destroyTask: function (createTimestamp: string) { - return log.recordAndApply(`${Date.now()} Destroy ${createTimestamp} ${Model.getTask(createTimestamp)?.textContent}`); - }, setPriority: function (createTimestamp: string, priority: number) { return log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${priority}`); }, @@ -231,7 +218,7 @@ function handleKey(event: any) { if (event.key == "w") return BrowserUI.setState("waiting"); if (event.key == "d") return BrowserUI.setState("done"); if (event.key == "c") return BrowserUI.setState("cancelled"); - if (event.key == "X") return BrowserUI.destroyTask(); + if (event.key == "X") return BrowserUI.setState("deleted"); } } -- 2.44.1 From 43f3cc0cb1e4b64b0560ca8d722754cf6ebb1b8d Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 14:46:28 -0800 Subject: [PATCH 034/100] Undo for task creation and reordering --- vopamoi.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 5e645b8..a88408f 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -40,19 +40,20 @@ const Model = { } }, - setPriority: function (createTimestamp: string, priority: number) { + setPriority: function (createTimestamp: string, priority: number): Element | null { const target = this.getTask(createTimestamp); - if (!target) return; + if (!target) return null; target.setAttribute("data-priority", `${priority}`); for (const task of document.getElementsByClassName("task")) { if (task !== target && this.getPriority(task) > priority) { task.parentElement!.insertBefore(target, task); target instanceof HTMLElement && target.focus(); - return; + return target; } } document.getElementById("tasks")!.appendChild(target); target instanceof HTMLElement && target.focus(); + return target; }, setState: function (stateTimestamp: string, createTimestamp: string, state: string) { @@ -107,16 +108,26 @@ function Log(prefix: string = "vp-") { } const log = Log(); +const undoLog: string[] = []; + const UI = { addTask: function (description: string): Element { - return log.recordAndApply(`${Date.now()} Create ${description}`); + const now = Date.now(); + undoLog.push(`State ${now} deleted`); + return log.recordAndApply(`${now} Create ${description}`); }, - setPriority: function (createTimestamp: string, priority: number) { - return log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${priority}`); + setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) { + undoLog.push(`Priority ${createTimestamp} ${oldPriority}`); + return log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${newPriority}`); }, setState: function (createTimestamp: string, state: string) { return log.recordAndApply(`${Date.now()} State ${createTimestamp} ${state}`); }, + undo: function () { + if (undoLog.length > 0) { + return log.recordAndApply(`${Date.now()} ${undoLog.pop()}`); + } + }, }; const BrowserUI = { @@ -195,7 +206,7 @@ const BrowserUI = { console.assert(aPriority < newPriority && newPriority < bPriority, aPriority, "<", newPriority, "<", bPriority); const newPriorityRounded = Math.round(newPriority); const okToRound = aPriority < newPriorityRounded && newPriorityRounded < bPriority; - UI.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority); + UI.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, Model.getPriority(task)); }, setState: function (state: string) { @@ -203,6 +214,11 @@ const BrowserUI = { this.moveCursor(1) || this.moveCursor(-1); return UI.setState(createTimestamp!, state); }, + + undo: function () { + const ret = UI.undo(); + if (ret && ret instanceof HTMLElement) ret.focus(); + }, }; function handleKey(event: any) { @@ -219,6 +235,7 @@ function handleKey(event: any) { if (event.key == "d") return BrowserUI.setState("done"); if (event.key == "c") return BrowserUI.setState("cancelled"); if (event.key == "X") return BrowserUI.setState("deleted"); + if (event.key == "u") return BrowserUI.undo(); } } -- 2.44.1 From 5350da9f7576c4af01c7359db78e4d3f658d98dc Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 17:51:51 -0800 Subject: [PATCH 035/100] Tasks have one state. Enables undo for state changes --- vopamoi.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index a88408f..298e513 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -32,6 +32,10 @@ const Model = { return parseFloat(task.getAttribute("data-created")!); }, + getState: function (task: Element): string { + return task.getAttribute("data-state") ?? "todo"; + }, + getTask: function (createTimestamp: string) { for (const task of document.getElementsByClassName("task")) { if (task.getAttribute("data-created") === createTimestamp) { @@ -59,9 +63,9 @@ const Model = { setState: function (stateTimestamp: string, createTimestamp: string, state: string) { const task = this.getTask(createTimestamp); if (task) { - task.setAttribute(`data-${state}`, stateTimestamp); + task.setAttribute("data-state", state); if (task instanceof HTMLElement) { - task.style.display = "none"; // Until view filtering + task.style.display = state == "todo" ? "block" : "none"; // Until view filtering } } }, @@ -120,8 +124,9 @@ const UI = { undoLog.push(`Priority ${createTimestamp} ${oldPriority}`); return log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${newPriority}`); }, - setState: function (createTimestamp: string, state: string) { - return log.recordAndApply(`${Date.now()} State ${createTimestamp} ${state}`); + setState: function (createTimestamp: string, newState: string, oldState: string) { + undoLog.push(`State ${createTimestamp} ${oldState}`); + return log.recordAndApply(`${Date.now()} State ${createTimestamp} ${newState}`); }, undo: function () { if (undoLog.length > 0) { @@ -210,9 +215,11 @@ const BrowserUI = { }, setState: function (state: string) { - const createTimestamp = document.activeElement?.getAttribute("data-created"); + const task = document.activeElement; + if (!task) return; + const createTimestamp = task.getAttribute("data-created")!; this.moveCursor(1) || this.moveCursor(-1); - return UI.setState(createTimestamp!, state); + return UI.setState(createTimestamp, state, Model.getState(task)); }, undo: function () { -- 2.44.1 From 9f6be65ee10f3cfd4ad55b7326e8fb35fc78151a Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 18:44:21 -0800 Subject: [PATCH 036/100] Change focus in BrowserUI, never in Model We don't want updates coming in from network sync to move focus around. --- vopamoi.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 298e513..a98a29b 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -21,7 +21,6 @@ const Model = { task.setAttribute("tabindex", "0"); task.setAttribute("data-created", timestamp); document.getElementById("tasks")!.appendChild(task); - task.focus(); return task; }, @@ -51,12 +50,10 @@ const Model = { for (const task of document.getElementsByClassName("task")) { if (task !== target && this.getPriority(task) > priority) { task.parentElement!.insertBefore(target, task); - target instanceof HTMLElement && target.focus(); return target; } } document.getElementById("tasks")!.appendChild(target); - target instanceof HTMLElement && target.focus(); return target; }, @@ -140,6 +137,7 @@ const BrowserUI = { const input = document.getElementById("taskName"); if (input.value) { const task = UI.addTask(input.value); + if (task && task instanceof HTMLElement) task.focus(); input.value = ""; if (event.getModifierState("Control")) { this.setPriority(task, null, document.getElementsByClassName("task")[0]); @@ -212,6 +210,7 @@ const BrowserUI = { const newPriorityRounded = Math.round(newPriority); const okToRound = aPriority < newPriorityRounded && newPriorityRounded < bPriority; UI.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, Model.getPriority(task)); + task instanceof HTMLElement && task.focus(); }, setState: function (state: string) { -- 2.44.1 From da9a271623620d92a82608592d65fc2e4ca08491 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 19:32:16 -0800 Subject: [PATCH 037/100] Don't change state if it's already in that state --- vopamoi.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index a98a29b..e997d1c 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -213,12 +213,14 @@ const BrowserUI = { task instanceof HTMLElement && task.focus(); }, - setState: function (state: string) { + setState: function (newState: string) { const task = document.activeElement; if (!task) return; + const oldState = Model.getState(task); + if (newState === oldState) return; const createTimestamp = task.getAttribute("data-created")!; this.moveCursor(1) || this.moveCursor(-1); - return UI.setState(createTimestamp, state, Model.getState(task)); + return UI.setState(createTimestamp, newState, oldState); }, undo: function () { @@ -240,6 +242,7 @@ function handleKey(event: any) { if (event.key == "w") return BrowserUI.setState("waiting"); if (event.key == "d") return BrowserUI.setState("done"); if (event.key == "c") return BrowserUI.setState("cancelled"); + if (event.key == "t") return BrowserUI.setState("todo"); if (event.key == "X") return BrowserUI.setState("deleted"); if (event.key == "u") return BrowserUI.undo(); } -- 2.44.1 From 7b57440781029bab8be76950847e36e7a2303cf6 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 22:07:18 -0800 Subject: [PATCH 038/100] Edit task descriptions --- vopamoi.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index e997d1c..151a9e4 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -24,6 +24,32 @@ const Model = { return task; }, + edit: function (createTimestamp: string, newDescription: string): Element | null { + const target = this.getTask(createTimestamp); + if (!target) return null; + if (target.hasAttribute("data-description")) { + // Oh no: An edit has arrived from a replica while a local edit is in progress. + const input = target.children[0] as HTMLInputElement; + if ( + input.value === target.getAttribute("data-description") && + input.selectionStart === 0 && + input.selectionEnd === input.value.length + ) { + // No local changes have actually been made yet. Change the contents of the edit box! + input.value = newDescription; + input.select(); + } else { + // No great options. + // Prefer not to interrupt the local user's edit. + // The remote edit is mostly lost; this mostly becomes last-write-wins. + target.setAttribute("data-description", newDescription); + } + } else { + target.textContent = newDescription; + } + return target; + }, + getPriority: function (task: Element): number { if (task.hasAttribute("data-priority")) { return parseFloat(task.getAttribute("data-priority")!); @@ -76,6 +102,10 @@ function Log(prefix: string = "vp-") { if (command == "Create") { return Model.addTask(timestamp, data); } + if (command == "Edit") { + const [createTimestamp, description] = splitN(data, " ", 1); + return Model.edit(createTimestamp, description); + } if (command == "State") { const [createTimestamp, state] = splitN(data, " ", 1); return Model.setState(timestamp, createTimestamp, state); @@ -117,6 +147,10 @@ const UI = { undoLog.push(`State ${now} deleted`); return log.recordAndApply(`${now} Create ${description}`); }, + edit: function (createTimestamp: string, newDescription: string, oldDescription: string) { + undoLog.push(`Edit ${createTimestamp} ${oldDescription}`); + return log.recordAndApply(`${Date.now()} Edit ${createTimestamp} ${newDescription}`); + }, setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) { undoLog.push(`Priority ${createTimestamp} ${oldPriority}`); return log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${newPriority}`); @@ -145,6 +179,36 @@ const BrowserUI = { } }, + beginEdit: function (event: Event) { + const task = document.activeElement; + if (!task) return; + const input = document.createElement("input"); + const oldDescription = task.textContent!; + task.setAttribute("data-description", oldDescription); + input.value = oldDescription; + input.addEventListener("blur", BrowserUI.completeEdit, { once: true }); + task.textContent = ""; + task.appendChild(input); + input.focus(); + input.select(); + event.preventDefault(); + }, + + completeEdit: function (event: Event) { + const input = event.target as HTMLInputElement; + const task = input.parentElement!; + const oldDescription = task.getAttribute("data-description")!; + const newDescription = input.value; + task.removeChild(task.children[0]); + task.removeAttribute("data-description"); + task.focus(); + if (newDescription === oldDescription) { + task.textContent = oldDescription; + } else { + UI.edit(task.getAttribute("data-created")!, newDescription, oldDescription); + } + }, + firstVisibleTask: function () { for (const task of document.getElementsByClassName("task")) { if (task instanceof HTMLElement && task.style.display !== "none") { @@ -231,7 +295,11 @@ const BrowserUI = { function handleKey(event: any) { if (event.target.tagName === "INPUT") { - if (event.key == "Enter") return BrowserUI.addTask(event); + if (event.target.id === "taskName") { + if (event.key == "Enter") return BrowserUI.addTask(event); + } else { + if (event.key == "Enter") return BrowserUI.completeEdit(event); + } } else { if (event.key == "j") return BrowserUI.moveCursor(1); if (event.key == "k") return BrowserUI.moveCursor(-1); @@ -245,6 +313,7 @@ function handleKey(event: any) { if (event.key == "t") return BrowserUI.setState("todo"); if (event.key == "X") return BrowserUI.setState("deleted"); if (event.key == "u") return BrowserUI.undo(); + if (event.key == "e") return BrowserUI.beginEdit(event); } } -- 2.44.1 From 8ca3cda9da4db4ac0bd05462b54e04fabbf75451 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 22:17:43 -0800 Subject: [PATCH 039/100] Explicitly remove the completeEdit onblur event listener When the completeEdit is invoked by keystroke, we don't want it invoked a second time as a result of its own action removing the input field. Firefox does *not* call the "blur" event when a focused input element is removed. Chromium *does* call the "blur" event when a focused input element is removed. So this is needed for Chromium. --- vopamoi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vopamoi.ts b/vopamoi.ts index 151a9e4..04c5d41 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -199,6 +199,7 @@ const BrowserUI = { const task = input.parentElement!; const oldDescription = task.getAttribute("data-description")!; const newDescription = input.value; + input.removeEventListener("blur", BrowserUI.completeEdit); task.removeChild(task.children[0]); task.removeAttribute("data-description"); task.focus(); -- 2.44.1 From 27c6778445473dedef08331260f1db3f2bea75ef Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 22:33:53 -0800 Subject: [PATCH 040/100] Use a monotonic clock Generally assume that clock values always increase to simplify reasoning everywhere clock values are used. Then enforce that assumption. Clock values don't really, truly always go up because page reloads & multiple devices, but since I'm not currently planning to spend the effort that would be required to perfectly handle this in all cases, making it happen in fewer cases is a win (rather than just making bugs I want to fix harder to reproduce). --- vopamoi.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 04c5d41..271354f 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -13,6 +13,22 @@ function splitN(str: string, delimiter: string, limit: number = MAX_SAFE_INTEGER return at === -1 ? [str] : [str.substring(0, at)].concat(splitN(str.substring(at + delimiter.length), delimiter, limit - 1)); } +// A clock that never goes backwards; monotonic. +function Clock() { + var previousNow = Date.now(); + return { + now: function (): number { + const now = Date.now(); + if (now > previousNow) { + previousNow = now; + return now; + } + return ++previousNow; + }, + }; +} +const clock = Clock(); + const Model = { addTask: function (timestamp: string, description: string): Element { const task = document.createElement("div"); @@ -143,25 +159,25 @@ const undoLog: string[] = []; const UI = { addTask: function (description: string): Element { - const now = Date.now(); + const now = clock.now(); undoLog.push(`State ${now} deleted`); return log.recordAndApply(`${now} Create ${description}`); }, edit: function (createTimestamp: string, newDescription: string, oldDescription: string) { undoLog.push(`Edit ${createTimestamp} ${oldDescription}`); - return log.recordAndApply(`${Date.now()} Edit ${createTimestamp} ${newDescription}`); + return log.recordAndApply(`${clock.now()} Edit ${createTimestamp} ${newDescription}`); }, setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) { undoLog.push(`Priority ${createTimestamp} ${oldPriority}`); - return log.recordAndApply(`${Date.now()} Priority ${createTimestamp} ${newPriority}`); + return log.recordAndApply(`${clock.now()} Priority ${createTimestamp} ${newPriority}`); }, setState: function (createTimestamp: string, newState: string, oldState: string) { undoLog.push(`State ${createTimestamp} ${oldState}`); - return log.recordAndApply(`${Date.now()} State ${createTimestamp} ${newState}`); + return log.recordAndApply(`${clock.now()} State ${createTimestamp} ${newState}`); }, undo: function () { if (undoLog.length > 0) { - return log.recordAndApply(`${Date.now()} ${undoLog.pop()}`); + return log.recordAndApply(`${clock.now()} ${undoLog.pop()}`); } }, }; @@ -267,7 +283,7 @@ const BrowserUI = { // Change task's priority to be between other tasks a and b. setPriority: function (task: Element, a: Element | null, b: Element | null) { const aPriority = a === null ? 0 : Model.getPriority(a); - const bPriority = b === null ? Date.now() : Model.getPriority(b); + const bPriority = b === null ? clock.now() : Model.getPriority(b); console.assert(aPriority < bPriority, aPriority, "<", bPriority); const span = bPriority - aPriority; const newPriority = aPriority + 0.1 * span + 0.8 * span * Math.random(); -- 2.44.1 From e94e9f27c762273111ae0526d6ca24cee0c02592 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 22:53:54 -0800 Subject: [PATCH 041/100] Commands are in command mode --- vopamoi.ts | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 271354f..80e8ff9 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -310,6 +310,11 @@ const BrowserUI = { }, }; +enum InputState { + Command, +} +var inputState = InputState.Command; + function handleKey(event: any) { if (event.target.tagName === "INPUT") { if (event.target.id === "taskName") { @@ -318,19 +323,21 @@ function handleKey(event: any) { if (event.key == "Enter") return BrowserUI.completeEdit(event); } } else { - if (event.key == "j") return BrowserUI.moveCursor(1); - if (event.key == "k") return BrowserUI.moveCursor(-1); - if (event.key == "J") return BrowserUI.moveTask(1); - if (event.key == "K") return BrowserUI.moveTask(-1); - if (event.key == "n") return BrowserUI.focusTaskNameInput(event); - if (event.key == "s") return BrowserUI.setState("someday-maybe"); - if (event.key == "w") return BrowserUI.setState("waiting"); - if (event.key == "d") return BrowserUI.setState("done"); - if (event.key == "c") return BrowserUI.setState("cancelled"); - if (event.key == "t") return BrowserUI.setState("todo"); - if (event.key == "X") return BrowserUI.setState("deleted"); - if (event.key == "u") return BrowserUI.undo(); - if (event.key == "e") return BrowserUI.beginEdit(event); + if (inputState === InputState.Command) { + if (event.key == "j") return BrowserUI.moveCursor(1); + if (event.key == "k") return BrowserUI.moveCursor(-1); + if (event.key == "J") return BrowserUI.moveTask(1); + if (event.key == "K") return BrowserUI.moveTask(-1); + if (event.key == "n") return BrowserUI.focusTaskNameInput(event); + if (event.key == "s") return BrowserUI.setState("someday-maybe"); + if (event.key == "w") return BrowserUI.setState("waiting"); + if (event.key == "d") return BrowserUI.setState("done"); + if (event.key == "c") return BrowserUI.setState("cancelled"); + if (event.key == "t") return BrowserUI.setState("todo"); + if (event.key == "X") return BrowserUI.setState("deleted"); + if (event.key == "u") return BrowserUI.undo(); + if (event.key == "e") return BrowserUI.beginEdit(event); + } } } -- 2.44.1 From 854992ec001df4ca15d25e24dbc558353032e3b7 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 23:00:48 -0800 Subject: [PATCH 042/100] Leader key "v" for selecting views --- vopamoi.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vopamoi.ts b/vopamoi.ts index 80e8ff9..4280ffc 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -312,6 +312,7 @@ const BrowserUI = { enum InputState { Command, + View, } var inputState = InputState.Command; @@ -337,6 +338,9 @@ function handleKey(event: any) { if (event.key == "X") return BrowserUI.setState("deleted"); if (event.key == "u") return BrowserUI.undo(); if (event.key == "e") return BrowserUI.beginEdit(event); + if (event.key == "v") return (inputState = InputState.View); + } else if (inputState === InputState.View) { + return (inputState = InputState.Command); } } } -- 2.44.1 From 682139fcfed4fc2110ebef8a47953bb8655c8fba Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 23:12:18 -0800 Subject: [PATCH 043/100] Always set state attribute --- vopamoi.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 4280ffc..b16bc70 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -36,6 +36,7 @@ const Model = { task.setAttribute("class", "task"); task.setAttribute("tabindex", "0"); task.setAttribute("data-created", timestamp); + task.setAttribute("data-state", "todo"); document.getElementById("tasks")!.appendChild(task); return task; }, @@ -73,10 +74,6 @@ const Model = { return parseFloat(task.getAttribute("data-created")!); }, - getState: function (task: Element): string { - return task.getAttribute("data-state") ?? "todo"; - }, - getTask: function (createTimestamp: string) { for (const task of document.getElementsByClassName("task")) { if (task.getAttribute("data-created") === createTimestamp) { @@ -297,7 +294,7 @@ const BrowserUI = { setState: function (newState: string) { const task = document.activeElement; if (!task) return; - const oldState = Model.getState(task); + const oldState = task.getAttribute("data-state"); if (newState === oldState) return; const createTimestamp = task.getAttribute("data-created")!; this.moveCursor(1) || this.moveCursor(-1); -- 2.44.1 From 8e91a18ee2db4b5595192f9772c5d375d4619efc Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 23:19:31 -0800 Subject: [PATCH 044/100] Use CSS for filtering the view --- index.html | 1 + vopamoi.ts | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index 55c3b35..8453007 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + diff --git a/vopamoi.ts b/vopamoi.ts index b16bc70..f93c51c 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -100,9 +100,6 @@ const Model = { const task = this.getTask(createTimestamp); if (task) { task.setAttribute("data-state", state); - if (task instanceof HTMLElement) { - task.style.display = state == "todo" ? "block" : "none"; // Until view filtering - } } }, }; @@ -225,7 +222,7 @@ const BrowserUI = { firstVisibleTask: function () { for (const task of document.getElementsByClassName("task")) { - if (task instanceof HTMLElement && task.style.display !== "none") { + if (task instanceof HTMLElement && task.getAttribute("data-state")! === "todo") { return task; } } @@ -243,7 +240,7 @@ const BrowserUI = { while (true) { cursor = increment > 0 ? cursor.nextElementSibling : cursor.previousElementSibling; if (!cursor || !(cursor instanceof HTMLElement)) break; - if (cursor.style.display !== "none") { + if (cursor.getAttribute("data-state")! === "todo") { offset -= increment; valid_cursor = cursor; } @@ -294,7 +291,7 @@ const BrowserUI = { setState: function (newState: string) { const task = document.activeElement; if (!task) return; - const oldState = task.getAttribute("data-state"); + const oldState = task.getAttribute("data-state")!; if (newState === oldState) return; const createTimestamp = task.getAttribute("data-created")!; this.moveCursor(1) || this.moveCursor(-1); -- 2.44.1 From ada060d76e8d158ee56aee89402a674e3a82be9f Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 23:28:51 -0800 Subject: [PATCH 045/100] Allow browserUI private fields --- vopamoi.ts | 263 +++++++++++++++++++++++++++-------------------------- 1 file changed, 133 insertions(+), 130 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index f93c51c..94c26e6 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -176,133 +176,136 @@ const UI = { }, }; -const BrowserUI = { - addTask: function (event: KeyboardEvent) { - const input = document.getElementById("taskName"); - if (input.value) { - const task = UI.addTask(input.value); - if (task && task instanceof HTMLElement) task.focus(); - input.value = ""; - if (event.getModifierState("Control")) { - this.setPriority(task, null, document.getElementsByClassName("task")[0]); +function BrowserUI() { + return { + addTask: function (event: KeyboardEvent) { + const input = document.getElementById("taskName"); + if (input.value) { + const task = UI.addTask(input.value); + if (task && task instanceof HTMLElement) task.focus(); + input.value = ""; + if (event.getModifierState("Control")) { + this.setPriority(task, null, document.getElementsByClassName("task")[0]); + } } - } - }, + }, - beginEdit: function (event: Event) { - const task = document.activeElement; - if (!task) return; - const input = document.createElement("input"); - const oldDescription = task.textContent!; - task.setAttribute("data-description", oldDescription); - input.value = oldDescription; - input.addEventListener("blur", BrowserUI.completeEdit, { once: true }); - task.textContent = ""; - task.appendChild(input); - input.focus(); - input.select(); - event.preventDefault(); - }, + beginEdit: function (event: Event) { + const task = document.activeElement; + if (!task) return; + const input = document.createElement("input"); + const oldDescription = task.textContent!; + task.setAttribute("data-description", oldDescription); + input.value = oldDescription; + input.addEventListener("blur", this.completeEdit, { once: true }); + task.textContent = ""; + task.appendChild(input); + input.focus(); + input.select(); + event.preventDefault(); + }, - completeEdit: function (event: Event) { - const input = event.target as HTMLInputElement; - const task = input.parentElement!; - const oldDescription = task.getAttribute("data-description")!; - const newDescription = input.value; - input.removeEventListener("blur", BrowserUI.completeEdit); - task.removeChild(task.children[0]); - task.removeAttribute("data-description"); - task.focus(); - if (newDescription === oldDescription) { - task.textContent = oldDescription; - } else { - UI.edit(task.getAttribute("data-created")!, newDescription, oldDescription); - } - }, + completeEdit: function (event: Event) { + const input = event.target as HTMLInputElement; + const task = input.parentElement!; + const oldDescription = task.getAttribute("data-description")!; + const newDescription = input.value; + input.removeEventListener("blur", this.completeEdit); + task.removeChild(task.children[0]); + task.removeAttribute("data-description"); + task.focus(); + if (newDescription === oldDescription) { + task.textContent = oldDescription; + } else { + UI.edit(task.getAttribute("data-created")!, newDescription, oldDescription); + } + }, - firstVisibleTask: function () { - for (const task of document.getElementsByClassName("task")) { - if (task instanceof HTMLElement && task.getAttribute("data-state")! === "todo") { - return task; + firstVisibleTask: function () { + for (const task of document.getElementsByClassName("task")) { + if (task instanceof HTMLElement && task.getAttribute("data-state")! === "todo") { + return task; + } } - } - }, + }, - focusTaskNameInput: function (event: Event) { - document.getElementById("taskName")!.focus(); - event.preventDefault(); - }, + focusTaskNameInput: function (event: Event) { + document.getElementById("taskName")!.focus(); + event.preventDefault(); + }, - visibleTaskAtOffset(task: Element, offset: number): Element { - var cursor: Element | null = task; - var valid_cursor = cursor; - const increment = offset / Math.abs(offset); - while (true) { - cursor = increment > 0 ? cursor.nextElementSibling : cursor.previousElementSibling; - if (!cursor || !(cursor instanceof HTMLElement)) break; - if (cursor.getAttribute("data-state")! === "todo") { - offset -= increment; - valid_cursor = cursor; + visibleTaskAtOffset(task: Element, offset: number): Element { + var cursor: Element | null = task; + var valid_cursor = cursor; + const increment = offset / Math.abs(offset); + while (true) { + cursor = increment > 0 ? cursor.nextElementSibling : cursor.previousElementSibling; + if (!cursor || !(cursor instanceof HTMLElement)) break; + if (cursor.getAttribute("data-state")! === "todo") { + offset -= increment; + valid_cursor = cursor; + } + if (Math.abs(offset) < 0.5) break; } - if (Math.abs(offset) < 0.5) break; - } - return valid_cursor; - }, + return valid_cursor; + }, - moveCursor: function (offset: number): boolean { - const active = document.activeElement; - if (!active) return false; - const dest = this.visibleTaskAtOffset(active, offset); - if (dest !== active && dest instanceof HTMLElement) { - dest.focus(); - return true; - } - return false; - }, + moveCursor: function (offset: number): boolean { + const active = document.activeElement; + if (!active) return false; + const dest = this.visibleTaskAtOffset(active, offset); + if (dest !== active && dest instanceof HTMLElement) { + dest.focus(); + return true; + } + return false; + }, - moveTask: function (offset: number) { - const active = document.activeElement; - if (!active) return; - const dest = this.visibleTaskAtOffset(active, offset); - if (dest === active) return; // Already extremal - var onePastDest: Element | null = this.visibleTaskAtOffset(dest, offset / Math.abs(offset)); - if (onePastDest == dest) onePastDest = null; // Will become extremal - if (offset > 0) { - this.setPriority(active, dest, onePastDest); - } else { - this.setPriority(active, onePastDest, dest); - } - }, + moveTask: function (offset: number) { + const active = document.activeElement; + if (!active) return; + const dest = this.visibleTaskAtOffset(active, offset); + if (dest === active) return; // Already extremal + var onePastDest: Element | null = this.visibleTaskAtOffset(dest, offset / Math.abs(offset)); + if (onePastDest == dest) onePastDest = null; // Will become extremal + if (offset > 0) { + this.setPriority(active, dest, onePastDest); + } else { + this.setPriority(active, onePastDest, dest); + } + }, - // Change task's priority to be between other tasks a and b. - setPriority: function (task: Element, a: Element | null, b: Element | null) { - const aPriority = a === null ? 0 : Model.getPriority(a); - const bPriority = b === null ? clock.now() : Model.getPriority(b); - console.assert(aPriority < bPriority, aPriority, "<", bPriority); - const span = bPriority - aPriority; - const newPriority = aPriority + 0.1 * span + 0.8 * span * Math.random(); - console.assert(aPriority < newPriority && newPriority < bPriority, aPriority, "<", newPriority, "<", bPriority); - const newPriorityRounded = Math.round(newPriority); - const okToRound = aPriority < newPriorityRounded && newPriorityRounded < bPriority; - UI.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, Model.getPriority(task)); - task instanceof HTMLElement && task.focus(); - }, + // Change task's priority to be between other tasks a and b. + setPriority: function (task: Element, a: Element | null, b: Element | null) { + const aPriority = a === null ? 0 : Model.getPriority(a); + const bPriority = b === null ? clock.now() : Model.getPriority(b); + console.assert(aPriority < bPriority, aPriority, "<", bPriority); + const span = bPriority - aPriority; + const newPriority = aPriority + 0.1 * span + 0.8 * span * Math.random(); + console.assert(aPriority < newPriority && newPriority < bPriority, aPriority, "<", newPriority, "<", bPriority); + const newPriorityRounded = Math.round(newPriority); + const okToRound = aPriority < newPriorityRounded && newPriorityRounded < bPriority; + UI.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, Model.getPriority(task)); + task instanceof HTMLElement && task.focus(); + }, - setState: function (newState: string) { - const task = document.activeElement; - if (!task) return; - const oldState = task.getAttribute("data-state")!; - if (newState === oldState) return; - const createTimestamp = task.getAttribute("data-created")!; - this.moveCursor(1) || this.moveCursor(-1); - return UI.setState(createTimestamp, newState, oldState); - }, + setState: function (newState: string) { + const task = document.activeElement; + if (!task) return; + const oldState = task.getAttribute("data-state")!; + if (newState === oldState) return; + const createTimestamp = task.getAttribute("data-created")!; + this.moveCursor(1) || this.moveCursor(-1); + return UI.setState(createTimestamp, newState, oldState); + }, - undo: function () { - const ret = UI.undo(); - if (ret && ret instanceof HTMLElement) ret.focus(); - }, -}; + undo: function () { + const ret = UI.undo(); + if (ret && ret instanceof HTMLElement) ret.focus(); + }, + }; +} +const browserUI = BrowserUI(); enum InputState { Command, @@ -313,25 +316,25 @@ var inputState = InputState.Command; function handleKey(event: any) { if (event.target.tagName === "INPUT") { if (event.target.id === "taskName") { - if (event.key == "Enter") return BrowserUI.addTask(event); + if (event.key == "Enter") return browserUI.addTask(event); } else { - if (event.key == "Enter") return BrowserUI.completeEdit(event); + if (event.key == "Enter") return browserUI.completeEdit(event); } } else { if (inputState === InputState.Command) { - if (event.key == "j") return BrowserUI.moveCursor(1); - if (event.key == "k") return BrowserUI.moveCursor(-1); - if (event.key == "J") return BrowserUI.moveTask(1); - if (event.key == "K") return BrowserUI.moveTask(-1); - if (event.key == "n") return BrowserUI.focusTaskNameInput(event); - if (event.key == "s") return BrowserUI.setState("someday-maybe"); - if (event.key == "w") return BrowserUI.setState("waiting"); - if (event.key == "d") return BrowserUI.setState("done"); - if (event.key == "c") return BrowserUI.setState("cancelled"); - if (event.key == "t") return BrowserUI.setState("todo"); - if (event.key == "X") return BrowserUI.setState("deleted"); - if (event.key == "u") return BrowserUI.undo(); - if (event.key == "e") return BrowserUI.beginEdit(event); + if (event.key == "j") return browserUI.moveCursor(1); + if (event.key == "k") return browserUI.moveCursor(-1); + if (event.key == "J") return browserUI.moveTask(1); + if (event.key == "K") return browserUI.moveTask(-1); + if (event.key == "n") return browserUI.focusTaskNameInput(event); + if (event.key == "s") return browserUI.setState("someday-maybe"); + if (event.key == "w") return browserUI.setState("waiting"); + if (event.key == "d") return browserUI.setState("done"); + if (event.key == "c") return browserUI.setState("cancelled"); + if (event.key == "t") return browserUI.setState("todo"); + if (event.key == "X") return browserUI.setState("deleted"); + if (event.key == "u") return browserUI.undo(); + if (event.key == "e") return browserUI.beginEdit(event); if (event.key == "v") return (inputState = InputState.View); } else if (inputState === InputState.View) { return (inputState = InputState.Command); @@ -342,5 +345,5 @@ function handleKey(event: any) { function browserInit() { document.body.addEventListener("keydown", handleKey, { capture: false }); log.replay(); - BrowserUI.firstVisibleTask()?.focus(); + browserUI.firstVisibleTask()?.focus(); } -- 2.44.1 From 868667c1142d78afe4955a6f394e9cb661eedc54 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Wed, 26 Jan 2022 23:49:58 -0800 Subject: [PATCH 046/100] Views: Show tasks by state --- vopamoi.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 94c26e6..3c59226 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -177,6 +177,7 @@ const UI = { }; function BrowserUI() { + var currentViewState = "todo"; return { addTask: function (event: KeyboardEvent) { const input = document.getElementById("taskName"); @@ -223,7 +224,7 @@ function BrowserUI() { firstVisibleTask: function () { for (const task of document.getElementsByClassName("task")) { - if (task instanceof HTMLElement && task.getAttribute("data-state")! === "todo") { + if (task instanceof HTMLElement && task.getAttribute("data-state") === currentViewState) { return task; } } @@ -241,7 +242,7 @@ function BrowserUI() { while (true) { cursor = increment > 0 ? cursor.nextElementSibling : cursor.previousElementSibling; if (!cursor || !(cursor instanceof HTMLElement)) break; - if (cursor.getAttribute("data-state")! === "todo") { + if (cursor.getAttribute("data-state")! === currentViewState) { offset -= increment; valid_cursor = cursor; } @@ -299,6 +300,16 @@ function BrowserUI() { return UI.setState(createTimestamp, newState, oldState); }, + setView: function (state: string) { + const sheet = (document.getElementById("viewStyle") as HTMLStyleElement).sheet!; + sheet.insertRule(`.task:not([data-state=${state}]) { display: none }`); + sheet.removeRule(1); + currentViewState = state; + if (document.activeElement?.getAttribute("data-state") !== state) { + this.firstVisibleTask()?.focus(); + } + }, + undo: function () { const ret = UI.undo(); if (ret && ret instanceof HTMLElement) ret.focus(); @@ -327,17 +338,23 @@ function handleKey(event: any) { if (event.key == "J") return browserUI.moveTask(1); if (event.key == "K") return browserUI.moveTask(-1); if (event.key == "n") return browserUI.focusTaskNameInput(event); - if (event.key == "s") return browserUI.setState("someday-maybe"); - if (event.key == "w") return browserUI.setState("waiting"); - if (event.key == "d") return browserUI.setState("done"); if (event.key == "c") return browserUI.setState("cancelled"); + if (event.key == "d") return browserUI.setState("done"); + if (event.key == "s") return browserUI.setState("someday-maybe"); if (event.key == "t") return browserUI.setState("todo"); + if (event.key == "w") return browserUI.setState("waiting"); if (event.key == "X") return browserUI.setState("deleted"); if (event.key == "u") return browserUI.undo(); if (event.key == "e") return browserUI.beginEdit(event); if (event.key == "v") return (inputState = InputState.View); } else if (inputState === InputState.View) { - return (inputState = InputState.Command); + inputState = InputState.Command; + if (event.key == "c") return browserUI.setView("cancelled"); + if (event.key == "d") return browserUI.setView("done"); + if (event.key == "s") return browserUI.setView("someday-maybe"); + if (event.key == "t") return browserUI.setView("todo"); + if (event.key == "w") return browserUI.setView("waiting"); + if (event.key == "x") return browserUI.setView("deleted"); } } } -- 2.44.1 From b56a37d31873fa82e6196cde9479b689a14e4629 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 00:07:27 -0800 Subject: [PATCH 047/100] undoLog is private to the UI --- vopamoi.ts | 63 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 3c59226..a3b7824 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -149,32 +149,35 @@ function Log(prefix: string = "vp-") { } const log = Log(); -const undoLog: string[] = []; -const UI = { - addTask: function (description: string): Element { - const now = clock.now(); - undoLog.push(`State ${now} deleted`); - return log.recordAndApply(`${now} Create ${description}`); - }, - edit: function (createTimestamp: string, newDescription: string, oldDescription: string) { - undoLog.push(`Edit ${createTimestamp} ${oldDescription}`); - return log.recordAndApply(`${clock.now()} Edit ${createTimestamp} ${newDescription}`); - }, - setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) { - undoLog.push(`Priority ${createTimestamp} ${oldPriority}`); - return log.recordAndApply(`${clock.now()} Priority ${createTimestamp} ${newPriority}`); - }, - setState: function (createTimestamp: string, newState: string, oldState: string) { - undoLog.push(`State ${createTimestamp} ${oldState}`); - return log.recordAndApply(`${clock.now()} State ${createTimestamp} ${newState}`); - }, - undo: function () { - if (undoLog.length > 0) { - return log.recordAndApply(`${clock.now()} ${undoLog.pop()}`); - } - }, -}; +function UI() { + const undoLog: string[] = []; + return { + addTask: function (description: string): Element { + const now = clock.now(); + undoLog.push(`State ${now} deleted`); + return log.recordAndApply(`${now} Create ${description}`); + }, + edit: function (createTimestamp: string, newDescription: string, oldDescription: string) { + undoLog.push(`Edit ${createTimestamp} ${oldDescription}`); + return log.recordAndApply(`${clock.now()} Edit ${createTimestamp} ${newDescription}`); + }, + setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) { + undoLog.push(`Priority ${createTimestamp} ${oldPriority}`); + return log.recordAndApply(`${clock.now()} Priority ${createTimestamp} ${newPriority}`); + }, + setState: function (createTimestamp: string, newState: string, oldState: string) { + undoLog.push(`State ${createTimestamp} ${oldState}`); + return log.recordAndApply(`${clock.now()} State ${createTimestamp} ${newState}`); + }, + undo: function () { + if (undoLog.length > 0) { + return log.recordAndApply(`${clock.now()} ${undoLog.pop()}`); + } + }, + }; +} +const ui = UI(); function BrowserUI() { var currentViewState = "todo"; @@ -182,7 +185,7 @@ function BrowserUI() { addTask: function (event: KeyboardEvent) { const input = document.getElementById("taskName"); if (input.value) { - const task = UI.addTask(input.value); + const task = ui.addTask(input.value); if (task && task instanceof HTMLElement) task.focus(); input.value = ""; if (event.getModifierState("Control")) { @@ -218,7 +221,7 @@ function BrowserUI() { if (newDescription === oldDescription) { task.textContent = oldDescription; } else { - UI.edit(task.getAttribute("data-created")!, newDescription, oldDescription); + ui.edit(task.getAttribute("data-created")!, newDescription, oldDescription); } }, @@ -286,7 +289,7 @@ function BrowserUI() { console.assert(aPriority < newPriority && newPriority < bPriority, aPriority, "<", newPriority, "<", bPriority); const newPriorityRounded = Math.round(newPriority); const okToRound = aPriority < newPriorityRounded && newPriorityRounded < bPriority; - UI.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, Model.getPriority(task)); + ui.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, Model.getPriority(task)); task instanceof HTMLElement && task.focus(); }, @@ -297,7 +300,7 @@ function BrowserUI() { if (newState === oldState) return; const createTimestamp = task.getAttribute("data-created")!; this.moveCursor(1) || this.moveCursor(-1); - return UI.setState(createTimestamp, newState, oldState); + return ui.setState(createTimestamp, newState, oldState); }, setView: function (state: string) { @@ -311,7 +314,7 @@ function BrowserUI() { }, undo: function () { - const ret = UI.undo(); + const ret = ui.undo(); if (ret && ret instanceof HTMLElement) ret.focus(); }, }; -- 2.44.1 From a59fbe41a148be79ea3623cd9a07cecf11b67d6c Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 00:25:01 -0800 Subject: [PATCH 048/100] Always return to command mode (leave input box) after creating a task Even in other views, where the just-created task cannot accept focus. --- vopamoi.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index a3b7824..fdc4723 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -149,7 +149,6 @@ function Log(prefix: string = "vp-") { } const log = Log(); - function UI() { const undoLog: string[] = []; return { @@ -181,12 +180,18 @@ const ui = UI(); function BrowserUI() { var currentViewState = "todo"; + var taskFocusedBeforeJumpingToInput: HTMLElement | null = null; return { addTask: function (event: KeyboardEvent) { const input = document.getElementById("taskName"); if (input.value) { const task = ui.addTask(input.value); - if (task && task instanceof HTMLElement) task.focus(); + if (currentViewState === "todo") { + task instanceof HTMLElement && task.focus(); + } else if (this.returnFocusAfterInput()) { + } else { + this.firstVisibleTask()?.focus(); + } input.value = ""; if (event.getModifierState("Control")) { this.setPriority(task, null, document.getElementsByClassName("task")[0]); @@ -234,6 +239,9 @@ function BrowserUI() { }, focusTaskNameInput: function (event: Event) { + if (document.activeElement instanceof HTMLElement) { + taskFocusedBeforeJumpingToInput = document.activeElement; + } document.getElementById("taskName")!.focus(); event.preventDefault(); }, @@ -279,6 +287,14 @@ function BrowserUI() { } }, + returnFocusAfterInput: function (): boolean { + if (taskFocusedBeforeJumpingToInput) { + taskFocusedBeforeJumpingToInput.focus(); + return true; + } + return false; + }, + // Change task's priority to be between other tasks a and b. setPriority: function (task: Element, a: Element | null, b: Element | null) { const aPriority = a === null ? 0 : Model.getPriority(a); @@ -331,6 +347,7 @@ function handleKey(event: any) { if (event.target.tagName === "INPUT") { if (event.target.id === "taskName") { if (event.key == "Enter") return browserUI.addTask(event); + if (event.key == "Escape") return browserUI.returnFocusAfterInput(); } else { if (event.key == "Enter") return browserUI.completeEdit(event); } -- 2.44.1 From ad72cd51833c7202a3af3bd403c06616e4050b88 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 00:30:22 -0800 Subject: [PATCH 049/100] Escape to abort a task edit --- vopamoi.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index fdc4723..7bdf4b8 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -178,6 +178,11 @@ function UI() { } const ui = UI(); +enum CommitOrAbort { + Commit, + Abort, +} + function BrowserUI() { var currentViewState = "todo"; var taskFocusedBeforeJumpingToInput: HTMLElement | null = null; @@ -214,7 +219,7 @@ function BrowserUI() { event.preventDefault(); }, - completeEdit: function (event: Event) { + completeEdit: function (event: Event, resolution: CommitOrAbort = CommitOrAbort.Commit) { const input = event.target as HTMLInputElement; const task = input.parentElement!; const oldDescription = task.getAttribute("data-description")!; @@ -223,7 +228,7 @@ function BrowserUI() { task.removeChild(task.children[0]); task.removeAttribute("data-description"); task.focus(); - if (newDescription === oldDescription) { + if (newDescription === oldDescription || resolution === CommitOrAbort.Abort) { task.textContent = oldDescription; } else { ui.edit(task.getAttribute("data-created")!, newDescription, oldDescription); @@ -350,6 +355,7 @@ function handleKey(event: any) { if (event.key == "Escape") return browserUI.returnFocusAfterInput(); } else { if (event.key == "Enter") return browserUI.completeEdit(event); + if (event.key == "Escape") return browserUI.completeEdit(event, CommitOrAbort.Abort); } } else { if (inputState === InputState.Command) { -- 2.44.1 From 3a731557d09232dbf6871a33cff2cbeda42db880 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 00:36:25 -0800 Subject: [PATCH 050/100] Don't select-all the task description when beginning editing I find that * most edits are more gentle than complete replacement * I consistently reach for the "end" key to begin editing * If I want complete replacement, ^A is easier to type than "end" --- vopamoi.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 7bdf4b8..01e761c 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -49,12 +49,11 @@ const Model = { const input = target.children[0] as HTMLInputElement; if ( input.value === target.getAttribute("data-description") && - input.selectionStart === 0 && + input.selectionStart === input.value.length && input.selectionEnd === input.value.length ) { // No local changes have actually been made yet. Change the contents of the edit box! input.value = newDescription; - input.select(); } else { // No great options. // Prefer not to interrupt the local user's edit. @@ -215,7 +214,6 @@ function BrowserUI() { task.textContent = ""; task.appendChild(input); input.focus(); - input.select(); event.preventDefault(); }, -- 2.44.1 From 1f300e109962a47d2e1928018ea220d98dd0ab26 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 11:06:48 -0800 Subject: [PATCH 051/100] Change "todo" view key "t" -> "q" ("queue") --- vopamoi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 01e761c..a76db5b 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -364,8 +364,8 @@ function handleKey(event: any) { if (event.key == "n") return browserUI.focusTaskNameInput(event); if (event.key == "c") return browserUI.setState("cancelled"); if (event.key == "d") return browserUI.setState("done"); + if (event.key == "q") return browserUI.setState("todo"); if (event.key == "s") return browserUI.setState("someday-maybe"); - if (event.key == "t") return browserUI.setState("todo"); if (event.key == "w") return browserUI.setState("waiting"); if (event.key == "X") return browserUI.setState("deleted"); if (event.key == "u") return browserUI.undo(); @@ -375,8 +375,8 @@ function handleKey(event: any) { inputState = InputState.Command; if (event.key == "c") return browserUI.setView("cancelled"); if (event.key == "d") return browserUI.setView("done"); + if (event.key == "q") return browserUI.setView("todo"); if (event.key == "s") return browserUI.setView("someday-maybe"); - if (event.key == "t") return browserUI.setView("todo"); if (event.key == "w") return browserUI.setView("waiting"); if (event.key == "x") return browserUI.setView("deleted"); } -- 2.44.1 From 7ccc80f6ed4900117a4a9e481c8e82670e006b86 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 11:12:40 -0800 Subject: [PATCH 052/100] Use classList to set class --- vopamoi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index a76db5b..c079bd4 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -33,7 +33,7 @@ const Model = { addTask: function (timestamp: string, description: string): Element { const task = document.createElement("div"); task.appendChild(document.createTextNode(description)); - task.setAttribute("class", "task"); + task.classList.add("task"); task.setAttribute("tabindex", "0"); task.setAttribute("data-created", timestamp); task.setAttribute("data-state", "todo"); -- 2.44.1 From 6a5644f3a4e8f13ab79cc4ac44ea29f0d5f1fad3 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 11:40:09 -0800 Subject: [PATCH 053/100] Sort log apply actions --- vopamoi.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index c079bd4..6cee2a1 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -115,14 +115,14 @@ function Log(prefix: string = "vp-") { const [createTimestamp, description] = splitN(data, " ", 1); return Model.edit(createTimestamp, description); } - if (command == "State") { - const [createTimestamp, state] = splitN(data, " ", 1); - return Model.setState(timestamp, createTimestamp, state); - } if (command == "Priority") { const [createTimestamp, newPriority] = splitN(data, " ", 1); return Model.setPriority(createTimestamp, parseFloat(newPriority)); } + if (command == "State") { + const [createTimestamp, state] = splitN(data, " ", 1); + return Model.setState(timestamp, createTimestamp, state); + } }, record: function (entry: string) { -- 2.44.1 From 132921e6693ae5492fded334a12c66b82f4b79f9 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 11:41:01 -0800 Subject: [PATCH 054/100] Avoid children[0] --- vopamoi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 6cee2a1..8112c4f 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -46,7 +46,7 @@ const Model = { if (!target) return null; if (target.hasAttribute("data-description")) { // Oh no: An edit has arrived from a replica while a local edit is in progress. - const input = target.children[0] as HTMLInputElement; + const input = target.firstChild as HTMLInputElement; if ( input.value === target.getAttribute("data-description") && input.selectionStart === input.value.length && @@ -223,7 +223,7 @@ function BrowserUI() { const oldDescription = task.getAttribute("data-description")!; const newDescription = input.value; input.removeEventListener("blur", this.completeEdit); - task.removeChild(task.children[0]); + task.removeChild(input); task.removeAttribute("data-description"); task.focus(); if (newDescription === oldDescription || resolution === CommitOrAbort.Abort) { -- 2.44.1 From 7b5b90b9a161fdeeb9842029a369d0484146ef4d Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 11:57:00 -0800 Subject: [PATCH 055/100] Add tags --- vopamoi.css | 7 +++++++ vopamoi.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/vopamoi.css b/vopamoi.css index d8365cb..814de51 100644 --- a/vopamoi.css +++ b/vopamoi.css @@ -1,3 +1,10 @@ input { width: calc(100% - 8px); /* 8px to account for the default padding and border */ } +.tag { + float: right; + margin-left: 1em; +} +input.tag { + width: 7em; +} diff --git a/vopamoi.ts b/vopamoi.ts index 8112c4f..5ac82ef 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -41,6 +41,17 @@ const Model = { return task; }, + addTag: function (createTimestamp: string, tagName: string): Element | null { + const task = this.getTask(createTimestamp); + if (!task) return null; + const tag = document.createElement("span"); + tag.appendChild(document.createTextNode(tagName)); + tag.classList.add("tag"); + tag.setAttribute("tabindex", "0"); + task.appendChild(tag); + return tag; + }, + edit: function (createTimestamp: string, newDescription: string): Element | null { const target = this.getTask(createTimestamp); if (!target) return null; @@ -123,6 +134,10 @@ function Log(prefix: string = "vp-") { const [createTimestamp, state] = splitN(data, " ", 1); return Model.setState(timestamp, createTimestamp, state); } + if (command == "Tag") { + const [createTimestamp, tag] = splitN(data, " ", 1); + return Model.addTag(createTimestamp, tag); + } }, record: function (entry: string) { @@ -156,6 +171,10 @@ function UI() { undoLog.push(`State ${now} deleted`); return log.recordAndApply(`${now} Create ${description}`); }, + addTag: function (createTimestamp: string, tag: string) { + // TODO: undo + return log.recordAndApply(`${clock.now()} Tag ${createTimestamp} ${tag}`); + }, edit: function (createTimestamp: string, newDescription: string, oldDescription: string) { undoLog.push(`Edit ${createTimestamp} ${oldDescription}`); return log.recordAndApply(`${clock.now()} Edit ${createTimestamp} ${newDescription}`); @@ -212,6 +231,17 @@ function BrowserUI() { input.value = oldDescription; input.addEventListener("blur", this.completeEdit, { once: true }); task.textContent = ""; + task.insertBefore(input, task.firstChild); + input.focus(); + event.preventDefault(); + }, + + beginTagEdit: function (event: Event) { + const task = document.activeElement; + if (!task) return; + const input = document.createElement("input"); + input.classList.add("tag"); + input.addEventListener("blur", this.completeTagEdit, { once: true }); task.appendChild(input); input.focus(); event.preventDefault(); @@ -233,6 +263,16 @@ function BrowserUI() { } }, + completeTagEdit: function (event: Event, resolution: CommitOrAbort = CommitOrAbort.Commit) { + const input = event.target as HTMLInputElement; + const task = input.parentElement!; + const newTagName = input.value; + input.removeEventListener("blur", this.completeTagEdit); + task.removeChild(input); + task.focus(); + ui.addTag(task.getAttribute("data-created")!, newTagName); + }, + firstVisibleTask: function () { for (const task of document.getElementsByClassName("task")) { if (task instanceof HTMLElement && task.getAttribute("data-state") === currentViewState) { @@ -351,6 +391,9 @@ function handleKey(event: any) { if (event.target.id === "taskName") { if (event.key == "Enter") return browserUI.addTask(event); if (event.key == "Escape") return browserUI.returnFocusAfterInput(); + } else if (event.target.classList.contains("tag")) { + if (event.key == "Enter") return browserUI.completeTagEdit(event); + if (event.key == "Escape") return browserUI.completeTagEdit(event, CommitOrAbort.Abort); } else { if (event.key == "Enter") return browserUI.completeEdit(event); if (event.key == "Escape") return browserUI.completeEdit(event, CommitOrAbort.Abort); @@ -370,6 +413,7 @@ function handleKey(event: any) { if (event.key == "X") return browserUI.setState("deleted"); if (event.key == "u") return browserUI.undo(); if (event.key == "e") return browserUI.beginEdit(event); + if (event.key == "t") return browserUI.beginTagEdit(event); if (event.key == "v") return (inputState = InputState.View); } else if (inputState === InputState.View) { inputState = InputState.Command; -- 2.44.1 From e1eb33ad260e5da0ec6859baa2368d8a08538e11 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 11:59:03 -0800 Subject: [PATCH 056/100] Don't attempt to create duplicate tags --- vopamoi.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index 5ac82ef..7c51935 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -77,6 +77,15 @@ const Model = { return target; }, + hasTag: function (task: Element, tag: string) { + for (const child of task.children) { + if (child.classList.contains("tag") && child.textContent === tag) { + return true; + } + } + return false; + }, + getPriority: function (task: Element): number { if (task.hasAttribute("data-priority")) { return parseFloat(task.getAttribute("data-priority")!); @@ -270,7 +279,9 @@ function BrowserUI() { input.removeEventListener("blur", this.completeTagEdit); task.removeChild(input); task.focus(); - ui.addTag(task.getAttribute("data-created")!, newTagName); + if (!Model.hasTag(task, newTagName)) { + ui.addTag(task.getAttribute("data-created")!, newTagName); + } }, firstVisibleTask: function () { -- 2.44.1 From 3916a89c8a391d43c8160409862bcba18259f659 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 12:02:27 -0800 Subject: [PATCH 057/100] Don't create duplicate tags; Tagging is idempotent --- vopamoi.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 7c51935..3b2f163 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -44,6 +44,8 @@ const Model = { addTag: function (createTimestamp: string, tagName: string): Element | null { const task = this.getTask(createTimestamp); if (!task) return null; + const existingTag = this.hasTag(task, tagName); + if (existingTag) return existingTag; const tag = document.createElement("span"); tag.appendChild(document.createTextNode(tagName)); tag.classList.add("tag"); @@ -77,13 +79,13 @@ const Model = { return target; }, - hasTag: function (task: Element, tag: string) { + hasTag: function (task: Element, tag: string): Element | null { for (const child of task.children) { if (child.classList.contains("tag") && child.textContent === tag) { - return true; + return child; } } - return false; + return null; }, getPriority: function (task: Element): number { -- 2.44.1 From 09cd65ad6907a157c1907c6b30325e31128e0267 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 12:10:11 -0800 Subject: [PATCH 058/100] Use previous tag name as default new tag name This makes tagging a bunch of tasks with the same tag easier. --- vopamoi.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vopamoi.ts b/vopamoi.ts index 3b2f163..a6f85f2 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -215,6 +215,7 @@ enum CommitOrAbort { function BrowserUI() { var currentViewState = "todo"; var taskFocusedBeforeJumpingToInput: HTMLElement | null = null; + var lastTagNameEntered = ""; return { addTask: function (event: KeyboardEvent) { const input = document.getElementById("taskName"); @@ -253,8 +254,10 @@ function BrowserUI() { const input = document.createElement("input"); input.classList.add("tag"); input.addEventListener("blur", this.completeTagEdit, { once: true }); + input.value = lastTagNameEntered; task.appendChild(input); input.focus(); + input.select(); event.preventDefault(); }, @@ -283,6 +286,7 @@ function BrowserUI() { task.focus(); if (!Model.hasTag(task, newTagName)) { ui.addTag(task.getAttribute("data-created")!, newTagName); + lastTagNameEntered = newTagName; } }, -- 2.44.1 From f366a8213c2d36700fbf7d384ac93d55f0934f72 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 12:13:49 -0800 Subject: [PATCH 059/100] Disallow empty-string as a tag name --- vopamoi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index a6f85f2..6e511a5 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -284,7 +284,7 @@ function BrowserUI() { input.removeEventListener("blur", this.completeTagEdit); task.removeChild(input); task.focus(); - if (!Model.hasTag(task, newTagName)) { + if (newTagName && !Model.hasTag(task, newTagName)) { ui.addTag(task.getAttribute("data-created")!, newTagName); lastTagNameEntered = newTagName; } -- 2.44.1 From 187164d50f67ca24cf7769c2d1746caae51f72b7 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 12:26:23 -0800 Subject: [PATCH 060/100] Fix: Escape aborts tagging --- vopamoi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index 6e511a5..d0f4fa1 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -284,7 +284,7 @@ function BrowserUI() { input.removeEventListener("blur", this.completeTagEdit); task.removeChild(input); task.focus(); - if (newTagName && !Model.hasTag(task, newTagName)) { + if (resolution === CommitOrAbort.Commit && newTagName && !Model.hasTag(task, newTagName)) { ui.addTag(task.getAttribute("data-created")!, newTagName); lastTagNameEntered = newTagName; } -- 2.44.1 From 634868c9a2d36621da56cc6be8a863b5e90db941 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 12:40:30 -0800 Subject: [PATCH 061/100] The tag-input box appearing should not move other tasks' tags around --- vopamoi.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vopamoi.css b/vopamoi.css index 814de51..9f4469c 100644 --- a/vopamoi.css +++ b/vopamoi.css @@ -4,7 +4,9 @@ input { .tag { float: right; margin-left: 1em; + font-size: 85%; } input.tag { width: 7em; + border: none; } -- 2.44.1 From 54c191805520acf16e11b41f812571cfaf93706f Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 12:45:58 -0800 Subject: [PATCH 062/100] Do more filtering in faster browser-native code --- vopamoi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index d0f4fa1..cbb9af2 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -80,8 +80,8 @@ const Model = { }, hasTag: function (task: Element, tag: string): Element | null { - for (const child of task.children) { - if (child.classList.contains("tag") && child.textContent === tag) { + for (const child of task.getElementsByClassName("tag")) { + if (child.textContent === tag) { return child; } } -- 2.44.1 From 267376878a35c6548d3b3bf2ea70cc2344450da5 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 12:52:53 -0800 Subject: [PATCH 063/100] Keep description in its own span Tags broke task.textContent being an easy way to get the task description, which editTask was using. This restores neatness & fixes a bug in which tag names were slurped into the edit-task buffer. --- vopamoi.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index cbb9af2..5916d0a 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -32,7 +32,10 @@ const clock = Clock(); const Model = { addTask: function (timestamp: string, description: string): Element { const task = document.createElement("div"); - task.appendChild(document.createTextNode(description)); + const desc = document.createElement("span"); + desc.textContent = description; + desc.classList.add("desc"); + task.appendChild(desc); task.classList.add("task"); task.setAttribute("tabindex", "0"); task.setAttribute("data-created", timestamp); @@ -74,7 +77,7 @@ const Model = { target.setAttribute("data-description", newDescription); } } else { - target.textContent = newDescription; + target.getElementsByClassName("desc")[0].textContent = newDescription; } return target; }, @@ -238,11 +241,12 @@ function BrowserUI() { const task = document.activeElement; if (!task) return; const input = document.createElement("input"); - const oldDescription = task.textContent!; + const desc = task.getElementsByClassName("desc")[0]; + const oldDescription = desc.textContent!; task.setAttribute("data-description", oldDescription); input.value = oldDescription; input.addEventListener("blur", this.completeEdit, { once: true }); - task.textContent = ""; + desc.textContent = ""; task.insertBefore(input, task.firstChild); input.focus(); event.preventDefault(); @@ -264,6 +268,7 @@ function BrowserUI() { completeEdit: function (event: Event, resolution: CommitOrAbort = CommitOrAbort.Commit) { const input = event.target as HTMLInputElement; const task = input.parentElement!; + const desc = task.getElementsByClassName("desc")[0]; const oldDescription = task.getAttribute("data-description")!; const newDescription = input.value; input.removeEventListener("blur", this.completeEdit); @@ -271,7 +276,7 @@ function BrowserUI() { task.removeAttribute("data-description"); task.focus(); if (newDescription === oldDescription || resolution === CommitOrAbort.Abort) { - task.textContent = oldDescription; + desc.textContent = oldDescription; } else { ui.edit(task.getAttribute("data-created")!, newDescription, oldDescription); } -- 2.44.1 From c70b3eedcfa3521a21239d7f0a6d2168e063dea2 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 13:24:18 -0800 Subject: [PATCH 064/100] Color tags --- vopamoi.css | 7 ++++++- vopamoi.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/vopamoi.css b/vopamoi.css index 9f4469c..1fceed9 100644 --- a/vopamoi.css +++ b/vopamoi.css @@ -4,7 +4,12 @@ input { .tag { float: right; margin-left: 1em; - font-size: 85%; + font-size: 80%; +} +span.tag { + color: white; + padding: 1px .5em; + font-weight: bold; } input.tag { width: 7em; diff --git a/vopamoi.ts b/vopamoi.ts index 5916d0a..135fe0a 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -29,6 +29,12 @@ function Clock() { } const clock = Clock(); +// Returns a promise for a hue based on a hash of the string +function hashHue(str: string) { + // Using crypto for this is overkill + return crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)).then((buf) => (new Uint16Array(buf)[0] * 360) / 2 ** 16); +} + const Model = { addTask: function (timestamp: string, description: string): Element { const task = document.createElement("div"); @@ -53,6 +59,7 @@ const Model = { tag.appendChild(document.createTextNode(tagName)); tag.classList.add("tag"); tag.setAttribute("tabindex", "0"); + hashHue(tagName).then((hue) => (tag.style.backgroundColor = `hsl(${hue},90%,45%)`)); task.appendChild(tag); return tag; }, -- 2.44.1 From 360beccbbfadcc25606af08d13d64fd878469ecc Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 13:34:30 -0800 Subject: [PATCH 065/100] Keep tags sorted --- vopamoi.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vopamoi.ts b/vopamoi.ts index 135fe0a..31a4cdc 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -60,6 +60,12 @@ const Model = { tag.classList.add("tag"); tag.setAttribute("tabindex", "0"); hashHue(tagName).then((hue) => (tag.style.backgroundColor = `hsl(${hue},90%,45%)`)); + for (const child of task.getElementsByClassName("tag")) { + if (tagName > child.textContent!) { + task.insertBefore(tag, child); + return tag; + } + } task.appendChild(tag); return tag; }, -- 2.44.1 From 73ffc3d0a75d43591035c83f123525faddae3a3b Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 15:05:50 -0800 Subject: [PATCH 066/100] Keep tags visually associated with their task If a task's tags line-wrap, the shouldn't go into the next task's space. --- vopamoi.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vopamoi.css b/vopamoi.css index 1fceed9..8483797 100644 --- a/vopamoi.css +++ b/vopamoi.css @@ -15,3 +15,6 @@ input.tag { width: 7em; border: none; } +.task { + clear: right; +} -- 2.44.1 From 4c532769ab489589eeb7ece02dbbc798d13c47f2 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 15:27:19 -0800 Subject: [PATCH 067/100] Provide a visual indicator of the current view-state h/t https://css-tricks.com/body-border/ --- index.html | 11 ++++++++--- vopamoi.css | 23 +++++++++++++++++++++++ vopamoi.ts | 18 ++++++++++-------- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index 8453007..2714223 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,15 @@ - + - -
+
+ +
+
diff --git a/vopamoi.css b/vopamoi.css index 8483797..31d439d 100644 --- a/vopamoi.css +++ b/vopamoi.css @@ -18,3 +18,26 @@ input.tag { .task { clear: right; } +#ui { + padding: 15px 10px; +} +body:before, body:after { + content: ""; + position: fixed; + background: var(--view-state-indicator-color); + left: 0; + right: 0; + height: 5px; +} +body:before { + top: 0; +} +body:after { + bottom: 0; +} +body { + margin: 0; + min-height: 100vh; + border-left: 5px solid var(--view-state-indicator-color); + border-right: 5px solid var(--view-state-indicator-color); +} diff --git a/vopamoi.ts b/vopamoi.ts index 31a4cdc..e99777c 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -397,10 +397,12 @@ function BrowserUI() { return ui.setState(createTimestamp, newState, oldState); }, - setView: function (state: string) { + setView: function (state: string, color: string) { const sheet = (document.getElementById("viewStyle") as HTMLStyleElement).sheet!; sheet.insertRule(`.task:not([data-state=${state}]) { display: none }`); - sheet.removeRule(1); + sheet.insertRule(`:root { --view-state-indicator-color: ${color}; }`); + sheet.removeRule(2); + sheet.removeRule(2); currentViewState = state; if (document.activeElement?.getAttribute("data-state") !== state) { this.firstVisibleTask()?.focus(); @@ -452,12 +454,12 @@ function handleKey(event: any) { if (event.key == "v") return (inputState = InputState.View); } else if (inputState === InputState.View) { inputState = InputState.Command; - if (event.key == "c") return browserUI.setView("cancelled"); - if (event.key == "d") return browserUI.setView("done"); - if (event.key == "q") return browserUI.setView("todo"); - if (event.key == "s") return browserUI.setView("someday-maybe"); - if (event.key == "w") return browserUI.setView("waiting"); - if (event.key == "x") return browserUI.setView("deleted"); + if (event.key == "c") return browserUI.setView("cancelled", "Red"); + if (event.key == "d") return browserUI.setView("done", "LawnGreen"); + if (event.key == "q") return browserUI.setView("todo", "White"); + if (event.key == "s") return browserUI.setView("someday-maybe", "DeepSkyBlue"); + if (event.key == "w") return browserUI.setView("waiting", "MediumOrchid"); + if (event.key == "x") return browserUI.setView("deleted", "Black"); } } } -- 2.44.1 From bd267c298a7d31760d9e78e250bb40650d03c5a0 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 15:32:16 -0800 Subject: [PATCH 068/100] Don't listen for keystrokes until log replay is done --- vopamoi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index e99777c..633ffef 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -465,7 +465,7 @@ function handleKey(event: any) { } function browserInit() { - document.body.addEventListener("keydown", handleKey, { capture: false }); log.replay(); browserUI.firstVisibleTask()?.focus(); + document.body.addEventListener("keydown", handleKey, { capture: false }); } -- 2.44.1 From fb19ac80ac51baf83c7f5d007586212e0bc0868e Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 15:37:51 -0800 Subject: [PATCH 069/100] Disallow all-spaces task descriptions and tag-names --- vopamoi.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 633ffef..6519cbf 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -235,18 +235,17 @@ function BrowserUI() { return { addTask: function (event: KeyboardEvent) { const input = document.getElementById("taskName"); - if (input.value) { - const task = ui.addTask(input.value); - if (currentViewState === "todo") { - task instanceof HTMLElement && task.focus(); - } else if (this.returnFocusAfterInput()) { - } else { - this.firstVisibleTask()?.focus(); - } - input.value = ""; - if (event.getModifierState("Control")) { - this.setPriority(task, null, document.getElementsByClassName("task")[0]); - } + if (input.value.match(/^ *$/)) return; + const task = ui.addTask(input.value); + if (currentViewState === "todo") { + task instanceof HTMLElement && task.focus(); + } else if (this.returnFocusAfterInput()) { + } else { + this.firstVisibleTask()?.focus(); + } + input.value = ""; + if (event.getModifierState("Control")) { + this.setPriority(task, null, document.getElementsByClassName("task")[0]); } }, @@ -288,7 +287,7 @@ function BrowserUI() { task.removeChild(input); task.removeAttribute("data-description"); task.focus(); - if (newDescription === oldDescription || resolution === CommitOrAbort.Abort) { + if (resolution === CommitOrAbort.Abort || newDescription.match(/^ *$/) || newDescription === oldDescription) { desc.textContent = oldDescription; } else { ui.edit(task.getAttribute("data-created")!, newDescription, oldDescription); @@ -302,7 +301,7 @@ function BrowserUI() { input.removeEventListener("blur", this.completeTagEdit); task.removeChild(input); task.focus(); - if (resolution === CommitOrAbort.Commit && newTagName && !Model.hasTag(task, newTagName)) { + if (resolution === CommitOrAbort.Commit && !newTagName.match(/^ *$/) && !Model.hasTag(task, newTagName)) { ui.addTag(task.getAttribute("data-created")!, newTagName); lastTagNameEntered = newTagName; } -- 2.44.1 From 4c9b0554f34d5cec1d4edea51880438081615757 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 19:09:20 -0800 Subject: [PATCH 070/100] Accept "vv" for jumping to the default view --- vopamoi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vopamoi.ts b/vopamoi.ts index 6519cbf..366edd6 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -457,6 +457,7 @@ function handleKey(event: any) { if (event.key == "d") return browserUI.setView("done", "LawnGreen"); if (event.key == "q") return browserUI.setView("todo", "White"); if (event.key == "s") return browserUI.setView("someday-maybe", "DeepSkyBlue"); + if (event.key == "v") return browserUI.setView("todo", "White"); if (event.key == "w") return browserUI.setView("waiting", "MediumOrchid"); if (event.key == "x") return browserUI.setView("deleted", "Black"); } -- 2.44.1 From 0726872b8e043c19883d26492ce42d3411505179 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 19:15:37 -0800 Subject: [PATCH 071/100] Undo tag --- vopamoi.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index 366edd6..c17a2f0 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -119,6 +119,14 @@ const Model = { } }, + removeTag: function (createTimestamp: string, tagName: string) { + const task = this.getTask(createTimestamp); + if (!task) return null; + const tag = this.hasTag(task, tagName); + if (!tag) return; + task.removeChild(tag); + }, + setPriority: function (createTimestamp: string, priority: number): Element | null { const target = this.getTask(createTimestamp); if (!target) return null; @@ -165,6 +173,10 @@ function Log(prefix: string = "vp-") { const [createTimestamp, tag] = splitN(data, " ", 1); return Model.addTag(createTimestamp, tag); } + if (command == "Untag") { + const [createTimestamp, tag] = splitN(data, " ", 1); + return Model.removeTag(createTimestamp, tag); + } }, record: function (entry: string) { @@ -199,7 +211,7 @@ function UI() { return log.recordAndApply(`${now} Create ${description}`); }, addTag: function (createTimestamp: string, tag: string) { - // TODO: undo + undoLog.push(`Untag ${createTimestamp} ${tag}`); return log.recordAndApply(`${clock.now()} Tag ${createTimestamp} ${tag}`); }, edit: function (createTimestamp: string, newDescription: string, oldDescription: string) { -- 2.44.1 From b5f15e0e536c9898458cd7ab9b440dbec404ce6a Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 19:26:04 -0800 Subject: [PATCH 072/100] Press "x" to remove tags --- vopamoi.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/vopamoi.ts b/vopamoi.ts index c17a2f0..17d81b6 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -125,6 +125,7 @@ const Model = { const tag = this.hasTag(task, tagName); if (!tag) return; task.removeChild(tag); + if (task instanceof HTMLElement) task.focus(); }, setPriority: function (createTimestamp: string, priority: number): Element | null { @@ -218,6 +219,10 @@ function UI() { undoLog.push(`Edit ${createTimestamp} ${oldDescription}`); return log.recordAndApply(`${clock.now()} Edit ${createTimestamp} ${newDescription}`); }, + removeTag: function (createTimestamp: string, tag: string) { + undoLog.push(`Tag ${createTimestamp} ${tag}`); + return log.recordAndApply(`${clock.now()} Untag ${createTimestamp} ${tag}`); + }, setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) { undoLog.push(`Priority ${createTimestamp} ${oldPriority}`); return log.recordAndApply(`${clock.now()} Priority ${createTimestamp} ${newPriority}`); @@ -376,6 +381,12 @@ function BrowserUI() { } }, + removeTag: function () { + const tag = document.activeElement; + if (!tag || !tag.classList.contains("tag")) return; + ui.removeTag(tag.parentElement!.getAttribute("data-created")!, tag.textContent!); + }, + returnFocusAfterInput: function (): boolean { if (taskFocusedBeforeJumpingToInput) { taskFocusedBeforeJumpingToInput.focus(); @@ -459,6 +470,7 @@ function handleKey(event: any) { if (event.key == "s") return browserUI.setState("someday-maybe"); if (event.key == "w") return browserUI.setState("waiting"); if (event.key == "X") return browserUI.setState("deleted"); + if (event.key == "x") return browserUI.removeTag(); if (event.key == "u") return browserUI.undo(); if (event.key == "e") return browserUI.beginEdit(event); if (event.key == "t") return browserUI.beginTagEdit(event); -- 2.44.1 From 4ccaa1d6e256ca198e0d15e5b51553edb063f3bf Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 19:34:24 -0800 Subject: [PATCH 073/100] "x" removes the first tag if a task, rather than a tag, is selected --- vopamoi.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 17d81b6..fe97410 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -382,9 +382,14 @@ function BrowserUI() { }, removeTag: function () { - const tag = document.activeElement; - if (!tag || !tag.classList.contains("tag")) return; - ui.removeTag(tag.parentElement!.getAttribute("data-created")!, tag.textContent!); + var target = document.activeElement; + if (!target) return; + if (target.classList.contains("task")) { + const tags = target.getElementsByClassName("tag"); + target = tags[tags.length - 1]; + } + if (!target || !target.classList.contains("tag")) return; + ui.removeTag(target.parentElement!.getAttribute("data-created")!, target.textContent!); }, returnFocusAfterInput: function (): boolean { -- 2.44.1 From 792b8a180ea729fd3cbfffaa915c6df9041e391d Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 21:59:47 -0800 Subject: [PATCH 074/100] Factor out makeTopPriority --- vopamoi.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index fe97410..ede5911 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -262,7 +262,7 @@ function BrowserUI() { } input.value = ""; if (event.getModifierState("Control")) { - this.setPriority(task, null, document.getElementsByClassName("task")[0]); + this.makeTopPriority(task); } }, @@ -356,6 +356,10 @@ function BrowserUI() { return valid_cursor; }, + makeTopPriority: function (task: Element) { + this.setPriority(task, null, document.getElementsByClassName("task")[0]); + }, + moveCursor: function (offset: number): boolean { const active = document.activeElement; if (!active) return false; -- 2.44.1 From 55a4baa82e843ccb8a0ced1c1dc75a38bfe5f70e Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 22:20:21 -0800 Subject: [PATCH 075/100] "T" to make task "Top" priority --- vopamoi.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vopamoi.ts b/vopamoi.ts index ede5911..83354a4 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -356,7 +356,9 @@ function BrowserUI() { return valid_cursor; }, - makeTopPriority: function (task: Element) { + makeTopPriority: function (task: Element | null = null) { + if (!task) task = document.activeElement; + if (!task) return; this.setPriority(task, null, document.getElementsByClassName("task")[0]); }, @@ -472,6 +474,7 @@ function handleKey(event: any) { if (event.key == "k") return browserUI.moveCursor(-1); if (event.key == "J") return browserUI.moveTask(1); if (event.key == "K") return browserUI.moveTask(-1); + if (event.key == "T") return browserUI.makeTopPriority(); if (event.key == "n") return browserUI.focusTaskNameInput(event); if (event.key == "c") return browserUI.setState("cancelled"); if (event.key == "d") return browserUI.setState("done"); -- 2.44.1 From 02c8a409c4aac79c5f0d2cc34ffba182198126a0 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 22:21:06 -0800 Subject: [PATCH 076/100] Rename inputStates to be less semantic and more concrete --- vopamoi.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 83354a4..50b25fa 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -451,10 +451,10 @@ function BrowserUI() { const browserUI = BrowserUI(); enum InputState { - Command, - View, + Root, + V, } -var inputState = InputState.Command; +var inputState = InputState.Root; function handleKey(event: any) { if (event.target.tagName === "INPUT") { @@ -469,7 +469,7 @@ function handleKey(event: any) { if (event.key == "Escape") return browserUI.completeEdit(event, CommitOrAbort.Abort); } } else { - if (inputState === InputState.Command) { + if (inputState === InputState.Root) { if (event.key == "j") return browserUI.moveCursor(1); if (event.key == "k") return browserUI.moveCursor(-1); if (event.key == "J") return browserUI.moveTask(1); @@ -486,9 +486,9 @@ function handleKey(event: any) { if (event.key == "u") return browserUI.undo(); if (event.key == "e") return browserUI.beginEdit(event); if (event.key == "t") return browserUI.beginTagEdit(event); - if (event.key == "v") return (inputState = InputState.View); - } else if (inputState === InputState.View) { - inputState = InputState.Command; + if (event.key == "v") return (inputState = InputState.V); + } else if (inputState === InputState.V) { + inputState = InputState.Root; if (event.key == "c") return browserUI.setView("cancelled", "Red"); if (event.key == "d") return browserUI.setView("done", "LawnGreen"); if (event.key == "q") return browserUI.setView("todo", "White"); -- 2.44.1 From 36ddfad1a14ce3bf0e47114933941166c56e65a7 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 22:24:42 -0800 Subject: [PATCH 077/100] "sm" and "vsm" for someday-*maybe*, rather than just "s" The "maybe" in "someday-maybe" feels really important. It feels importantly different than "someday". It definitely feels worth the extra keystrokes & extra keystroke state logic. --- vopamoi.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/vopamoi.ts b/vopamoi.ts index 50b25fa..5b1ce6b 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -452,7 +452,9 @@ const browserUI = BrowserUI(); enum InputState { Root, + S, V, + VS, } var inputState = InputState.Root; @@ -479,7 +481,7 @@ function handleKey(event: any) { if (event.key == "c") return browserUI.setState("cancelled"); if (event.key == "d") return browserUI.setState("done"); if (event.key == "q") return browserUI.setState("todo"); - if (event.key == "s") return browserUI.setState("someday-maybe"); + if (event.key == "s") return (inputState = InputState.S); if (event.key == "w") return browserUI.setState("waiting"); if (event.key == "X") return browserUI.setState("deleted"); if (event.key == "x") return browserUI.removeTag(); @@ -487,15 +489,21 @@ function handleKey(event: any) { if (event.key == "e") return browserUI.beginEdit(event); if (event.key == "t") return browserUI.beginTagEdit(event); if (event.key == "v") return (inputState = InputState.V); + } else if (inputState === InputState.S) { + inputState = InputState.Root; + if (event.key == "m") return browserUI.setState("someday-maybe"); } else if (inputState === InputState.V) { inputState = InputState.Root; if (event.key == "c") return browserUI.setView("cancelled", "Red"); if (event.key == "d") return browserUI.setView("done", "LawnGreen"); if (event.key == "q") return browserUI.setView("todo", "White"); - if (event.key == "s") return browserUI.setView("someday-maybe", "DeepSkyBlue"); + if (event.key == "s") return (inputState = InputState.VS); if (event.key == "v") return browserUI.setView("todo", "White"); if (event.key == "w") return browserUI.setView("waiting", "MediumOrchid"); if (event.key == "x") return browserUI.setView("deleted", "Black"); + } else if (inputState === InputState.VS) { + inputState = InputState.Root; + if (event.key == "m") return browserUI.setView("someday-maybe", "DeepSkyBlue"); } } } -- 2.44.1 From a6268469ffa825e27c6ade6e38772ca307c52742 Mon Sep 17 00:00:00 2001 From: Scott Worley Date: Thu, 27 Jan 2022 22:37:20 -0800 Subject: [PATCH 078/100] Declare character encoding This quiets a warning in the Firefox console. --- index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/index.html b/index.html index 2714223..9833510 100644 --- a/index.html +++ b/index.html @@ -1,6 +1,7 @@ +