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/4] 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/4] 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 6723dc7fe1c906214d0aa50323e02d8c331f7070 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:23:20 -0800 Subject: [PATCH 3/4] feat: refactor TODONT module with configurable hotkey and bulk archive - Accept extensionAPI parameter, return { toggle, cleanup } for proper lifecycle management - Register Archive command via extensionAPI.ui.commandPalette.addCommand with configurable hotkey (replaces raw keydown listener) - Add archiveBlock helper using updateBlock for multi-select support - Use CSS ::before pseudo-element for ARCHIVED button styling (keeps text as "ARCHIVED" for accessibility, hides visually) - Replace styleArchivedButtons with syncArchivedButton/syncArchivedButtons pattern (handles added Text nodes, instanceof checks) - Add null-safety guards for location in replaceText and previousActiveElement - Move TODONT Mode setting higher in settings panel - Add settings caret style fix - Guard Cmd/Ctrl+Shift+Enter in keydown (reserve for Archive command) - Initialize default todont-mode if not set - Add cleanupTodont() to extension unload --- src/index.ts | 62 +++++--- src/utils/todont.ts | 349 ++++++++++++++++++++++++++------------------ 2 files changed, 255 insertions(+), 156 deletions(-) diff --git a/src/index.ts b/src/index.ts index b6a1127..b58b596 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,16 @@ import initializeTodont, { TODONT_MODES } from "./utils/todont"; import normalizeTodoArchivedPrefix from "./utils/normalizeTodoArchivedPrefix"; export default runExtension(async ({ extensionAPI }) => { - const toggleTodont = initializeTodont(); + const { toggle: toggleTodont, cleanup: cleanupTodont } = + initializeTodont(extensionAPI); + const getTodontMode = (): (typeof TODONT_MODES)[number] => { + const configuredMode = extensionAPI.settings.get("todont-mode"); + return TODONT_MODES.includes( + configuredMode as (typeof TODONT_MODES)[number], + ) + ? (configuredMode as (typeof TODONT_MODES)[number]) + : "icon"; + }; extensionAPI.settings.panel.create({ tabTitle: "TODO Trigger", settings: [ @@ -44,6 +53,18 @@ export default runExtension(async ({ extensionAPI }) => { "The set of pairs that you would want to be replaced upon switching between todo and done", action: { type: "input", placeholder: "#toRead, #Read" }, }, + { + id: "todont-mode", + name: "TODONT Mode", + description: + "Whether to incorporate styling when TODOS turn into ARCHIVED buttons.", + action: { + type: "select", + items: TODONT_MODES.slice(0), + onChange: (e) => + toggleTodont(e.target.value as (typeof TODONT_MODES)[number]), + }, + }, { id: "ignore-tags", name: "Ignore Tags", @@ -87,20 +108,23 @@ export default runExtension(async ({ extensionAPI }) => { placeholder: "Block reference or page name", }, }, - { - id: "todont-mode", - name: "TODONT Mode", - description: - "Whether to incorporate styling when TODOS turn into ARCHIVED buttons.", - action: { - type: "select", - items: TODONT_MODES.slice(0), - onChange: (e) => - toggleTodont(e.target.value as (typeof TODONT_MODES)[number]), - }, - }, ], }); + const settingsCaretStyle = document.createElement("style"); + settingsCaretStyle.textContent = ` +.rm-settings .bp3-button .bp3-icon-caret-up { + transform: rotate(180deg); +} +`; + document.head.appendChild(settingsCaretStyle); + + if ( + !TODONT_MODES.includes( + extensionAPI.settings.get("todont-mode") as (typeof TODONT_MODES)[number], + ) + ) { + await extensionAPI.settings.set("todont-mode", "icon"); + } const CLASSNAMES_TO_CHECK = [ "rm-block-ref", @@ -393,6 +417,10 @@ export default runExtension(async ({ extensionAPI }) => { const ROAM_STATE_SETTLE_MS = 50; if (e.key === "Enter") { if (isControl(e)) { + // Cmd/Ctrl+Shift+Enter is reserved for the Archive TODO command. + if (e.shiftKey) { + return; + } const target = e.target as HTMLElement; if (target.tagName === "TEXTAREA") { const textArea = target as HTMLTextAreaElement; @@ -545,11 +573,7 @@ export default runExtension(async ({ extensionAPI }) => { } addDeferTODOsCommand(); - toggleTodont( - (extensionAPI.settings.get( - "todont-mode", - ) as (typeof TODONT_MODES)[number]) || "off", - ); + toggleTodont(getTodontMode()); return { domListeners: [ @@ -557,6 +581,8 @@ export default runExtension(async ({ extensionAPI }) => { ], commands: ["Defer TODO"], unload: () => { + cleanupTodont(); + settingsCaretStyle.remove(); document.removeEventListener("focusin", focusinListener, true); document.removeEventListener("focusout", focusoutListener, true); initialEditStateByBlock.clear(); diff --git a/src/utils/todont.ts b/src/utils/todont.ts index b6f966e..a2615d8 100644 --- a/src/utils/todont.ts +++ b/src/utils/todont.ts @@ -1,13 +1,8 @@ -import { createConfigObserver } from "roamjs-components/components/ConfigPage"; -import getSubTree from "roamjs-components/util/getSubTree"; -import runExtension from "roamjs-components/util/runExtension"; -import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel"; -import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; import createHTMLObserver from "roamjs-components/dom/createHTMLObserver"; import createObserver from "roamjs-components/dom/createObserver"; -import toFlexRegex from "roamjs-components/util/toFlexRegex"; -import isControl from "roamjs-components/util/isControl"; import getUids from "roamjs-components/dom/getUids"; +import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; +import updateBlock from "roamjs-components/writes/updateBlock"; import { OnloadArgs } from "roamjs-components/types"; const CLASSNAMES_TO_CHECK = [ @@ -55,6 +50,7 @@ export const replaceText = ({ : `${oldValue}${after}` : oldValue.replace(`${before}${!after && prepend ? " " : ""}`, after); const location = window.roamAlphaAPI.ui.getFocusedBlock(); + if (!location) return; const blockUid = location["block-uid"]; window.roamAlphaAPI.updateBlock({ block: { string: text, uid: blockUid } }); const diff = text.length - oldValue.length; @@ -62,7 +58,7 @@ export const replaceText = ({ let index = 0; const maxIndex = Math.min( Math.max(oldValue.length, text.length), - Math.max(start, end) + 1 + Math.max(start, end) + 1, ); for (index = 0; index < maxIndex; index++) { if (oldValue.charAt(index) !== text.charAt(index)) { @@ -82,13 +78,103 @@ export const replaceText = ({ export const TODONT_MODES = ["off", "icon", "strikethrough"] as const; -const initializeTodont = () => { +const ARCHIVE_COMMAND_LABEL = "TODONT Hotkey"; + +const initializeTodont = (extensionAPI: OnloadArgs["extensionAPI"]) => { const unloads = new Set<() => void>(); - return async (todontMode: typeof TODONT_MODES[number]) => { - if (todontMode !== "off") { - const TODONT_CLASSNAME = "roamjs-todont"; - const css = document.createElement("style"); - css.textContent = `.bp3-button.bp3-small.${TODONT_CLASSNAME} { + const cleanup = () => { + unloads.forEach((u) => u()); + unloads.clear(); + }; + + const archiveBlock = (blockUid: string, value: string) => { + const firstButtonTag = /{{\[\[([A-Z]{4,8})\]\]}}/.exec(value)?.[1]; + let newText: string; + if (firstButtonTag === "TODO") { + newText = value.replace("{{[[TODO]]}}", "{{[[ARCHIVED]]}}"); + } else if (firstButtonTag === "DONE") { + newText = value.replace("{{[[DONE]]}}", "{{[[ARCHIVED]]}}"); + } else if (firstButtonTag === "ARCHIVED") { + newText = value.replace(/^\{\{\[\[ARCHIVED\]\]\}}\s*/, ""); + } else { + newText = `{{[[ARCHIVED]]}} ${value}`; + } + if (newText !== value) { + updateBlock({ uid: blockUid, text: newText }); + } + }; + + const todontCallback = () => { + const selectedBlocks = Array.from( + document.getElementsByClassName("block-highlight-blue"), + ); + if (selectedBlocks.length > 0) { + selectedBlocks + .map( + (d) => d.getElementsByClassName("roam-block")[0] as HTMLDivElement, + ) + .filter((d) => !!d) + .forEach((d) => { + const { blockUid } = getUids(d); + const value = getTextByBlockUid(blockUid); + if (value) { + archiveBlock(blockUid, value); + } + }); + return; + } + if (document.activeElement?.tagName === "TEXTAREA") { + const textArea = document.activeElement as HTMLTextAreaElement; + const firstButtonTag = /{{\[\[([A-Z]{4,8})\]\]}}/.exec( + textArea.value, + )?.[1]; + if (firstButtonTag === "TODO") { + replaceText({ before: "{{[[TODO]]}}", after: "{{[[ARCHIVED]]}}" }); + } else if (firstButtonTag === "DONE") { + replaceText({ before: "{{[[DONE]]}}", after: "{{[[ARCHIVED]]}}" }); + } else if (firstButtonTag === "ARCHIVED") { + replaceText({ + before: "{{[[ARCHIVED]]}}", + after: "", + prepend: true, + }); + } else { + replaceText({ + before: "", + prepend: true, + after: "{{[[ARCHIVED]]}}", + }); + } + } + }; + + const toggle = (todontMode: typeof TODONT_MODES[number]) => { + cleanup(); + + const defaultArchiveHotkey = /Mac|iPhone|iPad|iPod/i.test( + navigator.platform, + ) + ? "cmd+shift+enter" + : "ctrl+shift+enter"; + extensionAPI.ui.commandPalette.addCommand({ + label: ARCHIVE_COMMAND_LABEL, + callback: todontCallback, + defaultHotkey: defaultArchiveHotkey, + disableHotkey: false, + }); + unloads.add(() => { + extensionAPI.ui.commandPalette.removeCommand({ + label: ARCHIVE_COMMAND_LABEL, + }); + }); + + if (todontMode === "off") { + return; + } + + const TODONT_CLASSNAME = "roamjs-todont"; + const css = document.createElement("style"); + css.textContent = `.bp3-button.bp3-small.${TODONT_CLASSNAME} { padding: 0; min-height: 0; min-width: 0; @@ -97,149 +183,136 @@ const initializeTodont = () => { height: 14px; border-radius: 4px; width: 14px; - color: #2E2E2E; + color: transparent; background-color: #EF5151; border: #EA666656; border-width: 0 2px 2px 0; + font-size: 0; + line-height: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.bp3-button.bp3-small.${TODONT_CLASSNAME}::before { + content: "x"; + color: #2E2E2E; + font-size: 12px; + line-height: 14px; + font-weight: 700; }`; - document.getElementsByTagName("head")[0].appendChild(css); - unloads.add(() => { - css.remove(); - }); + document.getElementsByTagName("head")[0].appendChild(css); + unloads.add(() => { + css.remove(); + }); - const styleArchivedButtons = (node: HTMLElement) => { - const buttons = node.getElementsByTagName("button"); - Array.from(buttons).forEach((button) => { - if ( - button.innerText === "ARCHIVED" && - button.className.indexOf(TODONT_CLASSNAME) < 0 - ) { - button.innerText = "x"; - button.className = `${button.className} ${TODONT_CLASSNAME}`; - } - }); - }; - styleArchivedButtons(document.body); - unloads.add(() => { - document - .querySelectorAll(`.${TODONT_CLASSNAME}`) - .forEach((b) => { - if (b.innerText === "x") { - b.innerText = "ARCHIVED"; - } - b.classList.remove(TODONT_CLASSNAME); - }); + const isArchivedButton = (button: HTMLButtonElement): boolean => + /\bARCHIVED\b/i.test(button.textContent || ""); + const syncArchivedButton = (button: HTMLButtonElement) => { + if (isArchivedButton(button)) { + button.classList.add(TODONT_CLASSNAME); + } else { + button.classList.remove(TODONT_CLASSNAME); + } + }; + const syncArchivedButtons = (node?: HTMLElement | null) => { + if (!node) { + return; + } + if (node instanceof HTMLButtonElement) { + syncArchivedButton(node); + return; + } + node.querySelectorAll("button").forEach(syncArchivedButton); + }; + syncArchivedButtons(document.body); + unloads.add(() => { + document.querySelectorAll(`.${TODONT_CLASSNAME}`).forEach((b) => { + b.classList.remove(TODONT_CLASSNAME); }); + }); - let previousActiveElement: HTMLElement; - const todontIconButton = createMobileIcon( - "mobile-todont-icon-button", - "minus-square" - ); - todontIconButton.onclick = () => { - if (previousActiveElement.tagName === "TEXTAREA") { - previousActiveElement.focus(); - todontCallback(); - } - }; + let previousActiveElement: HTMLElement | null = null; + const todontIconButton = createMobileIcon( + "mobile-todont-icon-button", + "minus-square", + ); + todontIconButton.onclick = () => { + if (previousActiveElement?.tagName === "TEXTAREA") { + previousActiveElement.focus(); + todontCallback(); + } + }; - todontIconButton.onmousedown = () => { - previousActiveElement = document.activeElement as HTMLElement; - }; - unloads.add(() => { - todontIconButton.remove(); - }); + todontIconButton.onmousedown = () => { + previousActiveElement = document.activeElement as HTMLElement; + }; + unloads.add(() => { + todontIconButton.remove(); + }); - const iconObserver = createObserver((mutationList: MutationRecord[]) => { - mutationList.forEach((record) => { - styleArchivedButtons(record.target as HTMLElement); - }); - const mobileBackButton = document.getElementById( - "mobile-back-icon-button" - ); - if ( - !!mobileBackButton && - !document.getElementById("mobile-todont-icon-button") - ) { - const mobileBar = document.getElementById("rm-mobile-bar"); - if (mobileBar) { - mobileBar.insertBefore(todontIconButton, mobileBackButton); - } + const iconObserver = createObserver((mutationList: MutationRecord[]) => { + mutationList.forEach((record) => { + if (record.target instanceof HTMLElement) { + syncArchivedButtons(record.target); } - }); - unloads.add(() => { - iconObserver.disconnect(); - }); - - const todontCallback = () => { - if (document.activeElement.tagName === "TEXTAREA") { - const textArea = document.activeElement as HTMLTextAreaElement; - const firstButtonTag = /{{\[\[([A-Z]{4,8})\]\]}}/.exec( - textArea.value - )?.[1]; - if (firstButtonTag === "TODO") { - replaceText({ before: "{{[[TODO]]}}", after: "{{[[ARCHIVED]]}}" }); - } else if (firstButtonTag === "DONE") { - replaceText({ before: "{{[[DONE]]}}", after: "{{[[ARCHIVED]]}}" }); - } else if (firstButtonTag === "ARCHIVED") { - replaceText({ - before: "{{[[ARCHIVED]]}}", - after: "", - prepend: true, - }); - } else { - replaceText({ - before: "", - prepend: true, - after: "{{[[ARCHIVED]]}}", - }); + record.addedNodes.forEach((node) => { + if (node instanceof HTMLElement) { + syncArchivedButtons(node); + } else if (node instanceof Text) { + syncArchivedButtons(node.parentElement); } + }); + }); + const mobileBackButton = document.getElementById( + "mobile-back-icon-button", + ); + if ( + !!mobileBackButton && + !document.getElementById("mobile-todont-icon-button") + ) { + const mobileBar = document.getElementById("rm-mobile-bar"); + if (mobileBar) { + mobileBar.insertBefore(todontIconButton, mobileBackButton); } - }; + } + }); + unloads.add(() => { + iconObserver.disconnect(); + }); - const keydownEventListener = async (e: KeyboardEvent) => { - if (e.key === "Enter" && e.shiftKey && isControl(e)) { - todontCallback(); + if (todontMode === "strikethrough") { + const styleBlock = (block?: HTMLElement) => { + if (block) { + block.style.textDecoration = "line-through"; } }; - - document.addEventListener("keydown", keydownEventListener); - unloads.add(() => { - document.removeEventListener("keydown", keydownEventListener); - }); - - if (todontMode === "strikethrough") { - const styleBlock = (block?: HTMLElement) => { + const strikethroughObserver = createHTMLObserver({ + callback: (b: HTMLButtonElement) => { + const zoom = b.closest(".rm-zoom-item-content") as HTMLSpanElement; + if (zoom) { + styleBlock( + zoom.firstElementChild?.firstElementChild as HTMLDivElement, + ); + return; + } + const block = CLASSNAMES_TO_CHECK.map( + (c) => b.closest(`.${c}`) as HTMLElement, + ).find((d) => !!d); if (block) { - block.style.textDecoration = "line-through"; + styleBlock(block); } - }; - const strikethroughObserver = createHTMLObserver({ - callback: (b: HTMLButtonElement) => { - const zoom = b.closest(".rm-zoom-item-content") as HTMLSpanElement; - if (zoom) { - styleBlock( - zoom.firstElementChild.firstElementChild as HTMLDivElement - ); - return; - } - const block = CLASSNAMES_TO_CHECK.map( - (c) => b.closest(`.${c}`) as HTMLElement - ).find((d) => !!d); - if (block) { - styleBlock(block); - } - }, - tag: "BUTTON", - className: TODONT_CLASSNAME, - }); - unloads.add(() => strikethroughObserver.disconnect()); - } - } else { - unloads.forEach((u) => u()); - unloads.clear(); + }, + tag: "BUTTON", + className: TODONT_CLASSNAME, + }); + unloads.add(() => strikethroughObserver.disconnect()); } }; + + return { + toggle, + cleanup, + }; }; export default initializeTodont; From 5d8af05fed04b8c768071773ad6f4d276938397c Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:25:40 -0700 Subject: [PATCH 4/4] fix: address todont cleanup review notes --- src/index.ts | 1 + src/utils/todont.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index b58b596..0b4a9c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -578,6 +578,7 @@ export default runExtension(async ({ extensionAPI }) => { return { domListeners: [ { type: "keydown", el: document, listener: keydownEventListener }, + { type: "click", el: document, listener: clickListener }, ], commands: ["Defer TODO"], unload: () => { diff --git a/src/utils/todont.ts b/src/utils/todont.ts index a2615d8..7c9d464 100644 --- a/src/utils/todont.ts +++ b/src/utils/todont.ts @@ -83,7 +83,9 @@ const ARCHIVE_COMMAND_LABEL = "TODONT Hotkey"; const initializeTodont = (extensionAPI: OnloadArgs["extensionAPI"]) => { const unloads = new Set<() => void>(); const cleanup = () => { - unloads.forEach((u) => u()); + unloads.forEach((u) => { + void u(); + }); unloads.clear(); }; @@ -151,9 +153,15 @@ const initializeTodont = (extensionAPI: OnloadArgs["extensionAPI"]) => { const toggle = (todontMode: typeof TODONT_MODES[number]) => { cleanup(); - const defaultArchiveHotkey = /Mac|iPhone|iPad|iPod/i.test( - navigator.platform, - ) + const platform = + ( + navigator as Navigator & { + userAgentData?: { platform?: string }; + } + ).userAgentData?.platform || + navigator.platform || + ""; + const defaultArchiveHotkey = /Mac|iPhone|iPad|iPod/i.test(platform) ? "cmd+shift+enter" : "ctrl+shift+enter"; extensionAPI.ui.commandPalette.addCommand({