From 60d020e5e81642dd7907684f171de90d9ee0684b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:57:41 -0800 Subject: [PATCH 1/7] Fix #4 #8 #10 TODO transition handling --- README.md | 2 +- src/index.ts | 131 ++++++++++++++++++++++++++++---------------- src/utils/todont.ts | 3 +- 3 files changed, 86 insertions(+), 50 deletions(-) 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..e10db4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,27 +271,61 @@ 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 hasHandledRecently = (blockUid: string, state: TodoState) => { + const now = Date.now(); + 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); }); @@ -301,18 +335,21 @@ export default runExtension(async ({ extensionAPI }) => { const clickListener = async (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); + } + }, 50); } }; document.addEventListener("click", clickListener); @@ -325,46 +362,46 @@ 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); + } + }, 50); 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.startsWith("{{[[DONE]]}}")) { + triggerOnDone(blockUid, value); + } else if (value.startsWith("{{[[TODO]]}}")) { + triggerOnTodo(blockUid, value); } }); + }, 50); } 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); + } + }, 50); } } } 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({ From 3df1df5872510330d274efe34aa28772483301a1 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:10:33 -0800 Subject: [PATCH 2/7] refactor: extract setTimeout delay into ROAM_STATE_SETTLE_MS constant Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index e10db4d..7c4c5c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -277,6 +277,7 @@ export default runExtension(async ({ extensionAPI }) => { { 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(); const previous = latestHandledTransition.get(blockUid); @@ -327,7 +328,7 @@ export default runExtension(async ({ extensionAPI }) => { } else { triggerOnTodo(blockUid, value); } - }, 50); + }, ROAM_STATE_SETTLE_MS); }); } }, @@ -349,7 +350,7 @@ export default runExtension(async ({ extensionAPI }) => { if (value.startsWith("{{[[TODO]]}}")) { triggerOnTodo(blockUid, value); } - }, 50); + }, ROAM_STATE_SETTLE_MS); } }; document.addEventListener("click", clickListener); @@ -369,7 +370,7 @@ export default runExtension(async ({ extensionAPI }) => { } else if (value.startsWith("{{[[TODO]]}}")) { triggerOnTodo(blockUid, value); } - }, 50); + }, ROAM_STATE_SETTLE_MS); return; } const blockUids = Array.from( @@ -388,7 +389,7 @@ export default runExtension(async ({ extensionAPI }) => { triggerOnTodo(blockUid, value); } }); - }, 50); + }, ROAM_STATE_SETTLE_MS); } else { const target = e.target as HTMLElement; if (target.tagName === "TEXTAREA") { @@ -401,7 +402,7 @@ export default runExtension(async ({ extensionAPI }) => { if (value.startsWith("{{[[TODO]]}}")) { triggerOnTodo(blockUid, value); } - }, 50); + }, ROAM_STATE_SETTLE_MS); } } } From 958196c4da9bbf2faffa1175f4f09d4d2294996b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:12:59 -0800 Subject: [PATCH 3/7] fix: add periodic cleanup to latestHandledTransition Map to prevent memory leak Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/index.ts b/src/index.ts index 7c4c5c4..f2c581c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,6 +280,13 @@ export default runExtension(async ({ extensionAPI }) => { 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 ( From 75aee619da1301a0ff98755ded27125df4543ba3 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:15:16 -0800 Subject: [PATCH 4/7] fix: register clickListener in cleanup return to prevent leaked listener on unload Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index f2c581c..a2f5a44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -482,6 +482,7 @@ export default runExtension(async ({ extensionAPI }) => { return { domListeners: [ { type: "keydown", el: document, listener: keydownEventListener }, + { type: "click", el: document, listener: clickListener }, ], commands: ["Defer TODO"], }; From c4607b88bc6c81ea982498860bd8ec57374f9e2b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:15:35 -0800 Subject: [PATCH 5/7] fix: clean up todoTriggerBound flags and dedup map on extension unload Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/index.ts b/src/index.ts index a2f5a44..44f272e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -485,5 +485,13 @@ export default runExtension(async ({ extensionAPI }) => { { type: "click", el: document, listener: clickListener }, ], commands: ["Defer TODO"], + unload: () => { + document + .querySelectorAll("[data-todo-trigger-bound]") + .forEach((el) => { + delete el.dataset.todoTriggerBound; + }); + latestHandledTransition.clear(); + }, }; }); From c56d0a8486028e5f14976f1e9a045bde085eab92 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:15:53 -0800 Subject: [PATCH 6/7] fix: guard against empty block text in bulk selection handler Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 44f272e..f4aaa4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -390,6 +390,7 @@ export default runExtension(async ({ extensionAPI }) => { setTimeout(() => { blockUids.forEach((blockUid) => { const value = getTextByBlockUid(blockUid); + if (!value) return; if (value.startsWith("{{[[DONE]]}}")) { triggerOnDone(blockUid, value); } else if (value.startsWith("{{[[TODO]]}}")) { From e4f8d90dc068b7caeb300eb019a72cb8d4ad824b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:16:52 -0800 Subject: [PATCH 7/7] refactor: remove unnecessary async from event listeners Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index f4aaa4c..e4c8e1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -341,7 +341,7 @@ export default runExtension(async ({ extensionAPI }) => { }, }); - const clickListener = async (e: MouseEvent) => { + const clickListener = (e: MouseEvent) => { const target = e.target as HTMLElement; const menuItem = target.closest(".bp3-menu-item") as HTMLElement; const menuLabel = menuItem @@ -362,7 +362,7 @@ export default runExtension(async ({ extensionAPI }) => { }; 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)) { @@ -482,8 +482,8 @@ export default runExtension(async ({ extensionAPI }) => { return { domListeners: [ - { type: "keydown", el: document, listener: keydownEventListener }, - { type: "click", el: document, listener: clickListener }, + { type: "keydown" as const, el: document, listener: keydownEventListener }, + { type: "click" as const, el: document, listener: clickListener as EventListener }, ], commands: ["Defer TODO"], unload: () => {