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 ( +