Skip to content
73 changes: 44 additions & 29 deletions src/HotKeyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import React, { useMemo, useState, useRef, useEffect } from "react";
import MenuItemSelect from "roamjs-components/components/MenuItemSelect";
import { getCleanCustomWorkflows } from "./utils/core";
import type { OnloadArgs } from "roamjs-components/types/native";
import getNextAvailableHotKey from "./utils/getNextAvailableHotKey";

const HotKeyEntry = ({
hotkey,
value,
order,
keys,
setKeys,
extensionAPI,
workflows,
Expand All @@ -17,8 +17,7 @@ const HotKeyEntry = ({
hotkey: string;
value: string;
order: number;
keys: Record<string, string>;
setKeys: (r: Record<string, string>) => void;
setKeys: React.Dispatch<React.SetStateAction<Record<string, string>>>;
extensionAPI: OnloadArgs["extensionAPI"];
workflows: { uid: string }[];
workflowNamesByUid: Record<string, string>;
Expand Down Expand Up @@ -53,16 +52,21 @@ const HotKeyEntry = ({
: parts.concat(e.key.toLowerCase())
).join("+");
if (formatValue === hotkey) return;
const error = !formatValue || !!keys[formatValue];
const newKeys = Object.fromEntries(
Object.entries(keys).map((k, o) =>
o !== order ? k : [formatValue, k[1]]
)
);
setKeys(newKeys);
if (!error) {
setKeys((currentKeys) => {
if (
!formatValue ||
(formatValue !== hotkey && !!currentKeys[formatValue])
) {
return currentKeys;
}
const newKeys = Object.fromEntries(
Object.entries(currentKeys).map((k, o) =>
o !== order ? k : [formatValue, k[1]]
)
);
extensionAPI.settings.set("hot-keys", newKeys);
}
return newKeys;
});
}}
intent={Intent.NONE}
/>
Expand All @@ -73,11 +77,15 @@ const HotKeyEntry = ({
activeItem={value}
items={workflows.map((w) => w.uid)}
onItemSelect={(e) => {
const newKeys = Object.fromEntries(
Object.entries(keys).map((k, o) => (o !== order ? k : [k[0], e]))
);
setKeys(newKeys);
extensionAPI.settings.set("hot-keys", newKeys);
setKeys((currentKeys) => {
const newKeys = Object.fromEntries(
Object.entries(currentKeys).map((k, o) =>
o !== order ? k : [k[0], e]
)
);
extensionAPI.settings.set("hot-keys", newKeys);
return newKeys;
});
}}
transformItem={(e) => workflowNamesByUid[e]}
className={"w-full"}
Expand All @@ -89,11 +97,13 @@ const HotKeyEntry = ({
style={{ width: 32, height: 32 }}
minimal
onClick={() => {
const newKeys = Object.fromEntries(
Object.entries(keys).filter((_, o) => o !== order)
);
setKeys(newKeys);
extensionAPI.settings.set("hot-keys", newKeys);
setKeys((currentKeys) => {
const newKeys = Object.fromEntries(
Object.entries(currentKeys).filter((_, o) => o !== order)
);
extensionAPI.settings.set("hot-keys", newKeys);
return newKeys;
});
}}
/>
</div>
Expand Down Expand Up @@ -126,14 +136,13 @@ const HotKeyPanel = (extensionAPI: OnloadArgs["extensionAPI"]) => () => {
{Object.entries(keys).map(([key, value], order) => {
return (
<HotKeyEntry
key={order}
key={`${key}-${order}`}
hotkey={key}
value={value}
order={order}
workflows={workflows}
workflowNamesByUid={workflowNamesByUid}
extensionAPI={extensionAPI}
keys={keys}
setKeys={setKeys}
/>
);
Expand All @@ -145,13 +154,19 @@ const HotKeyPanel = (extensionAPI: OnloadArgs["extensionAPI"]) => () => {
minimal
style={{ marginTop: 8 }}
onClick={async () => {
if (!workflows.length) return;
const randomWorkflow =
workflows[Math.floor(Math.random() * workflows.length)];
const newKeys = Object.fromEntries(
Object.entries(keys).concat([["control+o", randomWorkflow.uid]])
);
setKeys(newKeys);
extensionAPI.settings.set("hot-keys", newKeys);
setKeys((currentKeys) => {
const nextHotkey = getNextAvailableHotKey(currentKeys);
const newKeys = Object.fromEntries(
Object.entries(currentKeys).concat([
[nextHotkey, randomWorkflow.uid],
])
);
extensionAPI.settings.set("hot-keys", newKeys);
return newKeys;
});
Comment on lines +160 to +169
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

getNextAvailableHotKey can throw inside a state updater, which may crash the component.

If all 144 hotkey combinations are exhausted, getNextAvailableHotKey throws. Since this throw occurs inside the setKeys callback (a React state updater), React will propagate it as an unhandled error, potentially unmounting the component tree. Consider catching the error and showing a user-friendly notification instead.

🛡️ Proposed fix
          setKeys((currentKeys) => {
+            let nextHotkey: string;
+            try {
+              nextHotkey = getNextAvailableHotKey(currentKeys);
+            } catch {
+              // All combinations exhausted — return state unchanged
+              return currentKeys;
+            }
-            const nextHotkey = getNextAvailableHotKey(currentKeys);
             const newKeys = Object.fromEntries(
               Object.entries(currentKeys).concat([
                 [nextHotkey, randomWorkflow.uid],
               ])
             );
             extensionAPI.settings.set("hot-keys", newKeys);
             return newKeys;
           });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setKeys((currentKeys) => {
const nextHotkey = getNextAvailableHotKey(currentKeys);
const newKeys = Object.fromEntries(
Object.entries(currentKeys).concat([
[nextHotkey, randomWorkflow.uid],
])
);
extensionAPI.settings.set("hot-keys", newKeys);
return newKeys;
});
setKeys((currentKeys) => {
let nextHotkey: string;
try {
nextHotkey = getNextAvailableHotKey(currentKeys);
} catch {
// All combinations exhausted — return state unchanged
return currentKeys;
}
const newKeys = Object.fromEntries(
Object.entries(currentKeys).concat([
[nextHotkey, randomWorkflow.uid],
])
);
extensionAPI.settings.set("hot-keys", newKeys);
return newKeys;
});
🤖 Prompt for AI Agents
In `@src/HotKeyPanel.tsx` around lines 160 - 169, The callback passed to setKeys
calls getNextAvailableHotKey which can throw and crash React; wrap the call to
getNextAvailableHotKey in a try/catch inside the setKeys updater (the function
that currently builds newKeys and calls extensionAPI.settings.set) and on error
do not throw—show a user-friendly notification (or toast) and return currentKeys
unchanged so state and settings are not mutated; only call
extensionAPI.settings.set("hot-keys", newKeys) when getNextAvailableHotKey
succeeds. Ensure you reference getNextAvailableHotKey, setKeys,
extensionAPI.settings.set, and randomWorkflow.uid when locating the code.

}}
/>
</div>
Expand Down
2 changes: 0 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ export default runExtension(async ({ extensionAPI }) => {
.trim();
triggerRegex = new RegExp(`${trigger}(.*)$`);
};

let isCustomOnly = extensionAPI.settings.get("custom-only") as boolean;
let hideButtonIcon = extensionAPI.settings.get("hide-button-icon") as boolean;
let highlighting = extensionAPI.settings.get("highlighting") as boolean;
Expand Down Expand Up @@ -301,7 +300,6 @@ export default runExtension(async ({ extensionAPI }) => {
commandPaletteEnabled = !!extensionAPI.settings.get("command-palette");
syncCommandPaletteCommands();
refreshTrigger(extensionAPI.settings.get("trigger") as string);

const customCommands: { text: string; help: string }[] = [];

window.roamjs.extension.smartblocks = {
Expand Down
64 changes: 31 additions & 33 deletions src/utils/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import apiPost from "roamjs-components/util/apiPost";
import deleteBlock from "roamjs-components/writes/deleteBlock";
import { zCommandOutput } from "./zodTypes";
import { z } from "zod";
import splitSmartBlockArgs from "./splitSmartBlockArgs";

type FormDialogProps = Parameters<typeof FormDialog>[0];
const renderFormDialog = createOverlayRender<FormDialogProps>(
Expand Down Expand Up @@ -128,6 +129,17 @@ const getDateFromBlock = (args: { text: string; title: string }) => {
if (fromTitle) return window.roamAlphaAPI.util.pageTitleToDate(fromTitle);
return new Date("");
};

const parseBlockMentionsDatedArg = (dateArg: string, referenceDate: Date) => {
const normalizedArg = dateArg.trim().replace(/^first of\b/i, "start of");
const title =
DAILY_REF_REGEX.exec(normalizedArg)?.[1] ||
DAILY_NOTE_PAGE_TITLE_REGEX.exec(extractTag(normalizedArg))?.[0];
return title
? window.roamAlphaAPI.util.pageTitleToDate(title) ||
parseNlpDate(normalizedArg, referenceDate)
: parseNlpDate(normalizedArg, referenceDate);
};
const getPageUidByBlockUid = (blockUid: string): string =>
(
window.roamAlphaAPI.q(
Expand Down Expand Up @@ -974,6 +986,8 @@ export const COMMANDS: {
title,
submitButtonText,
cancelButtonText,
// Prevent keyboard input leaking to Roam while form is open (#128, #130)
enforceFocus: true,
})
).then((values) => {
if (!values) {
Expand Down Expand Up @@ -1052,7 +1066,7 @@ export const COMMANDS: {
},
{
text: "BREADCRUMBS",
help: "Returns a list of parent block refs to a given block ref\n\n1: Block reference\n\n2: Separator used between block references",
help: "Returns page title and parent block refs to a given block ref\n\n1: Block reference\nPrefix with + to return page title only\nPrefix with - to return parent chain only\n\n2: Separator used between block references",
handler: (uidArg = "", ...delim) => {
const separator = delim.join(",") || " > ";
const uid = uidArg.replace(/^(\+|-)?\(\(/, "").replace(/\)\)$/, "");
Expand Down Expand Up @@ -1218,11 +1232,11 @@ export const COMMANDS: {
const undated = startArg === "-1" && endArg === "-1";
const start =
!undated && startArg && startArg !== "0"
? startOfDay(parseNlpDate(startArg, referenceDate))
? startOfDay(parseBlockMentionsDatedArg(startArg, referenceDate))
: new Date(0);
const end =
!undated && endArg && endArg !== "0"
? endOfDay(parseNlpDate(endArg, referenceDate))
? endOfDay(parseBlockMentionsDatedArg(endArg, referenceDate))
: new Date(9999, 11, 31);
const limit = Number(limitArg);
const title = extractTag(titleArg);
Expand Down Expand Up @@ -2479,24 +2493,7 @@ const processBlockTextToPromises = (s: string) => {
const split = c.value.indexOf(":");
const cmd = split < 0 ? c.value : c.value.substring(0, split);
const afterColon = split < 0 ? "" : c.value.substring(split + 1);
let commandStack = 0;
const args = afterColon.split("").reduce((prev, cur, i, arr) => {
if (cur === "," && !commandStack && arr[i - 1] !== "\\") {
return [...prev, ""];
} else if (cur === "\\" && arr[i + 1] === ",") {
return prev;
} else {
if (cur === "%") {
if (arr[i - 1] === "<") {
commandStack++;
} else if (arr[i + 1] === ">") {
commandStack--;
}
}
const current = prev.slice(-1)[0] || "";
return [...prev.slice(0, -1), `${current}${cur}`];
}
}, [] as string[]);
const args = splitSmartBlockArgs(cmd, afterColon);
const { handler, delayArgs, illegal } = handlerByCommand[cmd] || {};
if (illegal) smartBlocksContext.illegalCommands.add(cmd);
return (
Expand Down Expand Up @@ -2805,19 +2802,20 @@ export const sbBomb = async ({
.findIndex(
(c, i) => c !== (props.introContent || "").charAt(i)
);
const finalText = `${
indexDiffered < 0
? textPostProcess
: textPostProcess.slice(0, indexDiffered)
}${firstChild.text || ""}${
indexDiffered < 0
? ""
: textPostProcess.substring(indexDiffered)
}`;
return updateBlock({
...firstChild,
uid,
text: `${
indexDiffered < 0
? textPostProcess
: textPostProcess.slice(0, indexDiffered)
}${firstChild.text || ""}${
indexDiffered < 0
? ""
: textPostProcess.substring(indexDiffered)
}`,
});
...firstChild,
uid,
text: finalText,
});
})
.then(() =>
Promise.all(
Expand Down
21 changes: 21 additions & 0 deletions src/utils/getNextAvailableHotKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const HOTKEY_MODIFIERS = ["control", "alt", "shift", "meta"];

// Ordered by ergonomic preference: right-hand home row first (o, p, k, l),
// then surrounding keys, then left hand, then digits.
const HOTKEY_SUFFIXES = "opklijuyhgfdsawertqzxcvbnm1234567890".split("");

const getNextAvailableHotKey = (keys: Record<string, string>) => {
for (const modifier of HOTKEY_MODIFIERS) {
for (const suffix of HOTKEY_SUFFIXES) {
const candidate = `${modifier}+${suffix}`;
if (!keys[candidate]) {
return candidate;
}
}
}
throw new Error(
"All hotkey combinations are in use. Remove an existing hotkey first."
);
};

export default getNextAvailableHotKey;
72 changes: 72 additions & 0 deletions src/utils/splitSmartBlockArgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const MONTH_DAY_REGEX =
/^(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:st|nd|rd|th)?$/i;
const YEAR_REGEX = /^\d{4}$/;

/**
* Re-joins "Month Day, Year" date tokens that were split on commas.
*
* BLOCKMENTIONSDATED signature: (limit, title, startDate, endDate, sort, format, ...search)
* Positions 0-1 (limit, title) are passed through unchanged.
* Starting at position 2, up to 2 date tokens are coalesced (startDate + endDate).
* The `mergedDates < 2` guard stops coalescing after both date slots are filled.
*/
const coalesceBlockMentionsDatedDates = (args: string[]) => {
const merged = args.slice(0, 2);
let i = 2;
let mergedDates = 0;
while (i < args.length) {
const current = args[i] || "";
const next = args[i + 1];
if (
mergedDates < 2 &&
typeof next === "string" &&
MONTH_DAY_REGEX.test(current.trim()) &&
YEAR_REGEX.test(next.trim())
) {
merged.push(`${current.trimEnd()}, ${next.trimStart()}`);
i += 2;
mergedDates += 1;
} else {
merged.push(current);
i += 1;
}
}
return merged;
};

const splitSmartBlockArgs = (cmd: string, afterColon: string) => {
let commandStack = 0;
let pageRefStack = 0;
const args = [] as string[];
for (let i = 0; i < afterColon.length; i += 1) {
const cur = afterColon[i];
const prev = afterColon[i - 1];
const next = afterColon[i + 1];
if (cur === "%" && prev === "<") {
commandStack += 1;
} else if (cur === "%" && next === ">" && commandStack) {
commandStack -= 1;
} else if (cur === "[" && next === "[") {
pageRefStack += 1;
} else if (cur === "]" && prev === "]" && pageRefStack) {
pageRefStack -= 1;
}
if (cur === "," && !commandStack && !pageRefStack && prev !== "\\") {
args.push("");
continue;
} else if (cur === "\\" && next === ",") {
continue;
}
const current = args[args.length - 1] || "";
if (!args.length) {
args.push(cur);
} else {
args[args.length - 1] = `${current}${cur}`;
}
}
return cmd.toUpperCase() === "BLOCKMENTIONSDATED"
? coalesceBlockMentionsDatedDates(args)
: args;
};

export default splitSmartBlockArgs;
25 changes: 25 additions & 0 deletions tests/hotKeyPanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { test, expect } from "@playwright/test";
import getNextAvailableHotKey from "../src/utils/getNextAvailableHotKey";

test("picks next control hotkey when control+o is taken", () => {
expect(getNextAvailableHotKey({ "control+o": "uid-1" })).toBe("control+p");
});

test("falls back to alt hotkeys when control combos are exhausted", () => {
const taken = Object.fromEntries(
"opklijuyhgfdsawertqzxcvbnm1234567890"
.split("")
.map((k, i) => [`control+${k}`, `uid-${i}`])
);
expect(getNextAvailableHotKey(taken)).toBe("alt+o");
});

test("throws when all modifier+suffix combos are exhausted", () => {
const allTaken: Record<string, string> = {};
for (const mod of ["control", "alt", "shift", "meta"]) {
for (const key of "opklijuyhgfdsawertqzxcvbnm1234567890".split("")) {
allTaken[`${mod}+${key}`] = `uid-${mod}-${key}`;
}
}
expect(() => getNextAvailableHotKey(allTaken)).toThrow();
});
Loading
Loading