From 823bc6a08fad93218912e6db48833c3a28f07bb6 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 25 Aug 2025 21:13:44 -0600 Subject: [PATCH 1/4] Enhance Tldraw component with keyboard shortcut support for Discourse Tool - Added a new setting for the Discourse Tool keyboard shortcut in HomePersonalSettings. - Updated uiOverrides to utilize the custom keyboard shortcut for the Discourse Tool. - Improved warning message formatting in Tldraw component for better readability. - Introduced DISCOURSE_TOOL_SHORTCUT_KEY in userSettings for better configuration management. --- apps/roam/src/components/canvas/Tldraw.tsx | 4 +- .../src/components/canvas/uiOverrides.tsx | 15 +- .../settings/HomePersonalSettings.tsx | 13 +- .../settings/KeyboardShortcutInput.tsx | 153 ++++++++++++++++++ apps/roam/src/data/userSettings.ts | 1 + apps/roam/src/utils/keyboardShortcutUtils.ts | 56 +++++++ 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 apps/roam/src/components/settings/KeyboardShortcutInput.tsx create mode 100644 apps/roam/src/utils/keyboardShortcutUtils.ts 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) => { + 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, "") + .catch(() => console.error("Failed to set setting")); + }, [extensionAPI, settingKey]); + + return ( +
+
+ {label} + +
+ setIsActive(true)} + onBlur={() => setIsActive(false)} + rightElement={ +
+ ); +}; + +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() }; +}; From 7682e61953d0250fbd914175cafdbd897226ed1e Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 25 Aug 2025 21:21:22 -0600 Subject: [PATCH 2/4] Refactor KeyboardShortcutInput component to use Label for better accessibility - Replaced the div wrapper with Label to enhance semantic structure. - Maintained existing functionality while improving the component's accessibility and styling consistency. --- .../settings/KeyboardShortcutInput.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/roam/src/components/settings/KeyboardShortcutInput.tsx b/apps/roam/src/components/settings/KeyboardShortcutInput.tsx index 831f0879b..d4ef60b06 100644 --- a/apps/roam/src/components/settings/KeyboardShortcutInput.tsx +++ b/apps/roam/src/components/settings/KeyboardShortcutInput.tsx @@ -1,6 +1,12 @@ import React, { useState, useCallback, useMemo, useRef } from "react"; import { OnloadArgs } from "roamjs-components/types"; -import { InputGroup, Button, getKeyCombo, IKeyCombo } from "@blueprintjs/core"; +import { + InputGroup, + Button, + getKeyCombo, + IKeyCombo, + Label, +} from "@blueprintjs/core"; import Description from "roamjs-components/components/Description"; import { DISCOURSE_TOOL_SHORTCUT_KEY } from "~/data/userSettings"; @@ -125,11 +131,9 @@ const KeyboardShortcutInput = ({ }, [extensionAPI, settingKey]); return ( -
-
- {label} - -
+
+ ); }; From e29522c6229dc03352f95d9a98ae912e52abca22 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 25 Aug 2025 22:06:49 -0600 Subject: [PATCH 3/4] Update apps/roam/src/components/settings/KeyboardShortcutInput.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../roam/src/components/settings/KeyboardShortcutInput.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/settings/KeyboardShortcutInput.tsx b/apps/roam/src/components/settings/KeyboardShortcutInput.tsx index d4ef60b06..c4bedfd03 100644 --- a/apps/roam/src/components/settings/KeyboardShortcutInput.tsx +++ b/apps/roam/src/components/settings/KeyboardShortcutInput.tsx @@ -82,9 +82,14 @@ const KeyboardShortcutInput = ({ 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 From ec997bbc7311598b4683e1f9692ebbcce7fc825c Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 25 Aug 2025 22:07:24 -0600 Subject: [PATCH 4/4] Update apps/roam/src/components/settings/KeyboardShortcutInput.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/roam/src/components/settings/KeyboardShortcutInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/roam/src/components/settings/KeyboardShortcutInput.tsx b/apps/roam/src/components/settings/KeyboardShortcutInput.tsx index c4bedfd03..cfddeb1d6 100644 --- a/apps/roam/src/components/settings/KeyboardShortcutInput.tsx +++ b/apps/roam/src/components/settings/KeyboardShortcutInput.tsx @@ -131,7 +131,7 @@ const KeyboardShortcutInput = ({ const handleClear = useCallback(() => { setComboKey({ modifiers: 0, key: "" }); extensionAPI.settings - .set(settingKey, "") + .set(settingKey, { modifiers: 0, key: "" }) .catch(() => console.error("Failed to set setting")); }, [extensionAPI, settingKey]);