From 00ab2b9915425e16c220b9d107f6fdabc9ea8c7f Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:19:19 -0800 Subject: [PATCH 1/6] fix: normalize TODO+ARCHIVED prefix on Cmd/Ctrl+Enter When pressing Cmd/Ctrl+Enter on an ARCHIVED block, Roam prepends TODO to get TODO+ARCHIVED. This collapses that into just TODO. Also reads block text from Roam's data layer instead of the textarea DOM value, which can lag behind after API updates. Closes #4 --- src/index.ts | 30 ++++++++++++++++++++---- src/utils/normalizeTodoArchivedPrefix.ts | 7 ++++++ 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 src/utils/normalizeTodoArchivedPrefix.ts diff --git a/src/index.ts b/src/index.ts index e4cd138..e2a3487 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import extractRef from "roamjs-components/util/extractRef"; import extractTag from "roamjs-components/util/extractTag"; import getChildrenLengthByParentUid from "roamjs-components/queries/getChildrenLengthByParentUid"; import initializeTodont, { TODONT_MODES } from "./utils/todont"; +import normalizeTodoArchivedPrefix from "./utils/normalizeTodoArchivedPrefix"; export default runExtension(async ({ extensionAPI }) => { const toggleTodont = initializeTodont(); @@ -128,6 +129,9 @@ export default runExtension(async ({ extensionAPI }) => { } const text = extensionAPI.settings.get("append-text") as string; let value = oldValue; + // Roam's Cmd/Ctrl+Enter prepends TODO for non-checkbox blocks. + // If the block starts with ARCHIVED, collapse TODO+ARCHIVED into TODO. + value = normalizeTodoArchivedPrefix(value); if (text) { const formattedText = ` ${text .replace(new RegExp("\\^", "g"), "\\^") @@ -352,10 +356,28 @@ 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); + // Read from Roam's data layer — the textarea DOM value may lag + // behind after a recent API update (e.g. toggling to ARCHIVED). + const blockText = + getTextByBlockUid(blockUid) || textArea.value; + if (blockText.startsWith("{{[[ARCHIVED]]}}")) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const withoutArchived = blockText.replace( + /^\{\{\[\[ARCHIVED\]\]\}}\s*/, + "", + ); + const normalized = withoutArchived + ? `{{[[TODO]]}} ${withoutArchived}` + : "{{[[TODO]]}}"; + if (normalized !== blockText) { + updateBlock({ uid: blockUid, text: normalized }); + } + } else if (blockText.startsWith("{{[[DONE]]}}")) { + onDone(blockUid, blockText); + } else if (blockText.startsWith("{{[[TODO]]}}")) { + onTodo(blockUid, blockText); } return; } diff --git a/src/utils/normalizeTodoArchivedPrefix.ts b/src/utils/normalizeTodoArchivedPrefix.ts new file mode 100644 index 0000000..27896b1 --- /dev/null +++ b/src/utils/normalizeTodoArchivedPrefix.ts @@ -0,0 +1,7 @@ +const TODO_ARCHIVED_PREFIX_REGEX = + /^(\{\{\[\[TODO\]\]\}})\s*\{\{\[\[ARCHIVED\]\]\}}/; + +const normalizeTodoArchivedPrefix = (value: string): string => + value.replace(TODO_ARCHIVED_PREFIX_REGEX, "$1"); + +export default normalizeTodoArchivedPrefix; From 5021d77b1d7f6b7cf8fbc30f203a75d45ecc9035 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:20:40 -0800 Subject: [PATCH 2/6] fix: improve TODO state detection and prevent duplicate triggers - Use .closest('.bp3-menu-item') for reliable context menu detection instead of fragile parent element traversal - Wrap keydown DONE/TODO detection in setTimeout to read block text after Roam processes state changes, preventing stale reads - Collect multi-select block UIDs before setTimeout to avoid race conditions with DOM changes - Add focusin/focusout tracking to detect manual DONE transitions (blocks edited from non-TODO to DONE now fire onDone callbacks) - Register cleanup for focus listeners on extension unload Closes #8, closes #10 --- src/index.ts | 110 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 28 deletions(-) diff --git a/src/index.ts b/src/index.ts index e2a3487..b6a1127 100644 --- a/src/index.ts +++ b/src/index.ts @@ -302,6 +302,42 @@ export default runExtension(async ({ extensionAPI }) => { return { explode: !!extensionAPI.settings.get("explode") }; }; + type TodoState = "todo" | "done" | "other"; + const getTodoState = (value: string): TodoState => { + if (value.startsWith("{{[[DONE]]}}")) { + return "done"; + } + if (value.startsWith("{{[[TODO]]}}")) { + return "todo"; + } + return "other"; + }; + const initialEditStateByBlock = new Map(); + const focusinListener = (e: FocusEvent) => { + const target = e.target as HTMLElement; + if (target.tagName !== "TEXTAREA") { + return; + } + const textArea = target as HTMLTextAreaElement; + const { blockUid } = getUids(textArea); + const value = getTextByBlockUid(blockUid) || textArea.value; + initialEditStateByBlock.set(blockUid, getTodoState(value)); + }; + const focusoutListener = (e: FocusEvent) => { + const target = e.target as HTMLElement; + if (target.tagName !== "TEXTAREA") { + return; + } + const textArea = target as HTMLTextAreaElement; + const { blockUid } = getUids(textArea); + const initialState = initialEditStateByBlock.get(blockUid) || "other"; + initialEditStateByBlock.delete(blockUid); + const value = getTextByBlockUid(blockUid) || textArea.value || ""; + if (initialState === "other" && getTodoState(value) === "done") { + onDone(blockUid, value); + } + }; + createHTMLObserver({ tag: "LABEL", className: "check-container", @@ -330,26 +366,31 @@ export default runExtension(async ({ extensionAPI }) => { }, }); - 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"); + if (!menuItem) { + return; + } + const menuLabel = menuItem + .querySelector(".bp3-text-overflow-ellipsis") + ?.textContent?.trim(); + if (menuLabel !== "TODO") { + return; + } + const textarea = target + .closest(".roam-block-container") + ?.getElementsByTagName?.("textarea")?.[0]; + if (textarea) { + const { blockUid } = getUids(textarea); + onTodo(blockUid, textarea.value); } }; document.addEventListener("click", clickListener); const keydownEventListener = async (_e: Event) => { const e = _e as KeyboardEvent; + const ROAM_STATE_SETTLE_MS = 50; if (e.key === "Enter") { if (isControl(e)) { const target = e.target as HTMLElement; @@ -374,29 +415,35 @@ export default runExtension(async ({ extensionAPI }) => { if (normalized !== blockText) { updateBlock({ uid: blockUid, text: normalized }); } - } else if (blockText.startsWith("{{[[DONE]]}}")) { - onDone(blockUid, blockText); - } else if (blockText.startsWith("{{[[TODO]]}}")) { - onTodo(blockUid, blockText); + } else { + setTimeout(() => { + const value = getTextByBlockUid(blockUid); + if (value.startsWith("{{[[DONE]]}}")) { + onDone(blockUid, value); + } else if (value.startsWith("{{[[TODO]]}}")) { + onTodo(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.startsWith("{{[[DONE]]}}")) { + onDone(blockUid, value); + } else if (value.startsWith("{{[[TODO]]}}")) { + onTodo(blockUid, value); } }); + }, ROAM_STATE_SETTLE_MS); } else { const target = e.target as HTMLElement; if (target.tagName === "TEXTAREA") { @@ -421,6 +468,8 @@ export default runExtension(async ({ extensionAPI }) => { }; document.addEventListener("keydown", keydownEventListener); + document.addEventListener("focusin", focusinListener, true); + document.addEventListener("focusout", focusoutListener, true); const isStrikethrough = !!extensionAPI.settings.get("strikethrough"); const isClassname = !!extensionAPI.settings.get("classname"); @@ -507,5 +556,10 @@ export default runExtension(async ({ extensionAPI }) => { { type: "keydown", el: document, listener: keydownEventListener }, ], commands: ["Defer TODO"], + unload: () => { + document.removeEventListener("focusin", focusinListener, true); + document.removeEventListener("focusout", focusoutListener, true); + initialEditStateByBlock.clear(); + }, }; }); From 5b89cd57b5c4102879e8dd4dcef77bc391180a4d Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:28:44 -0700 Subject: [PATCH 3/6] fix: address todo state review notes --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index b6a1127..7ae9740 100644 --- a/src/index.ts +++ b/src/index.ts @@ -383,7 +383,7 @@ export default runExtension(async ({ extensionAPI }) => { ?.getElementsByTagName?.("textarea")?.[0]; if (textarea) { const { blockUid } = getUids(textarea); - onTodo(blockUid, textarea.value); + onTodo(blockUid, getTextByBlockUid(blockUid) || textarea.value); } }; document.addEventListener("click", clickListener); @@ -554,6 +554,7 @@ export default runExtension(async ({ extensionAPI }) => { return { domListeners: [ { type: "keydown", el: document, listener: keydownEventListener }, + { type: "click", el: document, listener: clickListener }, ], commands: ["Defer TODO"], unload: () => { From e483f1e464a586c88b0b0c14e10959100c84a47f Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:22:55 -0700 Subject: [PATCH 4/6] fix: avoid duplicate todo state triggers after keyboard toggles --- src/index.ts | 23 ++++++++---------- src/utils/todoStateTracking.ts | 32 ++++++++++++++++++++++++ test/todoStateTracking.test.mjs | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 src/utils/todoStateTracking.ts create mode 100644 test/todoStateTracking.test.mjs diff --git a/src/index.ts b/src/index.ts index 7ae9740..f8f54b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,12 @@ import extractTag from "roamjs-components/util/extractTag"; import getChildrenLengthByParentUid from "roamjs-components/queries/getChildrenLengthByParentUid"; import initializeTodont, { TODONT_MODES } from "./utils/todont"; import normalizeTodoArchivedPrefix from "./utils/normalizeTodoArchivedPrefix"; +import { + captureInitialTodoState, + markHandledTodoState, + shouldHandleManualDoneOnFocusout, +} from "./utils/todoStateTracking"; +import type { TodoState } from "./utils/todoStateTracking"; export default runExtension(async ({ extensionAPI }) => { const toggleTodont = initializeTodont(); @@ -302,16 +308,6 @@ export default runExtension(async ({ extensionAPI }) => { return { explode: !!extensionAPI.settings.get("explode") }; }; - type TodoState = "todo" | "done" | "other"; - const getTodoState = (value: string): TodoState => { - if (value.startsWith("{{[[DONE]]}}")) { - return "done"; - } - if (value.startsWith("{{[[TODO]]}}")) { - return "todo"; - } - return "other"; - }; const initialEditStateByBlock = new Map(); const focusinListener = (e: FocusEvent) => { const target = e.target as HTMLElement; @@ -321,7 +317,7 @@ export default runExtension(async ({ extensionAPI }) => { const textArea = target as HTMLTextAreaElement; const { blockUid } = getUids(textArea); const value = getTextByBlockUid(blockUid) || textArea.value; - initialEditStateByBlock.set(blockUid, getTodoState(value)); + captureInitialTodoState(initialEditStateByBlock, blockUid, value); }; const focusoutListener = (e: FocusEvent) => { const target = e.target as HTMLElement; @@ -330,10 +326,10 @@ export default runExtension(async ({ extensionAPI }) => { } const textArea = target as HTMLTextAreaElement; const { blockUid } = getUids(textArea); - const initialState = initialEditStateByBlock.get(blockUid) || "other"; + const initialState = initialEditStateByBlock.get(blockUid); initialEditStateByBlock.delete(blockUid); const value = getTextByBlockUid(blockUid) || textArea.value || ""; - if (initialState === "other" && getTodoState(value) === "done") { + if (shouldHandleManualDoneOnFocusout(initialState, value)) { onDone(blockUid, value); } }; @@ -420,6 +416,7 @@ export default runExtension(async ({ extensionAPI }) => { const value = getTextByBlockUid(blockUid); if (value.startsWith("{{[[DONE]]}}")) { onDone(blockUid, value); + markHandledTodoState(initialEditStateByBlock, blockUid, value); } else if (value.startsWith("{{[[TODO]]}}")) { onTodo(blockUid, value); } diff --git a/src/utils/todoStateTracking.ts b/src/utils/todoStateTracking.ts new file mode 100644 index 0000000..3f84a21 --- /dev/null +++ b/src/utils/todoStateTracking.ts @@ -0,0 +1,32 @@ +export type TodoState = "todo" | "done" | "other"; + +export const getTodoState = (value: string): TodoState => { + if (value.startsWith("{{[[DONE]]}}")) { + return "done"; + } + if (value.startsWith("{{[[TODO]]}}")) { + return "todo"; + } + return "other"; +}; + +export const captureInitialTodoState = ( + trackedStates: Map, + blockUid: string, + value: string, +): void => { + trackedStates.set(blockUid, getTodoState(value)); +}; + +export const markHandledTodoState = ( + trackedStates: Map, + blockUid: string, + value: string, +): void => { + trackedStates.set(blockUid, getTodoState(value)); +}; + +export const shouldHandleManualDoneOnFocusout = ( + initialState: TodoState | undefined, + value: string, +): boolean => (initialState || "other") === "other" && getTodoState(value) === "done"; diff --git a/test/todoStateTracking.test.mjs b/test/todoStateTracking.test.mjs new file mode 100644 index 0000000..f913435 --- /dev/null +++ b/test/todoStateTracking.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + captureInitialTodoState, + getTodoState, + markHandledTodoState, + shouldHandleManualDoneOnFocusout, +} from "../src/utils/todoStateTracking.ts"; + +test("getTodoState classifies todo prefixes", () => { + assert.equal(getTodoState("{{[[TODO]]}} task"), "todo"); + assert.equal(getTodoState("{{[[DONE]]}} task"), "done"); + assert.equal(getTodoState("plain task"), "other"); +}); + +test("handled keyboard toggles update tracked state", () => { + const trackedStates = new Map(); + captureInitialTodoState(trackedStates, "abc", "plain task"); + + markHandledTodoState(trackedStates, "abc", "{{[[DONE]]}} task"); + + assert.equal(trackedStates.get("abc"), "done"); + assert.equal( + shouldHandleManualDoneOnFocusout( + trackedStates.get("abc"), + "{{[[DONE]]}} task", + ), + false, + ); +}); + +test("focusout still fires for untouched other to done edits", () => { + const trackedStates = new Map(); + captureInitialTodoState(trackedStates, "abc", "plain task"); + + assert.equal( + shouldHandleManualDoneOnFocusout( + trackedStates.get("abc"), + "{{[[DONE]]}} task", + ), + true, + ); +}); From 252487b578c788bdefaf4283af31d197ea918aa4 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Tue, 31 Mar 2026 04:54:24 -0700 Subject: [PATCH 5/6] fix: widen clickListener param to Event for domListeners type compat The domListeners Registry type expects (this: Document, ev: DocumentEventMap[...]) => void, which is incompatible with (e: MouseEvent) => void due to parameter contravariance. Cast to MouseEvent inside, matching the keydownEventListener pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index f8f54b2..27401f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -362,8 +362,8 @@ export default runExtension(async ({ extensionAPI }) => { }, }); - const clickListener = (e: MouseEvent) => { - const target = e.target as HTMLElement; + const clickListener = (_e: Event) => { + const target = (_e as MouseEvent).target as HTMLElement; const menuItem = target.closest(".bp3-menu-item"); if (!menuItem) { return; From 65e3c8ff0c5521961cb9db001162a27cf2e5ef04 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Tue, 31 Mar 2026 05:04:44 -0700 Subject: [PATCH 6/6] fix: revert TODO/DONE toggle to read textarea value synchronously MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 50ms setTimeout + getTextByBlockUid approach caused a regression where Roam's data layer hadn't settled, leading to stale pre-toggle reads — onDone fired repeatedly instead of toggling, duplicating append text. Reverting to textArea.value (which Roam updates synchronously) fixes the toggle cycle. The ARCHIVED handler still uses getTextByBlockUid since textarea lags for that case. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 43 ++++++++++++++++++------------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/src/index.ts b/src/index.ts index 27401f4..f05396c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -386,15 +386,14 @@ export default runExtension(async ({ extensionAPI }) => { const keydownEventListener = async (_e: Event) => { const e = _e as KeyboardEvent; - const ROAM_STATE_SETTLE_MS = 50; if (e.key === "Enter") { if (isControl(e)) { const target = e.target as HTMLElement; if (target.tagName === "TEXTAREA") { const textArea = target as HTMLTextAreaElement; const { blockUid } = getUids(textArea); - // Read from Roam's data layer — the textarea DOM value may lag - // behind after a recent API update (e.g. toggling to ARCHIVED). + // Check data layer for ARCHIVED state — the textarea DOM value + // may lag behind after a recent API update. const blockText = getTextByBlockUid(blockUid) || textArea.value; if (blockText.startsWith("{{[[ARCHIVED]]}}")) { @@ -411,36 +410,30 @@ export default runExtension(async ({ extensionAPI }) => { if (normalized !== blockText) { updateBlock({ uid: blockUid, text: normalized }); } - } else { - setTimeout(() => { - const value = getTextByBlockUid(blockUid); - if (value.startsWith("{{[[DONE]]}}")) { - onDone(blockUid, value); - markHandledTodoState(initialEditStateByBlock, blockUid, value); - } else if (value.startsWith("{{[[TODO]]}}")) { - onTodo(blockUid, value); - } - }, ROAM_STATE_SETTLE_MS); + } else if (textArea.value.startsWith("{{[[DONE]]}}")) { + onDone(blockUid, textArea.value); + markHandledTodoState(initialEditStateByBlock, blockUid, textArea.value); + } else if (textArea.value.startsWith("{{[[TODO]]}}")) { + onTodo(blockUid, textArea.value); } return; } - const blockUids = Array.from( - document.getElementsByClassName("block-highlight-blue"), - ) + Array.from(document.getElementsByClassName("block-highlight-blue")) .map( (d) => d.getElementsByClassName("roam-block")[0] as HTMLDivElement, ) - .map((d) => getUids(d).blockUid); - setTimeout(() => { - blockUids.forEach((blockUid) => { - const value = getTextByBlockUid(blockUid); - if (value.startsWith("{{[[DONE]]}}")) { - onDone(blockUid, value); - } else if (value.startsWith("{{[[TODO]]}}")) { - onTodo(blockUid, value); + .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); } }); - }, ROAM_STATE_SETTLE_MS); } else { const target = e.target as HTMLElement; if (target.tagName === "TEXTAREA") {