X-Git-Url: http://git.scottworley.com/vopamoi/blobdiff_plain/1f300e109962a47d2e1928018ea220d98dd0ab26..cddbdce1bb49d2568668fccc788c8356012b3fe2:/vopamoi.ts?ds=inline diff --git a/vopamoi.ts b/vopamoi.ts index a76db5b..24b8774 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -29,11 +29,20 @@ 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"); - task.appendChild(document.createTextNode(description)); - task.setAttribute("class", "task"); + 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); task.setAttribute("data-state", "todo"); @@ -41,12 +50,32 @@ const Model = { return task; }, + 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"); + 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; + }, + 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; + const input = target.firstChild as HTMLInputElement; if ( input.value === target.getAttribute("data-description") && input.selectionStart === input.value.length && @@ -61,11 +90,20 @@ const Model = { target.setAttribute("data-description", newDescription); } } else { - target.textContent = newDescription; + target.getElementsByClassName("desc")[0].textContent = newDescription; } return target; }, + hasTag: function (task: Element, tag: string): Element | null { + for (const child of task.getElementsByClassName("tag")) { + if (child.textContent === tag) { + return child; + } + } + return null; + }, + getPriority: function (task: Element): number { if (task.hasAttribute("data-priority")) { return parseFloat(task.getAttribute("data-priority")!); @@ -81,6 +119,15 @@ 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); + if (task instanceof HTMLElement) task.focus(); + }, + setPriority: function (createTimestamp: string, priority: number): Element | null { const target = this.getTask(createTimestamp); if (!target) return null; @@ -97,9 +144,20 @@ const Model = { setState: function (stateTimestamp: string, createTimestamp: string, state: string) { const task = this.getTask(createTimestamp); - if (task) { - task.setAttribute("data-state", state); + if (!task) return; + task.setAttribute("data-state", state); + var date = task.getElementsByClassName("statedate")[0]; + if (state === "todo") { + task.removeChild(date); + return; } + if (!date) { + date = document.createElement("span"); + date.classList.add("statedate"); + task.insertBefore(date, task.firstChild); + } + const d = new Date(parseInt(stateTimestamp)); + date.textContent = `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, "0")}-${`${d.getDate()}`.padStart(2, "0")}`; }, }; @@ -115,13 +173,21 @@ function Log(prefix: string = "vp-") { const [createTimestamp, description] = splitN(data, " ", 1); return Model.edit(createTimestamp, description); } + 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); } - if (command == "Priority") { - const [createTimestamp, newPriority] = splitN(data, " ", 1); - return Model.setPriority(createTimestamp, parseFloat(newPriority)); + if (command == "Tag") { + 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); } }, @@ -156,10 +222,18 @@ function UI() { undoLog.push(`State ${now} deleted`); return log.recordAndApply(`${now} Create ${description}`); }, + addTag: function (createTimestamp: string, tag: string) { + undoLog.push(`Untag ${createTimestamp} ${tag}`); + 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}`); }, + 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}`); @@ -185,21 +259,21 @@ enum CommitOrAbort { function BrowserUI() { var currentViewState = "todo"; var taskFocusedBeforeJumpingToInput: HTMLElement | null = null; + var lastTagNameEntered = ""; 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" || currentViewState === "all") { + task instanceof HTMLElement && task.focus(); + } else if (this.returnFocusAfterInput()) { + } else { + this.firstVisibleTask()?.focus(); + } + input.value = ""; + if (event.getModifierState("Control")) { + this.makeTopPriority(task); } }, @@ -207,35 +281,64 @@ 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(); + }, + + 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 }); + input.value = lastTagNameEntered; task.appendChild(input); input.focus(); + input.select(); event.preventDefault(); }, 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); - task.removeChild(task.children[0]); + task.removeChild(input); task.removeAttribute("data-description"); task.focus(); - if (newDescription === oldDescription || resolution === CommitOrAbort.Abort) { - task.textContent = oldDescription; + if (resolution === CommitOrAbort.Abort || newDescription.match(/^ *$/) || newDescription === oldDescription) { + desc.textContent = oldDescription; } else { ui.edit(task.getAttribute("data-created")!, newDescription, oldDescription); } }, + 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(); + if (resolution === CommitOrAbort.Commit && !newTagName.match(/^ *$/) && !Model.hasTag(task, newTagName)) { + ui.addTag(task.getAttribute("data-created")!, newTagName); + lastTagNameEntered = newTagName; + } + }, + firstVisibleTask: function () { for (const task of document.getElementsByClassName("task")) { - if (task instanceof HTMLElement && task.getAttribute("data-state") === currentViewState) { + const state = task.getAttribute("data-state"); + if (task instanceof HTMLElement && (state === currentViewState || (currentViewState === "all" && state !== "deleted"))) { return task; } } @@ -256,7 +359,8 @@ function BrowserUI() { while (true) { cursor = increment > 0 ? cursor.nextElementSibling : cursor.previousElementSibling; if (!cursor || !(cursor instanceof HTMLElement)) break; - if (cursor.getAttribute("data-state")! === currentViewState) { + const state = cursor.getAttribute("data-state")!; + if (state === currentViewState || (currentViewState === "all" && state !== "deleted")) { offset -= increment; valid_cursor = cursor; } @@ -265,6 +369,12 @@ function BrowserUI() { return valid_cursor; }, + makeTopPriority: function (task: Element | null = null) { + if (!task) task = document.activeElement; + if (!task) return; + this.setPriority(task, null, document.getElementsByClassName("task")[0]); + }, + moveCursor: function (offset: number): boolean { const active = document.activeElement; if (!active) return false; @@ -290,6 +400,17 @@ function BrowserUI() { } }, + removeTag: function () { + 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 { if (taskFocusedBeforeJumpingToInput) { taskFocusedBeforeJumpingToInput.focus(); @@ -318,14 +439,22 @@ function BrowserUI() { const oldState = task.getAttribute("data-state")!; if (newState === oldState) return; const createTimestamp = task.getAttribute("data-created")!; - this.moveCursor(1) || this.moveCursor(-1); + if (currentViewState !== "all" || newState == "deleted") { + this.moveCursor(1) || this.moveCursor(-1); + } 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); + if (state === "all") { + sheet.insertRule(`.task[data-state=deleted] { display: none }`); + } else { + sheet.insertRule(`.task:not([data-state=${state}]) { display: none }`); + } + 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(); @@ -341,50 +470,66 @@ function BrowserUI() { const browserUI = BrowserUI(); enum InputState { - Command, - View, + Root, + S, + V, + VS, } -var inputState = InputState.Command; +var inputState = InputState.Root; 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.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); } } 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); 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"); 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(); 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) { - 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 == "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 == "a") return browserUI.setView("all", "Gold"); + 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 (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"); } } } function browserInit() { - document.body.addEventListener("keydown", handleKey, { capture: false }); log.replay(); browserUI.firstVisibleTask()?.focus(); + document.body.addEventListener("keydown", handleKey, { capture: false }); }