diff --git a/README.md b/README.md index 381f92c..394bd79 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ When None are configured, nothing happens. TODONT Mode allows users to archive todos, by replacing the `{{[[TODO]]}}` with a `{{[[ARCHIVED]]}}`. To enable, switch on `icon` in the `TODONT MODE` field in your Roam Depot Settings. -To archive a `TODO`, just hit CMD+SHIFT+ENTER (CTRL in windows). In the text area it inserts `{{[[ARCHIVED]]}}` at the beginning of the block. Any TODOs or DONEs will be replaced with an ARCHIVED. If an ARCHIVED exists, it will be cleared. If none of the above exists, an ARCHIVED is inserted in the block. +To archive a `TODO`, just hit CMD+SHIFT+ENTER (CTRL in windows). In the text area it inserts `{{[[ARCHIVED]]}}` at the beginning of the block. Any TODOs or DONEs will be replaced with an ARCHIVED. If an ARCHIVED exists, it will be replaced with a TODO. If none of the above exists, an ARCHIVED is inserted in the block. To change the CSS styling of the archive display, you'll want to change the CSS associated with the `roamjs-todont` class. diff --git a/src/index.ts b/src/index.ts index 0eacc23..e4c8e1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,53 +271,98 @@ export default runExtension(async ({ extensionAPI }) => { return { explode: !!extensionAPI.settings.get("explode") }; }; + type TodoState = "TODO" | "DONE"; + const latestHandledTransition = new Map< + string, + { state: TodoState; timestamp: number } + >(); + const HANDLED_TRANSITION_WINDOW_MS = 300; + const ROAM_STATE_SETTLE_MS = 50; + const hasHandledRecently = (blockUid: string, state: TodoState) => { + const now = Date.now(); + if (latestHandledTransition.size > 100) { + for (const [uid, entry] of latestHandledTransition) { + if (now - entry.timestamp > HANDLED_TRANSITION_WINDOW_MS) { + latestHandledTransition.delete(uid); + } + } + } + const previous = latestHandledTransition.get(blockUid); + latestHandledTransition.set(blockUid, { state, timestamp: now }); + return ( + !!previous && + previous.state === state && + now - previous.timestamp < HANDLED_TRANSITION_WINDOW_MS + ); + }; + + const triggerOnTodo = (blockUid: string, value: string) => { + if (!hasHandledRecently(blockUid, "TODO")) { + onTodo(blockUid, value); + } + }; + + const triggerOnDone = (blockUid: string, value: string) => { + if (hasHandledRecently(blockUid, "DONE")) { + return { explode: false }; + } + return onDone(blockUid, value); + }; + createHTMLObserver({ tag: "LABEL", className: "check-container", callback: (_l) => { const l = _l as HTMLLabelElement; const inputTarget = l.querySelector("input"); - if (inputTarget?.type === "checkbox") { + if ( + inputTarget?.type === "checkbox" && + inputTarget.dataset.todoTriggerBound !== "true" + ) { + inputTarget.dataset.todoTriggerBound = "true"; const blockUid = getBlockUidFromTarget(inputTarget); inputTarget.addEventListener("click", () => { const position = inputTarget.getBoundingClientRect(); setTimeout(() => { - const oldValue = getTextByBlockUid(blockUid); + const value = getTextByBlockUid(blockUid); if (inputTarget.checked) { - onTodo(blockUid, oldValue); - } else { - const config = onDone(blockUid, oldValue); + const config = triggerOnDone(blockUid, value); if (config.explode) { setTimeout(() => { explode(position.x, position.y); }, 50); } + } else { + triggerOnTodo(blockUid, value); } - }, 50); + }, ROAM_STATE_SETTLE_MS); }); } }, }); - const clickListener = async (e: MouseEvent) => { + const clickListener = (e: MouseEvent) => { const target = e.target as HTMLElement; - if ( - target.parentElement?.getElementsByClassName( - "bp3-text-overflow-ellipsis" - )[0]?.innerHTML === "TODO" - ) { - const textarea = target - .closest(".roam-block-container") - ?.getElementsByTagName?.("textarea")?.[0]; - if (textarea) { - const { blockUid } = getUids(textarea); - onTodo(blockUid, textarea.value); - } + const menuItem = target.closest(".bp3-menu-item") as HTMLElement; + const menuLabel = menuItem + ?.querySelector(".bp3-text-overflow-ellipsis") + ?.textContent?.trim(); + if (menuLabel === "TODO") { + setTimeout(() => { + const blockUid = window.roamAlphaAPI.ui.getFocusedBlock()?.["block-uid"]; + if (!blockUid) { + return; + } + const value = getTextByBlockUid(blockUid); + if (value.startsWith("{{[[TODO]]}}")) { + triggerOnTodo(blockUid, value); + } + }, ROAM_STATE_SETTLE_MS); } }; document.addEventListener("click", clickListener); - const keydownEventListener = async (_e: Event) => { + const keydownEventListener = (_e: Event) => { const e = _e as KeyboardEvent; if (e.key === "Enter") { if (isControl(e)) { @@ -325,46 +370,47 @@ export default runExtension(async ({ extensionAPI }) => { if (target.tagName === "TEXTAREA") { const textArea = target as HTMLTextAreaElement; const { blockUid } = getUids(textArea); - if (textArea.value.startsWith("{{[[DONE]]}}")) { - onDone(blockUid, textArea.value); - } else if (textArea.value.startsWith("{{[[TODO]]}}")) { - onTodo(blockUid, textArea.value); - } + setTimeout(() => { + const value = getTextByBlockUid(blockUid); + if (value.startsWith("{{[[DONE]]}}")) { + triggerOnDone(blockUid, value); + } else if (value.startsWith("{{[[TODO]]}}")) { + triggerOnTodo(blockUid, value); + } + }, ROAM_STATE_SETTLE_MS); return; } - Array.from(document.getElementsByClassName("block-highlight-blue")) + const blockUids = Array.from( + document.getElementsByClassName("block-highlight-blue") + ) .map( (d) => d.getElementsByClassName("roam-block")[0] as HTMLDivElement ) - .map((d) => getUids(d).blockUid) - .map((blockUid) => ({ - blockUid, - text: getTextByBlockUid(blockUid), - })) - .forEach(({ blockUid, text }) => { - if (text.startsWith("{{[[DONE]]}}")) { - onTodo(blockUid, text); - } else if (text.startsWith("{{[[TODO]]}}")) { - onDone(blockUid, text); + .map((d) => getUids(d).blockUid); + setTimeout(() => { + blockUids.forEach((blockUid) => { + const value = getTextByBlockUid(blockUid); + if (!value) return; + if (value.startsWith("{{[[DONE]]}}")) { + triggerOnDone(blockUid, value); + } else if (value.startsWith("{{[[TODO]]}}")) { + triggerOnTodo(blockUid, value); } }); + }, ROAM_STATE_SETTLE_MS); } else { const target = e.target as HTMLElement; if (target.tagName === "TEXTAREA") { - const todoItem = Array.from( - target.parentElement?.querySelectorAll( - ".bp3-text-overflow-ellipsis" - ) || [] - ).find((t) => t.innerText === "TODO"); - if ( - todoItem && - todoItem.parentElement && - getComputedStyle(todoItem.parentElement).backgroundColor === - "rgb(213, 218, 223)" - ) { - const textArea = target as HTMLTextAreaElement; - const { blockUid } = getUids(textArea); - onTodo(blockUid, textArea.value); + const textArea = target as HTMLTextAreaElement; + const beforeValue = textArea.value; + const { blockUid } = getUids(textArea); + if (!beforeValue.startsWith("{{[[TODO]]}}")) { + setTimeout(() => { + const value = getTextByBlockUid(blockUid); + if (value.startsWith("{{[[TODO]]}}")) { + triggerOnTodo(blockUid, value); + } + }, ROAM_STATE_SETTLE_MS); } } } @@ -436,8 +482,17 @@ export default runExtension(async ({ extensionAPI }) => { return { domListeners: [ - { type: "keydown", el: document, listener: keydownEventListener }, + { type: "keydown" as const, el: document, listener: keydownEventListener }, + { type: "click" as const, el: document, listener: clickListener as EventListener }, ], commands: ["Defer TODO"], + unload: () => { + document + .querySelectorAll("[data-todo-trigger-bound]") + .forEach((el) => { + delete el.dataset.todoTriggerBound; + }); + latestHandledTransition.clear(); + }, }; }); diff --git a/src/utils/todont.ts b/src/utils/todont.ts index b6f966e..1dc2963 100644 --- a/src/utils/todont.ts +++ b/src/utils/todont.ts @@ -184,8 +184,7 @@ const initializeTodont = () => { } else if (firstButtonTag === "ARCHIVED") { replaceText({ before: "{{[[ARCHIVED]]}}", - after: "", - prepend: true, + after: "{{[[TODO]]}}", }); } else { replaceText({