diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx
index bead7d019..3cf66ce3d 100644
--- a/apps/roam/src/components/canvas/Tldraw.tsx
+++ b/apps/roam/src/components/canvas/Tldraw.tsx
@@ -145,7 +145,9 @@ const TldrawCanvas = ({ title }: { title: string }) => {
if (cancelled) return;
if (!ready) {
- console.warn("Plugin timer timeout — proceeding with canvas mount anyway.");
+ console.warn(
+ "Plugin timer timeout — proceeding with canvas mount anyway.",
+ );
// Optional: dispatchToastEvent({ id: 'tldraw-plugin-timer-timeout', title: 'Timed out waiting for plugin init', severity: 'warning' })
}
diff --git a/apps/roam/src/components/canvas/uiOverrides.tsx b/apps/roam/src/components/canvas/uiOverrides.tsx
index 6753164fa..ec45515d8 100644
--- a/apps/roam/src/components/canvas/uiOverrides.tsx
+++ b/apps/roam/src/components/canvas/uiOverrides.tsx
@@ -32,6 +32,7 @@ import {
useValue,
useToasts,
} from "tldraw";
+import { IKeyCombo } from "@blueprintjs/core";
import { DiscourseNode } from "~/utils/getDiscourseNodes";
import { getNewDiscourseNodeText } from "~/utils/formatUtils";
import createDiscourseNode from "~/utils/createDiscourseNode";
@@ -45,6 +46,9 @@ import { AddReferencedNodeType } from "./DiscourseRelationShape/DiscourseRelatio
import { dispatchToastEvent } from "./ToastListener";
import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil";
import DiscourseGraphPanel from "./DiscourseToolPanel";
+import { convertComboToTldrawFormat } from "~/utils/keyboardShortcutUtils";
+import { DISCOURSE_TOOL_SHORTCUT_KEY } from "~/data/userSettings";
+import { getSetting } from "~/utils/extensionSettings";
const convertToDiscourseNode = async ({
text,
@@ -326,11 +330,20 @@ export const createUiOverrides = ({
setConvertToDialogOpen: (open: boolean) => void;
}): TLUiOverrides => ({
tools: (editor, tools) => {
+ // Get the custom keyboard shortcut for the discourse tool
+ const discourseToolCombo = getSetting(DISCOURSE_TOOL_SHORTCUT_KEY, {
+ key: "",
+ modifiers: 0,
+ }) as IKeyCombo;
+
+ // For discourse tool, just use the key directly since we don't allow modifiers
+ const discourseToolShortcut = discourseToolCombo?.key?.toUpperCase() || "";
+
tools["discourse-tool"] = {
id: "discourse-tool",
icon: "none",
label: "tool.discourse-tool" as TLUiTranslationKey,
- kbd: "",
+ kbd: discourseToolShortcut,
readonlyOk: true,
onSelect: () => {
editor.setCurrentTool("discourse-tool");
diff --git a/apps/roam/src/components/settings/HomePersonalSettings.tsx b/apps/roam/src/components/settings/HomePersonalSettings.tsx
index 467b91244..77f64cedd 100644
--- a/apps/roam/src/components/settings/HomePersonalSettings.tsx
+++ b/apps/roam/src/components/settings/HomePersonalSettings.tsx
@@ -13,7 +13,11 @@ import {
hideDiscourseFloatingMenu,
} from "~/components/DiscourseFloatingMenu";
import { NodeSearchMenuTriggerSetting } from "../DiscourseNodeSearchMenu";
-import { AUTO_CANVAS_RELATIONS_KEY } from "~/data/userSettings";
+import {
+ AUTO_CANVAS_RELATIONS_KEY,
+ DISCOURSE_TOOL_SHORTCUT_KEY,
+} from "~/data/userSettings";
+import KeyboardShortcutInput from "./KeyboardShortcutInput";
const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
const extensionAPI = onloadArgs.extensionAPI;
@@ -39,6 +43,13 @@ const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
/>
+
{
+ const platform =
+ typeof navigator !== "undefined" ? navigator.platform : undefined;
+ return platform == null ? false : /Mac|iPod|iPhone|iPad/.test(platform);
+};
+
+const MODIFIER_BIT_MASKS = {
+ alt: 1,
+ ctrl: 2,
+ meta: 4,
+ shift: 8,
+};
+
+const ALIASES: { [key: string]: string } = {
+ cmd: "meta",
+ command: "meta",
+ escape: "esc",
+ minus: "-",
+ mod: isMac() ? "meta" : "ctrl",
+ option: "alt",
+ plus: "+",
+ return: "enter",
+ win: "meta",
+};
+
+const normalizeKeyCombo = (combo: string) => {
+ const keys = combo.replace(/\s/g, "").split("+");
+ return keys.map((key) => {
+ const keyName = ALIASES[key] != null ? ALIASES[key] : key;
+ return keyName === "meta" ? (isMac() ? "cmd" : "win") : keyName;
+ });
+};
+
+const getModifiersFromCombo = (comboKey: IKeyCombo) => {
+ if (!comboKey) return [];
+ return [
+ comboKey.modifiers & MODIFIER_BIT_MASKS.alt && "alt",
+ comboKey.modifiers & MODIFIER_BIT_MASKS.ctrl && "ctrl",
+ comboKey.modifiers & MODIFIER_BIT_MASKS.shift && "shift",
+ comboKey.modifiers & MODIFIER_BIT_MASKS.meta && "meta",
+ ].filter(Boolean);
+};
+
+const KeyboardShortcutInput = ({
+ onloadArgs,
+ settingKey,
+ label,
+ description,
+ placeholder = "Click to set shortcut...",
+}: KeyboardShortcutInputProps) => {
+ const extensionAPI = onloadArgs.extensionAPI;
+ const inputRef = useRef(null);
+ const [isActive, setIsActive] = useState(false);
+ const [comboKey, setComboKey] = useState(
+ () =>
+ (extensionAPI.settings.get(settingKey) as IKeyCombo) || {
+ modifiers: 0,
+ key: "",
+ },
+ );
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ // Allow focus navigation & cancel without intercepting
+ if (e.key === "Tab") return;
+ if (e.key === "Escape") {
+ inputRef.current?.blur();
+ return;
+ }
+ e.stopPropagation();
+ e.preventDefault();
+ // For discourse tool, only allow single keys without modifiers
+ if (settingKey === DISCOURSE_TOOL_SHORTCUT_KEY) {
+ // Ignore modifier keys
+ if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
+ return;
+ }
+
+ // Only allow single character keys
+ if (e.key.length === 1) {
+ const comboObj = { key: e.key.toLowerCase(), modifiers: 0 };
+ setComboKey(comboObj);
+ extensionAPI.settings
+ .set(settingKey, comboObj)
+ .catch(() => console.error("Failed to set setting"));
+ }
+ return;
+ }
+
+ // For other shortcuts, use the full Blueprint logic
+ const comboObj = getKeyCombo(e.nativeEvent);
+ if (!comboObj.key) return;
+
+ setComboKey({ key: comboObj.key, modifiers: comboObj.modifiers });
+ extensionAPI.settings
+ .set(settingKey, comboObj)
+ .catch(() => console.error("Failed to set setting"));
+ },
+ [extensionAPI, settingKey],
+ );
+
+ const shortcut = useMemo(() => {
+ if (!comboKey.key) return "";
+
+ const modifiers = getModifiersFromCombo(comboKey);
+ const comboString = [...modifiers, comboKey.key].join("+");
+ return normalizeKeyCombo(comboString).join("+");
+ }, [comboKey]);
+
+ const handleClear = useCallback(() => {
+ setComboKey({ modifiers: 0, key: "" });
+ extensionAPI.settings
+ .set(settingKey, { modifiers: 0, key: "" })
+ .catch(() => console.error("Failed to set setting"));
+ }, [extensionAPI, settingKey]);
+
+ return (
+
+ );
+};
+
+export default KeyboardShortcutInput;
diff --git a/apps/roam/src/data/userSettings.ts b/apps/roam/src/data/userSettings.ts
index 1fab6bd06..89cd8298a 100644
--- a/apps/roam/src/data/userSettings.ts
+++ b/apps/roam/src/data/userSettings.ts
@@ -4,3 +4,4 @@ export const HIDE_METADATA_KEY = "hide-metadata";
export const DEFAULT_FILTERS_KEY = "default-filters";
export const QUERY_BUILDER_SETTINGS_KEY = "query-builder-settings";
export const AUTO_CANVAS_RELATIONS_KEY = "auto-canvas-relations";
+export const DISCOURSE_TOOL_SHORTCUT_KEY = "discourse-tool-shortcut";
diff --git a/apps/roam/src/utils/keyboardShortcutUtils.ts b/apps/roam/src/utils/keyboardShortcutUtils.ts
new file mode 100644
index 000000000..8500b7aa9
--- /dev/null
+++ b/apps/roam/src/utils/keyboardShortcutUtils.ts
@@ -0,0 +1,56 @@
+import { IKeyCombo } from "@blueprintjs/core";
+
+/**
+ * Convert Blueprint IKeyCombo to tldraw keyboard shortcut format
+ *
+ * tldraw format examples:
+ * - "?C" = Ctrl+C
+ * - "$!X" = Shift+Ctrl+X
+ * - "!3" = F3
+ * - "^A" = Alt+A
+ * - "@S" = Cmd+S (Mac) / Win+S (Windows)
+ */
+export const convertComboToTldrawFormat = (
+ combo: IKeyCombo | undefined,
+): string => {
+ if (!combo || !combo.key) return "";
+
+ const modifiers = [];
+ if (combo.modifiers & 2) modifiers.push("?"); // Ctrl
+ if (combo.modifiers & 8) modifiers.push("$"); // Shift
+ if (combo.modifiers & 1) modifiers.push("^"); // Alt
+ if (combo.modifiers & 4) modifiers.push("@"); // Meta/Cmd
+
+ return modifiers.join("") + combo.key.toUpperCase();
+};
+
+/**
+ * Convert tldraw keyboard shortcut format to Blueprint IKeyCombo
+ * This is useful for testing and validation
+ */
+export const convertTldrawFormatToCombo = (shortcut: string): IKeyCombo => {
+ if (!shortcut) return { modifiers: 0, key: "" };
+
+ let modifiers = 0;
+ let key = shortcut;
+
+ // Extract modifiers
+ if (shortcut.includes("?")) {
+ modifiers |= 2; // Ctrl
+ key = key.replace("?", "");
+ }
+ if (shortcut.includes("$")) {
+ modifiers |= 8; // Shift
+ key = key.replace("$", "");
+ }
+ if (shortcut.includes("^")) {
+ modifiers |= 1; // Alt
+ key = key.replace("^", "");
+ }
+ if (shortcut.includes("@")) {
+ modifiers |= 4; // Meta/Cmd
+ key = key.replace("@", "");
+ }
+
+ return { modifiers, key: key.toLowerCase() };
+};