X-Git-Url: http://git.scottworley.com/vopamoi/blobdiff_plain/55a4baa82e843ccb8a0ced1c1dc75a38bfe5f70e..58b569ce38e1d658d0cdfb31d53f2cad2d00e5bd:/vopamoi.ts diff --git a/vopamoi.ts b/vopamoi.ts index 83354a4..931505b 100644 --- a/vopamoi.ts +++ b/vopamoi.ts @@ -46,7 +46,8 @@ const Model = { task.setAttribute("tabindex", "0"); task.setAttribute("data-created", timestamp); task.setAttribute("data-state", "todo"); - document.getElementById("tasks")!.appendChild(task); + const tasks = document.getElementById("tasks")!; + tasks.insertBefore(task, tasks.firstElementChild); return task; }, @@ -133,7 +134,7 @@ const Model = { if (!target) return null; target.setAttribute("data-priority", `${priority}`); for (const task of document.getElementsByClassName("task")) { - if (task !== target && this.getPriority(task) > priority) { + if (task !== target && this.getPriority(task) < priority) { task.parentElement!.insertBefore(target, task); return target; } @@ -144,9 +145,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")}`; }, }; @@ -204,36 +216,45 @@ function Log(prefix: string = "vp-") { const log = Log(); function UI() { - const undoLog: string[] = []; + const undoLog: string[][] = []; + const redoLog: string[][] = []; + function perform(forward: string, reverse: string) { + undoLog.push([reverse, forward]); + return log.recordAndApply(`${clock.now()} ${forward}`); + } return { addTask: function (description: string): Element { const now = clock.now(); - undoLog.push(`State ${now} deleted`); + undoLog.push([`State ${now} deleted`, `State ${now} todo`]); 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}`); + return perform(`Tag ${createTimestamp} ${tag}`, `Untag ${createTimestamp} ${tag}`); }, edit: function (createTimestamp: string, newDescription: string, oldDescription: string) { - undoLog.push(`Edit ${createTimestamp} ${oldDescription}`); - return log.recordAndApply(`${clock.now()} Edit ${createTimestamp} ${newDescription}`); + return perform(`Edit ${createTimestamp} ${newDescription}`, `Edit ${createTimestamp} ${oldDescription}`); }, removeTag: function (createTimestamp: string, tag: string) { - undoLog.push(`Tag ${createTimestamp} ${tag}`); - return log.recordAndApply(`${clock.now()} Untag ${createTimestamp} ${tag}`); + return perform(`Untag ${createTimestamp} ${tag}`, `Tag ${createTimestamp} ${tag}`); }, setPriority: function (createTimestamp: string, newPriority: number, oldPriority: number) { - undoLog.push(`Priority ${createTimestamp} ${oldPriority}`); - return log.recordAndApply(`${clock.now()} Priority ${createTimestamp} ${newPriority}`); + return perform(`Priority ${createTimestamp} ${newPriority}`, `Priority ${createTimestamp} ${oldPriority}`); }, setState: function (createTimestamp: string, newState: string, oldState: string) { - undoLog.push(`State ${createTimestamp} ${oldState}`); - return log.recordAndApply(`${clock.now()} State ${createTimestamp} ${newState}`); + return perform(`State ${createTimestamp} ${newState}`, `State ${createTimestamp} ${oldState}`); }, undo: function () { - if (undoLog.length > 0) { - return log.recordAndApply(`${clock.now()} ${undoLog.pop()}`); + const entry = undoLog.pop(); + if (entry) { + redoLog.push(entry); + return log.recordAndApply(`${clock.now()} ${entry[0]}`); + } + }, + redo: function () { + const entry = redoLog.pop(); + if (entry) { + undoLog.push(entry); + return log.recordAndApply(`${clock.now()} ${entry[1]}`); } }, }; @@ -246,6 +267,15 @@ enum CommitOrAbort { } function BrowserUI() { + const viewColors: { [key: string]: string } = { + all: "Gold", + cancelled: "Red", + deleted: "Black", + done: "LawnGreen", + "someday-maybe": "DeepSkyBlue", + todo: "White", + waiting: "MediumOrchid", + }; var currentViewState = "todo"; var taskFocusedBeforeJumpingToInput: HTMLElement | null = null; var lastTagNameEntered = ""; @@ -254,7 +284,7 @@ function BrowserUI() { const input = document.getElementById("taskName"); if (input.value.match(/^ *$/)) return; const task = ui.addTask(input.value); - if (currentViewState === "todo") { + if (currentViewState === "todo" || currentViewState === "all") { task instanceof HTMLElement && task.focus(); } else if (this.returnFocusAfterInput()) { } else { @@ -262,13 +292,14 @@ function BrowserUI() { } input.value = ""; if (event.getModifierState("Control")) { - this.makeTopPriority(task); + this.makeBottomPriority(task); } }, beginEdit: function (event: Event) { - const task = document.activeElement; + var task = document.activeElement; if (!task) return; + if (task.classList.contains("tag")) task = task.parentElement!; const input = document.createElement("input"); const desc = task.getElementsByClassName("desc")[0]; const oldDescription = desc.textContent!; @@ -324,9 +355,21 @@ function BrowserUI() { } }, + currentTag: function (): Element | null { + var target = document.activeElement; + if (!target) return null; + if (target.classList.contains("task")) { + const tags = target.getElementsByClassName("tag"); + target = tags[tags.length - 1]; + } + if (!target || !target.classList.contains("tag")) return null; + return target; + }, + 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; } } @@ -337,6 +380,7 @@ function BrowserUI() { taskFocusedBeforeJumpingToInput = document.activeElement; } document.getElementById("taskName")!.focus(); + window.scroll(0, 0); event.preventDefault(); }, @@ -347,7 +391,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; } @@ -356,10 +401,24 @@ function BrowserUI() { return valid_cursor; }, + jumpCursor: function (position: number) { + const first = this.firstVisibleTask(); + if (!first) return; + const dest = this.visibleTaskAtOffset(first, position - 1); + if (dest instanceof HTMLElement) dest.focus(); + }, + + makeBottomPriority: function (task: Element | null = null) { + if (!task) task = document.activeElement; + if (!task) return; + this.setPriority(task, document.getElementById("tasks")!.lastElementChild, null); + }, + makeTopPriority: function (task: Element | null = null) { if (!task) task = document.activeElement; if (!task) return; - this.setPriority(task, null, document.getElementsByClassName("task")[0]); + ui.setPriority(task.getAttribute("data-created")!, clock.now(), Model.getPriority(task)); + task instanceof HTMLElement && task.focus(); }, moveCursor: function (offset: number): boolean { @@ -388,16 +447,15 @@ function BrowserUI() { }, removeTag: function () { - var target = document.activeElement; + const target = this.currentTag(); 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!); }, + resetView: function () { + this.setView("todo"); + }, + returnFocusAfterInput: function (): boolean { if (taskFocusedBeforeJumpingToInput) { taskFocusedBeforeJumpingToInput.focus(); @@ -408,14 +466,14 @@ function 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 ? 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 aPriority = a === null ? clock.now() : Model.getPriority(a); + const bPriority = b === null ? 0 : Model.getPriority(b); + console.assert(aPriority > bPriority, aPriority, ">", bPriority); + const span = aPriority - bPriority; + const newPriority = bPriority + 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; + const okToRound = aPriority > newPriorityRounded && newPriorityRounded > bPriority; ui.setPriority(task.getAttribute("data-created")!, okToRound ? newPriorityRounded : newPriority, Model.getPriority(task)); task instanceof HTMLElement && task.focus(); }, @@ -426,14 +484,20 @@ 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, color: string) { + setView: function (state: string) { const sheet = (document.getElementById("viewStyle") as HTMLStyleElement).sheet!; - sheet.insertRule(`.task:not([data-state=${state}]) { display: none }`); - sheet.insertRule(`:root { --view-state-indicator-color: ${color}; }`); + 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: ${viewColors[state]}; }`); sheet.removeRule(2); sheet.removeRule(2); currentViewState = state; @@ -446,17 +510,26 @@ function BrowserUI() { const ret = ui.undo(); if (ret && ret instanceof HTMLElement) ret.focus(); }, + redo: function () { + const ret = ui.redo(); + if (ret && ret instanceof HTMLElement) ret.focus(); + }, }; } const browserUI = BrowserUI(); +const scrollIncrement = 60; enum InputState { - Command, - View, + Root, + S, + V, + VS, } -var inputState = InputState.Command; +var inputState = InputState.Root; +var inputCount: number | null = null; function handleKey(event: any) { + if (["Alt", "Control", "Meta", "Shift"].includes(event.key)) return; if (event.target.tagName === "INPUT") { if (event.target.id === "taskName") { if (event.key == "Enter") return browserUI.addTask(event); @@ -469,33 +542,54 @@ function handleKey(event: any) { if (event.key == "Escape") return browserUI.completeEdit(event, CommitOrAbort.Abort); } } 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 == "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 == "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); - if (event.key == "v") return (inputState = InputState.View); - } else if (inputState === InputState.View) { - inputState = InputState.Command; - 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 == "v") return browserUI.setView("todo", "White"); - if (event.key == "w") return browserUI.setView("waiting", "MediumOrchid"); - if (event.key == "x") return browserUI.setView("deleted", "Black"); + if (inputState === InputState.Root) { + if ("0" <= event.key && event.key <= "9") { + return (inputCount = (inputCount ?? 0) * 10 + parseInt(event.key)); + } + try { + if (event.ctrlKey) { + if (event.key == "e") return window.scrollBy(0, (inputCount ?? 1) * scrollIncrement); + if (event.key == "y") return window.scrollBy(0, (inputCount ?? 1) * -scrollIncrement); + } else { + if (event.key == "j") return browserUI.moveCursor(inputCount ?? 1); + if (event.key == "k") return browserUI.moveCursor(-(inputCount ?? 1)); + if (event.key == "J") return browserUI.moveTask(inputCount ?? 1); + if (event.key == "K") return browserUI.moveTask(-(inputCount ?? 1)); + if (event.key == "G") return browserUI.jumpCursor(inputCount ?? MAX_SAFE_INTEGER); + 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 (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 == "r") return browserUI.redo(); + if (event.key == "e") return browserUI.beginEdit(event); + if (event.key == "t") return browserUI.beginTagEdit(event); + if (event.key == "v") return (inputState = InputState.V); + } + } finally { + inputCount = null; + } + } 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"); + 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 (inputState = InputState.VS); + if (event.key == "v") return browserUI.resetView(); + if (event.key == "w") return browserUI.setView("waiting"); + if (event.key == "x") return browserUI.setView("deleted"); + } else if (inputState === InputState.VS) { + inputState = InputState.Root; + if (event.key == "m") return browserUI.setView("someday-maybe"); } } }