Skip to content

Commit 9d38651

Browse files
ENG-775 - Add keyboard shortcut for base discourse tool (#378)
* 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. * 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. * Update apps/roam/src/components/settings/KeyboardShortcutInput.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update apps/roam/src/components/settings/KeyboardShortcutInput.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent fa89522 commit 9d38651

File tree

6 files changed

+248
-3
lines changed

6 files changed

+248
-3
lines changed

apps/roam/src/components/canvas/Tldraw.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ const TldrawCanvas = ({ title }: { title: string }) => {
145145
if (cancelled) return;
146146

147147
if (!ready) {
148-
console.warn("Plugin timer timeout — proceeding with canvas mount anyway.");
148+
console.warn(
149+
"Plugin timer timeout — proceeding with canvas mount anyway.",
150+
);
149151
// Optional: dispatchToastEvent({ id: 'tldraw-plugin-timer-timeout', title: 'Timed out waiting for plugin init', severity: 'warning' })
150152
}
151153

apps/roam/src/components/canvas/uiOverrides.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
useValue,
3333
useToasts,
3434
} from "tldraw";
35+
import { IKeyCombo } from "@blueprintjs/core";
3536
import { DiscourseNode } from "~/utils/getDiscourseNodes";
3637
import { getNewDiscourseNodeText } from "~/utils/formatUtils";
3738
import createDiscourseNode from "~/utils/createDiscourseNode";
@@ -45,6 +46,9 @@ import { AddReferencedNodeType } from "./DiscourseRelationShape/DiscourseRelatio
4546
import { dispatchToastEvent } from "./ToastListener";
4647
import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil";
4748
import DiscourseGraphPanel from "./DiscourseToolPanel";
49+
import { convertComboToTldrawFormat } from "~/utils/keyboardShortcutUtils";
50+
import { DISCOURSE_TOOL_SHORTCUT_KEY } from "~/data/userSettings";
51+
import { getSetting } from "~/utils/extensionSettings";
4852

4953
const convertToDiscourseNode = async ({
5054
text,
@@ -326,11 +330,20 @@ export const createUiOverrides = ({
326330
setConvertToDialogOpen: (open: boolean) => void;
327331
}): TLUiOverrides => ({
328332
tools: (editor, tools) => {
333+
// Get the custom keyboard shortcut for the discourse tool
334+
const discourseToolCombo = getSetting(DISCOURSE_TOOL_SHORTCUT_KEY, {
335+
key: "",
336+
modifiers: 0,
337+
}) as IKeyCombo;
338+
339+
// For discourse tool, just use the key directly since we don't allow modifiers
340+
const discourseToolShortcut = discourseToolCombo?.key?.toUpperCase() || "";
341+
329342
tools["discourse-tool"] = {
330343
id: "discourse-tool",
331344
icon: "none",
332345
label: "tool.discourse-tool" as TLUiTranslationKey,
333-
kbd: "",
346+
kbd: discourseToolShortcut,
334347
readonlyOk: true,
335348
onSelect: () => {
336349
editor.setCurrentTool("discourse-tool");

apps/roam/src/components/settings/HomePersonalSettings.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import {
1313
hideDiscourseFloatingMenu,
1414
} from "~/components/DiscourseFloatingMenu";
1515
import { NodeSearchMenuTriggerSetting } from "../DiscourseNodeSearchMenu";
16-
import { AUTO_CANVAS_RELATIONS_KEY } from "~/data/userSettings";
16+
import {
17+
AUTO_CANVAS_RELATIONS_KEY,
18+
DISCOURSE_TOOL_SHORTCUT_KEY,
19+
} from "~/data/userSettings";
20+
import KeyboardShortcutInput from "./KeyboardShortcutInput";
1721

1822
const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
1923
const extensionAPI = onloadArgs.extensionAPI;
@@ -39,6 +43,13 @@ const HomePersonalSettings = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => {
3943
/>
4044
<NodeSearchMenuTriggerSetting onloadArgs={onloadArgs} />
4145
</Label>
46+
<KeyboardShortcutInput
47+
onloadArgs={onloadArgs}
48+
settingKey={DISCOURSE_TOOL_SHORTCUT_KEY}
49+
label="Discourse Tool Keyboard Shortcut"
50+
description="Set a single key to activate the Discourse Tool in tldraw. Only single keys (no modifiers) are supported. Leave empty for no shortcut."
51+
placeholder="Click to set single key..."
52+
/>
4253
<Checkbox
4354
defaultChecked={
4455
extensionAPI.settings.get("discourse-context-overlay") as boolean
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import React, { useState, useCallback, useMemo, useRef } from "react";
2+
import { OnloadArgs } from "roamjs-components/types";
3+
import {
4+
InputGroup,
5+
Button,
6+
getKeyCombo,
7+
IKeyCombo,
8+
Label,
9+
} from "@blueprintjs/core";
10+
import Description from "roamjs-components/components/Description";
11+
import { DISCOURSE_TOOL_SHORTCUT_KEY } from "~/data/userSettings";
12+
13+
type KeyboardShortcutInputProps = {
14+
onloadArgs: OnloadArgs;
15+
settingKey: string;
16+
label: string;
17+
description: string;
18+
placeholder?: string;
19+
};
20+
21+
// Reuse the keyboard combo utilities from NodeMenuTriggerComponent
22+
const isMac = () => {
23+
const platform =
24+
typeof navigator !== "undefined" ? navigator.platform : undefined;
25+
return platform == null ? false : /Mac|iPod|iPhone|iPad/.test(platform);
26+
};
27+
28+
const MODIFIER_BIT_MASKS = {
29+
alt: 1,
30+
ctrl: 2,
31+
meta: 4,
32+
shift: 8,
33+
};
34+
35+
const ALIASES: { [key: string]: string } = {
36+
cmd: "meta",
37+
command: "meta",
38+
escape: "esc",
39+
minus: "-",
40+
mod: isMac() ? "meta" : "ctrl",
41+
option: "alt",
42+
plus: "+",
43+
return: "enter",
44+
win: "meta",
45+
};
46+
47+
const normalizeKeyCombo = (combo: string) => {
48+
const keys = combo.replace(/\s/g, "").split("+");
49+
return keys.map((key) => {
50+
const keyName = ALIASES[key] != null ? ALIASES[key] : key;
51+
return keyName === "meta" ? (isMac() ? "cmd" : "win") : keyName;
52+
});
53+
};
54+
55+
const getModifiersFromCombo = (comboKey: IKeyCombo) => {
56+
if (!comboKey) return [];
57+
return [
58+
comboKey.modifiers & MODIFIER_BIT_MASKS.alt && "alt",
59+
comboKey.modifiers & MODIFIER_BIT_MASKS.ctrl && "ctrl",
60+
comboKey.modifiers & MODIFIER_BIT_MASKS.shift && "shift",
61+
comboKey.modifiers & MODIFIER_BIT_MASKS.meta && "meta",
62+
].filter(Boolean);
63+
};
64+
65+
const KeyboardShortcutInput = ({
66+
onloadArgs,
67+
settingKey,
68+
label,
69+
description,
70+
placeholder = "Click to set shortcut...",
71+
}: KeyboardShortcutInputProps) => {
72+
const extensionAPI = onloadArgs.extensionAPI;
73+
const inputRef = useRef<HTMLInputElement>(null);
74+
const [isActive, setIsActive] = useState(false);
75+
const [comboKey, setComboKey] = useState<IKeyCombo>(
76+
() =>
77+
(extensionAPI.settings.get(settingKey) as IKeyCombo) || {
78+
modifiers: 0,
79+
key: "",
80+
},
81+
);
82+
83+
const handleKeyDown = useCallback(
84+
(e: React.KeyboardEvent) => {
85+
// Allow focus navigation & cancel without intercepting
86+
if (e.key === "Tab") return;
87+
if (e.key === "Escape") {
88+
inputRef.current?.blur();
89+
return;
90+
}
91+
e.stopPropagation();
92+
e.preventDefault();
93+
// For discourse tool, only allow single keys without modifiers
94+
if (settingKey === DISCOURSE_TOOL_SHORTCUT_KEY) {
95+
// Ignore modifier keys
96+
if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
97+
return;
98+
}
99+
100+
// Only allow single character keys
101+
if (e.key.length === 1) {
102+
const comboObj = { key: e.key.toLowerCase(), modifiers: 0 };
103+
setComboKey(comboObj);
104+
extensionAPI.settings
105+
.set(settingKey, comboObj)
106+
.catch(() => console.error("Failed to set setting"));
107+
}
108+
return;
109+
}
110+
111+
// For other shortcuts, use the full Blueprint logic
112+
const comboObj = getKeyCombo(e.nativeEvent);
113+
if (!comboObj.key) return;
114+
115+
setComboKey({ key: comboObj.key, modifiers: comboObj.modifiers });
116+
extensionAPI.settings
117+
.set(settingKey, comboObj)
118+
.catch(() => console.error("Failed to set setting"));
119+
},
120+
[extensionAPI, settingKey],
121+
);
122+
123+
const shortcut = useMemo(() => {
124+
if (!comboKey.key) return "";
125+
126+
const modifiers = getModifiersFromCombo(comboKey);
127+
const comboString = [...modifiers, comboKey.key].join("+");
128+
return normalizeKeyCombo(comboString).join("+");
129+
}, [comboKey]);
130+
131+
const handleClear = useCallback(() => {
132+
setComboKey({ modifiers: 0, key: "" });
133+
extensionAPI.settings
134+
.set(settingKey, { modifiers: 0, key: "" })
135+
.catch(() => console.error("Failed to set setting"));
136+
}, [extensionAPI, settingKey]);
137+
138+
return (
139+
<Label>
140+
{label}
141+
<Description description={description} />
142+
<InputGroup
143+
inputRef={inputRef}
144+
placeholder={isActive ? "Press keys ..." : placeholder}
145+
value={shortcut}
146+
onKeyDown={handleKeyDown}
147+
onFocus={() => setIsActive(true)}
148+
onBlur={() => setIsActive(false)}
149+
rightElement={
150+
<Button
151+
hidden={!comboKey.key}
152+
icon="remove"
153+
onClick={handleClear}
154+
minimal
155+
/>
156+
}
157+
/>
158+
</Label>
159+
);
160+
};
161+
162+
export default KeyboardShortcutInput;

apps/roam/src/data/userSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export const HIDE_METADATA_KEY = "hide-metadata";
44
export const DEFAULT_FILTERS_KEY = "default-filters";
55
export const QUERY_BUILDER_SETTINGS_KEY = "query-builder-settings";
66
export const AUTO_CANVAS_RELATIONS_KEY = "auto-canvas-relations";
7+
export const DISCOURSE_TOOL_SHORTCUT_KEY = "discourse-tool-shortcut";
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { IKeyCombo } from "@blueprintjs/core";
2+
3+
/**
4+
* Convert Blueprint IKeyCombo to tldraw keyboard shortcut format
5+
*
6+
* tldraw format examples:
7+
* - "?C" = Ctrl+C
8+
* - "$!X" = Shift+Ctrl+X
9+
* - "!3" = F3
10+
* - "^A" = Alt+A
11+
* - "@S" = Cmd+S (Mac) / Win+S (Windows)
12+
*/
13+
export const convertComboToTldrawFormat = (
14+
combo: IKeyCombo | undefined,
15+
): string => {
16+
if (!combo || !combo.key) return "";
17+
18+
const modifiers = [];
19+
if (combo.modifiers & 2) modifiers.push("?"); // Ctrl
20+
if (combo.modifiers & 8) modifiers.push("$"); // Shift
21+
if (combo.modifiers & 1) modifiers.push("^"); // Alt
22+
if (combo.modifiers & 4) modifiers.push("@"); // Meta/Cmd
23+
24+
return modifiers.join("") + combo.key.toUpperCase();
25+
};
26+
27+
/**
28+
* Convert tldraw keyboard shortcut format to Blueprint IKeyCombo
29+
* This is useful for testing and validation
30+
*/
31+
export const convertTldrawFormatToCombo = (shortcut: string): IKeyCombo => {
32+
if (!shortcut) return { modifiers: 0, key: "" };
33+
34+
let modifiers = 0;
35+
let key = shortcut;
36+
37+
// Extract modifiers
38+
if (shortcut.includes("?")) {
39+
modifiers |= 2; // Ctrl
40+
key = key.replace("?", "");
41+
}
42+
if (shortcut.includes("$")) {
43+
modifiers |= 8; // Shift
44+
key = key.replace("$", "");
45+
}
46+
if (shortcut.includes("^")) {
47+
modifiers |= 1; // Alt
48+
key = key.replace("^", "");
49+
}
50+
if (shortcut.includes("@")) {
51+
modifiers |= 4; // Meta/Cmd
52+
key = key.replace("@", "");
53+
}
54+
55+
return { modifiers, key: key.toLowerCase() };
56+
};

0 commit comments

Comments
 (0)