Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 82 additions & 15 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ 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";
import {
captureInitialTodoState,
markHandledTodoState,
shouldHandleManualDoneOnFocusout,
} from "./utils/todoStateTracking";
import type { TodoState } from "./utils/todoStateTracking";

export default runExtension(async ({ extensionAPI }) => {
const toggleTodont = initializeTodont();
Expand Down Expand Up @@ -128,6 +135,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"), "\\^")
Expand Down Expand Up @@ -298,6 +308,32 @@ export default runExtension(async ({ extensionAPI }) => {
return { explode: !!extensionAPI.settings.get("explode") };
};

const initialEditStateByBlock = new Map<string, TodoState>();
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;
captureInitialTodoState(initialEditStateByBlock, blockUid, 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);
initialEditStateByBlock.delete(blockUid);
const value = getTextByBlockUid(blockUid) || textArea.value || "";
if (shouldHandleManualDoneOnFocusout(initialState, value)) {
onDone(blockUid, value);
}
};

createHTMLObserver({
tag: "LABEL",
className: "check-container",
Expand Down Expand Up @@ -326,20 +362,24 @@ 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 clickListener = (_e: Event) => {
const target = (_e as MouseEvent).target as HTMLElement;
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, getTextByBlockUid(blockUid) || textarea.value);
}
};
document.addEventListener("click", clickListener);
Expand All @@ -352,8 +392,27 @@ export default runExtension(async ({ extensionAPI }) => {
if (target.tagName === "TEXTAREA") {
const textArea = target as HTMLTextAreaElement;
const { blockUid } = getUids(textArea);
if (textArea.value.startsWith("{{[[DONE]]}}")) {
// 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]]}}")) {
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 (textArea.value.startsWith("{{[[DONE]]}}")) {
onDone(blockUid, textArea.value);
markHandledTodoState(initialEditStateByBlock, blockUid, textArea.value);
} else if (textArea.value.startsWith("{{[[TODO]]}}")) {
onTodo(blockUid, textArea.value);
}
Expand Down Expand Up @@ -399,6 +458,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");
Expand Down Expand Up @@ -483,7 +544,13 @@ export default runExtension(async ({ extensionAPI }) => {
return {
domListeners: [
{ type: "keydown", el: document, listener: keydownEventListener },
{ type: "click", el: document, listener: clickListener },
],
commands: ["Defer TODO"],
unload: () => {
document.removeEventListener("focusin", focusinListener, true);
document.removeEventListener("focusout", focusoutListener, true);
initialEditStateByBlock.clear();
},
};
});
7 changes: 7 additions & 0 deletions src/utils/normalizeTodoArchivedPrefix.ts
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 32 additions & 0 deletions src/utils/todoStateTracking.ts
Original file line number Diff line number Diff line change
@@ -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<string, TodoState>,
blockUid: string,
value: string,
): void => {
trackedStates.set(blockUid, getTodoState(value));
};

export const markHandledTodoState = (
trackedStates: Map<string, TodoState>,
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";
43 changes: 43 additions & 0 deletions test/todoStateTracking.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});