Skip to content

Commit 3c66a72

Browse files
authored
Eng 1552 enable ability to change discourse graph specific keyboard (#952)
* ENG-1552: Add per-user canvas keyboard shortcut overrides Allow users to customize discourse node shortcuts in the canvas via a new "Canvas shortcuts" tab in personal settings. * ENG-1552: Rename tab to Canvas, fix empty shortcut fallback * ENG-1552: Switch canvas shortcuts to 3-state model (default/override/empty) Per review feedback: each node now has an explicit "use custom shortcut" toggle. Unchecked falls back to the global default; checked with a value overrides; checked with empty value disables the shortcut entirely. - Schema: `Record<nodeType, { value, enabled }>` replaces flat string map - Read side in createUiOverrides keys off `enabled` flag, not value presence - Each row uses PersonalFlagPanel + PersonalTextPanel; flag controls input's disabled state, uncheck clears stored value via remount - BaseTextPanel: added `disabled?: boolean` and made Description conditional on a non-empty description (no-op for existing callers) - Exported `CanvasNodeShortcuts` type from zodSchema; uiOverrides imports it * ENG-1552: Read canvas shortcuts from extensionAPI, dual-write to block props Settings tab opened slowly (~5s) because each row called getPersonalSetting twice — once on mount and again every render — and each call ran a Datalog query plus a full PersonalSettingsSchema.parse(). createUiOverrides hit the same accessor too. Switched to the same pattern other personal settings use (KeyboardShortcutInput, HomePersonalSettings flags): read from the Roam extensionAPI, dual-write to extensionAPI + block props on change. extensionAPI reads are sync and in-memory. - New constant CANVAS_NODE_SHORTCUTS_KEY in data/userSettings.ts - CanvasShortcutSettings: parent reads the record once, holds it in state, passes initial values down; row notifies parent on change, parent writes to extensionAPI; PersonalFlagPanel/PersonalTextPanel keep their internal block-prop writes so block props stay in sync - uiOverrides: getSetting(CANVAS_NODE_SHORTCUTS_KEY, {}) instead of getPersonalSetting(["Canvas node shortcuts"]) * ENG-1552: Redesign canvas shortcuts UI per review feedback - DRY layout: single Shortcuts tab inside Canvas, compact single-row per node - Move description to page header, simplify tooltips to show default key only - Restrict input to single key via keyDown handler (ignores modifier combos) - Replace PersonalFlagPanel/PersonalTextPanel with direct Blueprint components * ENG-1552: Allow Tab/Escape to pass through in shortcut input * ENG-1552: Align canvas shortcut inputs with grid layout * chore: retrigger CI * ENG-1552: Use custom CSS for canvas shortcuts grid template Roam's Tailwind doesn't generate arbitrary value classes from extension source files, so grid-template-columns moves to styles.css. * ENG-1552: Make canvas shortcuts grid explicit (inline-grid, fit-content) * ENG-1552: Add explicit CSS for canvas shortcuts grid alignment * ENG-1552: Use per-row grid with computed label column width * ENG-1552: Simplify canvas shortcuts grid to auto-sized columns * ENG-1552: Use Tailwind grid-cols-[auto_auto] directly, drop custom CSS * chore: retrigger CI * ENG-1552: Remove extra blank line in styles.css
1 parent c7eec41 commit 3c66a72

7 files changed

Lines changed: 192 additions & 3 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,12 @@ import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg";
5151
import { AddReferencedNodeType } from "./DiscourseRelationShape/DiscourseRelationTool";
5252
import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil";
5353
import DiscourseGraphPanel from "./DiscourseToolPanel";
54-
import { DISCOURSE_TOOL_SHORTCUT_KEY } from "~/data/userSettings";
54+
import {
55+
DISCOURSE_TOOL_SHORTCUT_KEY,
56+
CANVAS_NODE_SHORTCUTS_KEY,
57+
} from "~/data/userSettings";
5558
import { getSetting } from "~/utils/extensionSettings";
59+
import type { CanvasNodeShortcuts } from "~/components/settings/utils/zodSchema";
5660
import { CustomDefaultToolbar } from "./CustomDefaultToolbar";
5761
import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog";
5862
import { CanvasSyncMode } from "./canvasSyncMode";
@@ -396,13 +400,19 @@ export const createUiOverrides = ({
396400
editor.setCurrentTool("discourse-tool");
397401
},
398402
};
403+
const canvasNodeShortcuts = getSetting<CanvasNodeShortcuts>(
404+
CANVAS_NODE_SHORTCUTS_KEY,
405+
{},
406+
);
407+
399408
allNodes.forEach((node, index) => {
400409
const nodeId = node.type;
410+
const override = canvasNodeShortcuts[nodeId];
401411
tools[nodeId] = {
402412
id: nodeId,
403413
icon: "color",
404414
label: `shape.node.${node.type}` as TLUiTranslationKey,
405-
kbd: node.shortcut,
415+
kbd: override?.enabled ? override.value : node.shortcut,
406416
onSelect: () => {
407417
editor.setCurrentTool(nodeId);
408418
},
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React, { useState } from "react";
2+
import { Checkbox, InputGroup, Tabs, Tab } from "@blueprintjs/core";
3+
import Description from "roamjs-components/components/Description";
4+
import getDiscourseNodes, {
5+
excludeDefaultNodes,
6+
} from "~/utils/getDiscourseNodes";
7+
import { setPersonalSetting } from "~/components/settings/utils/accessors";
8+
import { getSetting, setSetting } from "~/utils/extensionSettings";
9+
import { CANVAS_NODE_SHORTCUTS_KEY } from "~/data/userSettings";
10+
import type { CanvasNodeShortcuts } from "./utils/zodSchema";
11+
12+
const BLOCK_PROP_KEY = "Canvas node shortcuts";
13+
14+
type ShortcutRowProps = {
15+
nodeType: string;
16+
nodeText: string;
17+
defaultShortcut: string;
18+
initialEnabled: boolean;
19+
initialValue: string;
20+
onEnabledChange: (enabled: boolean) => void;
21+
onValueChange: (value: string) => void;
22+
};
23+
24+
const ShortcutRow = ({
25+
nodeType,
26+
nodeText,
27+
defaultShortcut,
28+
initialEnabled,
29+
initialValue,
30+
onEnabledChange,
31+
onValueChange,
32+
}: ShortcutRowProps) => {
33+
const enabledKey = [BLOCK_PROP_KEY, nodeType, "enabled"];
34+
const valueKey = [BLOCK_PROP_KEY, nodeType, "value"];
35+
36+
const [enabled, setEnabled] = useState(initialEnabled);
37+
const [storedValue, setStoredValue] = useState(initialValue);
38+
39+
const persistValue = (value: string) => {
40+
setStoredValue(value);
41+
setPersonalSetting(valueKey, value);
42+
onValueChange(value);
43+
};
44+
45+
const handleEnabledChange = (e: React.FormEvent<HTMLInputElement>) => {
46+
const checked = e.currentTarget.checked;
47+
setEnabled(checked);
48+
setPersonalSetting(enabledKey, checked);
49+
if (!checked) {
50+
persistValue("");
51+
}
52+
onEnabledChange(checked);
53+
};
54+
55+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
56+
if (e.key === "Tab" || e.key === "Escape") return;
57+
e.preventDefault();
58+
if (e.key === "Backspace" || e.key === "Delete") {
59+
persistValue("");
60+
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
61+
persistValue(e.key);
62+
}
63+
};
64+
65+
return (
66+
<>
67+
<Checkbox
68+
checked={enabled}
69+
onChange={handleEnabledChange}
70+
className="mb-0"
71+
labelElement={
72+
<>
73+
<span className="font-medium">{nodeText} node</span>{" "}
74+
<Description
75+
description={`Default: ${defaultShortcut || "none"}`}
76+
/>
77+
</>
78+
}
79+
/>
80+
<InputGroup
81+
value={enabled ? storedValue : ""}
82+
onChange={() => {}}
83+
onKeyDown={handleKeyDown}
84+
disabled={!enabled}
85+
placeholder={defaultShortcut || "(no shortcut)"}
86+
className="w-20"
87+
/>
88+
</>
89+
);
90+
};
91+
92+
const CanvasShortcutSettings = () => {
93+
const nodes = getDiscourseNodes().filter(excludeDefaultNodes);
94+
const [shortcuts, setShortcuts] = useState<CanvasNodeShortcuts>(() =>
95+
getSetting<CanvasNodeShortcuts>(CANVAS_NODE_SHORTCUTS_KEY, {}),
96+
);
97+
98+
const updateShortcut = (
99+
nodeType: string,
100+
update: Partial<CanvasNodeShortcuts[string]>,
101+
) => {
102+
const current = shortcuts[nodeType] ?? { value: "", enabled: false };
103+
const next = { ...shortcuts, [nodeType]: { ...current, ...update } };
104+
void setSetting(CANVAS_NODE_SHORTCUTS_KEY, next);
105+
setShortcuts(next);
106+
};
107+
108+
return (
109+
<Tabs renderActiveTabPanelOnly={true}>
110+
<Tab
111+
id="shortcuts"
112+
title="Shortcuts"
113+
panel={
114+
<div className="inline-grid grid-cols-[auto_auto] items-center gap-x-4 gap-y-2 p-1">
115+
<div className="col-span-2 mb-2">
116+
<div className="text-base">
117+
Override the canvas keyboard shortcuts
118+
</div>
119+
<div className="text-sm italic text-gray-500">
120+
Changes take effect next time a canvas is opened
121+
</div>
122+
</div>
123+
{nodes.map((node) => {
124+
const override = shortcuts[node.type];
125+
return (
126+
<ShortcutRow
127+
key={node.type}
128+
nodeType={node.type}
129+
nodeText={node.text}
130+
defaultShortcut={node.shortcut}
131+
initialEnabled={override?.enabled ?? false}
132+
initialValue={override?.value ?? ""}
133+
onEnabledChange={(enabled) =>
134+
updateShortcut(node.type, {
135+
enabled,
136+
...(enabled ? {} : { value: "" }),
137+
})
138+
}
139+
onValueChange={(value) =>
140+
updateShortcut(node.type, { value })
141+
}
142+
/>
143+
);
144+
})}
145+
</div>
146+
}
147+
/>
148+
</Tabs>
149+
);
150+
};
151+
152+
export default CanvasShortcutSettings;

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import getDiscourseNodes, {
2323
} from "~/utils/getDiscourseNodes";
2424
import NodeConfig from "./NodeConfig";
2525
import HomePersonalSettings from "./HomePersonalSettings";
26+
import CanvasShortcutSettings from "./CanvasShortcutSettings";
2627
import refreshConfigTree from "~/utils/refreshConfigTree";
2728
import { FeedbackWidget } from "~/components/BirdEatsBugs";
2829
import { getVersionWithDate } from "~/utils/getVersion";
@@ -170,6 +171,12 @@ export const SettingsDialog = ({
170171
className="overflow-y-auto"
171172
panel={<QuerySettings extensionAPI={extensionAPI} />}
172173
/>
174+
<Tab
175+
id="canvas-shortcuts-personal-settings"
176+
title="Canvas"
177+
className="overflow-y-auto"
178+
panel={<CanvasShortcutSettings />}
179+
/>
173180
<Tab
174181
id="left-sidebar-personal-settings"
175182
title="Left sidebar"

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type BaseTextPanelProps = {
4949
placeholder?: string;
5050
multiline?: boolean;
5151
error?: string;
52+
disabled?: boolean;
5253
onChange?: (value: string) => void;
5354
} & RoamBlockSyncProps;
5455

@@ -104,6 +105,7 @@ const BaseTextPanel = ({
104105
placeholder,
105106
multiline,
106107
error,
108+
disabled,
107109
onChange,
108110
parentUid,
109111
uid,
@@ -148,20 +150,22 @@ const BaseTextPanel = ({
148150
<div className="flex flex-col">
149151
<Label>
150152
{title}
151-
<Description description={description} />
153+
{description && <Description description={description} />}
152154
{multiline ? (
153155
<TextArea
154156
value={value}
155157
onChange={handleChange}
156158
placeholder={placeholder || initialValue}
157159
className="w-full"
158160
style={{ minHeight: 80, resize: "vertical" }}
161+
disabled={disabled}
159162
/>
160163
) : (
161164
<InputGroup
162165
value={value}
163166
onChange={handleChange}
164167
placeholder={placeholder || initialValue}
168+
disabled={disabled}
165169
/>
166170
)}
167171
</Label>

apps/roam/src/components/settings/utils/zodSchema.example.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,10 @@ const personalSettings: PersonalSettings = {
373373
"Auto canvas relations": true,
374374
"Disable product diagnostics": false,
375375
"Reified relation triples": true,
376+
"Canvas node shortcuts": {
377+
"_CLM-node": { value: "X", enabled: true },
378+
"_QUE-node": { value: "", enabled: true },
379+
},
376380
Query: {
377381
"Hide query metadata": true,
378382
"Default page size": 25,
@@ -401,6 +405,7 @@ const defaultPersonalSettings: PersonalSettings = {
401405
"Auto canvas relations": false,
402406
"Disable product diagnostics": false,
403407
"Reified relation triples": false,
408+
"Canvas node shortcuts": {},
404409
Query: {
405410
"Hide query metadata": false,
406411
"Default page size": 10,

apps/roam/src/components/settings/utils/zodSchema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,15 @@ export const PersonalSettingsSchema = z.object({
257257
"Streamline styling": z.boolean().default(false),
258258
"Auto canvas relations": z.boolean().default(false),
259259
"Disable product diagnostics": z.boolean().default(false),
260+
"Canvas node shortcuts": z
261+
.record(
262+
z.string(),
263+
z.object({
264+
value: z.string().default(""),
265+
enabled: z.boolean().default(false),
266+
}),
267+
)
268+
.default({}),
260269
Query: QuerySettingsSchema.default({}),
261270
});
262271

@@ -314,6 +323,7 @@ export type LeftSidebarPersonalSettings = z.infer<
314323
export type StoredFilters = z.infer<typeof StoredFiltersSchema>;
315324
export type QuerySettings = z.infer<typeof QuerySettingsSchema>;
316325
export type PersonalSettings = z.infer<typeof PersonalSettingsSchema>;
326+
export type CanvasNodeShortcuts = PersonalSettings["Canvas node shortcuts"];
317327
export type GithubSettings = z.infer<typeof GithubSettingsSchema>;
318328
export type QueryCondition = z.infer<typeof ConditionSchema>;
319329
export type QuerySelection = z.infer<typeof SelectionSchema>;

apps/roam/src/data/userSettings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ 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";
77
export const DISCOURSE_TOOL_SHORTCUT_KEY = "discourse-tool-shortcut";
8+
export const CANVAS_NODE_SHORTCUTS_KEY = "canvas-node-shortcuts";
89
export const DISCOURSE_CONTEXT_OVERLAY_IN_CANVAS_KEY =
910
"discourse-context-overlay-in-canvas";
1011
export const STREAMLINE_STYLING_KEY = "streamline-styling";

0 commit comments

Comments
 (0)